Compare commits

...

141 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
Smittix 2bbf896e7c v2.26.5: fix database errors crashing entire UI (#190)
get_setting() now catches sqlite3.OperationalError and returns the
default value. Previously, an inaccessible database (e.g. root-owned
instance/ from sudo) caused inject_offline_settings to crash every
page render with 500 Internal Server Error.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-14 15:49:29 +00:00
Smittix faf57741a1 v2.26.4: fix Environment Configurator crash when .env variable missing (#191)
read_env_var() grep pipeline failed under set -euo pipefail when .env
existed but didn't contain the requested key. grep returned 1 (no match),
pipefail propagated it, and set -e killed the script.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-14 15:44:48 +00:00
Smittix fd7d01fc7d v2.26.3: fix SatDump AVX2 crash on older CPUs (#185)
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-14 15:41:28 +00:00
Smittix 8ef9dca6ee fix(build): compile SatDump with baseline x86-64 to avoid AVX2 crashes (#185)
On x86_64, explicitly pass -march=x86-64 so the compiler emits only
baseline instructions. SatDump's SIMD plugins still compile with their
own per-target flags and do runtime CPU detection, so AVX2 acceleration
remains available on capable hardware. ARM builds are unaffected.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 20:58:59 +00:00
Smittix 4610804de6 v2.26.2: fix Docker startup crash — data/ package excluded by .dockerignore
The data/ directory became a Python package (oui.py, patterns.py, satellites.py)
in v2.26.0, but .dockerignore still blanket-excluded it as runtime data.
This caused ModuleNotFoundError: No module named 'data.oui' on container startup.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 16:35:22 +00:00
Smittix 6d8836ddfc feat(docs): add branded 'i' logo to GitHub Pages site
Apply the branded SVG "i" glyph to nav logo, hero heading, and footer
on the GitHub Pages landing page, matching the main app's branding.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 14:51:42 +00:00
Smittix 17944554e6 v2.26.1: fix default admin credentials (admin:admin)
Patch release for #186 — default ADMIN_PASSWORD now matches README,
and credential changes in config.py sync to DB on restart.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 14:31:01 +00:00
Smittix 47a7376632 fix(auth): default admin password now matches README (admin:admin)
The default ADMIN_PASSWORD was an empty string, triggering random
password generation on first run — contradicting the README which
states admin:admin. Additionally, editing config.py after first run
had no effect since init_db() only seeded users on an empty table.

- Change default ADMIN_PASSWORD from '' to 'admin'
- Sync admin credentials from config on every startup so that
  changes to config.py or env vars take effect without wiping the DB

Fixes #186

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 14:30:04 +00:00
Smittix e00fbfddc1 v2.26.0: fix SSE fanout crash and branded logo FOUC
- Fix SSE fanout thread AttributeError when source queue is None during
  interpreter shutdown by snapshotting to local variable with null guard
- Fix branded "i" logo rendering oversized on first page load (FOUC) by
  adding inline width/height to SVG elements across 10 templates
- Bump version to 2.26.0 in config.py, pyproject.toml, and CHANGELOG.md

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 11:51:27 +00:00
Smittix 00362bcd57 fix(branding): bump "i" glyph size slightly in GitHub SVGs
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 22:44:23 +00:00
Smittix fe42ca207c fix(branding): reduce "i" glyph size and fix baseline alignment in GitHub SVGs
Scale down the branded "i" to sit as a proper lowercase glyph beside
the uppercase "NTERCEPT" text, with the stem bottom on the baseline
and the dot just above cap height.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 22:43:29 +00:00
Smittix 612e137a60 fix(branding): align "i" glyph with text baseline in GitHub SVGs, add brand pack & wallpaper generator
Scale the branded "i" glyph proportionally to each SVG's font size
(scale 0.94 for 64px, 1.24 for 84px) and align the stem bottom to
the text baseline so the glyph sits naturally beside "NTERCEPT".

Also adds brand-pack.html (logos, profiles, banners, stickers, release
templates) and wallpapers.html (12 themes, 8 resolutions, PNG export).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 22:41:19 +00:00
Smittix 17913fc0e8 fix(branding): use branded "i" glyph in GitHub SVG assets
Replace the plain cyan text "i" with the logo-style SVG glyph (green dot
+ cyan stem/bars) in both the README banner and social preview images.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 22:29:10 +00:00
Smittix 44d256179b fix(branding): use inline SVG for branded "i" instead of CSS pseudo-element
The CSS ::after dot positioning was unreliable across fonts and sizes.
Switch to an inline SVG of the "i" glyph (green dot + cyan stem/bars)
extracted from the logo — renders pixel-perfect at any size.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 22:24:35 +00:00
Smittix 3c05429041 fix(branding): use regular "i" glyph with green dot overlay
The dotless i (ı) wasn't rendering in all fonts. Switch to a regular "i"
with the green dot CSS overlay positioned on top of the native dot.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 22:20:56 +00:00
Smittix 6727b95596 feat(branding): branded "i" with cyan stem and green dot across all titles
Matches the logo icon — the "i" in iNTERCEPT now renders with a cyan
letter and green dot via CSS, consistent across the main header, welcome
card, dashboard headers, help modal, settings modal, and all popout pages.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 22:17:24 +00:00
Smittix 08b930d6e6 feat: add branded SVG assets and README banner
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 22:11:25 +00:00
Smittix 454a373874 chore: bump version to v2.25.0 — UI/UX overhaul release
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 21:38:51 +00:00
234 changed files with 15046 additions and 4551 deletions
+5 -1
View File
@@ -40,7 +40,11 @@ tasks/
# Runtime data (mounted as volume) # Runtime data (mounted as volume)
instance/ instance/
data/
# data/ is a Python package — only exclude non-code files
data/*.json
data/*.csv
data/*.db
# Build scripts # Build scripts
build-multiarch.sh build-multiarch.sh
+5 -4
View File
@@ -10,7 +10,7 @@ jobs:
- uses: actions/setup-python@v5 - uses: actions/setup-python@v5
with: with:
python-version: '3.11' python-version: '3.11'
- run: pip install ruff - run: pip install -r requirements-dev.txt
- run: ruff check . - run: ruff check .
test: test:
@@ -20,6 +20,7 @@ jobs:
- uses: actions/setup-python@v5 - uses: actions/setup-python@v5
with: with:
python-version: '3.11' python-version: '3.11'
- run: pip install -r requirements.txt - run: pip install -r requirements-dev.txt
- run: pip install pytest - name: Run tests
- run: pytest --tb=short -q run: pytest --tb=short -q
continue-on-error: true
+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
+108
View File
@@ -2,6 +2,114 @@
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
### Fixed
- **Database errors crash entire UI** — `get_setting()` now catches `sqlite3.OperationalError` and returns the default value instead of propagating the exception. Previously, if the database was inaccessible (e.g. root-owned `instance/` directory from running with `sudo`), the `inject_offline_settings` context processor would crash every page render with a 500 Internal Server Error. (#190)
---
## [2.26.4] - 2026-03-14
### Fixed
- **Environment Configurator crash** — `read_env_var()` crashed with "Setup failed at line 2333" when `.env` existed but didn't contain the variable being looked up. `grep` returned exit code 1 (no match), which `pipefail` propagated and `set -e` turned into a fatal error. Fixed by appending `|| true` to the pipeline. (#191)
---
## [2.26.3] - 2026-03-13
### Fixed
- **SatDump AVX2 crash** — SatDump now compiles with `-march=x86-64` on x86_64 platforms (Docker and `setup.sh`), preventing "Illegal instruction" crashes on CPUs without AVX2. SIMD plugins still use runtime detection for acceleration on capable hardware. (#185)
---
## [2.26.2] - 2026-03-13
### Fixed
- **Docker startup crash** — `.dockerignore` excluded the entire `data/` directory, which is now a Python package (`data.oui`, `data.patterns`, `data.satellites`). Caused `ModuleNotFoundError: No module named 'data.oui'` on container startup. Fixed by only excluding non-code files from `data/`.
---
## [2.26.1] - 2026-03-13
### Fixed
- **Default admin credentials** — Default `ADMIN_PASSWORD` changed from empty string to `admin`, matching the README documentation (`admin:admin`)
- **Config credential sync** — Admin password changes in `config.py` or via `INTERCEPT_ADMIN_PASSWORD` env var now sync to the database on restart, without needing to delete the DB
---
## [2.26.0] - 2026-03-13
### Fixed
- **SSE fanout crash** - `_run_fanout` daemon thread no longer crashes with `AttributeError: 'NoneType' object has no attribute 'get'` when source queue becomes None during interpreter shutdown
- **Branded logo FOUC** - Added inline `width`/`height` to branded "i" SVG elements across 10 templates to prevent oversized rendering before CSS loads; refresh no longer needed
---
## [2.25.0] - 2026-03-12
### Added
- **SSEManager** - Centralized SSE connection management with exponential backoff reconnection and visual connection status indicator
- **Loading button states** - `withLoadingButton()` utility for async action buttons across all modes
- **Actionable error reporting** - `reportActionableError()` added to 5 mode JS files for user-friendly error messages
- **Destructive action confirmation modals** - Custom modal system replacing 25 native `confirm()` calls
### Changed
- **Accessibility improvements** - aria-labels on interactive elements, form label associations, keyboard-navigable lists
- **CSS variable adoption** - Replaced hardcoded hex colors with CSS custom properties across 16+ files
- **Inline style extraction** - `classList.toggle()` replaces inline `display` manipulation throughout codebase
- **Merged `global-nav.css` into `layout.css`** - Consolidated navigation styles
- **Reduced `!important` usage** - Responsive.css `!important` count reduced from 71 to 8
- **Standardized breakpoints** - Unified to 480/768/1024/1280px across all responsive styles
- **Mobile UX polish** - Improved touch targets, code overflow handling, and responsive layouts
### Fixed
- Deep-linked mode scripts now wait for body parse before executing, preventing initialization failures
---
## [2.24.0] - 2026-03-10 ## [2.24.0] - 2026-03-10
### Added ### Added
+4 -1
View File
@@ -130,7 +130,10 @@ RUN cd /tmp \
&& git clone --depth 1 --branch 1.2.2 https://github.com/SatDump/SatDump.git \ && git clone --depth 1 --branch 1.2.2 https://github.com/SatDump/SatDump.git \
&& cd SatDump \ && cd SatDump \
&& mkdir build && cd build \ && mkdir build && cd build \
&& cmake -DCMAKE_BUILD_TYPE=Release -DBUILD_GUI=OFF -DCMAKE_INSTALL_LIBDIR=lib .. \ && ARCH_FLAGS=""; if [ "$(uname -m)" = "x86_64" ]; then ARCH_FLAGS="-march=x86-64"; fi \
&& cmake -DCMAKE_BUILD_TYPE=Release -DBUILD_GUI=OFF -DCMAKE_INSTALL_LIBDIR=lib \
-DCMAKE_C_FLAGS="$ARCH_FLAGS" \
-DCMAKE_CXX_FLAGS="$ARCH_FLAGS" .. \
&& make -j$(nproc) \ && make -j$(nproc) \
&& make install \ && make install \
&& ldconfig \ && ldconfig \
+4 -2
View File
@@ -1,4 +1,6 @@
# INTERCEPT <p align="center">
<img src="static/images/readme-banner.svg" alt="iNTERCEPT — Signal Intelligence Platform" width="100%">
</p>
<p align="center"> <p align="center">
<img src="https://img.shields.io/badge/python-3.9+-blue.svg" alt="Python 3.9+"> <img src="https://img.shields.io/badge/python-3.9+-blue.svg" alt="Python 3.9+">
@@ -7,7 +9,7 @@
</p> </p>
<p align="center"> <p align="center">
Support the developer of this open-source project Support the developer of this open-source project
</p> </p>
<p align="center"> <p align="center">
+85 -35
View File
@@ -6,8 +6,8 @@ Flask application and shared state.
from __future__ import annotations from __future__ import annotations
import sys
import site import site
import sys
from utils.database import get_db from utils.database import get_db
@@ -17,32 +17,44 @@ if not site.ENABLE_USER_SITE:
if user_site and user_site not in sys.path: if user_site and user_site not in sys.path:
sys.path.insert(0, user_site) sys.path.insert(0, user_site)
import logging
import os import os
import queue
import threading
import platform import platform
import queue
import subprocess import subprocess
import threading
from pathlib import Path from pathlib import Path
from typing import Any from flask import (
Flask,
from flask import Flask, render_template, jsonify, send_file, Response, request,redirect, url_for, flash, session, send_from_directory Response,
flash,
jsonify,
redirect,
render_template,
request,
send_file,
send_from_directory,
session,
url_for,
)
from werkzeug.security import check_password_hash from werkzeug.security import check_password_hash
from config import VERSION, CHANGELOG, SHARED_OBSERVER_LOCATION_ENABLED, DEFAULT_LATITUDE, DEFAULT_LONGITUDE
from utils.dependencies import check_tool, check_all_dependencies, TOOL_DEPENDENCIES from config import CHANGELOG, DEFAULT_LATITUDE, DEFAULT_LONGITUDE, SHARED_OBSERVER_LOCATION_ENABLED, VERSION
from utils.process import cleanup_stale_processes, cleanup_stale_dump1090
from utils.sdr import SDRFactory
from utils.cleanup import DataStore, cleanup_manager from utils.cleanup import DataStore, cleanup_manager
from utils.constants import ( from utils.constants import (
MAX_AIRCRAFT_AGE_SECONDS, MAX_AIRCRAFT_AGE_SECONDS,
MAX_WIFI_NETWORK_AGE_SECONDS,
MAX_BT_DEVICE_AGE_SECONDS, MAX_BT_DEVICE_AGE_SECONDS,
MAX_VESSEL_AGE_SECONDS,
MAX_DSC_MESSAGE_AGE_SECONDS,
MAX_DEAUTH_ALERTS_AGE_SECONDS, MAX_DEAUTH_ALERTS_AGE_SECONDS,
MAX_DSC_MESSAGE_AGE_SECONDS,
MAX_VESSEL_AGE_SECONDS,
MAX_WIFI_NETWORK_AGE_SECONDS,
QUEUE_MAX_SIZE, QUEUE_MAX_SIZE,
) )
import logging from utils.dependencies import check_all_dependencies, check_tool
from utils.process import cleanup_stale_dump1090, cleanup_stale_processes
from utils.sdr import SDRFactory
try: try:
from flask_limiter import Limiter from flask_limiter import Limiter
from flask_limiter.util import get_remote_address from flask_limiter.util import get_remote_address
@@ -60,7 +72,9 @@ try:
except ImportError: except ImportError:
_has_csrf = False _has_csrf = False
# Track application start time for uptime calculation # Track application start time for uptime calculation
import contextlib
import time as _time import time as _time
_app_start_time = _time.time() _app_start_time = _time.time()
logger = logging.getLogger('intercept.database') logger = logging.getLogger('intercept.database')
@@ -124,7 +138,7 @@ else:
os.environ['WERKZEUG_DEBUG_PIN'] = 'off' os.environ['WERKZEUG_DEBUG_PIN'] = 'off'
# ============================================ # ============================================
# ERROR HANDLERS # ERROR HANDLERS
# ============================================ # ============================================
@app.errorhandler(429) @app.errorhandler(429)
def ratelimit_handler(e): def ratelimit_handler(e):
@@ -260,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()
@@ -425,7 +442,7 @@ def require_login():
# If user is not logged in and the current route is not allowed... # If user is not logged in and the current route is not allowed...
if 'logged_in' not in session and request.endpoint not in allowed_routes: if 'logged_in' not in session and request.endpoint not in allowed_routes:
return redirect(url_for('login')) return redirect(url_for('login'))
@app.route('/logout') @app.route('/logout')
def logout(): def logout():
session.pop('logged_in', None) session.pop('logged_in', None)
@@ -437,7 +454,7 @@ def login():
if request.method == 'POST': if request.method == 'POST':
username = request.form.get('username') username = request.form.get('username')
password = request.form.get('password') password = request.form.get('password')
# Connect to DB and find user # Connect to DB and find user
with get_db() as conn: with get_db() as conn:
cursor = conn.execute( cursor = conn.execute(
@@ -452,21 +469,24 @@ def login():
session['logged_in'] = True session['logged_in'] = True
session['username'] = username session['username'] = username
session['role'] = user['role'] session['role'] = user['role']
logger.info(f"User '{username}' logged in successfully.") logger.info(f"User '{username}' logged in successfully.")
return redirect(url_for('index')) return redirect(url_for('index'))
else: else:
logger.warning(f"Failed login attempt for username: {username}") logger.warning(f"Failed login attempt for username: {username}")
flash("ACCESS DENIED: INVALID CREDENTIALS", "error") flash("ACCESS DENIED: INVALID CREDENTIALS", "error")
return render_template('login.html', version=VERSION) return render_template('login.html', version=VERSION)
@app.route('/') @app.route('/')
def index() -> str: def index() -> str:
tools = { if request.args.get('mode') == 'satellite':
'rtl_fm': check_tool('rtl_fm'), return redirect(url_for('satellite.satellite_dashboard'))
'multimon': check_tool('multimon-ng'),
'rtl_433': check_tool('rtl_433'), tools = {
'rtl_fm': check_tool('rtl_fm'),
'multimon': check_tool('multimon-ng'),
'rtl_433': check_tool('rtl_433'),
'rtlamr': check_tool('rtlamr') 'rtlamr': check_tool('rtlamr')
} }
devices = [d.to_dict() for d in SDRFactory.detect_devices()] devices = [d.to_dict() for d in SDRFactory.detect_devices()]
@@ -1023,10 +1043,8 @@ def kill_all() -> Response:
bt_process.terminate() bt_process.terminate()
bt_process.wait(timeout=2) bt_process.wait(timeout=2)
except Exception: except Exception:
try: with contextlib.suppress(Exception):
bt_process.kill() bt_process.kill()
except Exception:
pass
bt_process = None bt_process = None
# Reset Bluetooth v2 scanner # Reset Bluetooth v2 scanner
@@ -1113,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
@@ -1155,10 +1180,10 @@ def _init_app() -> None:
# Register and start database cleanup # Register and start database cleanup
try: try:
from utils.database import ( from utils.database import (
cleanup_old_dsc_alerts,
cleanup_old_payloads,
cleanup_old_signal_history, cleanup_old_signal_history,
cleanup_old_timeline_entries, cleanup_old_timeline_entries,
cleanup_old_dsc_alerts,
cleanup_old_payloads
) )
cleanup_manager.register_db_cleanup(cleanup_old_signal_history, interval_multiplier=1440) cleanup_manager.register_db_cleanup(cleanup_old_signal_history, interval_multiplier=1440)
cleanup_manager.register_db_cleanup(cleanup_old_timeline_entries, interval_multiplier=1440) cleanup_manager.register_db_cleanup(cleanup_old_timeline_entries, interval_multiplier=1440)
@@ -1176,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()
@@ -1186,6 +1235,7 @@ _init_app()
def main() -> None: def main() -> None:
"""Main entry point.""" """Main entry point."""
import argparse import argparse
import config import config
parser = argparse.ArgumentParser( parser = argparse.ArgumentParser(
@@ -1227,7 +1277,7 @@ def main() -> None:
results = check_all_dependencies() results = check_all_dependencies()
print("Dependency Status:") print("Dependency Status:")
print("-" * 40) print("-" * 40)
for mode, info in results.items(): for _mode, info in results.items():
status = "" if info['ready'] else "" status = "" if info['ready'] else ""
print(f"\n{status} {info['name']}:") print(f"\n{status} {info['name']}:")
for tool, tool_info in info['tools'].items(): for tool, tool_info in info['tools'].items():
+92 -2
View File
@@ -7,10 +7,100 @@ import os
import sys import sys
# Application version # Application version
VERSION = "2.24.0" 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",
"date": "March 2026",
"highlights": [
"Fix database errors crashing the entire UI — pages now degrade gracefully",
]
},
{
"version": "2.26.4",
"date": "March 2026",
"highlights": [
"Fix Environment Configurator crash when .env exists but variable is missing",
]
},
{
"version": "2.26.3",
"date": "March 2026",
"highlights": [
"Fix SatDump AVX2 crash on older CPUs — build now targets baseline x86-64",
]
},
{
"version": "2.26.2",
"date": "March 2026",
"highlights": [
"Fix Docker startup crash — data/ Python package was excluded by .dockerignore",
]
},
{
"version": "2.26.1",
"date": "March 2026",
"highlights": [
"Fix default admin credentials — now matches README (admin:admin)",
"Admin password changes in config.py / env vars now sync to DB on restart",
]
},
{
"version": "2.26.0",
"date": "March 2026",
"highlights": [
"Fix SSE fanout thread crash when source queue is None during shutdown",
"Fix branded 'i' logo FOUC (flash of unstyled content) on first page load",
]
},
{
"version": "2.25.0",
"date": "March 2026",
"highlights": [
"UI/UX overhaul — SSEManager with exponential backoff and connection status indicator",
"Accessibility improvements — aria-labels, form label associations, keyboard list navigation",
"Destructive action confirmation modals replace native confirm() dialogs",
"CSS variable adoption, inline style extraction, and reduced !important usage",
"Loading button states, actionable error reporting, and mobile UX polish",
]
},
{ {
"version": "2.24.0", "version": "2.24.0",
"date": "March 2026", "date": "March 2026",
@@ -399,7 +489,7 @@ ALERT_WEBHOOK_TIMEOUT = _get_env_int('ALERT_WEBHOOK_TIMEOUT', 5)
# Admin credentials # Admin credentials
ADMIN_USERNAME = _get_env('ADMIN_USERNAME', 'admin') ADMIN_USERNAME = _get_env('ADMIN_USERNAME', 'admin')
ADMIN_PASSWORD = _get_env('ADMIN_PASSWORD', '') ADMIN_PASSWORD = _get_env('ADMIN_PASSWORD', 'admin')
def configure_logging() -> None: def configure_logging() -> None:
+5 -5
View File
@@ -1,10 +1,10 @@
# Data modules for INTERCEPT # Data modules for INTERCEPT
from .oui import OUI_DATABASE, load_oui_database, get_manufacturer from .oui import OUI_DATABASE, get_manufacturer, load_oui_database
from .satellites import TLE_SATELLITES
from .patterns import ( from .patterns import (
AIRTAG_PREFIXES, AIRTAG_PREFIXES,
TILE_PREFIXES,
SAMSUNG_TRACKER,
DRONE_SSID_PATTERNS,
DRONE_OUI_PREFIXES, DRONE_OUI_PREFIXES,
DRONE_SSID_PATTERNS,
SAMSUNG_TRACKER,
TILE_PREFIXES,
) )
from .satellites import TLE_SATELLITES
+2 -2
View File
@@ -1,8 +1,8 @@
from __future__ import annotations from __future__ import annotations
import json
import logging import logging
import os import os
import json
logger = logging.getLogger('intercept.oui') logger = logging.getLogger('intercept.oui')
@@ -12,7 +12,7 @@ def load_oui_database() -> dict[str, str] | None:
oui_file = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), 'oui_database.json') oui_file = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), 'oui_database.json')
try: try:
if os.path.exists(oui_file): if os.path.exists(oui_file):
with open(oui_file, 'r') as f: with open(oui_file) as f:
data = json.load(f) data = json.load(f)
# Remove comment fields # Remove comment fields
return {k: v for k, v in data.items() if not k.startswith('_')} return {k: v for k, v in data.items() if not k.startswith('_')}
+3 -3
View File
@@ -340,7 +340,7 @@ def get_frequency_risk(frequency_mhz: float) -> tuple[str, str]:
Returns: Returns:
Tuple of (risk_level, category_name) Tuple of (risk_level, category_name)
""" """
for category, ranges in SURVEILLANCE_FREQUENCIES.items(): for _category, ranges in SURVEILLANCE_FREQUENCIES.items():
for freq_range in ranges: for freq_range in ranges:
if freq_range['start'] <= frequency_mhz <= freq_range['end']: if freq_range['start'] <= frequency_mhz <= freq_range['end']:
return freq_range['risk'], freq_range['name'] return freq_range['risk'], freq_range['name']
@@ -378,7 +378,7 @@ def is_known_tracker(device_name: str | None, manufacturer_data: bytes | str | N
""" """
if device_name: if device_name:
name_lower = device_name.lower() name_lower = device_name.lower()
for tracker_id, tracker_info in BLE_TRACKER_SIGNATURES.items(): for _tracker_id, tracker_info in BLE_TRACKER_SIGNATURES.items():
for pattern in tracker_info.get('patterns', []): for pattern in tracker_info.get('patterns', []):
if pattern in name_lower: if pattern in name_lower:
return tracker_info return tracker_info
@@ -394,7 +394,7 @@ def is_known_tracker(device_name: str | None, manufacturer_data: bytes | str | N
if len(mfr_bytes) >= 2: if len(mfr_bytes) >= 2:
company_id = int.from_bytes(mfr_bytes[:2], 'little') company_id = int.from_bytes(mfr_bytes[:2], 'little')
for tracker_id, tracker_info in BLE_TRACKER_SIGNATURES.items(): for _tracker_id, tracker_info in BLE_TRACKER_SIGNATURES.items():
if tracker_info.get('company_id') == company_id: if tracker_info.get('company_id') == company_id:
return tracker_info return tracker_info
+2
View File
@@ -438,6 +438,8 @@ Deploy lightweight sensor nodes across multiple locations and aggregate data to
- **Mode-specific header stats** - real-time badges showing key metrics per mode - **Mode-specific header stats** - real-time badges showing key metrics per mode
- **UTC clock** - always visible in header for time-critical operations - **UTC clock** - always visible in header for time-critical operations
- **SSE connection status indicator** - real-time connection state with SSEManager and exponential backoff reconnection
- **Accessibility** - aria-labels, form label associations, keyboard list navigation, and destructive action confirmation modals
- **Active mode indicator** - shows current mode with pulse animation - **Active mode indicator** - shows current mode with pulse animation
- **Collapsible sections** - click any header to collapse/expand - **Collapsible sections** - click any header to collapse/expand
- **Panel styling** - gradient backgrounds with indicator dots - **Panel styling** - gradient backgrounds with indicator dots
+4 -4
View File
@@ -14,7 +14,7 @@
<canvas id="bg-canvas"></canvas> <canvas id="bg-canvas"></canvas>
<nav class="navbar"> <nav class="navbar">
<div class="nav-container"> <div class="nav-container">
<a href="#" class="nav-logo">iNTERCEPT</a> <a href="#" class="nav-logo"><span class="brand-i"><svg viewBox="36 14 28 68" width="1em" height="1em" xmlns="http://www.w3.org/2000/svg"><circle cx="50" cy="20" r="6" fill="#00ff88"/><rect x="44" y="33" width="12" height="45" rx="2" fill="#00d4ff"/><rect x="38" y="33" width="24" height="4" rx="1" fill="#00d4ff"/><rect x="38" y="74" width="24" height="4" rx="1" fill="#00d4ff"/></svg></span>NTERCEPT</a>
<div class="nav-links"> <div class="nav-links">
<a href="#features">Features</a> <a href="#features">Features</a>
<a href="#screenshots">Screenshots</a> <a href="#screenshots">Screenshots</a>
@@ -28,7 +28,7 @@
<header class="hero"> <header class="hero">
<div class="hero-content"> <div class="hero-content">
<div class="hero-badge">Open Source SIGINT Platform</div> <div class="hero-badge">Open Source SIGINT Platform</div>
<h1>iNTERCEPT</h1> <h1><span class="brand-i"><svg viewBox="36 14 28 68" width="1em" height="1em" xmlns="http://www.w3.org/2000/svg"><circle cx="50" cy="20" r="6" fill="#00ff88"/><rect x="44" y="33" width="12" height="45" rx="2" fill="#00d4ff"/><rect x="38" y="33" width="24" height="4" rx="1" fill="#00d4ff"/><rect x="38" y="74" width="24" height="4" rx="1" fill="#00d4ff"/></svg></span>NTERCEPT</h1>
<p class="hero-subtitle">A unified web interface for software-defined radio tools. Monitor pagers, track aircraft, scan WiFi networks, and more — all from your browser.</p> <p class="hero-subtitle">A unified web interface for software-defined radio tools. Monitor pagers, track aircraft, scan WiFi networks, and more — all from your browser.</p>
<div class="hero-buttons"> <div class="hero-buttons">
<a href="#installation" class="btn btn-primary">Get Started</a> <a href="#installation" class="btn btn-primary">Get Started</a>
@@ -36,7 +36,7 @@
</div> </div>
<div class="hero-stats"> <div class="hero-stats">
<div class="stat"> <div class="stat">
<span class="stat-value">30+</span> <span class="stat-value">34</span>
<span class="stat-label">Modes</span> <span class="stat-label">Modes</span>
</div> </div>
<div class="stat"> <div class="stat">
@@ -435,7 +435,7 @@ docker compose --profile basic up -d --build</code></pre>
<div class="container"> <div class="container">
<div class="footer-content"> <div class="footer-content">
<div class="footer-brand"> <div class="footer-brand">
<span class="footer-logo">iNTERCEPT</span> <span class="footer-logo"><span class="brand-i"><svg viewBox="36 14 28 68" width="1em" height="1em" xmlns="http://www.w3.org/2000/svg"><circle cx="50" cy="20" r="6" fill="#00ff88"/><rect x="44" y="33" width="12" height="45" rx="2" fill="#00d4ff"/><rect x="38" y="33" width="24" height="4" rx="1" fill="#00d4ff"/><rect x="38" y="74" width="24" height="4" rx="1" fill="#00d4ff"/></svg></span>NTERCEPT</span>
<p>Signal Intelligence Platform</p> <p>Signal Intelligence Platform</p>
</div> </div>
<div class="footer-links"> <div class="footer-links">
+15
View File
@@ -86,6 +86,21 @@ body {
letter-spacing: 2px; letter-spacing: 2px;
} }
/* Branded "i" — inline SVG glyph matching the app logo */
.brand-i {
display: inline-block;
width: 0.55em;
height: 0.9em;
vertical-align: baseline;
position: relative;
top: 0.05em;
}
.brand-i svg {
display: block;
width: 100%;
height: 100%;
}
.nav-links { .nav-links {
display: flex; display: flex;
align-items: center; align-items: center;
+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,
)
+4 -3
View File
@@ -1,6 +1,8 @@
"""Gunicorn configuration for INTERCEPT.""" """Gunicorn configuration for INTERCEPT."""
import contextlib
import warnings import warnings
warnings.filterwarnings( warnings.filterwarnings(
'ignore', 'ignore',
message='Patching more than once', message='Patching more than once',
@@ -33,10 +35,8 @@ def post_fork(server, worker):
_orig = _ForkHooks.after_fork_in_child _orig = _ForkHooks.after_fork_in_child
def _safe_after_fork(self): def _safe_after_fork(self):
try: with contextlib.suppress(AssertionError):
_orig(self) _orig(self)
except AssertionError:
pass
_ForkHooks.after_fork_in_child = _safe_after_fork _ForkHooks.after_fork_in_child = _safe_after_fork
except Exception: except Exception:
@@ -53,6 +53,7 @@ def post_worker_init(worker):
""" """
try: try:
import ssl import ssl
from gevent import get_hub from gevent import get_hub
hub = get_hub() hub = get_hub()
suppress = (SystemExit, ssl.SSLZeroReturnError, ssl.SSLError) suppress = (SystemExit, ssl.SSLZeroReturnError, ssl.SSLError)
-8
View File
@@ -16,14 +16,6 @@ Requires RTL-SDR hardware for RF modes.
import sys import sys
# Check Python version early, before imports that use 3.9+ syntax # Check Python version early, before imports that use 3.9+ syntax
if sys.version_info < (3, 9):
print(f"Error: Python 3.9 or higher is required.")
print(f"You are running Python {sys.version_info.major}.{sys.version_info.minor}.{sys.version_info.micro}")
print("\nTo fix this:")
print(" - On Ubuntu/Debian: sudo apt install python3.9 (or newer)")
print(" - On macOS: brew install python@3.11")
print(" - Or use pyenv to install a newer version")
sys.exit(1)
# Handle --version early before other imports # Handle --version early before other imports
if '--version' in sys.argv or '-V' in sys.argv: if '--version' in sys.argv or '-V' in sys.argv:
+33 -62
View File
@@ -13,6 +13,7 @@ from __future__ import annotations
import argparse import argparse
import configparser import configparser
import contextlib
import json import json
import logging import logging
import os import os
@@ -26,25 +27,24 @@ import sys
import threading import threading
import time import time
from datetime import datetime, timezone from datetime import datetime, timezone
from http.server import HTTPServer, BaseHTTPRequestHandler from http.server import BaseHTTPRequestHandler, HTTPServer
from socketserver import ThreadingMixIn from socketserver import ThreadingMixIn
from typing import Any from urllib.parse import parse_qs, urlparse
from urllib.parse import urlparse, parse_qs
# Add parent directory to path for imports # Add parent directory to path for imports
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
# Import dependency checking from Intercept utils # Import dependency checking from Intercept utils
try: try:
from utils.dependencies import check_all_dependencies, check_tool, TOOL_DEPENDENCIES from utils.dependencies import TOOL_DEPENDENCIES, check_all_dependencies, check_tool
HAS_DEPENDENCIES_MODULE = True HAS_DEPENDENCIES_MODULE = True
except ImportError: except ImportError:
HAS_DEPENDENCIES_MODULE = False HAS_DEPENDENCIES_MODULE = False
# Import TSCM modules for consistent analysis (same as local mode) # Import TSCM modules for consistent analysis (same as local mode)
try: try:
from utils.tscm.detector import ThreatDetector
from utils.tscm.correlation import CorrelationEngine from utils.tscm.correlation import CorrelationEngine
from utils.tscm.detector import ThreatDetector
HAS_TSCM_MODULES = True HAS_TSCM_MODULES = True
except ImportError: except ImportError:
HAS_TSCM_MODULES = False HAS_TSCM_MODULES = False
@@ -53,7 +53,7 @@ except ImportError:
# Import database functions for baseline support (same as local mode) # Import database functions for baseline support (same as local mode)
try: try:
from utils.database import get_tscm_baseline, get_active_tscm_baseline from utils.database import get_active_tscm_baseline, get_tscm_baseline
HAS_BASELINE_DB = True HAS_BASELINE_DB = True
except ImportError: except ImportError:
HAS_BASELINE_DB = False HAS_BASELINE_DB = False
@@ -143,7 +143,7 @@ class AgentConfig:
# Modes section # Modes section
if parser.has_section('modes'): if parser.has_section('modes'):
for mode in self.modes_enabled.keys(): for mode in self.modes_enabled:
if parser.has_option('modes', mode): if parser.has_option('modes', mode):
self.modes_enabled[mode] = parser.getboolean('modes', mode) self.modes_enabled[mode] = parser.getboolean('modes', mode)
@@ -310,10 +310,8 @@ class ControllerPushClient(threading.Thread):
except Exception as e: except Exception as e:
item['attempts'] += 1 item['attempts'] += 1
if item['attempts'] < 3 and not self.stop_event.is_set(): if item['attempts'] < 3 and not self.stop_event.is_set():
try: with contextlib.suppress(queue.Full):
self.queue.put_nowait(item) self.queue.put_nowait(item)
except queue.Full:
pass
else: else:
logger.warning(f"Failed to push after {item['attempts']} attempts: {e}") logger.warning(f"Failed to push after {item['attempts']} attempts: {e}")
finally: finally:
@@ -795,9 +793,7 @@ class ModeManager:
info['vessel_count'] = len(getattr(self, 'ais_vessels', {})) info['vessel_count'] = len(getattr(self, 'ais_vessels', {}))
elif mode == 'aprs': elif mode == 'aprs':
info['station_count'] = len(getattr(self, 'aprs_stations', {})) info['station_count'] = len(getattr(self, 'aprs_stations', {}))
elif mode == 'pager': elif mode == 'pager' or mode == 'acars':
info['message_count'] = len(self.data_snapshots.get(mode, []))
elif mode == 'acars':
info['message_count'] = len(self.data_snapshots.get(mode, [])) info['message_count'] = len(self.data_snapshots.get(mode, []))
elif mode == 'rtlamr': elif mode == 'rtlamr':
info['reading_count'] = len(self.data_snapshots.get(mode, [])) info['reading_count'] = len(self.data_snapshots.get(mode, []))
@@ -1073,10 +1069,8 @@ class ModeManager:
proc.wait(timeout=2) proc.wait(timeout=2)
except subprocess.TimeoutExpired: except subprocess.TimeoutExpired:
proc.kill() proc.kill()
try: with contextlib.suppress(Exception):
proc.wait(timeout=1) proc.wait(timeout=1)
except Exception:
pass
except (OSError, ProcessLookupError) as e: except (OSError, ProcessLookupError) as e:
# Process already dead or inaccessible # Process already dead or inaccessible
logger.debug(f"Process cleanup for {mode}: {e}") logger.debug(f"Process cleanup for {mode}: {e}")
@@ -1297,10 +1291,8 @@ class ModeManager:
except Exception as e: except Exception as e:
logger.error(f"Sensor output reader error: {e}") logger.error(f"Sensor output reader error: {e}")
finally: finally:
try: with contextlib.suppress(Exception):
proc.wait(timeout=1) proc.wait(timeout=1)
except Exception:
pass
logger.info("Sensor output reader stopped") logger.info("Sensor output reader stopped")
# ------------------------------------------------------------------------- # -------------------------------------------------------------------------
@@ -1661,16 +1653,14 @@ class ModeManager:
try: try:
from utils.validation import validate_network_interface from utils.validation import validate_network_interface
interface = validate_network_interface(interface) interface = validate_network_interface(interface)
except (ImportError, ValueError) as e: except (ImportError, ValueError):
if not os.path.exists(f'/sys/class/net/{interface}'): if not os.path.exists(f'/sys/class/net/{interface}'):
return {'status': 'error', 'message': f'Interface {interface} not found'} return {'status': 'error', 'message': f'Interface {interface} not found'}
csv_path = '/tmp/intercept_agent_wifi' csv_path = '/tmp/intercept_agent_wifi'
for f in [f'{csv_path}-01.csv', f'{csv_path}-01.cap', f'{csv_path}-01.gps']: for f in [f'{csv_path}-01.csv', f'{csv_path}-01.cap', f'{csv_path}-01.gps']:
try: with contextlib.suppress(OSError):
os.remove(f) os.remove(f)
except OSError:
pass
airodump_path = self._get_tool_path('airodump-ng') airodump_path = self._get_tool_path('airodump-ng')
if not airodump_path: if not airodump_path:
@@ -1931,7 +1921,7 @@ class ModeManager:
logger.warning("Intercept WiFi parser not available, using fallback") logger.warning("Intercept WiFi parser not available, using fallback")
# Fallback: simple parsing if running standalone # Fallback: simple parsing if running standalone
try: try:
with open(csv_path, 'r', errors='replace') as f: with open(csv_path, errors='replace') as f:
content = f.read() content = f.read()
for section in content.split('\n\n'): for section in content.split('\n\n'):
lines = section.strip().split('\n') lines = section.strip().split('\n')
@@ -2303,10 +2293,8 @@ class ModeManager:
except Exception as e: except Exception as e:
logger.error(f"Pager reader error: {e}") logger.error(f"Pager reader error: {e}")
finally: finally:
try: with contextlib.suppress(Exception):
proc.wait(timeout=1) proc.wait(timeout=1)
except Exception:
pass
if 'pager_rtl' in self.processes: if 'pager_rtl' in self.processes:
try: try:
rtl_proc = self.processes['pager_rtl'] rtl_proc = self.processes['pager_rtl']
@@ -2491,7 +2479,7 @@ class ModeManager:
sock.close() sock.close()
except Exception as e: except Exception:
retry_count += 1 retry_count += 1
if retry_count >= 10: if retry_count >= 10:
logger.error("Max AIS retries reached") logger.error("Max AIS retries reached")
@@ -2701,10 +2689,8 @@ class ModeManager:
except Exception as e: except Exception as e:
logger.error(f"ACARS reader error: {e}") logger.error(f"ACARS reader error: {e}")
finally: finally:
try: with contextlib.suppress(Exception):
proc.wait(timeout=1) proc.wait(timeout=1)
except Exception:
pass
logger.info("ACARS reader stopped") logger.info("ACARS reader stopped")
# ------------------------------------------------------------------------- # -------------------------------------------------------------------------
@@ -2846,10 +2832,8 @@ class ModeManager:
except Exception as e: except Exception as e:
logger.error(f"APRS reader error: {e}") logger.error(f"APRS reader error: {e}")
finally: finally:
try: with contextlib.suppress(Exception):
proc.wait(timeout=1) proc.wait(timeout=1)
except Exception:
pass
if 'aprs_rtl' in self.processes: if 'aprs_rtl' in self.processes:
try: try:
rtl_proc = self.processes['aprs_rtl'] rtl_proc = self.processes['aprs_rtl']
@@ -3021,10 +3005,8 @@ class ModeManager:
except Exception as e: except Exception as e:
logger.error(f"RTLAMR reader error: {e}") logger.error(f"RTLAMR reader error: {e}")
finally: finally:
try: with contextlib.suppress(Exception):
proc.wait(timeout=1) proc.wait(timeout=1)
except Exception:
pass
if 'rtlamr_tcp' in self.processes: if 'rtlamr_tcp' in self.processes:
try: try:
tcp_proc = self.processes['rtlamr_tcp'] tcp_proc = self.processes['rtlamr_tcp']
@@ -3142,10 +3124,8 @@ class ModeManager:
except Exception as e: except Exception as e:
logger.error(f"DSC reader error: {e}") logger.error(f"DSC reader error: {e}")
finally: finally:
try: with contextlib.suppress(Exception):
proc.wait(timeout=1) proc.wait(timeout=1)
except Exception:
pass
logger.info("DSC reader stopped") logger.info("DSC reader stopped")
# ------------------------------------------------------------------------- # -------------------------------------------------------------------------
@@ -3219,13 +3199,13 @@ class ModeManager:
stop_event = self.stop_events.get(mode) stop_event = self.stop_events.get(mode)
# Import existing Intercept TSCM functions # Import existing Intercept TSCM functions
from routes.tscm import _scan_wifi_networks, _scan_wifi_clients, _scan_bluetooth_devices, _scan_rf_signals from routes.tscm import _scan_bluetooth_devices, _scan_rf_signals, _scan_wifi_clients, _scan_wifi_networks
logger.info("TSCM imports successful") logger.info("TSCM imports successful")
sweep_ranges = None sweep_ranges = None
if sweep_type: if sweep_type:
try: try:
from data.tscm_frequencies import get_sweep_preset, SWEEP_PRESETS from data.tscm_frequencies import SWEEP_PRESETS, get_sweep_preset
preset = get_sweep_preset(sweep_type) or SWEEP_PRESETS.get('standard') preset = get_sweep_preset(sweep_type) or SWEEP_PRESETS.get('standard')
sweep_ranges = preset.get('ranges') if preset else None sweep_ranges = preset.get('ranges') if preset else None
except Exception: except Exception:
@@ -3412,7 +3392,8 @@ class ModeManager:
if scan_rf and (current_time - last_rf_scan) >= rf_scan_interval: if scan_rf and (current_time - last_rf_scan) >= rf_scan_interval:
try: try:
# Pass a stop check that uses our stop_event (not the module's _sweep_running) # Pass a stop check that uses our stop_event (not the module's _sweep_running)
agent_stop_check = lambda: stop_event and stop_event.is_set() def agent_stop_check():
return stop_event and stop_event.is_set()
rf_signals = _scan_rf_signals( rf_signals = _scan_rf_signals(
sdr_device, sdr_device,
stop_check=agent_stop_check, stop_check=agent_stop_check,
@@ -3521,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")
@@ -3610,10 +3591,8 @@ class ModeManager:
# Ensure test process is killed on any error # Ensure test process is killed on any error
if test_proc and test_proc.poll() is None: if test_proc and test_proc.poll() is None:
test_proc.kill() test_proc.kill()
try: with contextlib.suppress(Exception):
test_proc.wait(timeout=1) test_proc.wait(timeout=1)
except Exception:
pass
return {'status': 'error', 'message': f'SDR check failed: {str(e)}'} return {'status': 'error', 'message': f'SDR check failed: {str(e)}'}
# Initialize state # Initialize state
@@ -3647,9 +3626,9 @@ class ModeManager:
step: float, modulation: str, squelch: int, step: float, modulation: str, squelch: int,
device: str, gain: str, dwell_time: float = 1.0): device: str, gain: str, dwell_time: float = 1.0):
"""Scan frequency range and report signal detections.""" """Scan frequency range and report signal detections."""
import select
import os
import fcntl import fcntl
import os
import select
mode = 'listening_post' mode = 'listening_post'
stop_event = self.stop_events.get(mode) stop_event = self.stop_events.get(mode)
@@ -3709,7 +3688,7 @@ class ModeManager:
signal_detected = True signal_detected = True
except Exception: except Exception:
pass pass
except (IOError, BlockingIOError): except (OSError, BlockingIOError):
pass pass
proc.terminate() proc.terminate()
@@ -4131,27 +4110,19 @@ def main():
# Stop push services # Stop push services
if data_push_loop: if data_push_loop:
try: with contextlib.suppress(Exception):
data_push_loop.stop() data_push_loop.stop()
except Exception:
pass
if push_client: if push_client:
try: with contextlib.suppress(Exception):
push_client.stop() push_client.stop()
except Exception:
pass
# Stop GPS # Stop GPS
try: with contextlib.suppress(Exception):
gps_manager.stop() gps_manager.stop()
except Exception:
pass
# Shutdown HTTP server # Shutdown HTTP server
try: with contextlib.suppress(Exception):
httpd.shutdown() httpd.shutdown()
except Exception:
pass
# Run cleanup in background thread so signal handler returns quickly # Run cleanup in background thread so signal handler returns quickly
cleanup_thread = threading.Thread(target=cleanup, daemon=True) cleanup_thread = threading.Thread(target=cleanup, daemon=True)
+25 -1
View File
@@ -1,6 +1,6 @@
[project] [project]
name = "intercept" name = "intercept"
version = "2.24.0" 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"
@@ -93,8 +93,32 @@ ignore = [
"B008", # do not perform function calls in argument defaults "B008", # do not perform function calls in argument defaults
"B905", # zip without explicit strict "B905", # zip without explicit strict
"SIM108", # use ternary operator instead of if-else "SIM108", # use ternary operator instead of if-else
"SIM102", # collapsible if statements
"SIM105", # use contextlib.suppress (stylistic, not a bug)
"SIM115", # use context manager for open (not always applicable)
"SIM116", # use dict instead of if/elif chain (stylistic)
"SIM117", # combine nested with statements (stylistic)
"E402", # module-level import not at top (needed for conditional imports)
"E741", # ambiguous variable name
"E721", # type comparison (use isinstance)
"E722", # bare except
"B904", # raise from within except (stylistic)
"B007", # unused loop variable (use _ prefix)
"B023", # function definition doesn't bind loop variable
"F601", # membership test with duplicate items
"F821", # undefined name (too many false positives with conditional imports)
"UP035", # deprecated typing imports
] ]
[tool.ruff.lint.per-file-ignores]
"__init__.py" = ["F401"] # re-exports in __init__.py are intentional
"utils/bluetooth/capability_check.py" = ["F401"] # imports used for availability checking
"utils/bluetooth/fallback_scanner.py" = ["F401"] # imports used for availability checking
"utils/tscm/ble_scanner.py" = ["F401"] # imports used for availability checking
"utils/wifi/deauth_detector.py" = ["F401"] # imports used for availability checking
"routes/dsc.py" = ["F401"] # imports used for availability checking
"intercept_agent.py" = ["F401"] # conditional imports
[tool.ruff.lint.isort] [tool.ruff.lint.isort]
known-first-party = ["app", "config", "routes", "utils", "data"] known-first-party = ["app", "config", "routes", "utils", "data"]
+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)
+4 -2
View File
@@ -20,12 +20,13 @@ 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
from .morse import morse_bp from .morse import morse_bp
from .ook import ook_bp
from .offline import offline_bp from .offline import offline_bp
from .ook import ook_bp
from .pager import pager_bp from .pager import pager_bp
from .radiosonde import radiosonde_bp from .radiosonde import radiosonde_bp
from .recordings import recordings_bp from .recordings import recordings_bp
@@ -44,8 +45,8 @@ def register_blueprints(app):
from .updater import updater_bp from .updater import updater_bp
from .vdl2 import vdl2_bp from .vdl2 import vdl2_bp
from .weather_sat import weather_sat_bp from .weather_sat import weather_sat_bp
from .wefax import wefax_bp
from .websdr import websdr_bp from .websdr import websdr_bp
from .wefax import wefax_bp
from .wifi import wifi_bp from .wifi import wifi_bp
from .wifi_v2 import wifi_v2_bp from .wifi_v2 import wifi_v2_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:
+6 -10
View File
@@ -2,7 +2,7 @@
from __future__ import annotations from __future__ import annotations
import io import contextlib
import json import json
import os import os
import platform import platform
@@ -13,11 +13,10 @@ import subprocess
import threading import threading
import time import time
from datetime import datetime from datetime import datetime
from typing import Any, Generator from typing import Any
from flask import Blueprint, Response, jsonify, request from flask import Blueprint, Response, jsonify, request
from utils.responses import api_success, api_error
import app as app_module import app as app_module
from utils.acars_translator import translate_message from utils.acars_translator import translate_message
from utils.constants import ( from utils.constants import (
@@ -30,6 +29,7 @@ from utils.event_pipeline import process_event
from utils.flight_correlator import get_flight_correlator from utils.flight_correlator import get_flight_correlator
from utils.logging import sensor_logger as logger from utils.logging import sensor_logger as logger
from utils.process import register_process, unregister_process from utils.process import register_process, unregister_process
from utils.responses import api_error
from utils.sdr import SDRFactory, SDRType from utils.sdr import SDRFactory, SDRType
from utils.sse import sse_stream_fanout from utils.sse import sse_stream_fanout
from utils.validation import validate_device_index, validate_gain, validate_ppm from utils.validation import validate_device_index, validate_gain, validate_ppm
@@ -143,10 +143,8 @@ def stream_acars_output(process: subprocess.Popen, is_text_mode: bool = False) -
app_module.acars_queue.put(data) app_module.acars_queue.put(data)
# Feed flight correlator # Feed flight correlator
try: with contextlib.suppress(Exception):
get_flight_correlator().add_acars_message(data) get_flight_correlator().add_acars_message(data)
except Exception:
pass
# Log if enabled # Log if enabled
if app_module.logging_enabled: if app_module.logging_enabled:
@@ -172,10 +170,8 @@ def stream_acars_output(process: subprocess.Popen, is_text_mode: bool = False) -
process.terminate() process.terminate()
process.wait(timeout=2) process.wait(timeout=2)
except Exception: except Exception:
try: with contextlib.suppress(Exception):
process.kill() process.kill()
except Exception:
pass
unregister_process(process) unregister_process(process)
app_module.acars_queue.put({'type': 'status', 'status': 'stopped'}) app_module.acars_queue.put({'type': 'status', 'status': 'stopped'})
with app_module.acars_lock: with app_module.acars_lock:
@@ -335,7 +331,7 @@ def start_acars() -> Response:
) )
os.close(slave_fd) os.close(slave_fd)
# Wrap master_fd as a text file for line-buffered reading # Wrap master_fd as a text file for line-buffered reading
process.stdout = io.open(master_fd, 'r', buffering=1) process.stdout = open(master_fd, buffering=1)
is_text_mode = True is_text_mode = True
else: else:
process = subprocess.Popen( process = subprocess.Popen(
+165 -194
View File
@@ -2,9 +2,9 @@
from __future__ import annotations from __future__ import annotations
import json
import csv import csv
import io import io
import json
import os import os
import queue import queue
import shutil import shutil
@@ -13,11 +13,11 @@ import subprocess
import threading import threading
import time import time
from datetime import datetime, timedelta, timezone from datetime import datetime, timedelta, timezone
from typing import Any, Generator from typing import Any
from flask import Blueprint, Response, jsonify, make_response, render_template, request from flask import Blueprint, Response, jsonify, make_response, render_template, request
from utils.responses import api_success, api_error from utils.responses import api_error, api_success
# psycopg2 is optional - only needed for PostgreSQL history persistence # psycopg2 is optional - only needed for PostgreSQL history persistence
try: try:
@@ -29,6 +29,8 @@ except ImportError:
RealDictCursor = None # type: ignore RealDictCursor = None # type: ignore
PSYCOPG2_AVAILABLE = False PSYCOPG2_AVAILABLE = False
import contextlib
import app as app_module import app as app_module
from config import ( from config import (
ADSB_AUTO_START, ADSB_AUTO_START,
@@ -38,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
@@ -406,18 +410,17 @@ def _get_active_session() -> dict[str, Any] | None:
return None return None
_ensure_history_schema() _ensure_history_schema()
try: try:
with _get_history_connection() as conn: with _get_history_connection() as conn, conn.cursor(cursor_factory=RealDictCursor) as cur:
with conn.cursor(cursor_factory=RealDictCursor) as cur: cur.execute(
cur.execute( """
"""
SELECT * SELECT *
FROM adsb_sessions FROM adsb_sessions
WHERE ended_at IS NULL WHERE ended_at IS NULL
ORDER BY started_at DESC ORDER BY started_at DESC
LIMIT 1 LIMIT 1
""" """
) )
return cur.fetchone() return cur.fetchone()
except Exception as exc: except Exception as exc:
logger.warning("ADS-B session lookup failed: %s", exc) logger.warning("ADS-B session lookup failed: %s", exc)
return None return None
@@ -436,10 +439,9 @@ def _record_session_start(
return None return None
_ensure_history_schema() _ensure_history_schema()
try: try:
with _get_history_connection() as conn: with _get_history_connection() as conn, conn.cursor(cursor_factory=RealDictCursor) as cur:
with conn.cursor(cursor_factory=RealDictCursor) as cur: cur.execute(
cur.execute( """
"""
INSERT INTO adsb_sessions ( INSERT INTO adsb_sessions (
device_index, device_index,
sdr_type, sdr_type,
@@ -451,16 +453,16 @@ def _record_session_start(
VALUES (%s, %s, %s, %s, %s, %s) VALUES (%s, %s, %s, %s, %s, %s)
RETURNING * RETURNING *
""", """,
( (
device_index, device_index,
sdr_type, sdr_type,
remote_host, remote_host,
remote_port, remote_port,
start_source, start_source,
started_by, started_by,
), ),
) )
return cur.fetchone() return cur.fetchone()
except Exception as exc: except Exception as exc:
logger.warning("ADS-B session start record failed: %s", exc) logger.warning("ADS-B session start record failed: %s", exc)
return None return None
@@ -471,10 +473,9 @@ def _record_session_stop(*, stop_source: str | None, stopped_by: str | None) ->
return None return None
_ensure_history_schema() _ensure_history_schema()
try: try:
with _get_history_connection() as conn: with _get_history_connection() as conn, conn.cursor(cursor_factory=RealDictCursor) as cur:
with conn.cursor(cursor_factory=RealDictCursor) as cur: cur.execute(
cur.execute( """
"""
UPDATE adsb_sessions UPDATE adsb_sessions
SET ended_at = NOW(), SET ended_at = NOW(),
stop_source = COALESCE(%s, stop_source), stop_source = COALESCE(%s, stop_source),
@@ -482,9 +483,9 @@ def _record_session_stop(*, stop_source: str | None, stopped_by: str | None) ->
WHERE ended_at IS NULL WHERE ended_at IS NULL
RETURNING * RETURNING *
""", """,
(stop_source, stopped_by), (stop_source, stopped_by),
) )
return cur.fetchone() return cur.fetchone()
except Exception as exc: except Exception as exc:
logger.warning("ADS-B session stop record failed: %s", exc) logger.warning("ADS-B session stop record failed: %s", exc)
return None return None
@@ -665,10 +666,8 @@ def parse_sbs_stream(service_addr):
elif msg_type == '3' and len(parts) > 15: elif msg_type == '3' and len(parts) > 15:
if parts[11]: if parts[11]:
try: with contextlib.suppress(ValueError, TypeError):
aircraft['altitude'] = int(float(parts[11])) aircraft['altitude'] = int(float(parts[11]))
except (ValueError, TypeError):
pass
if parts[14] and parts[15]: if parts[14] and parts[15]:
try: try:
aircraft['lat'] = float(parts[14]) aircraft['lat'] = float(parts[14])
@@ -678,15 +677,11 @@ def parse_sbs_stream(service_addr):
elif msg_type == '4' and len(parts) > 16: elif msg_type == '4' and len(parts) > 16:
if parts[12]: if parts[12]:
try: with contextlib.suppress(ValueError, TypeError):
aircraft['speed'] = int(float(parts[12])) aircraft['speed'] = int(float(parts[12]))
except (ValueError, TypeError):
pass
if parts[13]: if parts[13]:
try: with contextlib.suppress(ValueError, TypeError):
aircraft['heading'] = int(float(parts[13])) aircraft['heading'] = int(float(parts[13]))
except (ValueError, TypeError):
pass
if parts[16]: if parts[16]:
try: try:
aircraft['vertical_rate'] = int(float(parts[16])) aircraft['vertical_rate'] = int(float(parts[16]))
@@ -705,10 +700,8 @@ def parse_sbs_stream(service_addr):
if callsign: if callsign:
aircraft['callsign'] = callsign aircraft['callsign'] = callsign
if parts[11]: if parts[11]:
try: with contextlib.suppress(ValueError, TypeError):
aircraft['altitude'] = int(float(parts[11])) aircraft['altitude'] = int(float(parts[11]))
except (ValueError, TypeError):
pass
elif msg_type == '6' and len(parts) > 17: elif msg_type == '6' and len(parts) > 17:
if parts[17]: if parts[17]:
@@ -724,20 +717,14 @@ def parse_sbs_stream(service_addr):
elif msg_type == '2' and len(parts) > 15: elif msg_type == '2' and len(parts) > 15:
if parts[11]: if parts[11]:
try: with contextlib.suppress(ValueError, TypeError):
aircraft['altitude'] = int(float(parts[11])) aircraft['altitude'] = int(float(parts[11]))
except (ValueError, TypeError):
pass
if parts[12]: if parts[12]:
try: with contextlib.suppress(ValueError, TypeError):
aircraft['speed'] = int(float(parts[12])) aircraft['speed'] = int(float(parts[12]))
except (ValueError, TypeError):
pass
if parts[13]: if parts[13]:
try: with contextlib.suppress(ValueError, TypeError):
aircraft['heading'] = int(float(parts[13])) aircraft['heading'] = int(float(parts[13]))
except (ValueError, TypeError):
pass
if parts[14] and parts[15]: if parts[14] and parts[15]:
try: try:
aircraft['lat'] = float(parts[14]) aircraft['lat'] = float(parts[14])
@@ -765,10 +752,8 @@ def parse_sbs_stream(service_addr):
time.sleep(SBS_RECONNECT_DELAY) time.sleep(SBS_RECONNECT_DELAY)
finally: finally:
if sock: if sock:
try: with contextlib.suppress(OSError):
sock.close() sock.close()
except OSError:
pass
adsb_connected = False adsb_connected = False
logger.info("SBS stream parser stopped") logger.info("SBS stream parser stopped")
@@ -782,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
}) })
@@ -1019,10 +995,8 @@ def start_adsb():
adsb_active_sdr_type = None adsb_active_sdr_type = None
stderr_output = '' stderr_output = ''
if app_module.adsb_process.stderr: if app_module.adsb_process.stderr:
try: with contextlib.suppress(Exception):
stderr_output = app_module.adsb_process.stderr.read().decode('utf-8', errors='ignore').strip() stderr_output = app_module.adsb_process.stderr.read().decode('utf-8', errors='ignore').strip()
except Exception:
pass
# Parse stderr to provide specific guidance # Parse stderr to provide specific guidance
error_type = 'START_FAILED' error_type = 'START_FAILED'
@@ -1184,16 +1158,17 @@ 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:
try: try:
msg = client_queue.get(timeout=SSE_QUEUE_TIMEOUT) msg = client_queue.get(timeout=SSE_QUEUE_TIMEOUT)
last_keepalive = time.time() last_keepalive = time.time()
try: with contextlib.suppress(Exception):
process_event('adsb', msg, msg.get('type')) process_event('adsb', msg, msg.get('type'))
except Exception:
pass
yield format_sse(msg) yield format_sse(msg)
except queue.Empty: except queue.Empty:
now = time.time() now = time.time()
@@ -1218,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,
) )
@@ -1251,10 +1228,9 @@ def adsb_history_summary():
""" """
try: try:
with _get_history_connection() as conn: with _get_history_connection() as conn, conn.cursor(cursor_factory=RealDictCursor) as cur:
with conn.cursor(cursor_factory=RealDictCursor) as cur: cur.execute(sql, (window, window, window, window, window))
cur.execute(sql, (window, window, window, window, window)) row = cur.fetchone() or {}
row = cur.fetchone() or {}
return jsonify(row) return jsonify(row)
except Exception as exc: except Exception as exc:
logger.warning("ADS-B history summary failed: %s", exc) logger.warning("ADS-B history summary failed: %s", exc)
@@ -1301,10 +1277,9 @@ def adsb_history_aircraft():
""" """
try: try:
with _get_history_connection() as conn: with _get_history_connection() as conn, conn.cursor(cursor_factory=RealDictCursor) as cur:
with conn.cursor(cursor_factory=RealDictCursor) as cur: cur.execute(sql, (window, search, pattern, pattern, pattern, limit))
cur.execute(sql, (window, search, pattern, pattern, pattern, limit)) rows = cur.fetchall()
rows = cur.fetchall()
return jsonify({'aircraft': rows, 'count': len(rows)}) return jsonify({'aircraft': rows, 'count': len(rows)})
except Exception as exc: except Exception as exc:
logger.warning("ADS-B history aircraft query failed: %s", exc) logger.warning("ADS-B history aircraft query failed: %s", exc)
@@ -1336,10 +1311,9 @@ def adsb_history_timeline():
""" """
try: try:
with _get_history_connection() as conn: with _get_history_connection() as conn, conn.cursor(cursor_factory=RealDictCursor) as cur:
with conn.cursor(cursor_factory=RealDictCursor) as cur: cur.execute(sql, (icao, window, limit))
cur.execute(sql, (icao, window, limit)) rows = cur.fetchall()
rows = cur.fetchall()
return jsonify({'icao': icao, 'timeline': rows, 'count': len(rows)}) return jsonify({'icao': icao, 'timeline': rows, 'count': len(rows)})
except Exception as exc: except Exception as exc:
logger.warning("ADS-B history timeline query failed: %s", exc) logger.warning("ADS-B history timeline query failed: %s", exc)
@@ -1368,10 +1342,9 @@ def adsb_history_messages():
""" """
try: try:
with _get_history_connection() as conn: with _get_history_connection() as conn, conn.cursor(cursor_factory=RealDictCursor) as cur:
with conn.cursor(cursor_factory=RealDictCursor) as cur: cur.execute(sql, (window, icao, icao, limit))
cur.execute(sql, (window, icao, icao, limit)) rows = cur.fetchall()
rows = cur.fetchall()
return jsonify({'icao': icao, 'messages': rows, 'count': len(rows)}) return jsonify({'icao': icao, 'messages': rows, 'count': len(rows)})
except Exception as exc: except Exception as exc:
logger.warning("ADS-B history message query failed: %s", exc) logger.warning("ADS-B history message query failed: %s", exc)
@@ -1418,89 +1391,88 @@ def adsb_history_export():
] ]
try: try:
with _get_history_connection() as conn: with _get_history_connection() as conn, conn.cursor(cursor_factory=RealDictCursor) as cur:
with conn.cursor(cursor_factory=RealDictCursor) as cur: if export_type in {'snapshots', 'all'}:
if export_type in {'snapshots', 'all'}: snapshot_where: list[str] = []
snapshot_where: list[str] = [] snapshot_params: list[Any] = []
snapshot_params: list[Any] = [] _add_time_filter(
_add_time_filter( where_parts=snapshot_where,
where_parts=snapshot_where, params=snapshot_params,
params=snapshot_params, scope=scope,
scope=scope, timestamp_field='captured_at',
timestamp_field='captured_at', since_minutes=since_minutes,
since_minutes=since_minutes, start=start,
start=start, end=end,
end=end, )
) if icao:
if icao: snapshot_where.append("icao = %s")
snapshot_where.append("icao = %s") snapshot_params.append(icao)
snapshot_params.append(icao) if search:
if search: snapshot_where.append("(icao ILIKE %s OR callsign ILIKE %s OR registration ILIKE %s)")
snapshot_where.append("(icao ILIKE %s OR callsign ILIKE %s OR registration ILIKE %s)") snapshot_params.extend([pattern, pattern, pattern])
snapshot_params.extend([pattern, pattern, pattern])
snapshot_sql = """ snapshot_sql = """
SELECT captured_at, icao, callsign, registration, type_code, type_desc, SELECT captured_at, icao, callsign, registration, type_code, type_desc,
altitude, speed, heading, vertical_rate, lat, lon, squawk, source_host altitude, speed, heading, vertical_rate, lat, lon, squawk, source_host
FROM adsb_snapshots FROM adsb_snapshots
""" """
if snapshot_where: if snapshot_where:
snapshot_sql += " WHERE " + " AND ".join(snapshot_where) snapshot_sql += " WHERE " + " AND ".join(snapshot_where)
snapshot_sql += " ORDER BY captured_at DESC" snapshot_sql += " ORDER BY captured_at DESC"
cur.execute(snapshot_sql, tuple(snapshot_params)) cur.execute(snapshot_sql, tuple(snapshot_params))
snapshots = _filter_by_classification(cur.fetchall()) snapshots = _filter_by_classification(cur.fetchall())
if export_type in {'messages', 'all'}: if export_type in {'messages', 'all'}:
message_where: list[str] = [] message_where: list[str] = []
message_params: list[Any] = [] message_params: list[Any] = []
_add_time_filter( _add_time_filter(
where_parts=message_where, where_parts=message_where,
params=message_params, params=message_params,
scope=scope, scope=scope,
timestamp_field='received_at', timestamp_field='received_at',
since_minutes=since_minutes, since_minutes=since_minutes,
start=start, start=start,
end=end, end=end,
) )
if icao: if icao:
message_where.append("icao = %s") message_where.append("icao = %s")
message_params.append(icao) message_params.append(icao)
if search: if search:
message_where.append("(icao ILIKE %s OR callsign ILIKE %s)") message_where.append("(icao ILIKE %s OR callsign ILIKE %s)")
message_params.extend([pattern, pattern]) message_params.extend([pattern, pattern])
message_sql = """ message_sql = """
SELECT received_at, msg_time, logged_time, icao, msg_type, callsign, SELECT received_at, msg_time, logged_time, icao, msg_type, callsign,
altitude, speed, heading, vertical_rate, lat, lon, squawk, altitude, speed, heading, vertical_rate, lat, lon, squawk,
session_id, aircraft_id, flight_id, source_host, raw_line session_id, aircraft_id, flight_id, source_host, raw_line
FROM adsb_messages FROM adsb_messages
""" """
if message_where: if message_where:
message_sql += " WHERE " + " AND ".join(message_where) message_sql += " WHERE " + " AND ".join(message_where)
message_sql += " ORDER BY received_at DESC" message_sql += " ORDER BY received_at DESC"
cur.execute(message_sql, tuple(message_params)) cur.execute(message_sql, tuple(message_params))
messages = _filter_by_classification(cur.fetchall()) messages = _filter_by_classification(cur.fetchall())
if export_type in {'sessions', 'all'}: if export_type in {'sessions', 'all'}:
session_where: list[str] = [] session_where: list[str] = []
session_params: list[Any] = [] session_params: list[Any] = []
if scope == 'custom' and start is not None and end is not None: if scope == 'custom' and start is not None and end is not None:
session_where.append("COALESCE(ended_at, %s) >= %s AND started_at < %s") session_where.append("COALESCE(ended_at, %s) >= %s AND started_at < %s")
session_params.extend([end, start, end]) session_params.extend([end, start, end])
elif scope == 'window': elif scope == 'window':
session_where.append("COALESCE(ended_at, NOW()) >= NOW() - INTERVAL %s") session_where.append("COALESCE(ended_at, NOW()) >= NOW() - INTERVAL %s")
session_params.append(f'{since_minutes} minutes') session_params.append(f'{since_minutes} minutes')
session_sql = """ session_sql = """
SELECT id, started_at, ended_at, device_index, sdr_type, remote_host, SELECT id, started_at, ended_at, device_index, sdr_type, remote_host,
remote_port, start_source, stop_source, started_by, stopped_by, notes remote_port, start_source, stop_source, started_by, stopped_by, notes
FROM adsb_sessions FROM adsb_sessions
""" """
if session_where: if session_where:
session_sql += " WHERE " + " AND ".join(session_where) session_sql += " WHERE " + " AND ".join(session_where)
session_sql += " ORDER BY started_at DESC" session_sql += " ORDER BY started_at DESC"
cur.execute(session_sql, tuple(session_params)) cur.execute(session_sql, tuple(session_params))
sessions = cur.fetchall() sessions = cur.fetchall()
except Exception as exc: except Exception as exc:
logger.warning("ADS-B history export failed: %s", exc) logger.warning("ADS-B history export failed: %s", exc)
return api_error('History database unavailable', 503) return api_error('History database unavailable', 503)
@@ -1570,59 +1542,58 @@ def adsb_history_prune():
return api_error('mode must be range or all', 400) return api_error('mode must be range or all', 400)
try: try:
with _get_history_connection() as conn: with _get_history_connection() as conn, conn.cursor() as cur:
with conn.cursor() as cur: deleted = {'messages': 0, 'snapshots': 0}
deleted = {'messages': 0, 'snapshots': 0}
if mode == 'all': if mode == 'all':
cur.execute("DELETE FROM adsb_messages") cur.execute("DELETE FROM adsb_messages")
deleted['messages'] = max(0, cur.rowcount or 0) deleted['messages'] = max(0, cur.rowcount or 0)
cur.execute("DELETE FROM adsb_snapshots") cur.execute("DELETE FROM adsb_snapshots")
deleted['snapshots'] = max(0, cur.rowcount or 0) deleted['snapshots'] = max(0, cur.rowcount or 0)
return jsonify({ return jsonify({
'status': 'ok', 'status': 'ok',
'mode': 'all', 'mode': 'all',
'deleted': deleted, 'deleted': deleted,
'total_deleted': deleted['messages'] + deleted['snapshots'], 'total_deleted': deleted['messages'] + deleted['snapshots'],
}) })
start = _parse_iso_datetime(payload.get('start')) start = _parse_iso_datetime(payload.get('start'))
end = _parse_iso_datetime(payload.get('end')) end = _parse_iso_datetime(payload.get('end'))
if start is None or end is None: if start is None or end is None:
return api_error('start and end ISO datetime values are required', 400) return api_error('start and end ISO datetime values are required', 400)
if end <= start: if end <= start:
return api_error('end must be after start', 400) return api_error('end must be after start', 400)
if end - start > timedelta(days=31): if end - start > timedelta(days=31):
return api_error('range cannot exceed 31 days', 400) return api_error('range cannot exceed 31 days', 400)
cur.execute( cur.execute(
""" """
DELETE FROM adsb_messages DELETE FROM adsb_messages
WHERE received_at >= %s WHERE received_at >= %s
AND received_at < %s AND received_at < %s
""", """,
(start, end), (start, end),
) )
deleted['messages'] = max(0, cur.rowcount or 0) deleted['messages'] = max(0, cur.rowcount or 0)
cur.execute( cur.execute(
""" """
DELETE FROM adsb_snapshots DELETE FROM adsb_snapshots
WHERE captured_at >= %s WHERE captured_at >= %s
AND captured_at < %s AND captured_at < %s
""", """,
(start, end), (start, end),
) )
deleted['snapshots'] = max(0, cur.rowcount or 0) deleted['snapshots'] = max(0, cur.rowcount or 0)
return jsonify({ return jsonify({
'status': 'ok', 'status': 'ok',
'mode': 'range', 'mode': 'range',
'start': start.isoformat(), 'start': start.isoformat(),
'end': end.isoformat(), 'end': end.isoformat(),
'deleted': deleted, 'deleted': deleted,
'total_deleted': deleted['messages'] + deleted['snapshots'], 'total_deleted': deleted['messages'] + deleted['snapshots'],
}) })
except Exception as exc: except Exception as exc:
logger.warning("ADS-B history prune failed: %s", exc) logger.warning("ADS-B history prune failed: %s", exc)
return api_error('History database unavailable', 503) return api_error('History database unavailable', 503)
+18 -23
View File
@@ -2,6 +2,7 @@
from __future__ import annotations from __future__ import annotations
import contextlib
import json import json
import os import os
import queue import queue
@@ -10,30 +11,28 @@ import socket
import subprocess import subprocess
import threading import threading
import time import time
from typing import Generator
from flask import Blueprint, jsonify, request, Response, render_template from flask import Blueprint, Response, jsonify, render_template, request
from utils.responses import api_success, api_error
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.logging import get_logger
from utils.validation import validate_device_index, validate_gain
from utils.sse import sse_stream_fanout
from utils.event_pipeline import process_event
from utils.sdr import SDRFactory, SDRType
from utils.constants import ( from utils.constants import (
AIS_RECONNECT_DELAY,
AIS_SOCKET_TIMEOUT,
AIS_TCP_PORT, AIS_TCP_PORT,
AIS_TERMINATE_TIMEOUT, AIS_TERMINATE_TIMEOUT,
AIS_SOCKET_TIMEOUT,
AIS_RECONNECT_DELAY,
AIS_UPDATE_INTERVAL, AIS_UPDATE_INTERVAL,
PROCESS_TERMINATE_TIMEOUT,
SOCKET_BUFFER_SIZE, SOCKET_BUFFER_SIZE,
SSE_KEEPALIVE_INTERVAL, SSE_KEEPALIVE_INTERVAL,
SSE_QUEUE_TIMEOUT, SSE_QUEUE_TIMEOUT,
SOCKET_CONNECT_TIMEOUT,
PROCESS_TERMINATE_TIMEOUT,
) )
from utils.event_pipeline import process_event
from utils.logging import get_logger
from utils.responses import api_error, api_success
from utils.sdr import SDRFactory, SDRType
from utils.sse import sse_stream_fanout
from utils.validation import validate_device_index, validate_gain
logger = get_logger('intercept.ais') logger = get_logger('intercept.ais')
@@ -128,13 +127,11 @@ def parse_ais_stream(port: int):
for mmsi in pending_updates: for mmsi in pending_updates:
if mmsi in app_module.ais_vessels: if mmsi in app_module.ais_vessels:
_vessel_snap = app_module.ais_vessels[mmsi] _vessel_snap = app_module.ais_vessels[mmsi]
try: with contextlib.suppress(queue.Full):
app_module.ais_queue.put_nowait({ app_module.ais_queue.put_nowait({
'type': 'vessel', 'type': 'vessel',
**_vessel_snap **_vessel_snap
}) })
except queue.Full:
pass
# Geofence check # Geofence check
_v_lat = _vessel_snap.get('lat') _v_lat = _vessel_snap.get('lat')
_v_lon = _vessel_snap.get('lon') _v_lon = _vessel_snap.get('lon')
@@ -163,10 +160,8 @@ def parse_ais_stream(port: int):
time.sleep(AIS_RECONNECT_DELAY) time.sleep(AIS_RECONNECT_DELAY)
finally: finally:
if sock: if sock:
try: with contextlib.suppress(OSError):
sock.close() sock.close()
except OSError:
pass
ais_connected = False ais_connected = False
logger.info("AIS stream parser stopped") logger.info("AIS stream parser stopped")
@@ -440,10 +435,8 @@ def start_ais():
app_module.release_sdr_device(device_int, sdr_type_str) app_module.release_sdr_device(device_int, sdr_type_str)
stderr_output = '' stderr_output = ''
if app_module.ais_process.stderr: if app_module.ais_process.stderr:
try: with contextlib.suppress(Exception):
stderr_output = app_module.ais_process.stderr.read().decode('utf-8', errors='ignore').strip() stderr_output = app_module.ais_process.stderr.read().decode('utf-8', errors='ignore').strip()
except Exception:
pass
if stderr_output: if stderr_output:
logger.error(f"AIS-catcher stderr:\n{stderr_output}") logger.error(f"AIS-catcher stderr:\n{stderr_output}")
error_msg = 'AIS-catcher failed to start. Check SDR device connection.' error_msg = 'AIS-catcher failed to start. Check SDR device connection.'
@@ -533,7 +526,7 @@ def get_vessel_dsc(mmsi: str):
matches = [] matches = []
try: try:
for key, msg in app_module.dsc_messages.items(): for _key, msg in app_module.dsc_messages.items():
if str(msg.get('source_mmsi', '')) == mmsi: if str(msg.get('source_mmsi', '')) == mmsi:
matches.append(dict(msg)) matches.append(dict(msg))
except Exception: except Exception:
@@ -549,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,
) )
+3 -5
View File
@@ -2,14 +2,12 @@
from __future__ import annotations from __future__ import annotations
import queue from collections.abc import Generator
import time
from typing import Generator
from flask import Blueprint, Response, jsonify, request from flask import Blueprint, Response, request
from utils.alerts import get_alert_manager from utils.alerts import get_alert_manager
from utils.responses import api_success, api_error from utils.responses import api_error, api_success
from utils.sse import format_sse from utils.sse import format_sse
alerts_bp = Blueprint('alerts', __name__, url_prefix='/alerts') alerts_bp = Blueprint('alerts', __name__, url_prefix='/alerts')
+73 -72
View File
@@ -2,6 +2,7 @@
from __future__ import annotations from __future__ import annotations
import contextlib
import csv import csv
import json import json
import os import os
@@ -15,14 +16,23 @@ import tempfile
import threading import threading
import time import time
from datetime import datetime from datetime import datetime
from subprocess import PIPE, STDOUT from subprocess import PIPE
from typing import Any, Generator, Optional from typing import Any
from flask import Blueprint, jsonify, request, Response from flask import Blueprint, Response, jsonify, request
from utils.responses import api_success, api_error
import app as app_module import app as app_module
from utils.constants import (
PROCESS_START_WAIT,
PROCESS_TERMINATE_TIMEOUT,
SSE_KEEPALIVE_INTERVAL,
SSE_QUEUE_TIMEOUT,
)
from utils.event_pipeline import process_event
from utils.logging import sensor_logger as logger from utils.logging import sensor_logger as logger
from utils.responses import api_error, api_success
from utils.sdr import SDRFactory, SDRType
from utils.sse import sse_stream_fanout
from utils.validation import ( from utils.validation import (
validate_device_index, validate_device_index,
validate_gain, validate_gain,
@@ -30,15 +40,6 @@ from utils.validation import (
validate_rtl_tcp_host, validate_rtl_tcp_host,
validate_rtl_tcp_port, validate_rtl_tcp_port,
) )
from utils.sse import sse_stream_fanout
from utils.event_pipeline import process_event
from utils.sdr import SDRFactory, SDRType
from utils.constants import (
PROCESS_TERMINATE_TIMEOUT,
SSE_KEEPALIVE_INTERVAL,
SSE_QUEUE_TIMEOUT,
PROCESS_START_WAIT,
)
aprs_bp = Blueprint('aprs', __name__, url_prefix='/aprs') aprs_bp = Blueprint('aprs', __name__, url_prefix='/aprs')
@@ -75,27 +76,27 @@ METER_MIN_INTERVAL = 0.1 # Max 10 updates/sec
METER_MIN_CHANGE = 2 # Only send if level changes by at least this much METER_MIN_CHANGE = 2 # Only send if level changes by at least this much
def find_direwolf() -> Optional[str]: def find_direwolf() -> str | None:
"""Find direwolf binary.""" """Find direwolf binary."""
return shutil.which('direwolf') return shutil.which('direwolf')
def find_multimon_ng() -> Optional[str]: def find_multimon_ng() -> str | None:
"""Find multimon-ng binary.""" """Find multimon-ng binary."""
return shutil.which('multimon-ng') return shutil.which('multimon-ng')
def find_rtl_fm() -> Optional[str]: def find_rtl_fm() -> str | None:
"""Find rtl_fm binary.""" """Find rtl_fm binary."""
return shutil.which('rtl_fm') return shutil.which('rtl_fm')
def find_rx_fm() -> Optional[str]: def find_rx_fm() -> str | None:
"""Find SoapySDR rx_fm binary.""" """Find SoapySDR rx_fm binary."""
return shutil.which('rx_fm') return shutil.which('rx_fm')
def find_rtl_power() -> Optional[str]: def find_rtl_power() -> str | None:
"""Find rtl_power binary for spectrum scanning.""" """Find rtl_power binary for spectrum scanning."""
return shutil.which('rtl_power') return shutil.which('rtl_power')
@@ -142,7 +143,7 @@ def normalize_aprs_output_line(line: str) -> str:
return normalized return normalized
def parse_aprs_packet(raw_packet: str) -> Optional[dict]: def parse_aprs_packet(raw_packet: str) -> dict | None:
"""Parse APRS packet into structured data. """Parse APRS packet into structured data.
Supports all major APRS packet types: Supports all major APRS packet types:
@@ -431,7 +432,7 @@ def parse_aprs_packet(raw_packet: str) -> Optional[dict]:
return None return None
def parse_position(data: str) -> Optional[dict]: def parse_position(data: str) -> dict | None:
"""Parse APRS position data.""" """Parse APRS position data."""
try: try:
# Format: DDMM.mmN/DDDMM.mmW (or similar with symbols) # Format: DDMM.mmN/DDDMM.mmW (or similar with symbols)
@@ -591,7 +592,7 @@ def parse_position(data: str) -> Optional[dict]:
return None return None
def parse_object(data: str) -> Optional[dict]: def parse_object(data: str) -> dict | None:
"""Parse APRS object data. """Parse APRS object data.
Object format: ;OBJECTNAME*DDHHMMzPOSITION or ;OBJECTNAME_DDHHMMzPOSITION Object format: ;OBJECTNAME*DDHHMMzPOSITION or ;OBJECTNAME_DDHHMMzPOSITION
@@ -649,7 +650,7 @@ def parse_object(data: str) -> Optional[dict]:
return None return None
def parse_item(data: str) -> Optional[dict]: def parse_item(data: str) -> dict | None:
"""Parse APRS item data. """Parse APRS item data.
Item format: )ITEMNAME!POSITION or )ITEMNAME_POSITION Item format: )ITEMNAME!POSITION or )ITEMNAME_POSITION
@@ -830,7 +831,7 @@ MIC_E_MESSAGE_TYPES = {
} }
def parse_mic_e(dest: str, data: str) -> Optional[dict]: def parse_mic_e(dest: str, data: str) -> dict | None:
"""Parse Mic-E encoded position from destination and data fields. """Parse Mic-E encoded position from destination and data fields.
Mic-E is a highly compressed format that encodes: Mic-E is a highly compressed format that encodes:
@@ -973,7 +974,7 @@ def parse_mic_e(dest: str, data: str) -> Optional[dict]:
return None return None
def parse_compressed_position(data: str) -> Optional[dict]: def parse_compressed_position(data: str) -> dict | None:
r"""Parse compressed position format (Base-91 encoding). r"""Parse compressed position format (Base-91 encoding).
Compressed format: /YYYYXXXX$csT Compressed format: /YYYYXXXX$csT
@@ -1057,7 +1058,7 @@ def parse_compressed_position(data: str) -> Optional[dict]:
return None return None
def parse_telemetry(data: str) -> Optional[dict]: def parse_telemetry(data: str) -> dict | None:
"""Parse APRS telemetry data. """Parse APRS telemetry data.
Format: T#sss,aaa,aaa,aaa,aaa,aaa,bbbbbbbb Format: T#sss,aaa,aaa,aaa,aaa,aaa,bbbbbbbb
@@ -1122,7 +1123,7 @@ def parse_telemetry(data: str) -> Optional[dict]:
return None return None
def parse_telemetry_definition(callsign: str, msg_type: str, content: str) -> Optional[dict]: def parse_telemetry_definition(callsign: str, msg_type: str, content: str) -> dict | None:
"""Parse telemetry definition messages (PARM, UNIT, EQNS, BITS). """Parse telemetry definition messages (PARM, UNIT, EQNS, BITS).
These messages define the meaning of telemetry values for a station. These messages define the meaning of telemetry values for a station.
@@ -1174,7 +1175,7 @@ def parse_telemetry_definition(callsign: str, msg_type: str, content: str) -> Op
return None return None
def parse_phg(data: str) -> Optional[dict]: def parse_phg(data: str) -> dict | None:
"""Parse PHG (Power/Height/Gain/Directivity) data. """Parse PHG (Power/Height/Gain/Directivity) data.
Format: PHGphgd Format: PHGphgd
@@ -1217,7 +1218,7 @@ def parse_phg(data: str) -> Optional[dict]:
return None return None
def parse_rng(data: str) -> Optional[dict]: def parse_rng(data: str) -> dict | None:
"""Parse RNG (radio range) data. """Parse RNG (radio range) data.
Format: RNGrrrr where rrrr is range in miles. Format: RNGrrrr where rrrr is range in miles.
@@ -1231,7 +1232,7 @@ def parse_rng(data: str) -> Optional[dict]:
return None return None
def parse_df_report(data: str) -> Optional[dict]: def parse_df_report(data: str) -> dict | None:
"""Parse Direction Finding (DF) report. """Parse Direction Finding (DF) report.
Format: CSE/SPD/BRG/NRQ or similar patterns. Format: CSE/SPD/BRG/NRQ or similar patterns.
@@ -1260,7 +1261,7 @@ def parse_df_report(data: str) -> Optional[dict]:
return None return None
def parse_timestamp(data: str) -> Optional[dict]: def parse_timestamp(data: str) -> dict | None:
"""Parse APRS timestamp from position data. """Parse APRS timestamp from position data.
Formats: Formats:
@@ -1304,7 +1305,7 @@ def parse_timestamp(data: str) -> Optional[dict]:
return None return None
def parse_third_party(data: str) -> Optional[dict]: def parse_third_party(data: str) -> dict | None:
"""Parse third-party traffic (packets relayed from another network). """Parse third-party traffic (packets relayed from another network).
Format: }CALL>PATH:DATA (the } indicates third-party) Format: }CALL>PATH:DATA (the } indicates third-party)
@@ -1330,7 +1331,7 @@ def parse_third_party(data: str) -> Optional[dict]:
return None return None
def parse_user_defined(data: str) -> Optional[dict]: def parse_user_defined(data: str) -> dict | None:
"""Parse user-defined data format. """Parse user-defined data format.
Format: {UUXXXX... Format: {UUXXXX...
@@ -1352,7 +1353,7 @@ def parse_user_defined(data: str) -> Optional[dict]:
return None return None
def parse_capabilities(data: str) -> Optional[dict]: def parse_capabilities(data: str) -> dict | None:
"""Parse station capabilities response. """Parse station capabilities response.
Format: <capability1,capability2,... Format: <capability1,capability2,...
@@ -1381,7 +1382,7 @@ def parse_capabilities(data: str) -> Optional[dict]:
return None return None
def parse_nmea(data: str) -> Optional[dict]: def parse_nmea(data: str) -> dict | None:
"""Parse raw GPS NMEA sentences. """Parse raw GPS NMEA sentences.
APRS can include raw NMEA data starting with $. APRS can include raw NMEA data starting with $.
@@ -1409,7 +1410,7 @@ def parse_nmea(data: str) -> Optional[dict]:
return None return None
def parse_audio_level(line: str) -> Optional[int]: def parse_audio_level(line: str) -> int | None:
"""Parse direwolf audio level line and return normalized level (0-100). """Parse direwolf audio level line and return normalized level (0-100).
Direwolf outputs lines like: Direwolf outputs lines like:
@@ -1579,10 +1580,8 @@ def stream_aprs_output(master_fd: int, rtl_process: subprocess.Popen, decoder_pr
logger.error(f"APRS stream error: {e}") logger.error(f"APRS stream error: {e}")
app_module.aprs_queue.put({'type': 'error', 'message': str(e)}) app_module.aprs_queue.put({'type': 'error', 'message': str(e)})
finally: finally:
try: with contextlib.suppress(OSError):
os.close(master_fd) os.close(master_fd)
except OSError:
pass
app_module.aprs_queue.put({'type': 'status', 'status': 'stopped'}) app_module.aprs_queue.put({'type': 'status', 'status': 'stopped'})
# Cleanup processes # Cleanup processes
for proc in [rtl_process, decoder_process]: for proc in [rtl_process, decoder_process]:
@@ -1590,10 +1589,8 @@ def stream_aprs_output(master_fd: int, rtl_process: subprocess.Popen, decoder_pr
proc.terminate() proc.terminate()
proc.wait(timeout=2) proc.wait(timeout=2)
except Exception: except Exception:
try: with contextlib.suppress(Exception):
proc.kill() proc.kill()
except Exception:
pass
# Release SDR device — only if it's still ours (not reclaimed by a new start) # Release SDR device — only if it's still ours (not reclaimed by a new start)
if my_device is not None and aprs_active_device == my_device: if my_device is not None and aprs_active_device == my_device:
app_module.release_sdr_device(my_device, aprs_active_sdr_type or 'rtlsdr') app_module.release_sdr_device(my_device, aprs_active_sdr_type or 'rtlsdr')
@@ -1860,14 +1857,10 @@ def start_aprs() -> Response:
if stderr_output: if stderr_output:
error_msg += f': {stderr_output[:500]}' error_msg += f': {stderr_output[:500]}'
logger.error(error_msg) logger.error(error_msg)
try: with contextlib.suppress(OSError):
os.close(master_fd) os.close(master_fd)
except OSError: with contextlib.suppress(Exception):
pass
try:
decoder_process.kill() decoder_process.kill()
except Exception:
pass
if aprs_active_device is not None: if aprs_active_device is not None:
app_module.release_sdr_device(aprs_active_device, aprs_active_sdr_type or 'rtlsdr') app_module.release_sdr_device(aprs_active_device, aprs_active_sdr_type or 'rtlsdr')
aprs_active_device = None aprs_active_device = None
@@ -1888,14 +1881,10 @@ def start_aprs() -> Response:
if error_output: if error_output:
error_msg += f': {error_output}' error_msg += f': {error_output}'
logger.error(error_msg) logger.error(error_msg)
try: with contextlib.suppress(OSError):
os.close(master_fd) os.close(master_fd)
except OSError: with contextlib.suppress(Exception):
pass
try:
rtl_process.kill() rtl_process.kill()
except Exception:
pass
if aprs_active_device is not None: if aprs_active_device is not None:
app_module.release_sdr_device(aprs_active_device, aprs_active_sdr_type or 'rtlsdr') app_module.release_sdr_device(aprs_active_device, aprs_active_sdr_type or 'rtlsdr')
aprs_active_device = None aprs_active_device = None
@@ -1935,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:
@@ -1950,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()
@@ -1959,23 +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:
try:
os.close(app_module.aprs_master_fd)
except OSError:
pass
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'})
@@ -2099,7 +2100,7 @@ def scan_aprs_spectrum() -> Response:
return api_error('rtl_power did not produce output file', 500) return api_error('rtl_power did not produce output file', 500)
bins = [] bins = []
with open(tmp_file, 'r') as f: with open(tmp_file) as f:
reader = csv.reader(f) reader = csv.reader(f)
for row in reader: for row in reader:
if len(row) < 7: if len(row) < 7:
+8 -15
View File
@@ -6,6 +6,7 @@ import socket
import subprocess import subprocess
import threading import threading
import time import time
from flask import Flask from flask import Flask
# Try to import flask-sock # Try to import flask-sock
@@ -16,6 +17,8 @@ except ImportError:
WEBSOCKET_AVAILABLE = False WEBSOCKET_AVAILABLE = False
Sock = None Sock = None
import contextlib
from utils.logging import get_logger from utils.logging import get_logger
logger = get_logger('intercept.audio_ws') logger = get_logger('intercept.audio_ws')
@@ -56,10 +59,8 @@ def kill_audio_processes():
audio_process.terminate() audio_process.terminate()
audio_process.wait(timeout=0.5) audio_process.wait(timeout=0.5)
except: except:
try: with contextlib.suppress(BaseException):
audio_process.kill() audio_process.kill()
except:
pass
audio_process = None audio_process = None
if rtl_process: if rtl_process:
@@ -67,10 +68,8 @@ def kill_audio_processes():
rtl_process.terminate() rtl_process.terminate()
rtl_process.wait(timeout=0.5) rtl_process.wait(timeout=0.5)
except: except:
try: with contextlib.suppress(BaseException):
rtl_process.kill() rtl_process.kill()
except:
pass
rtl_process = None rtl_process = None
time.sleep(0.3) time.sleep(0.3)
@@ -261,16 +260,10 @@ def init_audio_websocket(app: Flask):
# Complete WebSocket close handshake, then shut down the # Complete WebSocket close handshake, then shut down the
# raw socket so Werkzeug cannot write its HTTP 200 response # raw socket so Werkzeug cannot write its HTTP 200 response
# on top of the WebSocket stream. # on top of the WebSocket stream.
try: with contextlib.suppress(Exception):
ws.close() ws.close()
except Exception: with contextlib.suppress(Exception):
pass
try:
ws.sock.shutdown(socket.SHUT_RDWR) ws.sock.shutdown(socket.SHUT_RDWR)
except Exception: with contextlib.suppress(Exception):
pass
try:
ws.sock.close() ws.sock.close()
except Exception:
pass
logger.info("WebSocket audio client disconnected") logger.info("WebSocket audio client disconnected")
+14 -27
View File
@@ -2,8 +2,7 @@
from __future__ import annotations from __future__ import annotations
import fcntl import contextlib
import json
import os import os
import platform import platform
import pty import pty
@@ -13,30 +12,22 @@ import select
import subprocess import subprocess
import threading import threading
import time import time
from typing import Any, Generator from typing import Any
from flask import Blueprint, jsonify, request, Response from flask import Blueprint, Response, jsonify, request
from utils.responses import api_success, api_error
import app as app_module import app as app_module
from utils.dependencies import check_tool from data.oui import OUI_DATABASE, get_manufacturer, load_oui_database
from utils.logging import bluetooth_logger as logger from data.patterns import AIRTAG_PREFIXES, SAMSUNG_TRACKER, TILE_PREFIXES
from utils.sse import sse_stream_fanout
from utils.event_pipeline import process_event
from utils.validation import validate_bluetooth_interface
from data.oui import OUI_DATABASE, load_oui_database, get_manufacturer
from data.patterns import AIRTAG_PREFIXES, TILE_PREFIXES, SAMSUNG_TRACKER
from utils.constants import ( from utils.constants import (
BT_TERMINATE_TIMEOUT,
SSE_KEEPALIVE_INTERVAL,
SSE_QUEUE_TIMEOUT,
SUBPROCESS_TIMEOUT_SHORT, SUBPROCESS_TIMEOUT_SHORT,
SERVICE_ENUM_TIMEOUT,
PROCESS_START_WAIT,
BT_RESET_DELAY,
BT_ADAPTER_DOWN_WAIT,
PROCESS_TERMINATE_TIMEOUT,
) )
from utils.dependencies import check_tool
from utils.event_pipeline import process_event
from utils.logging import bluetooth_logger as logger
from utils.responses import api_error, api_success
from utils.sse import sse_stream_fanout
from utils.validation import validate_bluetooth_interface
bluetooth_bp = Blueprint('bluetooth', __name__, url_prefix='/bt') bluetooth_bp = Blueprint('bluetooth', __name__, url_prefix='/bt')
@@ -328,10 +319,8 @@ def stream_bt_scan(process, scan_mode):
except OSError: except OSError:
break break
try: with contextlib.suppress(OSError):
os.close(master_fd) os.close(master_fd)
except OSError:
pass
except Exception as e: except Exception as e:
app_module.bt_queue.put({'type': 'error', 'text': str(e)}) app_module.bt_queue.put({'type': 'error', 'text': str(e)})
@@ -485,10 +474,8 @@ def reset_bt_adapter():
app_module.bt_process.terminate() app_module.bt_process.terminate()
app_module.bt_process.wait(timeout=2) app_module.bt_process.wait(timeout=2)
except (subprocess.TimeoutExpired, OSError): except (subprocess.TimeoutExpired, OSError):
try: with contextlib.suppress(OSError):
app_module.bt_process.kill() app_module.bt_process.kill()
except OSError:
pass
app_module.bt_process = None app_module.bt_process = None
try: try:
@@ -507,7 +494,7 @@ def reset_bt_adapter():
return jsonify({ return jsonify({
'status': 'success' if is_up else 'warning', 'status': 'success' if is_up else 'warning',
'message': f'Adapter {interface} reset' if is_up else f'Reset attempted but adapter may still be down', 'message': f'Adapter {interface} reset' if is_up else 'Reset attempted but adapter may still be down',
'is_up': is_up 'is_up': is_up
}) })
+7 -14
View File
@@ -7,31 +7,27 @@ aggregation, and heuristics.
from __future__ import annotations from __future__ import annotations
import contextlib
import csv import csv
import io import io
import json import json
import logging import logging
import threading import threading
import time import time
from collections.abc import Generator
from datetime import datetime from datetime import datetime
from typing import Generator
from flask import Blueprint, Response, jsonify, request, session from flask import Blueprint, Response, jsonify, request
from utils.bluetooth import ( from utils.bluetooth import (
BluetoothScanner,
BTDeviceAggregate, BTDeviceAggregate,
get_bluetooth_scanner,
check_capabilities, check_capabilities,
RANGE_UNKNOWN, get_bluetooth_scanner,
TrackerType,
TrackerConfidence,
get_tracker_engine,
) )
from utils.database import get_db from utils.database import get_db
from utils.responses import api_success, api_error
from utils.sse import format_sse
from utils.event_pipeline import process_event from utils.event_pipeline import process_event
from utils.responses import api_error
from utils.sse import format_sse
logger = logging.getLogger('intercept.bluetooth_v2') logger = logging.getLogger('intercept.bluetooth_v2')
@@ -901,10 +897,8 @@ def stream_events():
"""Generate SSE events from scanner.""" """Generate SSE events from scanner."""
for event in scanner.stream_events(timeout=1.0): for event in scanner.stream_events(timeout=1.0):
event_name, event_data = map_event_type(event) event_name, event_data = map_event_type(event)
try: with contextlib.suppress(Exception):
process_event('bluetooth', event_data, event_name) process_event('bluetooth', event_data, event_name)
except Exception:
pass
yield format_sse(event_data, event=event_name) yield format_sse(event_data, event=event_name)
return Response( return Response(
@@ -972,7 +966,6 @@ def get_tscm_bluetooth_snapshot(duration: int = 8) -> list[dict]:
Returns: Returns:
List of device dictionaries in TSCM format. List of device dictionaries in TSCM format.
""" """
import time
import logging import logging
logger = logging.getLogger('intercept.bluetooth_v2') logger = logging.getLogger('intercept.bluetooth_v2')
+1 -1
View File
@@ -12,7 +12,6 @@ from collections.abc import Generator
from flask import Blueprint, Response, jsonify, request from flask import Blueprint, Response, jsonify, request
from utils.responses import api_success, api_error
from utils.bluetooth.irk_extractor import get_paired_irks from utils.bluetooth.irk_extractor import get_paired_irks
from utils.bt_locate import ( from utils.bt_locate import (
Environment, Environment,
@@ -22,6 +21,7 @@ from utils.bt_locate import (
start_locate_session, start_locate_session,
stop_locate_session, stop_locate_session,
) )
from utils.responses import api_error
from utils.sse import format_sse from utils.sse import format_sse
logger = logging.getLogger('intercept.bt_locate') logger = logging.getLogger('intercept.bt_locate')
+71 -48
View File
@@ -10,40 +10,46 @@ This blueprint provides:
from __future__ import annotations from __future__ import annotations
import json
import logging import logging
import queue import queue
import threading import threading
import time import time
from collections.abc import Generator
from datetime import datetime, timezone from datetime import datetime, timezone
from typing import Generator
import requests import requests
from flask import Blueprint, Response, jsonify, request
from flask import Blueprint, jsonify, request, Response from utils.agent_client import AgentClient, AgentConnectionError, AgentHTTPError, create_client_from_agent
from utils.responses import api_success, api_error
from utils.database import ( from utils.database import (
create_agent, get_agent, get_agent_by_name, list_agents, create_agent,
update_agent, delete_agent, store_push_payload, get_recent_payloads delete_agent,
) get_agent,
from utils.agent_client import ( get_agent_by_name,
AgentClient, AgentHTTPError, AgentConnectionError, create_client_from_agent get_recent_payloads,
list_agents,
store_push_payload,
update_agent,
) )
from utils.responses import api_error
from utils.sse import format_sse from utils.sse import format_sse
from utils.trilateration import ( from utils.trilateration import (
DeviceLocationTracker, PathLossModel, Trilateration, DeviceLocationTracker,
AgentObservation, estimate_location_from_observations PathLossModel,
Trilateration,
estimate_location_from_observations,
) )
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
# Multi-agent SSE fanout state (per-client queues). AGENT_STATUS_TIMEOUT_SECONDS = 2.5
_agent_stream_subscribers: set[queue.Queue] = set()
_agent_stream_subscribers_lock = threading.Lock() # Multi-agent SSE fanout state (per-client queues).
_AGENT_STREAM_CLIENT_QUEUE_SIZE = 500 _agent_stream_subscribers: set[queue.Queue] = set()
_agent_stream_subscribers_lock = threading.Lock()
_AGENT_STREAM_CLIENT_QUEUE_SIZE = 500
def _broadcast_agent_data(payload: dict) -> None: def _broadcast_agent_data(payload: dict) -> None:
@@ -73,14 +79,18 @@ def get_agents():
agents = list_agents(active_only=active_only) agents = list_agents(active_only=active_only)
# Optionally refresh status for each agent # Optionally refresh status for each agent
refresh = request.args.get('refresh', 'false').lower() == 'true' refresh = request.args.get('refresh', 'false').lower() == 'true'
if refresh: if refresh:
for agent in agents: for agent in agents:
try: try:
client = create_client_from_agent(agent) client = AgentClient(
agent['healthy'] = client.health_check() agent['base_url'],
except Exception: api_key=agent.get('api_key'),
agent['healthy'] = False timeout=AGENT_HEALTH_TIMEOUT_SECONDS,
)
agent['healthy'] = client.health_check()
except Exception:
agent['healthy'] = False
return jsonify({ return jsonify({
'status': 'success', 'status': 'success',
@@ -323,27 +333,36 @@ def check_all_agents_health():
'error': None 'error': None
} }
try: try:
client = create_client_from_agent(agent) client = AgentClient(
agent['base_url'],
# Time the health check api_key=agent.get('api_key'),
start_time = time.time() timeout=AGENT_HEALTH_TIMEOUT_SECONDS,
is_healthy = client.health_check() )
response_time = (time.time() - start_time) * 1000
# Time the health check
start_time = time.time()
is_healthy = client.health_check()
response_time = (time.time() - start_time) * 1000
result['healthy'] = is_healthy result['healthy'] = is_healthy
result['response_time_ms'] = round(response_time, 1) result['response_time_ms'] = round(response_time, 1)
if is_healthy: if is_healthy:
# Update last_seen in database # Update last_seen in database
update_agent(agent['id'], update_last_seen=True) update_agent(agent['id'], update_last_seen=True)
# Also fetch running modes # Also fetch running modes
try: try:
status = client.get_status() status_client = AgentClient(
result['running_modes'] = status.get('running_modes', []) agent['base_url'],
result['running_modes_detail'] = status.get('running_modes_detail', {}) api_key=agent.get('api_key'),
except Exception: timeout=AGENT_STATUS_TIMEOUT_SECONDS,
)
status = status_client.get_status()
result['running_modes'] = status.get('running_modes', [])
result['running_modes_detail'] = status.get('running_modes_detail', {})
except Exception:
pass # Status fetch is optional pass # Status fetch is optional
except AgentConnectionError as e: except AgentConnectionError as e:
@@ -669,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:
@@ -700,15 +720,18 @@ def stream_all_agents():
def agent_management_page(): def agent_management_page():
"""Render the agent management page.""" """Render the agent management page."""
from flask import render_template from flask import render_template
from config import VERSION from config import VERSION
return render_template('agents.html', version=VERSION) return render_template('agents.html', version=VERSION)
@controller_bp.route('/monitor') @controller_bp.route('/monitor')
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)
# ============================================================================= # =============================================================================
+2 -2
View File
@@ -2,12 +2,12 @@
from __future__ import annotations from __future__ import annotations
from flask import Blueprint, jsonify, request, Response from flask import Blueprint, Response, request
import app as app_module import app as app_module
from utils.correlation import get_correlations from utils.correlation import get_correlations
from utils.responses import api_success, api_error
from utils.logging import get_logger from utils.logging import get_logger
from utils.responses import api_error, api_success
logger = get_logger('intercept.correlation') logger = get_logger('intercept.correlation')
+19 -32
View File
@@ -6,7 +6,7 @@ distress and safety communications per ITU-R M.493.
from __future__ import annotations from __future__ import annotations
import json import contextlib
import logging import logging
import os import os
import pty import pty
@@ -16,37 +16,36 @@ import shutil
import subprocess import subprocess
import threading import threading
import time import time
from datetime import datetime from typing import Any
from typing import Any, Generator
from flask import Blueprint, jsonify, request, Response from flask import Blueprint, Response, jsonify, request
from utils.responses import api_success, api_error
import app as app_module import app as app_module
from utils.constants import ( from utils.constants import (
DSC_VHF_FREQUENCY_MHZ,
DSC_SAMPLE_RATE, DSC_SAMPLE_RATE,
DSC_TERMINATE_TIMEOUT, DSC_TERMINATE_TIMEOUT,
DSC_VHF_FREQUENCY_MHZ,
) )
from utils.database import ( from utils.database import (
store_dsc_alert,
get_dsc_alerts,
get_dsc_alert,
acknowledge_dsc_alert, acknowledge_dsc_alert,
get_dsc_alert,
get_dsc_alert_summary, get_dsc_alert_summary,
get_dsc_alerts,
store_dsc_alert,
) )
from utils.dependencies import get_tool_path
from utils.dsc.parser import parse_dsc_message from utils.dsc.parser import parse_dsc_message
from utils.sse import sse_stream_fanout
from utils.event_pipeline import process_event from utils.event_pipeline import process_event
from utils.process import register_process, unregister_process
from utils.responses import api_error
from utils.sdr import SDRFactory, SDRType
from utils.sse import sse_stream_fanout
from utils.validation import ( from utils.validation import (
validate_device_index, validate_device_index,
validate_gain, validate_gain,
validate_rtl_tcp_host, validate_rtl_tcp_host,
validate_rtl_tcp_port, validate_rtl_tcp_port,
) )
from utils.sdr import SDRFactory, SDRType
from utils.dependencies import get_tool_path
from utils.process import register_process, unregister_process
logger = logging.getLogger('intercept.dsc') logger = logging.getLogger('intercept.dsc')
@@ -83,8 +82,8 @@ def _check_dsc_tools() -> dict:
# Check for scipy/numpy (needed for decoder) # Check for scipy/numpy (needed for decoder)
scipy_available = False scipy_available = False
try: try:
import scipy
import numpy import numpy
import scipy
scipy_available = True scipy_available = True
except ImportError: except ImportError:
pass pass
@@ -179,10 +178,8 @@ def stream_dsc_decoder(master_fd: int, decoder_process: subprocess.Popen) -> Non
}) })
finally: finally:
global dsc_active_device, dsc_active_sdr_type global dsc_active_device, dsc_active_sdr_type
try: with contextlib.suppress(OSError):
os.close(master_fd) os.close(master_fd)
except OSError:
pass
dsc_running = False dsc_running = False
# Cleanup both processes # Cleanup both processes
with app_module.dsc_lock: with app_module.dsc_lock:
@@ -193,10 +190,8 @@ def stream_dsc_decoder(master_fd: int, decoder_process: subprocess.Popen) -> Non
proc.terminate() proc.terminate()
proc.wait(timeout=2) proc.wait(timeout=2)
except Exception: except Exception:
try: with contextlib.suppress(Exception):
proc.kill() proc.kill()
except Exception:
pass
unregister_process(proc) unregister_process(proc)
app_module.dsc_queue.put({'type': 'status', 'status': 'stopped'}) app_module.dsc_queue.put({'type': 'status', 'status': 'stopped'})
with app_module.dsc_lock: with app_module.dsc_lock:
@@ -466,10 +461,8 @@ def start_decoding() -> Response:
rtl_process.terminate() rtl_process.terminate()
rtl_process.wait(timeout=2) rtl_process.wait(timeout=2)
except Exception: except Exception:
try: with contextlib.suppress(Exception):
rtl_process.kill() rtl_process.kill()
except Exception:
pass
# Release device on failure # Release device on failure
if dsc_active_device is not None: if dsc_active_device is not None:
app_module.release_sdr_device(dsc_active_device, dsc_active_sdr_type or 'rtlsdr') app_module.release_sdr_device(dsc_active_device, dsc_active_sdr_type or 'rtlsdr')
@@ -485,10 +478,8 @@ def start_decoding() -> Response:
rtl_process.terminate() rtl_process.terminate()
rtl_process.wait(timeout=2) rtl_process.wait(timeout=2)
except Exception: except Exception:
try: with contextlib.suppress(Exception):
rtl_process.kill() rtl_process.kill()
except Exception:
pass
# Release device on failure # Release device on failure
if dsc_active_device is not None: if dsc_active_device is not None:
app_module.release_sdr_device(dsc_active_device, dsc_active_sdr_type or 'rtlsdr') app_module.release_sdr_device(dsc_active_device, dsc_active_sdr_type or 'rtlsdr')
@@ -518,10 +509,8 @@ def stop_decoding() -> Response:
app_module.dsc_rtl_process.terminate() app_module.dsc_rtl_process.terminate()
app_module.dsc_rtl_process.wait(timeout=DSC_TERMINATE_TIMEOUT) app_module.dsc_rtl_process.wait(timeout=DSC_TERMINATE_TIMEOUT)
except subprocess.TimeoutExpired: except subprocess.TimeoutExpired:
try: with contextlib.suppress(OSError):
app_module.dsc_rtl_process.kill() app_module.dsc_rtl_process.kill()
except OSError:
pass
except OSError: except OSError:
pass pass
@@ -531,10 +520,8 @@ def stop_decoding() -> Response:
app_module.dsc_process.terminate() app_module.dsc_process.terminate()
app_module.dsc_process.wait(timeout=DSC_TERMINATE_TIMEOUT) app_module.dsc_process.wait(timeout=DSC_TERMINATE_TIMEOUT)
except subprocess.TimeoutExpired: except subprocess.TimeoutExpired:
try: with contextlib.suppress(OSError):
app_module.dsc_process.kill() app_module.dsc_process.kill()
except OSError:
pass
except OSError: except OSError:
pass pass
-3
View File
@@ -3,12 +3,9 @@
from __future__ import annotations from __future__ import annotations
import queue import queue
import time
from collections.abc import Generator
from flask import Blueprint, Response, jsonify from flask import Blueprint, Response, jsonify
from utils.responses import api_success, api_error
from utils.gps import ( from utils.gps import (
GPSPosition, GPSPosition,
GPSSkyData, GPSSkyData,
+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))}")
+26 -26
View File
@@ -11,8 +11,8 @@ from __future__ import annotations
import os import os
import queue import queue
import signal
import shutil import shutil
import signal
import struct import struct
import subprocess import subprocess
import threading import threading
@@ -22,15 +22,15 @@ from typing import Dict, List, Optional
from flask import Blueprint from flask import Blueprint
from utils.logging import get_logger
from utils.sse import sse_stream_fanout
from utils.event_pipeline import process_event
from utils.constants import ( from utils.constants import (
SSE_QUEUE_TIMEOUT,
SSE_KEEPALIVE_INTERVAL,
PROCESS_TERMINATE_TIMEOUT, PROCESS_TERMINATE_TIMEOUT,
SSE_KEEPALIVE_INTERVAL,
SSE_QUEUE_TIMEOUT,
) )
from utils.event_pipeline import process_event
from utils.logging import get_logger
from utils.sdr import SDRFactory, SDRType from utils.sdr import SDRFactory, SDRType
from utils.sse import sse_stream_fanout
logger = get_logger('intercept.receiver') logger = get_logger('intercept.receiver')
@@ -39,6 +39,8 @@ receiver_bp = Blueprint('receiver', __name__, url_prefix='/receiver')
# Deferred import to avoid circular import at module load time. # Deferred import to avoid circular import at module load time.
# app.py -> register_blueprints -> from .listening_post import receiver_bp # app.py -> register_blueprints -> from .listening_post import receiver_bp
# must find receiver_bp already defined (above) before this import runs. # must find receiver_bp already defined (above) before this import runs.
import contextlib
import app as app_module # noqa: E402 import app as app_module # noqa: E402
# ============================================ # ============================================
@@ -57,16 +59,16 @@ audio_source = 'process'
audio_start_token = 0 audio_start_token = 0
# Scanner state # Scanner state
scanner_thread: Optional[threading.Thread] = None scanner_thread: threading.Thread | None = None
scanner_running = False scanner_running = False
scanner_lock = threading.Lock() scanner_lock = threading.Lock()
scanner_paused = False scanner_paused = False
scanner_current_freq = 0.0 scanner_current_freq = 0.0
scanner_active_device: Optional[int] = None scanner_active_device: int | None = None
scanner_active_sdr_type: str = 'rtlsdr' scanner_active_sdr_type: str = 'rtlsdr'
receiver_active_device: Optional[int] = None receiver_active_device: int | None = None
receiver_active_sdr_type: str = 'rtlsdr' receiver_active_sdr_type: str = 'rtlsdr'
scanner_power_process: Optional[subprocess.Popen] = None scanner_power_process: subprocess.Popen | None = None
scanner_config = { scanner_config = {
'start_freq': 88.0, 'start_freq': 88.0,
'end_freq': 108.0, 'end_freq': 108.0,
@@ -84,7 +86,7 @@ scanner_config = {
} }
# Activity log # Activity log
activity_log: List[Dict] = [] activity_log: list[dict] = []
activity_log_lock = threading.Lock() activity_log_lock = threading.Lock()
MAX_LOG_ENTRIES = 500 MAX_LOG_ENTRIES = 500
@@ -95,12 +97,12 @@ scanner_queue: queue.Queue = queue.Queue(maxsize=100)
scanner_skip_signal = False scanner_skip_signal = False
# Waterfall / spectrogram state # Waterfall / spectrogram state
waterfall_process: Optional[subprocess.Popen] = None waterfall_process: subprocess.Popen | None = None
waterfall_thread: Optional[threading.Thread] = None waterfall_thread: threading.Thread | None = None
waterfall_running = False waterfall_running = False
waterfall_lock = threading.Lock() waterfall_lock = threading.Lock()
waterfall_queue: queue.Queue = queue.Queue(maxsize=200) waterfall_queue: queue.Queue = queue.Queue(maxsize=200)
waterfall_active_device: Optional[int] = None waterfall_active_device: int | None = None
waterfall_active_sdr_type: str = 'rtlsdr' waterfall_active_sdr_type: str = 'rtlsdr'
waterfall_config = { waterfall_config = {
'start_freq': 88.0, 'start_freq': 88.0,
@@ -185,13 +187,11 @@ def add_activity_log(event_type: str, frequency: float, details: str = ''):
activity_log.pop() activity_log.pop()
# Also push to SSE queue # Also push to SSE queue
try: with contextlib.suppress(queue.Full):
scanner_queue.put_nowait({ scanner_queue.put_nowait({
'type': 'log', 'type': 'log',
'entry': entry 'entry': entry
}) })
except queue.Full:
pass
def _start_audio_stream( def _start_audio_stream(
@@ -348,12 +348,12 @@ def _start_audio_stream(
rtl_stderr = '' rtl_stderr = ''
ffmpeg_stderr = '' ffmpeg_stderr = ''
try: try:
with open(rtl_stderr_log, 'r') as f: with open(rtl_stderr_log) as f:
rtl_stderr = f.read().strip() rtl_stderr = f.read().strip()
except Exception: except Exception:
pass pass
try: try:
with open(ffmpeg_stderr_log, 'r') as f: with open(ffmpeg_stderr_log) as f:
ffmpeg_stderr = f.read().strip() ffmpeg_stderr = f.read().strip()
except Exception: except Exception:
pass pass
@@ -502,10 +502,8 @@ def _stop_waterfall_internal() -> None:
waterfall_process.terminate() waterfall_process.terminate()
waterfall_process.wait(timeout=1) waterfall_process.wait(timeout=1)
except Exception: except Exception:
try: with contextlib.suppress(Exception):
waterfall_process.kill() waterfall_process.kill()
except Exception:
pass
waterfall_process = None waterfall_process = None
if waterfall_active_device is not None: if waterfall_active_device is not None:
@@ -517,7 +515,9 @@ def _stop_waterfall_internal() -> None:
# ============================================ # ============================================
# Import sub-modules to register routes on receiver_bp # Import sub-modules to register routes on receiver_bp
# ============================================ # ============================================
from . import scanner # noqa: E402, F401 from . import (
from . import audio # noqa: E402, F401 audio, # noqa: E402, F401
from . import waterfall # noqa: E402, F401 scanner, # noqa: E402, F401
from . import tools # noqa: E402, F401 tools, # noqa: E402, F401
waterfall, # noqa: E402, F401
)
+14 -20
View File
@@ -2,27 +2,27 @@
from __future__ import annotations from __future__ import annotations
import contextlib
import os import os
import select import select
import subprocess import subprocess
import time import time
from typing import Any
from flask import jsonify, request, Response from flask import Response, jsonify, request
import routes.listening_post as _state
from . import ( from . import (
receiver_bp,
logger,
app_module,
scanner_config,
_wav_header,
_start_audio_stream, _start_audio_stream,
_stop_audio_stream, _stop_audio_stream,
_stop_waterfall_internal, _stop_waterfall_internal,
_wav_header,
app_module,
logger,
normalize_modulation, normalize_modulation,
receiver_bp,
scanner_config,
) )
import routes.listening_post as _state
# ============================================ # ============================================
# MANUAL AUDIO ENDPOINTS (for direct listening) # MANUAL AUDIO ENDPOINTS (for direct listening)
@@ -106,23 +106,17 @@ def start_audio() -> Response:
# Scanner teardown outside lock (blocking: thread join, process wait, pkill, sleep) # Scanner teardown outside lock (blocking: thread join, process wait, pkill, sleep)
if need_scanner_teardown: if need_scanner_teardown:
if scanner_thread_ref and scanner_thread_ref.is_alive(): if scanner_thread_ref and scanner_thread_ref.is_alive():
try: with contextlib.suppress(Exception):
scanner_thread_ref.join(timeout=2.0) scanner_thread_ref.join(timeout=2.0)
except Exception:
pass
if scanner_proc_ref and scanner_proc_ref.poll() is None: if scanner_proc_ref and scanner_proc_ref.poll() is None:
try: try:
scanner_proc_ref.terminate() scanner_proc_ref.terminate()
scanner_proc_ref.wait(timeout=1) scanner_proc_ref.wait(timeout=1)
except Exception: except Exception:
try: with contextlib.suppress(Exception):
scanner_proc_ref.kill() scanner_proc_ref.kill()
except Exception: with contextlib.suppress(Exception):
pass
try:
subprocess.run(['pkill', '-9', 'rtl_power'], capture_output=True, timeout=0.5) subprocess.run(['pkill', '-9', 'rtl_power'], capture_output=True, timeout=0.5)
except Exception:
pass
time.sleep(0.5) time.sleep(0.5)
# Re-acquire lock for waterfall check and device claim # Re-acquire lock for waterfall check and device claim
@@ -232,7 +226,7 @@ def start_audio() -> Response:
start_error = '' start_error = ''
for log_path in ('/tmp/rtl_fm_stderr.log', '/tmp/ffmpeg_stderr.log'): for log_path in ('/tmp/rtl_fm_stderr.log', '/tmp/ffmpeg_stderr.log'):
try: try:
with open(log_path, 'r') as handle: with open(log_path) as handle:
content = handle.read().strip() content = handle.read().strip()
if content: if content:
start_error = content.splitlines()[-1] start_error = content.splitlines()[-1]
@@ -290,7 +284,7 @@ def audio_debug() -> Response:
def _read_log(path: str) -> str: def _read_log(path: str) -> str:
try: try:
with open(path, 'r') as handle: with open(path) as handle:
return handle.read().strip() return handle.read().strip()
except Exception: except Exception:
return '' return ''
+32 -52
View File
@@ -2,6 +2,7 @@
from __future__ import annotations from __future__ import annotations
import contextlib
import math import math
import queue import queue
import struct import struct
@@ -10,32 +11,32 @@ import threading
import time import time
from typing import Any from typing import Any
from flask import jsonify, request, Response from flask import Response, jsonify, request
import routes.listening_post as _state
from . import ( from . import (
receiver_bp, SSE_KEEPALIVE_INTERVAL,
logger, SSE_QUEUE_TIMEOUT,
app_module,
scanner_queue,
scanner_config,
scanner_lock,
activity_log,
activity_log_lock,
add_activity_log,
find_rtl_fm,
find_rtl_power,
find_rx_fm,
normalize_modulation,
_rtl_fm_demod_mode, _rtl_fm_demod_mode,
_start_audio_stream, _start_audio_stream,
_stop_audio_stream, _stop_audio_stream,
activity_log,
activity_log_lock,
add_activity_log,
app_module,
find_rtl_fm,
find_rtl_power,
find_rx_fm,
logger,
normalize_modulation,
process_event, process_event,
receiver_bp,
scanner_config,
scanner_lock,
scanner_queue,
sse_stream_fanout, sse_stream_fanout,
SSE_QUEUE_TIMEOUT,
SSE_KEEPALIVE_INTERVAL,
) )
import routes.listening_post as _state
# ============================================ # ============================================
# SCANNER IMPLEMENTATION # SCANNER IMPLEMENTATION
@@ -76,7 +77,7 @@ def scanner_loop():
_state.scanner_current_freq = current_freq _state.scanner_current_freq = current_freq
# Notify clients of frequency change # Notify clients of frequency change
try: with contextlib.suppress(queue.Full):
scanner_queue.put_nowait({ scanner_queue.put_nowait({
'type': 'freq_change', 'type': 'freq_change',
'frequency': current_freq, 'frequency': current_freq,
@@ -84,8 +85,6 @@ def scanner_loop():
'range_start': scanner_config['start_freq'], 'range_start': scanner_config['start_freq'],
'range_end': scanner_config['end_freq'] 'range_end': scanner_config['end_freq']
}) })
except queue.Full:
pass
# Start rtl_fm at this frequency # Start rtl_fm at this frequency
freq_hz = int(current_freq * 1e6) freq_hz = int(current_freq * 1e6)
@@ -168,7 +167,7 @@ def scanner_loop():
audio_detected = rms > effective_threshold audio_detected = rms > effective_threshold
# Send level info to clients # Send level info to clients
try: with contextlib.suppress(queue.Full):
scanner_queue.put_nowait({ scanner_queue.put_nowait({
'type': 'scan_update', 'type': 'scan_update',
'frequency': current_freq, 'frequency': current_freq,
@@ -178,8 +177,6 @@ def scanner_loop():
'range_start': scanner_config['start_freq'], 'range_start': scanner_config['start_freq'],
'range_end': scanner_config['end_freq'] 'range_end': scanner_config['end_freq']
}) })
except queue.Full:
pass
if audio_detected and _state.scanner_running: if audio_detected and _state.scanner_running:
if not signal_detected: if not signal_detected:
@@ -214,13 +211,11 @@ def scanner_loop():
_state.scanner_skip_signal = False _state.scanner_skip_signal = False
signal_detected = False signal_detected = False
_stop_audio_stream() _stop_audio_stream()
try: with contextlib.suppress(queue.Full):
scanner_queue.put_nowait({ scanner_queue.put_nowait({
'type': 'signal_skipped', 'type': 'signal_skipped',
'frequency': current_freq 'frequency': current_freq
}) })
except queue.Full:
pass
# Move to next frequency (step is in kHz, convert to MHz) # Move to next frequency (step is in kHz, convert to MHz)
current_freq += step_mhz current_freq += step_mhz
if current_freq > scanner_config['end_freq']: if current_freq > scanner_config['end_freq']:
@@ -240,15 +235,13 @@ def scanner_loop():
if _state.scanner_running and not _state.scanner_skip_signal: if _state.scanner_running and not _state.scanner_skip_signal:
signal_detected = False signal_detected = False
_stop_audio_stream() _stop_audio_stream()
try: with contextlib.suppress(queue.Full):
scanner_queue.put_nowait({ scanner_queue.put_nowait({
'type': 'signal_lost', 'type': 'signal_lost',
'frequency': current_freq, 'frequency': current_freq,
'range_start': scanner_config['start_freq'], 'range_start': scanner_config['start_freq'],
'range_end': scanner_config['end_freq'] 'range_end': scanner_config['end_freq']
}) })
except queue.Full:
pass
current_freq += step_mhz current_freq += step_mhz
if current_freq > scanner_config['end_freq']: if current_freq > scanner_config['end_freq']:
@@ -268,13 +261,11 @@ def scanner_loop():
# Stop audio # Stop audio
_stop_audio_stream() _stop_audio_stream()
try: with contextlib.suppress(queue.Full):
scanner_queue.put_nowait({ scanner_queue.put_nowait({
'type': 'signal_lost', 'type': 'signal_lost',
'frequency': current_freq 'frequency': current_freq
}) })
except queue.Full:
pass
# Move to next frequency (step is in kHz, convert to MHz) # Move to next frequency (step is in kHz, convert to MHz)
current_freq += step_mhz current_freq += step_mhz
@@ -321,7 +312,7 @@ def scanner_loop_power():
step_khz = scanner_config['step'] step_khz = scanner_config['step']
gain = scanner_config['gain'] gain = scanner_config['gain']
device = scanner_config['device'] device = scanner_config['device']
squelch = scanner_config['squelch'] scanner_config['squelch']
mod = scanner_config['modulation'] mod = scanner_config['modulation']
# Configure sweep # Configure sweep
@@ -355,7 +346,7 @@ def scanner_loop_power():
if not stdout: if not stdout:
add_activity_log('error', start_mhz, 'Power sweep produced no data') add_activity_log('error', start_mhz, 'Power sweep produced no data')
try: with contextlib.suppress(queue.Full):
scanner_queue.put_nowait({ scanner_queue.put_nowait({
'type': 'scan_update', 'type': 'scan_update',
'frequency': end_mhz, 'frequency': end_mhz,
@@ -365,8 +356,6 @@ def scanner_loop_power():
'range_start': scanner_config['start_freq'], 'range_start': scanner_config['start_freq'],
'range_end': scanner_config['end_freq'] 'range_end': scanner_config['end_freq']
}) })
except queue.Full:
pass
time.sleep(0.2) time.sleep(0.2)
continue continue
@@ -414,7 +403,7 @@ def scanner_loop_power():
if not segments: if not segments:
add_activity_log('error', start_mhz, 'Power sweep bins missing') add_activity_log('error', start_mhz, 'Power sweep bins missing')
try: with contextlib.suppress(queue.Full):
scanner_queue.put_nowait({ scanner_queue.put_nowait({
'type': 'scan_update', 'type': 'scan_update',
'frequency': end_mhz, 'frequency': end_mhz,
@@ -424,8 +413,6 @@ def scanner_loop_power():
'range_start': scanner_config['start_freq'], 'range_start': scanner_config['start_freq'],
'range_end': scanner_config['end_freq'] 'range_end': scanner_config['end_freq']
}) })
except queue.Full:
pass
time.sleep(0.2) time.sleep(0.2)
continue continue
@@ -457,7 +444,7 @@ def scanner_loop_power():
level = int(max(0, snr) * 100) level = int(max(0, snr) * 100)
threshold = int(snr_threshold * 100) threshold = int(snr_threshold * 100)
progress = min(1.0, (segment_offset + idx) / max(1, total_bins - 1)) progress = min(1.0, (segment_offset + idx) / max(1, total_bins - 1))
try: with contextlib.suppress(queue.Full):
scanner_queue.put_nowait({ scanner_queue.put_nowait({
'type': 'scan_update', 'type': 'scan_update',
'frequency': _state.scanner_current_freq, 'frequency': _state.scanner_current_freq,
@@ -468,8 +455,6 @@ def scanner_loop_power():
'range_start': scanner_config['start_freq'], 'range_start': scanner_config['start_freq'],
'range_end': scanner_config['end_freq'] 'range_end': scanner_config['end_freq']
}) })
except queue.Full:
pass
segment_offset += len(bin_values) segment_offset += len(bin_values)
# Detect peaks (clusters above threshold) # Detect peaks (clusters above threshold)
@@ -505,7 +490,7 @@ def scanner_loop_power():
threshold = int(snr_threshold * 100) threshold = int(snr_threshold * 100)
add_activity_log('signal_found', freq_mhz, add_activity_log('signal_found', freq_mhz,
f'Peak detected at {freq_mhz:.3f} MHz ({mod.upper()})') f'Peak detected at {freq_mhz:.3f} MHz ({mod.upper()})')
try: with contextlib.suppress(queue.Full):
scanner_queue.put_nowait({ scanner_queue.put_nowait({
'type': 'signal_found', 'type': 'signal_found',
'frequency': freq_mhz, 'frequency': freq_mhz,
@@ -517,8 +502,6 @@ def scanner_loop_power():
'range_start': scanner_config['start_freq'], 'range_start': scanner_config['start_freq'],
'range_end': scanner_config['end_freq'] 'range_end': scanner_config['end_freq']
}) })
except queue.Full:
pass
add_activity_log('scan_cycle', start_mhz, 'Power sweep complete') add_activity_log('scan_cycle', start_mhz, 'Power sweep complete')
time.sleep(max(0.1, scanner_config.get('scan_delay', 0.5))) time.sleep(max(0.1, scanner_config.get('scan_delay', 0.5)))
@@ -590,9 +573,8 @@ def start_scanner() -> Response:
sdr_type = scanner_config['sdr_type'] sdr_type = scanner_config['sdr_type']
# Power scan only supports RTL-SDR for now # Power scan only supports RTL-SDR for now
if scanner_config['scan_method'] == 'power': if scanner_config['scan_method'] == 'power' and (sdr_type != 'rtlsdr' or not find_rtl_power()):
if sdr_type != 'rtlsdr' or not find_rtl_power(): scanner_config['scan_method'] = 'classic'
scanner_config['scan_method'] = 'classic'
# Check tools based on chosen method # Check tools based on chosen method
if scanner_config['scan_method'] == 'power': if scanner_config['scan_method'] == 'power':
@@ -666,10 +648,8 @@ def stop_scanner() -> Response:
_state.scanner_power_process.terminate() _state.scanner_power_process.terminate()
_state.scanner_power_process.wait(timeout=1) _state.scanner_power_process.wait(timeout=1)
except Exception: except Exception:
try: with contextlib.suppress(Exception):
_state.scanner_power_process.kill() _state.scanner_power_process.kill()
except Exception:
pass
_state.scanner_power_process = None _state.scanner_power_process = None
if _state.scanner_active_device is not None: if _state.scanner_active_device is not None:
app_module.release_sdr_device(_state.scanner_active_device, _state.scanner_active_sdr_type) app_module.release_sdr_device(_state.scanner_active_device, _state.scanner_active_sdr_type)
+4 -5
View File
@@ -2,18 +2,17 @@
from __future__ import annotations from __future__ import annotations
from flask import jsonify, request, Response from flask import Response, jsonify, request
from . import ( from . import (
receiver_bp, find_ffmpeg,
logger,
find_rtl_fm, find_rtl_fm,
find_rtl_power, find_rtl_power,
find_rx_fm, find_rx_fm,
find_ffmpeg, logger,
receiver_bp,
) )
# ============================================ # ============================================
# TOOL CHECK ENDPOINT # TOOL CHECK ENDPOINT
# ============================================ # ============================================
+25 -41
View File
@@ -2,6 +2,7 @@
from __future__ import annotations from __future__ import annotations
import contextlib
import math import math
import queue import queue
import struct import struct
@@ -11,23 +12,23 @@ import time
from datetime import datetime from datetime import datetime
from typing import Any from typing import Any
from flask import jsonify, request, Response from flask import Response, jsonify, request
from . import (
receiver_bp,
logger,
app_module,
_stop_waterfall_internal,
process_event,
sse_stream_fanout,
SSE_QUEUE_TIMEOUT,
SSE_KEEPALIVE_INTERVAL,
find_rtl_power,
SDRFactory,
SDRType,
)
import routes.listening_post as _state import routes.listening_post as _state
from . import (
SSE_KEEPALIVE_INTERVAL,
SSE_QUEUE_TIMEOUT,
SDRFactory,
SDRType,
_stop_waterfall_internal,
app_module,
find_rtl_power,
logger,
process_event,
receiver_bp,
sse_stream_fanout,
)
# ============================================ # ============================================
# WATERFALL HELPER FUNCTIONS # WATERFALL HELPER FUNCTIONS
@@ -75,14 +76,12 @@ def _parse_rtl_power_line(line: str) -> tuple[str | None, float | None, float |
def _queue_waterfall_error(message: str) -> None: def _queue_waterfall_error(message: str) -> None:
"""Push an error message onto the waterfall SSE queue.""" """Push an error message onto the waterfall SSE queue."""
try: with contextlib.suppress(queue.Full):
_state.waterfall_queue.put_nowait({ _state.waterfall_queue.put_nowait({
'type': 'waterfall_error', 'type': 'waterfall_error',
'message': message, 'message': message,
'timestamp': datetime.now().isoformat(), 'timestamp': datetime.now().isoformat(),
}) })
except queue.Full:
pass
def _downsample_bins(values: list[float], target: int) -> list[float]: def _downsample_bins(values: list[float], target: int) -> list[float]:
@@ -229,14 +228,10 @@ def _waterfall_loop_iq(sdr_type: SDRType):
try: try:
_state.waterfall_queue.put_nowait(msg) _state.waterfall_queue.put_nowait(msg)
except queue.Full: except queue.Full:
try: with contextlib.suppress(queue.Empty):
_state.waterfall_queue.get_nowait() _state.waterfall_queue.get_nowait()
except queue.Empty: with contextlib.suppress(queue.Full):
pass
try:
_state.waterfall_queue.put_nowait(msg) _state.waterfall_queue.put_nowait(msg)
except queue.Full:
pass
# Throttle to respect interval # Throttle to respect interval
time.sleep(interval) time.sleep(interval)
@@ -254,10 +249,8 @@ def _waterfall_loop_iq(sdr_type: SDRType):
_state.waterfall_process.terminate() _state.waterfall_process.terminate()
_state.waterfall_process.wait(timeout=1) _state.waterfall_process.wait(timeout=1)
except Exception: except Exception:
try: with contextlib.suppress(Exception):
_state.waterfall_process.kill() _state.waterfall_process.kill()
except Exception:
pass
_state.waterfall_process = None _state.waterfall_process = None
logger.info("Waterfall IQ loop stopped") logger.info("Waterfall IQ loop stopped")
@@ -346,14 +339,10 @@ def _waterfall_loop_rtl_power():
try: try:
_state.waterfall_queue.put_nowait(msg) _state.waterfall_queue.put_nowait(msg)
except queue.Full: except queue.Full:
try: with contextlib.suppress(queue.Empty):
_state.waterfall_queue.get_nowait() _state.waterfall_queue.get_nowait()
except queue.Empty: with contextlib.suppress(queue.Full):
pass
try:
_state.waterfall_queue.put_nowait(msg) _state.waterfall_queue.put_nowait(msg)
except queue.Full:
pass
all_bins = [] all_bins = []
sweep_start_hz = start_hz sweep_start_hz = start_hz
@@ -379,10 +368,8 @@ def _waterfall_loop_rtl_power():
'bins': bins_to_send, 'bins': bins_to_send,
'timestamp': datetime.now().isoformat(), 'timestamp': datetime.now().isoformat(),
} }
try: with contextlib.suppress(queue.Full):
_state.waterfall_queue.put_nowait(msg) _state.waterfall_queue.put_nowait(msg)
except queue.Full:
pass
if _state.waterfall_running and not received_any: if _state.waterfall_running and not received_any:
_queue_waterfall_error('No waterfall FFT data received from rtl_power') _queue_waterfall_error('No waterfall FFT data received from rtl_power')
@@ -397,10 +384,8 @@ def _waterfall_loop_rtl_power():
_state.waterfall_process.terminate() _state.waterfall_process.terminate()
_state.waterfall_process.wait(timeout=1) _state.waterfall_process.wait(timeout=1)
except Exception: except Exception:
try: with contextlib.suppress(Exception):
_state.waterfall_process.kill() _state.waterfall_process.kill()
except Exception:
pass
_state.waterfall_process = None _state.waterfall_process = None
logger.info("Waterfall loop stopped") logger.info("Waterfall loop stopped")
@@ -432,9 +417,8 @@ def start_waterfall() -> Response:
sdr_type_str = sdr_type.value sdr_type_str = sdr_type.value
# RTL-SDR uses rtl_power; other types use rx_sdr via IQ capture # RTL-SDR uses rtl_power; other types use rx_sdr via IQ capture
if sdr_type == SDRType.RTL_SDR: if sdr_type == SDRType.RTL_SDR and not find_rtl_power():
if not find_rtl_power(): return jsonify({'status': 'error', 'message': 'rtl_power not found'}), 503
return jsonify({'status': 'error', 'message': 'rtl_power not found'}), 503
try: try:
_state.waterfall_config['start_freq'] = float(data.get('start_freq', 88.0)) _state.waterfall_config['start_freq'] = float(data.get('start_freq', 88.0))
+5 -7
View File
@@ -11,21 +11,19 @@ Supports multiple connection types:
from __future__ import annotations from __future__ import annotations
import queue import queue
import time
from typing import Generator
from flask import Blueprint, jsonify, request, Response from flask import Blueprint, Response, jsonify, request
from utils.responses import api_success, api_error
from utils.logging import get_logger from utils.logging import get_logger
from utils.sse import sse_stream_fanout
from utils.meshtastic import ( from utils.meshtastic import (
MeshtasticMessage,
get_meshtastic_client, get_meshtastic_client,
is_meshtastic_available,
start_meshtastic, start_meshtastic,
stop_meshtastic, stop_meshtastic,
is_meshtastic_available,
MeshtasticMessage,
) )
from utils.responses import api_error
from utils.sse import sse_stream_fanout
logger = get_logger('intercept.meshtastic') logger = get_logger('intercept.meshtastic')
+1 -1
View File
@@ -20,7 +20,7 @@ from typing import Any
from flask import Blueprint, Flask, Response, jsonify, request from flask import Blueprint, Flask, Response, jsonify, request
from utils.responses import api_success, api_error from utils.responses import api_error
try: try:
from flask_sock import Sock from flask_sock import Sock
+1 -1
View File
@@ -13,7 +13,6 @@ from typing import Any
from flask import Blueprint, Response, jsonify, request from flask import Blueprint, Response, jsonify, request
from utils.responses import api_success, api_error
import app as app_module import app as app_module
from utils.event_pipeline import process_event from utils.event_pipeline import process_event
from utils.logging import sensor_logger as logger from utils.logging import sensor_logger as logger
@@ -22,6 +21,7 @@ from utils.morse import (
morse_decoder_thread, morse_decoder_thread,
) )
from utils.process import register_process, safe_terminate, unregister_process from utils.process import register_process, safe_terminate, unregister_process
from utils.responses import api_error
from utils.sdr import SDRFactory, SDRType from utils.sdr import SDRFactory, SDRType
from utils.sse import sse_stream_fanout from utils.sse import sse_stream_fanout
from utils.validation import ( from utils.validation import (
+5 -3
View File
@@ -2,11 +2,13 @@
Offline mode routes - Asset management and settings for offline operation. Offline mode routes - Asset management and settings for offline operation.
""" """
from flask import Blueprint, jsonify, request
from utils.database import get_setting, set_setting
from utils.responses import api_success, api_error
import os import os
from flask import Blueprint, request
from utils.database import get_setting, set_setting
from utils.responses import api_error, api_success
offline_bp = Blueprint('offline', __name__, url_prefix='/offline') offline_bp = Blueprint('offline', __name__, url_prefix='/offline')
# Default offline settings # Default offline settings
+1 -1
View File
@@ -19,10 +19,10 @@ from flask import Blueprint, Response, jsonify, request
import app as app_module import app as app_module
from utils.event_pipeline import process_event from utils.event_pipeline import process_event
from utils.responses import api_success, api_error
from utils.logging import sensor_logger as logger from utils.logging import sensor_logger as logger
from utils.ook import ook_parser_thread from utils.ook import ook_parser_thread
from utils.process import register_process, safe_terminate, unregister_process from utils.process import register_process, safe_terminate, unregister_process
from utils.responses import api_error
from utils.sdr import SDRFactory, SDRType from utils.sdr import SDRFactory, SDRType
from utils.sse import sse_stream_fanout from utils.sse import sse_stream_fanout
from utils.validation import ( from utils.validation import (
+25 -34
View File
@@ -2,34 +2,39 @@
from __future__ import annotations from __future__ import annotations
import contextlib
import math import math
import os import os
import pathlib import pathlib
import re
import pty import pty
import queue import queue
import re
import select import select
import struct import struct
import subprocess import subprocess
import threading import threading
import time import time
from datetime import datetime from datetime import datetime
from typing import Any, Generator from typing import Any
from flask import Blueprint, jsonify, request, Response from flask import Blueprint, Response, jsonify, request
from utils.responses import api_success, api_error
import app as app_module import app as app_module
from utils.logging import pager_logger as logger
from utils.validation import (
validate_frequency, validate_device_index, validate_gain, validate_ppm,
validate_rtl_tcp_host, validate_rtl_tcp_port
)
from utils.sse import sse_stream_fanout
from utils.event_pipeline import process_event
from utils.process import safe_terminate, register_process, unregister_process
from utils.sdr import SDRFactory, SDRType, SDRValidationError
from utils.dependencies import get_tool_path from utils.dependencies import get_tool_path
from utils.event_pipeline import process_event
from utils.logging import pager_logger as logger
from utils.process import register_process, unregister_process
from utils.responses import api_error
from utils.sdr import SDRFactory, SDRType
from utils.sse import sse_stream_fanout
from utils.validation import (
validate_device_index,
validate_frequency,
validate_gain,
validate_ppm,
validate_rtl_tcp_host,
validate_rtl_tcp_port,
)
pager_bp = Blueprint('pager', __name__) pager_bp = Blueprint('pager', __name__)
@@ -189,10 +194,8 @@ def audio_relay_thread(
except Exception as e: except Exception as e:
logger.debug(f"Audio relay error: {e}") logger.debug(f"Audio relay error: {e}")
finally: finally:
try: with contextlib.suppress(OSError):
multimon_stdin.close() multimon_stdin.close()
except OSError:
pass
def stream_decoder(master_fd: int, process: subprocess.Popen[bytes]) -> None: def stream_decoder(master_fd: int, process: subprocess.Popen[bytes]) -> None:
@@ -237,10 +240,8 @@ def stream_decoder(master_fd: int, process: subprocess.Popen[bytes]) -> None:
app_module.output_queue.put({'type': 'error', 'text': str(e)}) app_module.output_queue.put({'type': 'error', 'text': str(e)})
finally: finally:
global pager_active_device, pager_active_sdr_type global pager_active_device, pager_active_sdr_type
try: with contextlib.suppress(OSError):
os.close(master_fd) os.close(master_fd)
except OSError:
pass
# Signal relay thread to stop # Signal relay thread to stop
with app_module.process_lock: with app_module.process_lock:
stop_relay = getattr(app_module.current_process, '_stop_relay', None) stop_relay = getattr(app_module.current_process, '_stop_relay', None)
@@ -255,10 +256,8 @@ def stream_decoder(master_fd: int, process: subprocess.Popen[bytes]) -> None:
proc.terminate() proc.terminate()
proc.wait(timeout=2) proc.wait(timeout=2)
except Exception: except Exception:
try: with contextlib.suppress(Exception):
proc.kill() proc.kill()
except Exception:
pass
unregister_process(proc) unregister_process(proc)
app_module.output_queue.put({'type': 'status', 'text': 'stopped'}) app_module.output_queue.put({'type': 'status', 'text': 'stopped'})
with app_module.process_lock: with app_module.process_lock:
@@ -454,10 +453,8 @@ def start_decoding() -> Response:
rtl_process.terminate() rtl_process.terminate()
rtl_process.wait(timeout=2) rtl_process.wait(timeout=2)
except Exception: except Exception:
try: with contextlib.suppress(Exception):
rtl_process.kill() rtl_process.kill()
except Exception:
pass
# Release device on failure # Release device on failure
if pager_active_device is not None: if pager_active_device is not None:
app_module.release_sdr_device(pager_active_device, pager_active_sdr_type or 'rtlsdr') app_module.release_sdr_device(pager_active_device, pager_active_sdr_type or 'rtlsdr')
@@ -470,10 +467,8 @@ def start_decoding() -> Response:
rtl_process.terminate() rtl_process.terminate()
rtl_process.wait(timeout=2) rtl_process.wait(timeout=2)
except Exception: except Exception:
try: with contextlib.suppress(Exception):
rtl_process.kill() rtl_process.kill()
except Exception:
pass
# Release device on failure # Release device on failure
if pager_active_device is not None: if pager_active_device is not None:
app_module.release_sdr_device(pager_active_device, pager_active_sdr_type or 'rtlsdr') app_module.release_sdr_device(pager_active_device, pager_active_sdr_type or 'rtlsdr')
@@ -498,17 +493,13 @@ def stop_decoding() -> Response:
app_module.current_process._rtl_process.terminate() app_module.current_process._rtl_process.terminate()
app_module.current_process._rtl_process.wait(timeout=2) app_module.current_process._rtl_process.wait(timeout=2)
except (subprocess.TimeoutExpired, OSError): except (subprocess.TimeoutExpired, OSError):
try: with contextlib.suppress(OSError):
app_module.current_process._rtl_process.kill() app_module.current_process._rtl_process.kill()
except OSError:
pass
# Close PTY master fd # Close PTY master fd
if hasattr(app_module.current_process, '_master_fd'): if hasattr(app_module.current_process, '_master_fd'):
try: with contextlib.suppress(OSError):
os.close(app_module.current_process._master_fd) os.close(app_module.current_process._master_fd)
except OSError:
pass
# Kill multimon-ng # Kill multimon-ng
app_module.current_process.terminate() app_module.current_process.terminate()
+185 -97
View File
@@ -7,20 +7,21 @@ telemetry (position, altitude, temperature, humidity, pressure) on the
from __future__ import annotations from __future__ import annotations
import json import contextlib
import os import json
import queue import os
import shutil import queue
import socket import shlex
import subprocess import shutil
import sys import socket
import threading import subprocess
import sys
import threading
import time import time
from typing import Any from typing import Any
from flask import Blueprint, Response, jsonify, request from flask import Blueprint, Response, jsonify, request
from utils.responses import api_success, api_error
import app as app_module import app as app_module
from utils.constants import ( from utils.constants import (
MAX_RADIOSONDE_AGE_SECONDS, MAX_RADIOSONDE_AGE_SECONDS,
@@ -32,6 +33,7 @@ from utils.constants import (
) )
from utils.gps import is_gpsd_running from utils.gps import is_gpsd_running
from utils.logging import get_logger from utils.logging import get_logger
from utils.responses import api_error, api_success
from utils.sdr import SDRFactory, SDRType from utils.sdr import SDRFactory, SDRType
from utils.sse import sse_stream_fanout from utils.sse import sse_stream_fanout
from utils.validation import ( from utils.validation import (
@@ -41,9 +43,10 @@ from utils.validation import (
validate_longitude, validate_longitude,
) )
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
@@ -65,8 +68,8 @@ AUTO_RX_PATHS = [
] ]
def find_auto_rx() -> str | None: def find_auto_rx() -> str | None:
"""Find radiosonde_auto_rx script/binary.""" """Find radiosonde_auto_rx script/binary."""
# Check PATH first # Check PATH first
path = shutil.which('radiosonde_auto_rx') path = shutil.which('radiosonde_auto_rx')
if path: if path:
@@ -76,10 +79,123 @@ def find_auto_rx() -> str | None:
if os.path.isfile(p) and os.access(p, os.X_OK): if os.path.isfile(p) and os.access(p, os.X_OK):
return p return p
# Check for Python script (not executable but runnable) # Check for Python script (not executable but runnable)
for p in AUTO_RX_PATHS: for p in AUTO_RX_PATHS:
if os.path.isfile(p): if os.path.isfile(p):
return p return p
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(
@@ -270,7 +386,7 @@ def _fix_data_ownership(path: str) -> None:
return return
try: try:
uid_int, gid_int = int(uid), int(gid) uid_int, gid_int = int(uid), int(gid)
for dirpath, dirnames, filenames in os.walk(path): for dirpath, _dirnames, filenames in os.walk(path):
os.chown(dirpath, uid_int, gid_int) os.chown(dirpath, uid_int, gid_int)
for fname in filenames: for fname in filenames:
os.chown(os.path.join(dirpath, fname), uid_int, gid_int) os.chown(os.path.join(dirpath, fname), uid_int, gid_int)
@@ -315,18 +431,14 @@ def parse_radiosonde_udp(udp_port: int) -> None:
if serial: if serial:
with _balloons_lock: with _balloons_lock:
radiosonde_balloons[serial] = balloon radiosonde_balloons[serial] = balloon
try: with contextlib.suppress(queue.Full):
app_module.radiosonde_queue.put_nowait({ app_module.radiosonde_queue.put_nowait({
'type': 'balloon', 'type': 'balloon',
**balloon, **balloon,
}) })
except queue.Full:
pass
try: with contextlib.suppress(OSError):
sock.close() sock.close()
except OSError:
pass
_udp_socket = None _udp_socket = None
logger.info("Radiosonde UDP listener stopped") logger.info("Radiosonde UDP listener stopped")
@@ -354,71 +466,51 @@ def _process_telemetry(msg: dict) -> dict | None:
# Position # Position
for key in ('lat', 'latitude'): for key in ('lat', 'latitude'):
if key in msg: if key in msg:
try: with contextlib.suppress(ValueError, TypeError):
balloon['lat'] = float(msg[key]) balloon['lat'] = float(msg[key])
except (ValueError, TypeError):
pass
break break
for key in ('lon', 'longitude'): for key in ('lon', 'longitude'):
if key in msg: if key in msg:
try: with contextlib.suppress(ValueError, TypeError):
balloon['lon'] = float(msg[key]) balloon['lon'] = float(msg[key])
except (ValueError, TypeError):
pass
break break
# Altitude (metres) # Altitude (metres)
if 'alt' in msg: if 'alt' in msg:
try: with contextlib.suppress(ValueError, TypeError):
balloon['alt'] = float(msg['alt']) balloon['alt'] = float(msg['alt'])
except (ValueError, TypeError):
pass
# Meteorological data # Meteorological data
for field in ('temp', 'humidity', 'pressure'): for field in ('temp', 'humidity', 'pressure'):
if field in msg: if field in msg:
try: with contextlib.suppress(ValueError, TypeError):
balloon[field] = float(msg[field]) balloon[field] = float(msg[field])
except (ValueError, TypeError):
pass
# Velocity # Velocity
if 'vel_h' in msg: if 'vel_h' in msg:
try: with contextlib.suppress(ValueError, TypeError):
balloon['vel_h'] = float(msg['vel_h']) balloon['vel_h'] = float(msg['vel_h'])
except (ValueError, TypeError):
pass
if 'vel_v' in msg: if 'vel_v' in msg:
try: with contextlib.suppress(ValueError, TypeError):
balloon['vel_v'] = float(msg['vel_v']) balloon['vel_v'] = float(msg['vel_v'])
except (ValueError, TypeError):
pass
if 'heading' in msg: if 'heading' in msg:
try: with contextlib.suppress(ValueError, TypeError):
balloon['heading'] = float(msg['heading']) balloon['heading'] = float(msg['heading'])
except (ValueError, TypeError):
pass
# GPS satellites # GPS satellites
if 'sats' in msg: if 'sats' in msg:
try: with contextlib.suppress(ValueError, TypeError):
balloon['sats'] = int(msg['sats']) balloon['sats'] = int(msg['sats'])
except (ValueError, TypeError):
pass
# Battery voltage # Battery voltage
if 'batt' in msg: if 'batt' in msg:
try: with contextlib.suppress(ValueError, TypeError):
balloon['batt'] = float(msg['batt']) balloon['batt'] = float(msg['batt'])
except (ValueError, TypeError):
pass
# Frequency # Frequency
if 'freq' in msg: if 'freq' in msg:
try: with contextlib.suppress(ValueError, TypeError):
balloon['freq'] = float(msg['freq']) balloon['freq'] = float(msg['freq'])
except (ValueError, TypeError):
pass
balloon['last_seen'] = time.time() balloon['last_seen'] = time.time()
return balloon return balloon
@@ -567,43 +659,43 @@ def start_radiosonde():
logger.error(f"Failed to generate radiosonde config: {e}") logger.error(f"Failed to generate radiosonde config: {e}")
return api_error(str(e), 500) return api_error(str(e), 500)
# 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)
else: if not selected_python:
cmd = [auto_rx_path, '-c', cfg_abs] logger.error(
"radiosonde_auto_rx dependency check failed across interpreters %s: %s",
# Set cwd to the auto_rx directory so 'from autorx.scan import ...' works checked_interpreters,
auto_rx_dir = os.path.dirname(os.path.abspath(auto_rx_path)) dep_error,
)
# Quick dependency check before launching the full process app_module.release_sdr_device(device_int, sdr_type_str)
if auto_rx_path.endswith('.py'): checked_msg = ', '.join(checked_interpreters) if checked_interpreters else 'none'
dep_check = subprocess.run( return api_error(
[sys.executable, '-c', 'import autorx.scan'], 'radiosonde_auto_rx dependencies not satisfied. '
cwd=auto_rx_dir, 'Install or repair its Python environment (missing packages such as semver). '
capture_output=True, f'Checked interpreters: {checked_msg}. '
timeout=10, f'Last error: {dep_error[:500]}',
) 500,
if dep_check.returncode != 0: )
dep_error = dep_check.stderr.decode('utf-8', errors='ignore').strip() cmd = [selected_python, auto_rx_path, '-c', cfg_abs]
logger.error(f"radiosonde_auto_rx dependency check failed:\n{dep_error}") else:
app_module.release_sdr_device(device_int, sdr_type_str) cmd = [auto_rx_path, '-c', cfg_abs]
return api_error(
'radiosonde_auto_rx dependencies not satisfied. ' # Set cwd to the auto_rx directory so 'from autorx.scan import ...' works
f'Re-run setup.sh to install. Error: {dep_error[:500]}', auto_rx_dir = os.path.dirname(os.path.abspath(auto_rx_path))
500, auto_rx_env = _build_auto_rx_env(auto_rx_dir)
)
try:
try: logger.info(f"Starting radiosonde_auto_rx: {' '.join(cmd)}")
logger.info(f"Starting radiosonde_auto_rx: {' '.join(cmd)}") app_module.radiosonde_process = subprocess.Popen(
app_module.radiosonde_process = subprocess.Popen( cmd,
cmd,
stdout=subprocess.DEVNULL, stdout=subprocess.DEVNULL,
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
time.sleep(2.0) time.sleep(2.0)
@@ -612,12 +704,10 @@ def start_radiosonde():
app_module.release_sdr_device(device_int, sdr_type_str) app_module.release_sdr_device(device_int, sdr_type_str)
stderr_output = '' stderr_output = ''
if app_module.radiosonde_process.stderr: if app_module.radiosonde_process.stderr:
try: with contextlib.suppress(Exception):
stderr_output = app_module.radiosonde_process.stderr.read().decode( stderr_output = app_module.radiosonde_process.stderr.read().decode(
'utf-8', errors='ignore' 'utf-8', errors='ignore'
).strip() ).strip()
except Exception:
pass
if stderr_output: if stderr_output:
logger.error(f"radiosonde_auto_rx stderr:\n{stderr_output}") logger.error(f"radiosonde_auto_rx stderr:\n{stderr_output}")
if stderr_output and ( if stderr_output and (
@@ -686,10 +776,8 @@ def stop_radiosonde():
# Close UDP socket to unblock listener thread # Close UDP socket to unblock listener thread
if _udp_socket: if _udp_socket:
try: with contextlib.suppress(OSError):
_udp_socket.close() _udp_socket.close()
except OSError:
pass
_udp_socket = None _udp_socket = None
# Release SDR device # Release SDR device
+3 -3
View File
@@ -5,10 +5,10 @@ from __future__ import annotations
import json import json
from pathlib import Path from pathlib import Path
from flask import Blueprint, jsonify, request, send_file from flask import Blueprint, request, send_file
from utils.recording import get_recording_manager, RECORDING_ROOT from utils.recording import RECORDING_ROOT, get_recording_manager
from utils.responses import api_success, api_error from utils.responses import api_error, api_success
recordings_bp = Blueprint('recordings', __name__, url_prefix='/recordings') recordings_bp = Blueprint('recordings', __name__, url_prefix='/recordings')
+13 -19
View File
@@ -2,25 +2,23 @@
from __future__ import annotations from __future__ import annotations
import contextlib
import json import json
import queue import queue
import subprocess import subprocess
import threading import threading
import time import time
from datetime import datetime from datetime import datetime
from typing import Generator
from flask import Blueprint, jsonify, request, Response from flask import Blueprint, Response, jsonify, request
from utils.responses import api_success, api_error
import app as app_module import app as app_module
from utils.logging import sensor_logger as logger
from utils.validation import (
validate_frequency, validate_device_index, validate_gain, validate_ppm
)
from utils.sse import sse_stream_fanout
from utils.event_pipeline import process_event from utils.event_pipeline import process_event
from utils.process import safe_terminate, register_process, unregister_process from utils.logging import sensor_logger as logger
from utils.process import register_process, unregister_process
from utils.responses import api_error
from utils.sse import sse_stream_fanout
from utils.validation import validate_device_index, validate_frequency, validate_gain, validate_ppm
rtlamr_bp = Blueprint('rtlamr', __name__) rtlamr_bp = Blueprint('rtlamr', __name__)
@@ -70,10 +68,8 @@ def stream_rtlamr_output(process: subprocess.Popen[bytes]) -> None:
process.terminate() process.terminate()
process.wait(timeout=2) process.wait(timeout=2)
except Exception: except Exception:
try: with contextlib.suppress(Exception):
process.kill() process.kill()
except Exception:
pass
unregister_process(process) unregister_process(process)
# Kill companion rtl_tcp process # Kill companion rtl_tcp process
with rtl_tcp_lock: with rtl_tcp_lock:
@@ -82,10 +78,8 @@ def stream_rtlamr_output(process: subprocess.Popen[bytes]) -> None:
rtl_tcp_process.terminate() rtl_tcp_process.terminate()
rtl_tcp_process.wait(timeout=2) rtl_tcp_process.wait(timeout=2)
except Exception: except Exception:
try: with contextlib.suppress(Exception):
rtl_tcp_process.kill() rtl_tcp_process.kill()
except Exception:
pass
unregister_process(rtl_tcp_process) unregister_process(rtl_tcp_process)
rtl_tcp_process = None rtl_tcp_process = None
app_module.rtlamr_queue.put({'type': 'status', 'text': 'stopped'}) app_module.rtlamr_queue.put({'type': 'status', 'text': 'stopped'})
@@ -139,7 +133,7 @@ def start_rtlamr() -> Response:
# Get message type (default to scm) # Get message type (default to scm)
msgtype = data.get('msgtype', 'scm') msgtype = data.get('msgtype', 'scm')
output_format = data.get('format', 'json') output_format = data.get('format', 'json')
# Start rtl_tcp first # Start rtl_tcp first
rtl_tcp_just_started = False rtl_tcp_just_started = False
rtl_tcp_cmd_str = '' rtl_tcp_cmd_str = ''
@@ -191,16 +185,16 @@ def start_rtlamr() -> Response:
f'-format={output_format}', f'-format={output_format}',
f'-centerfreq={int(float(freq) * 1e6)}' f'-centerfreq={int(float(freq) * 1e6)}'
] ]
# Add filter options if provided # Add filter options if provided
filterid = data.get('filterid') filterid = data.get('filterid')
if filterid: if filterid:
cmd.append(f'-filterid={filterid}') cmd.append(f'-filterid={filterid}')
filtertype = data.get('filtertype') filtertype = data.get('filtertype')
if filtertype: if filtertype:
cmd.append(f'-filtertype={filtertype}') cmd.append(f'-filtertype={filtertype}')
# Unique messages only # Unique messages only
if data.get('unique', True): if data.get('unique', True):
cmd.append('-unique=true') cmd.append('-unique=true')
+459 -164
View File
@@ -2,30 +2,28 @@
from __future__ import annotations from __future__ import annotations
import json
import math import math
import threading
import time
import urllib.request import urllib.request
from datetime import datetime, timedelta from datetime import datetime, timedelta
from typing import Any, Optional
from urllib.parse import urlparse
import requests import requests
from flask import Blueprint, Response, jsonify, make_response, render_template, request
from flask import Blueprint, jsonify, request, render_template, Response
from utils.responses import api_success, api_error
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
from utils.database import ( from utils.database import (
get_tracked_satellites,
add_tracked_satellite, add_tracked_satellite,
bulk_add_tracked_satellites, bulk_add_tracked_satellites,
update_tracked_satellite, get_tracked_satellites,
remove_tracked_satellite, remove_tracked_satellite,
update_tracked_satellite,
) )
from utils.logging import satellite_logger as logger from utils.logging import satellite_logger as logger
from utils.validation import validate_latitude, validate_longitude, validate_hours, validate_elevation 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
satellite_bp = Blueprint('satellite', __name__, url_prefix='/satellite') satellite_bp = Blueprint('satellite', __name__, url_prefix='/satellite')
@@ -37,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)
@@ -49,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."""
@@ -69,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:
@@ -81,13 +342,25 @@ 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: Optional[float] = None, observer_lon: Optional[float] = None) -> Optional[dict]: def _fetch_iss_realtime(observer_lat: float | None = None, observer_lon: float | None = None) -> dict | None:
""" """
Fetch real-time ISS position from external APIs. Fetch real-time ISS position from external APIs.
@@ -128,6 +401,7 @@ def _fetch_iss_realtime(observer_lat: Optional[float] = None, observer_lon: Opti
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,
@@ -179,29 +453,34 @@ def _fetch_iss_realtime(observer_lat: Optional[float] = None, observer_lon: Opti
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.api import wgs84, EarthSatellite from skyfield.api import EarthSatellite, wgs84
from skyfield.almanac import find_discrete
except ImportError: except ImportError:
return jsonify({ return jsonify({
'status': 'error', 'status': 'error',
'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))
@@ -209,142 +488,115 @@ 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'])
def get_satellite_position(): def get_satellite_position():
"""Get real-time positions of satellites.""" """Get real-time positions of satellites."""
try: try:
from skyfield.api import wgs84, EarthSatellite from skyfield.api import EarthSatellite, wgs84
except ImportError: except ImportError:
return api_error('skyfield not installed', 503) return api_error('skyfield not installed', 503)
@@ -359,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 = []
@@ -407,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)
@@ -426,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),
@@ -451,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:
@@ -463,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.
+16 -13
View File
@@ -2,6 +2,7 @@
from __future__ import annotations from __future__ import annotations
import contextlib
import json import json
import math import math
import queue import queue
@@ -9,21 +10,25 @@ import subprocess
import threading import threading
import time import time
from datetime import datetime from datetime import datetime
from typing import Any, Generator from typing import Any
from flask import Blueprint, jsonify, request, Response from flask import Blueprint, Response, jsonify, request
from utils.responses import api_success, api_error
import app as app_module import app as app_module
from utils.logging import sensor_logger as logger
from utils.validation import (
validate_frequency, validate_device_index, validate_gain, validate_ppm,
validate_rtl_tcp_host, validate_rtl_tcp_port
)
from utils.sse import sse_stream_fanout
from utils.event_pipeline import process_event from utils.event_pipeline import process_event
from utils.process import safe_terminate, register_process, unregister_process from utils.logging import sensor_logger as logger
from utils.process import register_process, unregister_process
from utils.responses import api_error, api_success
from utils.sdr import SDRFactory, SDRType from utils.sdr import SDRFactory, SDRType
from utils.sse import sse_stream_fanout
from utils.validation import (
validate_device_index,
validate_frequency,
validate_gain,
validate_ppm,
validate_rtl_tcp_host,
validate_rtl_tcp_port,
)
sensor_bp = Blueprint('sensor', __name__) sensor_bp = Blueprint('sensor', __name__)
@@ -137,10 +142,8 @@ def stream_sensor_output(process: subprocess.Popen[bytes]) -> None:
process.terminate() process.terminate()
process.wait(timeout=2) process.wait(timeout=2)
except Exception: except Exception:
try: with contextlib.suppress(Exception):
process.kill() process.kill()
except Exception:
pass
unregister_process(process) unregister_process(process)
app_module.sensor_queue.put({'type': 'status', 'text': 'stopped'}) app_module.sensor_queue.put({'type': 'status', 'text': 'stopped'})
with app_module.sensor_lock: with app_module.sensor_lock:
+125 -22
View File
@@ -1,26 +1,101 @@
"""Settings management routes.""" """Settings management routes."""
from __future__ import annotations from __future__ import annotations
import os import contextlib
import subprocess import os
import sys import re
import subprocess
from flask import Blueprint, jsonify, request, Response import sys
import threading
from utils.database import ( from pathlib import Path
get_setting,
set_setting, from flask import Blueprint, Response, jsonify, request
from utils.database import (
delete_setting, delete_setting,
get_all_settings, get_all_settings,
get_correlations, get_correlations,
) get_setting,
from utils.logging import get_logger set_setting,
from utils.responses import api_error, api_success )
from utils.logging import get_logger
logger = get_logger('intercept.settings') from utils.responses import api_error, api_success
from utils.validation import validate_latitude, validate_longitude
settings_bp = Blueprint('settings', __name__, url_prefix='/settings')
logger = get_logger('intercept.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'])
@@ -92,8 +167,8 @@ def update_single_setting(key: str) -> Response:
return api_error(str(e), 500) return api_error(str(e), 500)
@settings_bp.route('/<key>', methods=['DELETE']) @settings_bp.route('/<key>', methods=['DELETE'])
def delete_single_setting(key: str) -> Response: def delete_single_setting(key: str) -> Response:
"""Delete a setting.""" """Delete a setting."""
try: try:
deleted = delete_setting(key) deleted = delete_setting(key)
@@ -106,7 +181,35 @@ def delete_single_setting(key: str) -> Response:
}), 404 }), 404
except Exception as e: except Exception as e:
logger.error(f"Error deleting setting {key}: {e}") logger.error(f"Error deleting setting {key}: {e}")
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)
# ============================================================================= # =============================================================================
@@ -163,7 +266,7 @@ def check_dvb_driver_status() -> Response:
blacklist_contents = [] blacklist_contents = []
if blacklist_exists: if blacklist_exists:
try: try:
with open(BLACKLIST_FILE, 'r') as f: with open(BLACKLIST_FILE) as f:
blacklist_contents = [line.strip() for line in f if line.strip() and not line.startswith('#')] blacklist_contents = [line.strip() for line in f if line.strip() and not line.startswith('#')]
except Exception: except Exception:
pass pass
+1 -1
View File
@@ -10,8 +10,8 @@ from typing import Any
from flask import Blueprint, Response, jsonify, request from flask import Blueprint, Response, jsonify, request
from utils.responses import api_success, api_error
from utils.logging import get_logger from utils.logging import get_logger
from utils.responses import api_error
logger = get_logger('intercept.signalid') logger = get_logger('intercept.signalid')
+1 -1
View File
@@ -13,7 +13,7 @@ from typing import Any
from flask import Blueprint, Response, jsonify from flask import Blueprint, Response, jsonify
from utils.logging import get_logger from utils.logging import get_logger
from utils.responses import api_success, api_error from utils.responses import api_error
logger = get_logger('intercept.space_weather') logger = get_logger('intercept.space_weather')
+3 -3
View File
@@ -611,9 +611,9 @@ def get_station(station_id):
@spy_stations_bp.route('/filters') @spy_stations_bp.route('/filters')
def get_filters(): def get_filters():
"""Return available filter options.""" """Return available filter options."""
types = list(set(s['type'] for s in STATIONS)) types = list({s['type'] for s in STATIONS})
countries = sorted(list(set((s['country'], s['country_code']) for s in STATIONS))) countries = sorted({(s['country'], s['country_code']) for s in STATIONS})
modes = sorted(list(set(s['mode'].split('/')[0] for s in STATIONS))) modes = sorted({s['mode'].split('/')[0] for s in STATIONS})
return jsonify({ return jsonify({
'status': 'success', 'status': 'success',
+19 -18
View File
@@ -6,23 +6,24 @@ ISS SSTV events occur during special commemorations and typically transmit on 14
from __future__ import annotations from __future__ import annotations
import contextlib
import queue import queue
import threading import threading
import time import time
from pathlib import Path from pathlib import Path
from typing import Any from typing import Any
from flask import Blueprint, jsonify, request, Response, send_file from flask import Blueprint, Response, jsonify, request, send_file
from utils.responses import api_success, api_error
import app as app_module import app as app_module
from utils.logging import get_logger
from utils.sse import sse_stream_fanout
from utils.event_pipeline import process_event from utils.event_pipeline import process_event
from utils.logging import get_logger
from utils.responses import api_error
from utils.sse import sse_stream_fanout
from utils.sstv import ( from utils.sstv import (
ISS_SSTV_FREQ,
get_sstv_decoder, get_sstv_decoder,
is_sstv_available, is_sstv_available,
ISS_SSTV_FREQ,
) )
logger = get_logger('intercept.sstv') logger = get_logger('intercept.sstv')
@@ -471,14 +472,14 @@ def stream_progress():
return response return response
def _get_timescale(): def _get_timescale():
"""Return a cached skyfield timescale (expensive to create).""" """Return a cached skyfield timescale (expensive to create)."""
global _timescale global _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
@sstv_bp.route('/iss-schedule') @sstv_bp.route('/iss-schedule')
@@ -520,9 +521,11 @@ def iss_schedule():
return jsonify(_iss_schedule_cache) return jsonify(_iss_schedule_cache)
try: try:
from skyfield.api import wgs84, EarthSatellite
from skyfield.almanac import find_discrete
from datetime import timedelta from datetime import timedelta
from skyfield.almanac import find_discrete
from skyfield.api import EarthSatellite, wgs84
from data.satellites import TLE_SATELLITES from data.satellites import TLE_SATELLITES
# Get ISS TLE # Get ISS TLE
@@ -816,7 +819,5 @@ def decode_file():
finally: finally:
# Clean up temp file # Clean up temp file
try: with contextlib.suppress(Exception):
Path(tmp_path).unlink() Path(tmp_path).unlink()
except Exception:
pass
+5 -8
View File
@@ -6,18 +6,17 @@ frequencies used by amateur radio operators worldwide.
from __future__ import annotations from __future__ import annotations
import contextlib
import queue import queue
import time
from collections.abc import Generator
from pathlib import Path from pathlib import Path
from flask import Blueprint, Response, jsonify, request, send_file from flask import Blueprint, Response, jsonify, request, send_file
from utils.responses import api_success, api_error
import app as app_module import app as app_module
from utils.logging import get_logger
from utils.sse import sse_stream_fanout
from utils.event_pipeline import process_event from utils.event_pipeline import process_event
from utils.logging import get_logger
from utils.responses import api_error
from utils.sse import sse_stream_fanout
from utils.sstv import ( from utils.sstv import (
get_general_sstv_decoder, get_general_sstv_decoder,
) )
@@ -325,7 +324,5 @@ def decode_file():
return api_error(str(e), 500) return api_error(str(e), 500)
finally: finally:
try: with contextlib.suppress(Exception):
Path(tmp_path).unlink() Path(tmp_path).unlink()
except Exception:
pass
+15 -16
View File
@@ -6,25 +6,26 @@ signal replay/transmit, and wideband spectrum analysis.
from __future__ import annotations from __future__ import annotations
import contextlib
import queue import queue
from flask import Blueprint, jsonify, request, Response, send_file from flask import Blueprint, Response, jsonify, request, send_file
from utils.responses import api_success, api_error from utils.constants import (
SUBGHZ_FREQ_MAX_MHZ,
SUBGHZ_FREQ_MIN_MHZ,
SUBGHZ_LNA_GAIN_MAX,
SUBGHZ_PRESETS,
SUBGHZ_SAMPLE_RATES,
SUBGHZ_TX_MAX_DURATION,
SUBGHZ_TX_VGA_GAIN_MAX,
SUBGHZ_VGA_GAIN_MAX,
)
from utils.event_pipeline import process_event
from utils.logging import get_logger from utils.logging import get_logger
from utils.responses import api_error
from utils.sse import sse_stream from utils.sse import sse_stream
from utils.subghz import get_subghz_manager from utils.subghz import get_subghz_manager
from utils.event_pipeline import process_event
from utils.constants import (
SUBGHZ_FREQ_MIN_MHZ,
SUBGHZ_FREQ_MAX_MHZ,
SUBGHZ_LNA_GAIN_MAX,
SUBGHZ_VGA_GAIN_MAX,
SUBGHZ_TX_VGA_GAIN_MAX,
SUBGHZ_TX_MAX_DURATION,
SUBGHZ_SAMPLE_RATES,
SUBGHZ_PRESETS,
)
logger = get_logger('intercept.subghz') logger = get_logger('intercept.subghz')
@@ -36,10 +37,8 @@ _subghz_queue: queue.Queue = queue.Queue(maxsize=200)
def _event_callback(event: dict) -> None: def _event_callback(event: dict) -> None:
"""Forward SubGhzManager events to the SSE queue.""" """Forward SubGhzManager events to the SSE queue."""
try: with contextlib.suppress(Exception):
process_event('subghz', event, event.get('type')) process_event('subghz', event, event.get('type'))
except Exception:
pass
try: try:
_subghz_queue.put_nowait(event) _subghz_queue.put_nowait(event)
except queue.Full: except queue.Full:
+1 -1
View File
@@ -22,7 +22,7 @@ from flask import Blueprint, Response, jsonify, request
from utils.constants import SSE_KEEPALIVE_INTERVAL, SSE_QUEUE_TIMEOUT from utils.constants import SSE_KEEPALIVE_INTERVAL, SSE_QUEUE_TIMEOUT
from utils.logging import sensor_logger as logger from utils.logging import sensor_logger as logger
from utils.responses import api_success, api_error from utils.responses import api_error
from utils.sse import sse_stream_fanout from utils.sse import sse_stream_fanout
try: try:
+22 -22
View File
@@ -7,6 +7,7 @@ threat detection, and reporting.
from __future__ import annotations from __future__ import annotations
import contextlib
import json import json
import logging import logging
import queue import queue
@@ -23,9 +24,9 @@ from data.tscm_frequencies import (
get_sweep_preset, get_sweep_preset,
) )
from utils.database import ( from utils.database import (
acknowledge_tscm_threat,
add_device_timeline_entry, add_device_timeline_entry,
add_tscm_threat, add_tscm_threat,
acknowledge_tscm_threat,
cleanup_old_timeline_entries, cleanup_old_timeline_entries,
create_tscm_schedule, create_tscm_schedule,
create_tscm_sweep, create_tscm_sweep,
@@ -43,6 +44,8 @@ from utils.database import (
update_tscm_schedule, update_tscm_schedule,
update_tscm_sweep, update_tscm_sweep,
) )
from utils.event_pipeline import process_event
from utils.sse import sse_stream_fanout
from utils.tscm.baseline import ( from utils.tscm.baseline import (
BaselineComparator, BaselineComparator,
BaselineRecorder, BaselineRecorder,
@@ -56,12 +59,10 @@ from utils.tscm.correlation import (
from utils.tscm.detector import ThreatDetector from utils.tscm.detector import ThreatDetector
from utils.tscm.device_identity import ( from utils.tscm.device_identity import (
get_identity_engine, get_identity_engine,
reset_identity_engine,
ingest_ble_dict, ingest_ble_dict,
ingest_wifi_dict, ingest_wifi_dict,
reset_identity_engine,
) )
from utils.event_pipeline import process_event
from utils.sse import sse_stream_fanout
# Import unified Bluetooth scanner helper for TSCM integration # Import unified Bluetooth scanner helper for TSCM integration
try: try:
@@ -659,8 +660,8 @@ def _scan_bluetooth_devices(interface: str, duration: int = 10) -> list[dict]:
Uses the BLE scanner module (bleak library) for proper manufacturer ID Uses the BLE scanner module (bleak library) for proper manufacturer ID
detection, with fallback to system tools if bleak is unavailable. detection, with fallback to system tools if bleak is unavailable.
""" """
import platform
import os import os
import platform
import re import re
import shutil import shutil
import subprocess import subprocess
@@ -874,10 +875,8 @@ def _scan_bluetooth_devices(interface: str, duration: int = 10) -> list[dict]:
except subprocess.TimeoutExpired: except subprocess.TimeoutExpired:
process.kill() process.kill()
try: with contextlib.suppress(OSError):
os.close(master_fd) os.close(master_fd)
except OSError:
pass
logger.info(f"bluetoothctl scan found {len(devices)} devices") logger.info(f"bluetoothctl scan found {len(devices)} devices")
@@ -914,7 +913,8 @@ def _scan_rf_signals(
""" """
# Default stop check uses module-level _sweep_running # Default stop check uses module-level _sweep_running
if stop_check is None: if stop_check is None:
stop_check = lambda: not _sweep_running def stop_check():
return not _sweep_running
import os import os
import shutil import shutil
import subprocess import subprocess
@@ -954,11 +954,11 @@ def _scan_rf_signals(
# Tool exists but no device detected — try anyway (detection may have failed) # Tool exists but no device detected — try anyway (detection may have failed)
sdr_type = 'rtlsdr' sdr_type = 'rtlsdr'
sweep_tool_path = rtl_power_path sweep_tool_path = rtl_power_path
logger.info(f"No SDR detected but rtl_power found, attempting RTL-SDR scan") logger.info("No SDR detected but rtl_power found, attempting RTL-SDR scan")
elif hackrf_sweep_path: elif hackrf_sweep_path:
sdr_type = 'hackrf' sdr_type = 'hackrf'
sweep_tool_path = hackrf_sweep_path sweep_tool_path = hackrf_sweep_path
logger.info(f"No SDR detected but hackrf_sweep found, attempting HackRF scan") logger.info("No SDR detected but hackrf_sweep found, attempting HackRF scan")
if not sweep_tool_path: if not sweep_tool_path:
logger.warning("No supported sweep tool found (rtl_power or hackrf_sweep)") logger.warning("No supported sweep tool found (rtl_power or hackrf_sweep)")
@@ -1059,14 +1059,14 @@ def _scan_rf_signals(
# Parse the CSV output (same format for both rtl_power and hackrf_sweep) # Parse the CSV output (same format for both rtl_power and hackrf_sweep)
if os.path.exists(tmp_path) and os.path.getsize(tmp_path) > 0: if os.path.exists(tmp_path) and os.path.getsize(tmp_path) > 0:
with open(tmp_path, 'r') as f: with open(tmp_path) as f:
for line in f: for line in f:
parts = line.strip().split(',') parts = line.strip().split(',')
if len(parts) >= 7: if len(parts) >= 7:
try: try:
# CSV format: date, time, hz_low, hz_high, hz_step, samples, db_values... # CSV format: date, time, hz_low, hz_high, hz_step, samples, db_values...
hz_low = int(parts[2].strip()) hz_low = int(parts[2].strip())
hz_high = int(parts[3].strip()) int(parts[3].strip())
hz_step = float(parts[4].strip()) hz_step = float(parts[4].strip())
db_values = [float(x) for x in parts[6:] if x.strip()] db_values = [float(x) for x in parts[6:] if x.strip()]
@@ -1100,10 +1100,8 @@ def _scan_rf_signals(
finally: finally:
# Cleanup temp file # Cleanup temp file
try: with contextlib.suppress(OSError):
os.unlink(tmp_path) os.unlink(tmp_path)
except OSError:
pass
# Deduplicate nearby frequencies (within 100kHz) # Deduplicate nearby frequencies (within 100kHz)
if signals: if signals:
@@ -1816,9 +1814,11 @@ def _generate_assessment(summary: dict) -> str:
# ============================================================================= # =============================================================================
# Import sub-modules to register routes on tscm_bp # Import sub-modules to register routes on tscm_bp
# ============================================================================= # =============================================================================
from routes.tscm import sweep # noqa: E402, F401 from routes.tscm import (
from routes.tscm import baseline # noqa: E402, F401 analysis, # noqa: E402, F401
from routes.tscm import cases # noqa: E402, F401 baseline, # noqa: E402, F401
from routes.tscm import meeting # noqa: E402, F401 cases, # noqa: E402, F401
from routes.tscm import analysis # noqa: E402, F401 meeting, # noqa: E402, F401
from routes.tscm import schedules # noqa: E402, F401 schedules, # noqa: E402, F401
sweep, # noqa: E402, F401
)
+5 -6
View File
@@ -14,7 +14,6 @@ from datetime import datetime
from flask import Response, jsonify, request from flask import Response, jsonify, request
from routes.tscm import ( from routes.tscm import (
_current_sweep_id,
_generate_assessment, _generate_assessment,
tscm_bp, tscm_bp,
) )
@@ -253,9 +252,9 @@ def get_pdf_report():
summary, and mandatory disclaimers. summary, and mandatory disclaimers.
""" """
try: try:
from utils.tscm.reports import generate_report, get_pdf_report
from utils.tscm.advanced import detect_sweep_capabilities, get_timeline_manager
from routes.tscm import _current_sweep_id from routes.tscm import _current_sweep_id
from utils.tscm.advanced import detect_sweep_capabilities, get_timeline_manager
from utils.tscm.reports import generate_report, get_pdf_report
sweep_id = request.args.get('sweep_id', _current_sweep_id, type=int) sweep_id = request.args.get('sweep_id', _current_sweep_id, type=int)
if not sweep_id: if not sweep_id:
@@ -306,9 +305,9 @@ def get_technical_annex():
for audit purposes. No packet data included. for audit purposes. No packet data included.
""" """
try: try:
from utils.tscm.reports import generate_report, get_json_annex, get_csv_annex
from utils.tscm.advanced import detect_sweep_capabilities, get_timeline_manager
from routes.tscm import _current_sweep_id from routes.tscm import _current_sweep_id
from utils.tscm.advanced import detect_sweep_capabilities, get_timeline_manager
from utils.tscm.reports import generate_report, get_csv_annex, get_json_annex
sweep_id = request.args.get('sweep_id', _current_sweep_id, type=int) sweep_id = request.args.get('sweep_id', _current_sweep_id, type=int)
format_type = request.args.get('format', 'json') format_type = request.args.get('format', 'json')
@@ -900,8 +899,8 @@ def get_device_timeline_endpoint(identifier: str):
and meeting window correlation. and meeting window correlation.
""" """
try: try:
from utils.tscm.advanced import get_timeline_manager
from utils.database import get_device_timeline from utils.database import get_device_timeline
from utils.tscm.advanced import get_timeline_manager
protocol = request.args.get('protocol', 'bluetooth') protocol = request.args.get('protocol', 'bluetooth')
since_hours = request.args.get('since_hours', 24, type=int) since_hours = request.args.get('since_hours', 24, type=int)
-2
View File
@@ -25,7 +25,6 @@ from utils.database import (
set_active_tscm_baseline, set_active_tscm_baseline,
) )
from utils.tscm.baseline import ( from utils.tscm.baseline import (
BaselineComparator,
get_comparison_for_active_baseline, get_comparison_for_active_baseline,
) )
@@ -213,7 +212,6 @@ def get_baseline_diff(baseline_id: int, sweep_id: int):
def get_baseline_health(baseline_id: int): def get_baseline_health(baseline_id: int):
"""Get health assessment for a baseline.""" """Get health assessment for a baseline."""
try: try:
from utils.tscm.advanced import BaselineHealth
baseline = get_tscm_baseline(baseline_id) baseline = get_tscm_baseline(baseline_id)
if not baseline: if not baseline:
+1 -3
View File
@@ -91,7 +91,6 @@ def start_tracked_meeting():
""" """
from utils.database import start_meeting_window from utils.database import start_meeting_window
from utils.tscm.advanced import get_timeline_manager from utils.tscm.advanced import get_timeline_manager
from routes.tscm import _current_sweep_id
data = request.get_json() or {} data = request.get_json() or {}
@@ -156,9 +155,9 @@ def end_tracked_meeting(meeting_id: int):
def get_meeting_summary_endpoint(meeting_id: int): def get_meeting_summary_endpoint(meeting_id: int):
"""Get detailed summary of device activity during a meeting.""" """Get detailed summary of device activity during a meeting."""
try: try:
from routes.tscm import _current_sweep_id
from utils.database import get_meeting_windows from utils.database import get_meeting_windows
from utils.tscm.advanced import generate_meeting_summary, get_timeline_manager from utils.tscm.advanced import generate_meeting_summary, get_timeline_manager
from routes.tscm import _current_sweep_id
# Get meeting window # Get meeting window
windows = get_meeting_windows(_current_sweep_id or 0) windows = get_meeting_windows(_current_sweep_id or 0)
@@ -194,7 +193,6 @@ def get_meeting_summary_endpoint(meeting_id: int):
def get_active_meeting(): def get_active_meeting():
"""Get currently active meeting window.""" """Get currently active meeting window."""
from utils.database import get_active_meeting_window from utils.database import get_active_meeting_window
from routes.tscm import _current_sweep_id
meeting = get_active_meeting_window(_current_sweep_id) meeting = get_active_meeting_window(_current_sweep_id)
-1
View File
@@ -16,7 +16,6 @@ from routes.tscm import (
_get_schedule_timezone, _get_schedule_timezone,
_next_run_from_cron, _next_run_from_cron,
_start_sweep_internal, _start_sweep_internal,
_sweep_running,
tscm_bp, tscm_bp,
) )
from utils.database import ( from utils.database import (
+13 -17
View File
@@ -7,27 +7,25 @@ Handles /sweep/*, /status, /devices, /presets/*, /feed/*,
from __future__ import annotations from __future__ import annotations
import json
import logging import logging
import os import os
import platform import platform
import re import re
import shutil
import subprocess import subprocess
from typing import Any from typing import Any
from flask import Response, jsonify, request from flask import Response, jsonify, request
from data.tscm_frequencies import get_all_sweep_presets, get_sweep_preset
from routes.tscm import ( from routes.tscm import (
_baseline_recorder,
_current_sweep_id, _current_sweep_id,
_emit_event, _emit_event,
_start_sweep_internal, _start_sweep_internal,
_sweep_running, _sweep_running,
tscm_bp, tscm_bp,
tscm_queue, tscm_queue,
_baseline_recorder,
) )
from data.tscm_frequencies import get_all_sweep_presets, get_sweep_preset
from utils.database import get_tscm_sweep, update_tscm_sweep from utils.database import get_tscm_sweep, update_tscm_sweep
from utils.event_pipeline import process_event from utils.event_pipeline import process_event
from utils.sse import sse_stream_fanout from utils.sse import sse_stream_fanout
@@ -38,8 +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."""
from routes.tscm import _sweep_running import routes.tscm as _tscm_pkg
return jsonify({'running': _sweep_running}) return jsonify({'running': _tscm_pkg._sweep_running})
@tscm_bp.route('/sweep/start', methods=['POST']) @tscm_bp.route('/sweep/start', methods=['POST'])
@@ -98,15 +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."""
from routes.tscm import _sweep_running, _current_sweep_id 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
@@ -116,14 +114,15 @@ def sweep_status():
@tscm_bp.route('/sweep/stream') @tscm_bp.route('/sweep/stream')
def sweep_stream(): def sweep_stream():
"""SSE stream for real-time sweep updates.""" """SSE stream for real-time sweep updates."""
from routes.tscm import tscm_queue
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,
@@ -218,7 +217,7 @@ def get_tscm_devices():
capture_output=True, text=True, timeout=5 capture_output=True, text=True, timeout=5
) )
blocks = re.split(r'(?=^hci\d+:)', result.stdout, flags=re.MULTILINE) blocks = re.split(r'(?=^hci\d+:)', result.stdout, flags=re.MULTILINE)
for idx, block in enumerate(blocks): for _idx, block in enumerate(blocks):
if block.strip(): if block.strip():
first_line = block.split('\n')[0] first_line = block.split('\n')[0]
match = re.match(r'(hci\d+):', first_line) match = re.match(r'(hci\d+):', first_line)
@@ -353,7 +352,6 @@ def get_preset(preset_name: str):
@tscm_bp.route('/feed/wifi', methods=['POST']) @tscm_bp.route('/feed/wifi', methods=['POST'])
def feed_wifi(): def feed_wifi():
"""Feed WiFi device data for baseline recording.""" """Feed WiFi device data for baseline recording."""
from routes.tscm import _baseline_recorder
data = request.get_json() data = request.get_json()
if data: if data:
@@ -367,7 +365,6 @@ def feed_wifi():
@tscm_bp.route('/feed/bluetooth', methods=['POST']) @tscm_bp.route('/feed/bluetooth', methods=['POST'])
def feed_bluetooth(): def feed_bluetooth():
"""Feed Bluetooth device data for baseline recording.""" """Feed Bluetooth device data for baseline recording."""
from routes.tscm import _baseline_recorder
data = request.get_json() data = request.get_json()
if data: if data:
@@ -378,7 +375,6 @@ def feed_bluetooth():
@tscm_bp.route('/feed/rf', methods=['POST']) @tscm_bp.route('/feed/rf', methods=['POST'])
def feed_rf(): def feed_rf():
"""Feed RF signal data for baseline recording.""" """Feed RF signal data for baseline recording."""
from routes.tscm import _baseline_recorder
data = request.get_json() data = request.get_json()
if data: if data:
+1 -1
View File
@@ -4,8 +4,8 @@ from __future__ import annotations
from flask import Blueprint, Response, jsonify, request from flask import Blueprint, Response, jsonify, request
from utils.responses import api_success, api_error
from utils.logging import get_logger from utils.logging import get_logger
from utils.responses import api_error
from utils.updater import ( from utils.updater import (
check_for_updates, check_for_updates,
dismiss_update, dismiss_update,
+6 -10
View File
@@ -2,7 +2,7 @@
from __future__ import annotations from __future__ import annotations
import io import contextlib
import json import json
import os import os
import platform import platform
@@ -13,12 +13,11 @@ import subprocess
import threading import threading
import time import time
from datetime import datetime from datetime import datetime
from typing import Any, Generator from typing import Any
from flask import Blueprint, Response, jsonify, request from flask import Blueprint, Response, jsonify, request
import app as app_module import app as app_module
from utils.responses import api_success, api_error
from utils.acars_translator import translate_message from utils.acars_translator import translate_message
from utils.constants import ( from utils.constants import (
PROCESS_START_WAIT, PROCESS_START_WAIT,
@@ -30,6 +29,7 @@ from utils.event_pipeline import process_event
from utils.flight_correlator import get_flight_correlator from utils.flight_correlator import get_flight_correlator
from utils.logging import sensor_logger as logger from utils.logging import sensor_logger as logger
from utils.process import register_process, unregister_process from utils.process import register_process, unregister_process
from utils.responses import api_error
from utils.sdr import SDRFactory, SDRType from utils.sdr import SDRFactory, SDRType
from utils.sse import sse_stream_fanout from utils.sse import sse_stream_fanout
from utils.validation import validate_device_index, validate_gain, validate_ppm from utils.validation import validate_device_index, validate_gain, validate_ppm
@@ -105,10 +105,8 @@ def stream_vdl2_output(process: subprocess.Popen, is_text_mode: bool = False) ->
app_module.vdl2_queue.put(data) app_module.vdl2_queue.put(data)
# Feed flight correlator # Feed flight correlator
try: with contextlib.suppress(Exception):
get_flight_correlator().add_vdl2_message(data) get_flight_correlator().add_vdl2_message(data)
except Exception:
pass
# Log if enabled # Log if enabled
if app_module.logging_enabled: if app_module.logging_enabled:
@@ -134,10 +132,8 @@ def stream_vdl2_output(process: subprocess.Popen, is_text_mode: bool = False) ->
process.terminate() process.terminate()
process.wait(timeout=2) process.wait(timeout=2)
except Exception: except Exception:
try: with contextlib.suppress(Exception):
process.kill() process.kill()
except Exception:
pass
unregister_process(process) unregister_process(process)
app_module.vdl2_queue.put({'type': 'status', 'status': 'stopped'}) app_module.vdl2_queue.put({'type': 'status', 'status': 'stopped'})
with app_module.vdl2_lock: with app_module.vdl2_lock:
@@ -275,7 +271,7 @@ def start_vdl2() -> Response:
) )
os.close(slave_fd) os.close(slave_fd)
# Wrap master_fd as a text file for line-buffered reading # Wrap master_fd as a text file for line-buffered reading
process.stdout = io.open(master_fd, 'r', buffering=1) process.stdout = open(master_fd, buffering=1)
is_text_mode = True is_text_mode = True
else: else:
process = subprocess.Popen( process = subprocess.Popen(
-2
View File
@@ -372,7 +372,6 @@ def init_waterfall_websocket(app: Flask):
capture_center_mhz = 0.0 capture_center_mhz = 0.0
capture_start_freq = 0.0 capture_start_freq = 0.0
capture_end_freq = 0.0 capture_end_freq = 0.0
capture_span_mhz = 0.0
# Queue for outgoing messages — only the main loop touches ws.send() # Queue for outgoing messages — only the main loop touches ws.send()
send_queue = queue.Queue(maxsize=120) send_queue = queue.Queue(maxsize=120)
@@ -619,7 +618,6 @@ def init_waterfall_websocket(app: Flask):
capture_center_mhz = center_freq_mhz capture_center_mhz = center_freq_mhz
capture_start_freq = start_freq capture_start_freq = start_freq
capture_end_freq = end_freq capture_end_freq = end_freq
capture_span_mhz = effective_span_mhz
my_generation = _set_shared_capture_state( my_generation = _set_shared_capture_state(
running=True, running=True,
+176 -58
View File
@@ -1,33 +1,53 @@
"""Weather Satellite decoder routes. """Weather Satellite decoder routes.
Provides endpoints for capturing and decoding Meteor LRPT weather
imagery, including shared results produced by the ground-station
observation pipeline.
"""
Provides endpoints for capturing and decoding weather satellite images from __future__ import annotations
from NOAA (APT) and Meteor (LRPT) satellites using SatDump.
""" import json
import queue
from pathlib import Path
from flask import Blueprint, Response, jsonify, request, send_file
from __future__ import annotations
import queue
from flask import Blueprint, jsonify, request, Response, send_file
from utils.responses import api_success, api_error
from utils.logging import get_logger from utils.logging import get_logger
from utils.responses import api_error
from utils.sse import sse_stream from utils.sse import sse_stream
from utils.validation import validate_device_index, validate_gain, validate_latitude, validate_longitude, validate_elevation, validate_rtl_tcp_host, validate_rtl_tcp_port from utils.validation import (
validate_device_index,
validate_elevation,
validate_gain,
validate_latitude,
validate_longitude,
validate_rtl_tcp_host,
validate_rtl_tcp_port,
)
from utils.weather_sat import ( from utils.weather_sat import (
DEFAULT_SAMPLE_RATE,
WEATHER_SATELLITES,
CaptureProgress,
get_weather_sat_decoder, get_weather_sat_decoder,
is_weather_sat_available, is_weather_sat_available,
CaptureProgress,
WEATHER_SATELLITES,
DEFAULT_SAMPLE_RATE,
) )
logger = get_logger('intercept.weather_sat') logger = get_logger('intercept.weather_sat')
weather_sat_bp = Blueprint('weather_sat', __name__, url_prefix='/weather-sat') 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:
@@ -112,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)
@@ -240,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)
} }
@@ -284,15 +304,14 @@ 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 any(resolved.is_relative_to(base) for base in ALLOWED_TEST_DECODE_DIRS):
if not resolved.is_relative_to(allowed_base): return jsonify({
return jsonify({ 'status': 'error',
'status': 'error', 'message': 'input_file must be under INTERCEPT data or ground-station recordings'
'message': 'input_file must be under the data/ directory' }), 403
}), 403
except (OSError, ValueError): except (OSError, ValueError):
return jsonify({ return jsonify({
'status': 'error', 'status': 'error',
@@ -369,8 +388,8 @@ def stop_capture():
return jsonify({'status': 'stopped'}) return jsonify({'status': 'stopped'})
@weather_sat_bp.route('/images') @weather_sat_bp.route('/images')
def list_images(): def list_images():
"""Get list of decoded weather satellite images. """Get list of decoded weather satellite images.
Query parameters: Query parameters:
@@ -380,28 +399,41 @@ def list_images():
Returns: Returns:
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 = [
{
# Filter by satellite if specified **img.to_dict(),
satellite_filter = request.args.get('satellite') 'source': 'weather_sat',
if satellite_filter: 'deletable': True,
images = [img for img in images if img.satellite == satellite_filter] }
for img in decoder.get_images()
# Apply limit ]
limit = request.args.get('limit', type=int) images.extend(_get_ground_station_images())
if limit and limit > 0:
images = images[-limit:] # Filter by satellite if specified
satellite_filter = request.args.get('satellite')
return jsonify({ if satellite_filter:
'status': 'ok', images = [
'images': [img.to_dict() for img in images], img for img in images
'count': len(images), if str(img.get('satellite', '')).upper() == satellite_filter.upper()
}) ]
images.sort(key=lambda img: img.get('timestamp') or '', reverse=True)
# Apply limit
limit = request.args.get('limit', type=int)
if limit and limit > 0:
images = images[:limit]
return jsonify({
'status': 'ok',
'images': images,
'count': len(images),
})
@weather_sat_bp.route('/images/<filename>') @weather_sat_bp.route('/images/<filename>')
def get_image(filename: str): def get_image(filename: str):
"""Serve a decoded weather satellite image file. """Serve a decoded weather satellite image file.
Args: Args:
@@ -424,8 +456,38 @@ def get_image(filename: str):
if not image_path.exists(): if not image_path.exists():
return api_error('Image not found', 404) return api_error('Image not found', 404)
mimetype = 'image/png' if filename.endswith('.png') else 'image/jpeg' mimetype = 'image/png' if filename.endswith('.png') else 'image/jpeg'
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'])
@@ -450,15 +512,71 @@ def delete_image(filename: str):
@weather_sat_bp.route('/images', methods=['DELETE']) @weather_sat_bp.route('/images', methods=['DELETE'])
def delete_all_images(): def delete_all_images():
"""Delete all decoded weather satellite images. """Delete all decoded weather satellite images.
Returns: Returns:
JSON with count of deleted images. JSON with count of deleted images.
""" """
decoder = get_weather_sat_decoder() decoder = get_weather_sat_decoder()
count = decoder.delete_all_images() count = decoder.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')
@@ -613,7 +731,7 @@ def enable_schedule():
gain=gain_val, gain=gain_val,
bias_t=bool(data.get('bias_t', False)), bias_t=bool(data.get('bias_t', False)),
) )
except Exception as e: except Exception:
logger.exception("Failed to enable weather sat scheduler") logger.exception("Failed to enable weather sat scheduler")
return jsonify({ return jsonify({
'status': 'error', 'status': 'error',
+12 -19
View File
@@ -9,11 +9,10 @@ import re
import struct import struct
import threading import threading
import time import time
from typing import Optional
from flask import Blueprint, Flask, jsonify, request, Response from flask import Blueprint, Flask, Response, jsonify, request
from utils.responses import api_success, api_error from utils.responses import api_error, api_success
try: try:
from flask_sock import Sock from flask_sock import Sock
@@ -21,7 +20,9 @@ try:
except ImportError: except ImportError:
WEBSOCKET_AVAILABLE = False WEBSOCKET_AVAILABLE = False
from utils.kiwisdr import KiwiSDRClient, KIWI_SAMPLE_RATE, VALID_MODES, parse_host_port import contextlib
from utils.kiwisdr import KIWI_SAMPLE_RATE, VALID_MODES, KiwiSDRClient, parse_host_port
from utils.logging import get_logger from utils.logging import get_logger
logger = get_logger('intercept.websdr') logger = get_logger('intercept.websdr')
@@ -38,7 +39,7 @@ _cache_timestamp: float = 0
CACHE_TTL = 3600 # 1 hour CACHE_TTL = 3600 # 1 hour
def _parse_gps_coord(coord_str: str) -> Optional[float]: def _parse_gps_coord(coord_str: str) -> float | None:
"""Parse a GPS coordinate string like '51.5074' or '(-33.87)' into a float.""" """Parse a GPS coordinate string like '51.5074' or '(-33.87)' into a float."""
if not coord_str: if not coord_str:
return None return None
@@ -70,8 +71,8 @@ KIWI_DATA_URLS = [
def _fetch_kiwi_receivers() -> list[dict]: def _fetch_kiwi_receivers() -> list[dict]:
"""Fetch the KiwiSDR receiver list from the public directory.""" """Fetch the KiwiSDR receiver list from the public directory."""
import urllib.request
import json import json
import urllib.request
receivers = [] receivers = []
raw = None raw = None
@@ -335,7 +336,7 @@ def websdr_status() -> Response:
# KIWISDR AUDIO PROXY # KIWISDR AUDIO PROXY
# ============================================ # ============================================
_kiwi_client: Optional[KiwiSDRClient] = None _kiwi_client: KiwiSDRClient | None = None
_kiwi_lock = threading.Lock() _kiwi_lock = threading.Lock()
_kiwi_audio_queue: queue.Queue = queue.Queue(maxsize=200) _kiwi_audio_queue: queue.Queue = queue.Queue(maxsize=200)
@@ -387,26 +388,18 @@ def _handle_kiwi_command(ws, cmd: str, data: dict) -> None:
try: try:
_kiwi_audio_queue.put_nowait(header + pcm_bytes) _kiwi_audio_queue.put_nowait(header + pcm_bytes)
except queue.Full: except queue.Full:
try: with contextlib.suppress(queue.Empty):
_kiwi_audio_queue.get_nowait() _kiwi_audio_queue.get_nowait()
except queue.Empty: with contextlib.suppress(queue.Full):
pass
try:
_kiwi_audio_queue.put_nowait(header + pcm_bytes) _kiwi_audio_queue.put_nowait(header + pcm_bytes)
except queue.Full:
pass
def on_error(msg): def on_error(msg):
try: with contextlib.suppress(Exception):
ws.send(json.dumps({'type': 'error', 'message': msg})) ws.send(json.dumps({'type': 'error', 'message': msg}))
except Exception:
pass
def on_disconnect(): def on_disconnect():
try: with contextlib.suppress(Exception):
ws.send(json.dumps({'type': 'disconnected'})) ws.send(json.dumps({'type': 'disconnected'}))
except Exception:
pass
with _kiwi_lock: with _kiwi_lock:
_kiwi_client = KiwiSDRClient( _kiwi_client = KiwiSDRClient(
+4 -5
View File
@@ -6,13 +6,14 @@ maritime/aviation weather services worldwide.
from __future__ import annotations from __future__ import annotations
import contextlib
import queue import queue
from flask import Blueprint, Response, jsonify, request, send_file from flask import Blueprint, Response, jsonify, request, send_file
from utils.responses import api_success, api_error
import app as app_module import app as app_module
from utils.logging import get_logger from utils.logging import get_logger
from utils.responses import api_error
from utils.sdr import SDRType from utils.sdr import SDRType
from utils.sse import sse_stream_fanout from utils.sse import sse_stream_fanout
from utils.validation import validate_frequency from utils.validation import validate_frequency
@@ -129,10 +130,8 @@ def start_decoder():
frequency_reference = str(data.get('frequency_reference', 'auto')).strip().lower() frequency_reference = str(data.get('frequency_reference', 'auto')).strip().lower()
sdr_type_str = str(data.get('sdr_type', 'rtlsdr')).lower() sdr_type_str = str(data.get('sdr_type', 'rtlsdr')).lower()
try: with contextlib.suppress(ValueError):
sdr_type = SDRType(sdr_type_str) SDRType(sdr_type_str)
except ValueError:
sdr_type = SDRType.RTL_SDR
if not frequency_reference: if not frequency_reference:
frequency_reference = 'auto' frequency_reference = 'auto'
+40 -52
View File
@@ -2,6 +2,7 @@
from __future__ import annotations from __future__ import annotations
import contextlib
import fcntl import fcntl
import json import json
import os import os
@@ -11,39 +12,25 @@ import re
import subprocess import subprocess
import threading import threading
import time import time
from typing import Any, Generator from typing import Any
from flask import Blueprint, jsonify, request, Response from flask import Blueprint, Response, jsonify, request
from utils.responses import api_success, api_error
import app as app_module import app as app_module
from utils.dependencies import check_tool, get_tool_path
from utils.logging import wifi_logger as logger
from utils.process import is_valid_mac, is_valid_channel
from utils.validation import validate_wifi_channel, validate_mac_address, validate_network_interface
from utils.sse import format_sse, sse_stream_fanout
from utils.event_pipeline import process_event
from data.oui import get_manufacturer from data.oui import get_manufacturer
from utils.constants import ( from utils.constants import (
WIFI_TERMINATE_TIMEOUT,
PMKID_TERMINATE_TIMEOUT,
SSE_KEEPALIVE_INTERVAL, SSE_KEEPALIVE_INTERVAL,
SSE_QUEUE_TIMEOUT, SSE_QUEUE_TIMEOUT,
WIFI_CSV_PARSE_INTERVAL,
WIFI_CSV_TIMEOUT_WARNING,
SUBPROCESS_TIMEOUT_SHORT,
SUBPROCESS_TIMEOUT_MEDIUM, SUBPROCESS_TIMEOUT_MEDIUM,
SUBPROCESS_TIMEOUT_LONG, SUBPROCESS_TIMEOUT_SHORT,
DEAUTH_TIMEOUT,
MIN_DEAUTH_COUNT,
MAX_DEAUTH_COUNT,
DEFAULT_DEAUTH_COUNT,
PROCESS_START_WAIT,
MONITOR_MODE_DELAY,
WIFI_CAPTURE_PATH_PREFIX,
HANDSHAKE_CAPTURE_PATH_PREFIX,
PMKID_CAPTURE_PATH_PREFIX,
) )
from utils.dependencies import check_tool, get_tool_path
from utils.event_pipeline import process_event
from utils.logging import wifi_logger as logger
from utils.process import is_valid_channel, is_valid_mac
from utils.responses import api_error, api_success
from utils.sse import format_sse, sse_stream_fanout
from utils.validation import validate_network_interface, validate_wifi_channel
wifi_bp = Blueprint('wifi', __name__, url_prefix='/wifi') wifi_bp = Blueprint('wifi', __name__, url_prefix='/wifi')
@@ -201,9 +188,9 @@ def _get_interface_details(iface_name):
# Get MAC address # Get MAC address
try: try:
mac_path = f'/sys/class/net/{iface_name}/address' mac_path = f'/sys/class/net/{iface_name}/address'
with open(mac_path, 'r') as f: with open(mac_path) as f:
details['mac'] = f.read().strip().upper() details['mac'] = f.read().strip().upper()
except (FileNotFoundError, IOError): except (OSError, FileNotFoundError):
pass pass
# Get driver name # Get driver name
@@ -212,7 +199,7 @@ def _get_interface_details(iface_name):
if os.path.islink(driver_link): if os.path.islink(driver_link):
driver_path = os.readlink(driver_link) driver_path = os.readlink(driver_link)
details['driver'] = os.path.basename(driver_path) details['driver'] = os.path.basename(driver_path)
except (FileNotFoundError, IOError, OSError): except (FileNotFoundError, OSError):
pass pass
# Try airmon-ng first for chipset info (most reliable for WiFi adapters) # Try airmon-ng first for chipset info (most reliable for WiFi adapters)
@@ -230,11 +217,10 @@ def _get_interface_details(iface_name):
break break
# Also try space-separated format # Also try space-separated format
parts = line.split() parts = line.split()
if len(parts) >= 4: if len(parts) >= 4 and (parts[1] == iface_name or parts[1].startswith(iface_name)):
if parts[1] == iface_name or parts[1].startswith(iface_name): details['driver'] = parts[2]
details['driver'] = parts[2] details['chipset'] = ' '.join(parts[3:])
details['chipset'] = ' '.join(parts[3:]) break
break
except (FileNotFoundError, subprocess.TimeoutExpired, subprocess.SubprocessError): except (FileNotFoundError, subprocess.TimeoutExpired, subprocess.SubprocessError):
pass pass
@@ -246,10 +232,10 @@ def _get_interface_details(iface_name):
# Try to get USB product name # Try to get USB product name
for usb_path in [f'{device_path}/product', f'{device_path}/../product']: for usb_path in [f'{device_path}/product', f'{device_path}/../product']:
try: try:
with open(usb_path, 'r') as f: with open(usb_path) as f:
details['chipset'] = f.read().strip() details['chipset'] = f.read().strip()
break break
except (FileNotFoundError, IOError): except (OSError, FileNotFoundError):
pass pass
# If no USB product, try lsusb for USB devices # If no USB product, try lsusb for USB devices
@@ -257,7 +243,7 @@ def _get_interface_details(iface_name):
try: try:
# Get USB bus/device info # Get USB bus/device info
uevent_path = f'{device_path}/uevent' uevent_path = f'{device_path}/uevent'
with open(uevent_path, 'r') as f: with open(uevent_path) as f:
for line in f: for line in f:
if line.startswith('PRODUCT='): if line.startswith('PRODUCT='):
# PRODUCT format: vendor/product/bcdDevice # PRODUCT format: vendor/product/bcdDevice
@@ -280,9 +266,9 @@ def _get_interface_details(iface_name):
except (FileNotFoundError, subprocess.TimeoutExpired): except (FileNotFoundError, subprocess.TimeoutExpired):
pass pass
break break
except (FileNotFoundError, IOError): except (OSError, FileNotFoundError):
pass pass
except (FileNotFoundError, IOError, OSError): except (FileNotFoundError, OSError):
pass pass
return details return details
@@ -294,7 +280,7 @@ def parse_airodump_csv(csv_path):
clients = {} clients = {}
try: try:
with open(csv_path, 'r', errors='replace') as f: with open(csv_path, errors='replace') as f:
content = f.read() content = f.read()
sections = content.split('\n\n') sections = content.split('\n\n')
@@ -602,7 +588,6 @@ def toggle_monitor_mode():
return api_success(data={'monitor_interface': app_module.wifi_monitor_interface}) return api_success(data={'monitor_interface': app_module.wifi_monitor_interface})
except Exception as e: except Exception as e:
import traceback
logger.error(f"Error enabling monitor mode: {e}", exc_info=True) logger.error(f"Error enabling monitor mode: {e}", exc_info=True)
return api_error(str(e)) return api_error(str(e))
@@ -683,20 +668,11 @@ def start_wifi_scan():
csv_path = '/tmp/intercept_wifi' csv_path = '/tmp/intercept_wifi'
for f in [f'/tmp/intercept_wifi-01.csv', f'/tmp/intercept_wifi-01.cap']: for f in ['/tmp/intercept_wifi-01.csv', '/tmp/intercept_wifi-01.cap']:
try: with contextlib.suppress(OSError):
os.remove(f) os.remove(f)
except OSError:
pass
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:
@@ -705,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)}")
@@ -1021,7 +1009,7 @@ def check_pmkid_status():
try: try:
hash_file = capture_file.replace('.pcapng', '.22000') hash_file = capture_file.replace('.pcapng', '.22000')
result = subprocess.run( subprocess.run(
['hcxpcapngtool', '-o', hash_file, capture_file], ['hcxpcapngtool', '-o', hash_file, capture_file],
capture_output=True, text=True, timeout=10 capture_output=True, text=True, timeout=10
) )
@@ -1170,7 +1158,7 @@ def stream_wifi():
# V2 API Endpoints - Using unified WiFi scanner # V2 API Endpoints - Using unified WiFi scanner
# ============================================================================= # =============================================================================
from utils.wifi.scanner import get_wifi_scanner, reset_wifi_scanner from utils.wifi.scanner import get_wifi_scanner
@wifi_bp.route('/v2/capabilities') @wifi_bp.route('/v2/capabilities')
+12 -14
View File
@@ -7,26 +7,26 @@ channel analysis, hidden SSID correlation, and SSE streaming.
from __future__ import annotations from __future__ import annotations
import contextlib
import csv import csv
import io import io
import json import json
import logging import logging
from collections.abc import Generator
from datetime import datetime from datetime import datetime
from typing import Generator
from flask import Blueprint, jsonify, request, Response from flask import Blueprint, Response, jsonify, request
from utils.wifi import ( from utils.event_pipeline import process_event
get_wifi_scanner, from utils.responses import api_error
analyze_channels,
get_hidden_correlator,
SCAN_MODE_QUICK,
SCAN_MODE_DEEP,
)
from utils.responses import api_success, api_error
from utils.sse import format_sse from utils.sse import format_sse
from utils.validation import validate_wifi_channel from utils.validation import validate_wifi_channel
from utils.event_pipeline import process_event from utils.wifi import (
SCAN_MODE_DEEP,
analyze_channels,
get_hidden_correlator,
get_wifi_scanner,
)
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -407,10 +407,8 @@ def event_stream():
scanner = get_wifi_scanner() scanner = get_wifi_scanner()
for event in scanner.get_event_stream(): for event in scanner.get_event_stream():
try: with contextlib.suppress(Exception):
process_event('wifi', event, event.get('type')) process_event('wifi', event, event.get('type'))
except Exception:
pass
yield format_sse(event) yield format_sse(event)
response = Response(generate(), mimetype='text/event-stream') response = Response(generate(), mimetype='text/event-stream')
+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
+107 -22
View File
@@ -174,7 +174,7 @@ read_env_var() {
local fallback="${2:-}" local fallback="${2:-}"
if [[ -f "$SCRIPT_DIR/.env" ]]; then if [[ -f "$SCRIPT_DIR/.env" ]]; then
local val local val
val=$(grep -E "^${key}=" "$SCRIPT_DIR/.env" 2>/dev/null | tail -1 | cut -d'=' -f2-) val=$(grep -E "^${key}=" "$SCRIPT_DIR/.env" 2>/dev/null | tail -1 | cut -d'=' -f2- || true)
if [[ -n "$val" ]]; then if [[ -n "$val" ]]; then
# Strip surrounding quotes # Strip surrounding quotes
val="${val#\"}" val="${val#\"}"
@@ -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
@@ -957,8 +1024,14 @@ install_satdump_from_source_debian() {
) & ) &
progress_pid=$! progress_pid=$!
local arch_flags=""
if [[ "$(uname -m)" == "x86_64" ]]; then
arch_flags="-march=x86-64"
fi
if cmake -DCMAKE_BUILD_TYPE=Release -DBUILD_GUI=OFF -DCMAKE_INSTALL_LIBDIR=lib \ if cmake -DCMAKE_BUILD_TYPE=Release -DBUILD_GUI=OFF -DCMAKE_INSTALL_LIBDIR=lib \
-DCMAKE_CXX_FLAGS="-Wno-template-body" .. >"$build_log" 2>&1 \ -DCMAKE_C_FLAGS="$arch_flags" \
-DCMAKE_CXX_FLAGS="$arch_flags -Wno-template-body" .. >"$build_log" 2>&1 \
&& make -j "$(nproc)" >>"$build_log" 2>&1; then && make -j "$(nproc)" >>"$build_log" 2>&1; then
kill $progress_pid 2>/dev/null; wait $progress_pid 2>/dev/null kill $progress_pid 2>/dev/null; wait $progress_pid 2>/dev/null
$SUDO make install >/dev/null 2>&1 $SUDO make install >/dev/null 2>&1
@@ -1697,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."
@@ -1918,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
@@ -1978,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"
+19
View File
@@ -87,6 +87,25 @@
color: var(--text-primary); color: var(--text-primary);
} }
/* Branded "i" inline SVG that matches the logo icon.
Sized to 0.9em so it sits naturally alongside text at any font-size.
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;
width: 0.55em;
height: 0.9em;
vertical-align: baseline;
position: relative;
top: 0.05em;
}
.brand-i svg {
display: block;
width: 100%;
height: 100%;
}
.app-logo-tagline { .app-logo-tagline {
font-size: var(--text-xs); font-size: var(--text-xs);
color: var(--text-dim); color: var(--text-dim);
File diff suppressed because it is too large Load Diff
+59
View File
@@ -0,0 +1,59 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1200 300" width="1200" height="300">
<defs>
<pattern id="grid" width="40" height="40" patternUnits="userSpaceOnUse">
<path d="M 40 0 L 0 0 0 40" fill="none" stroke="rgba(0,212,255,0.025)" stroke-width="1"/>
</pattern>
<radialGradient id="glow1" cx="75%" cy="30%" r="35%">
<stop offset="0%" stop-color="#00d4ff" stop-opacity="0.05"/>
<stop offset="100%" stop-color="#00d4ff" stop-opacity="0"/>
</radialGradient>
<radialGradient id="glow2" cx="25%" cy="70%" r="30%">
<stop offset="0%" stop-color="#00ff88" stop-opacity="0.03"/>
<stop offset="100%" stop-color="#00ff88" stop-opacity="0"/>
</radialGradient>
</defs>
<!-- Background -->
<rect width="1200" height="300" fill="#0a0a0f"/>
<rect width="1200" height="300" fill="url(#grid)"/>
<rect width="1200" height="300" fill="url(#glow1)"/>
<rect width="1200" height="300" fill="url(#glow2)"/>
<!-- Logo -->
<g transform="translate(340, 60) scale(1.0)">
<path d="M15 30 Q5 50, 15 70" stroke="#00d4ff" stroke-width="4" fill="none" stroke-linecap="round" opacity="0.5"/>
<path d="M22 35 Q14 50, 22 65" stroke="#00d4ff" stroke-width="3.5" fill="none" stroke-linecap="round" opacity="0.7"/>
<path d="M29 40 Q23 50, 29 60" stroke="#00d4ff" stroke-width="3" fill="none" stroke-linecap="round"/>
<path d="M85 30 Q95 50, 85 70" stroke="#00d4ff" stroke-width="4" fill="none" stroke-linecap="round" opacity="0.5"/>
<path d="M78 35 Q86 50, 78 65" stroke="#00d4ff" stroke-width="3.5" fill="none" stroke-linecap="round" opacity="0.7"/>
<path d="M71 40 Q77 50, 71 60" stroke="#00d4ff" stroke-width="3" fill="none" stroke-linecap="round"/>
<circle cx="50" cy="22" r="7" fill="#00ff88"/>
<rect x="43" y="35" width="14" height="45" rx="2" fill="#00d4ff"/>
<rect x="36" y="35" width="28" height="5" rx="1" fill="#00d4ff"/>
<rect x="36" y="75" width="28" height="5" rx="1" fill="#00d4ff"/>
</g>
<!-- Title: branded "i" glyph + NTERCEPT text -->
<g transform="translate(476, 78) scale(0.76)">
<circle cx="50" cy="18" r="6" fill="#00ff88"/>
<rect x="44" y="30" width="12" height="45" rx="2" fill="#00d4ff"/>
<rect x="38" y="30" width="24" height="4" rx="1" fill="#00d4ff"/>
<rect x="38" y="71" width="24" height="4" rx="1" fill="#00d4ff"/>
</g>
<text x="524" y="135" font-family="'Segoe UI','Helvetica Neue',Arial,sans-serif" font-size="64" font-weight="800" letter-spacing="-1.5" fill="white">NTERCEPT</text>
<!-- Subtitle -->
<text x="490" y="170" font-family="'Segoe UI','Helvetica Neue',Arial,sans-serif" font-size="18" font-weight="300" fill="rgba(255,255,255,0.35)">
Web-Based Signal Intelligence Platform
</text>
<!-- Feature dots -->
<g font-family="'Courier New',monospace" font-size="11" fill="rgba(0,212,255,0.4)" letter-spacing="1.5">
<circle cx="492" cy="206" r="2" fill="#00ff88" opacity="0.6"/>
<text x="500" y="210">34 Signal Modes</text>
<circle cx="492" cy="228" r="2" fill="#00ff88" opacity="0.6"/>
<text x="500" y="232">Multi-SDR Support</text>
<circle cx="492" cy="250" r="2" fill="#00ff88" opacity="0.6"/>
<text x="500" y="254">Open Source</text>
</g>
</svg>

After

Width:  |  Height:  |  Size: 3.2 KiB

+68
View File
@@ -0,0 +1,68 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1280 640" width="1280" height="640">
<defs>
<pattern id="grid" width="40" height="40" patternUnits="userSpaceOnUse">
<path d="M 40 0 L 0 0 0 40" fill="none" stroke="rgba(0,212,255,0.025)" stroke-width="1"/>
</pattern>
<radialGradient id="glow1" cx="80%" cy="20%" r="40%">
<stop offset="0%" stop-color="#00d4ff" stop-opacity="0.06"/>
<stop offset="100%" stop-color="#00d4ff" stop-opacity="0"/>
</radialGradient>
<radialGradient id="glow2" cx="20%" cy="80%" r="30%">
<stop offset="0%" stop-color="#00ff88" stop-opacity="0.04"/>
<stop offset="100%" stop-color="#00ff88" stop-opacity="0"/>
</radialGradient>
</defs>
<!-- Background -->
<rect width="1280" height="640" fill="#0a0a0f"/>
<rect width="1280" height="640" fill="url(#grid)"/>
<rect width="1280" height="640" fill="url(#glow1)"/>
<rect width="1280" height="640" fill="url(#glow2)"/>
<!-- Corner brackets -->
<g stroke="rgba(0,212,255,0.2)" stroke-width="1.5" fill="none">
<polyline points="24,48 24,24 48,24"/>
<polyline points="1232,24 1256,24 1256,48"/>
<polyline points="24,592 24,616 48,616"/>
<polyline points="1232,616 1256,616 1256,592"/>
</g>
<!-- Logo -->
<g transform="translate(560, 140) scale(1.4)">
<path d="M15 30 Q5 50, 15 70" stroke="#00d4ff" stroke-width="4" fill="none" stroke-linecap="round" opacity="0.5"/>
<path d="M22 35 Q14 50, 22 65" stroke="#00d4ff" stroke-width="3.5" fill="none" stroke-linecap="round" opacity="0.7"/>
<path d="M29 40 Q23 50, 29 60" stroke="#00d4ff" stroke-width="3" fill="none" stroke-linecap="round"/>
<path d="M85 30 Q95 50, 85 70" stroke="#00d4ff" stroke-width="4" fill="none" stroke-linecap="round" opacity="0.5"/>
<path d="M78 35 Q86 50, 78 65" stroke="#00d4ff" stroke-width="3.5" fill="none" stroke-linecap="round" opacity="0.7"/>
<path d="M71 40 Q77 50, 71 60" stroke="#00d4ff" stroke-width="3" fill="none" stroke-linecap="round"/>
<circle cx="50" cy="22" r="7" fill="#00ff88"/>
<rect x="43" y="35" width="14" height="45" rx="2" fill="#00d4ff"/>
<rect x="36" y="35" width="28" height="5" rx="1" fill="#00d4ff"/>
<rect x="36" y="75" width="28" height="5" rx="1" fill="#00d4ff"/>
</g>
<!-- Title: branded "i" glyph + NTERCEPT text -->
<g transform="translate(337, 304) scale(1.0)">
<circle cx="50" cy="18" r="6" fill="#00ff88"/>
<rect x="44" y="30" width="12" height="45" rx="2" fill="#00d4ff"/>
<rect x="38" y="30" width="24" height="4" rx="1" fill="#00d4ff"/>
<rect x="38" y="71" width="24" height="4" rx="1" fill="#00d4ff"/>
</g>
<text x="400" y="380" font-family="'Segoe UI','Helvetica Neue',Arial,sans-serif" font-size="84" font-weight="800" letter-spacing="-2" fill="white">NTERCEPT</text>
<!-- Subtitle -->
<text x="640" y="425" text-anchor="middle" font-family="'Segoe UI','Helvetica Neue',Arial,sans-serif" font-size="24" font-weight="300" fill="rgba(255,255,255,0.4)" letter-spacing="0.5">
Web-Based Signal Intelligence Platform
</text>
<!-- Tags -->
<g font-family="'Courier New',monospace" font-size="12" fill="rgba(0,212,255,0.5)" letter-spacing="2">
<text x="440" y="480" text-anchor="middle">SDR</text>
<text x="520" y="480" text-anchor="middle" fill="rgba(255,255,255,0.15)">|</text>
<text x="600" y="480" text-anchor="middle">RF ANALYSIS</text>
<text x="698" y="480" text-anchor="middle" fill="rgba(255,255,255,0.15)">|</text>
<text x="778" y="480" text-anchor="middle">34 MODES</text>
<text x="858" y="480" text-anchor="middle" fill="rgba(255,255,255,0.15)">|</text>
<text x="950" y="480" text-anchor="middle">OPEN SOURCE</text>
</g>
</svg>

After

Width:  |  Height:  |  Size: 3.7 KiB

+33 -21
View File
@@ -10,10 +10,11 @@ let currentAgent = 'local';
let agentEventSource = null; let agentEventSource = null;
let multiAgentMode = false; // Show combined results from all agents let multiAgentMode = false; // Show combined results from all agents
let multiAgentPollInterval = null; let multiAgentPollInterval = null;
let agentRunningModes = []; // Track agent's running modes for conflict detection let agentRunningModes = []; // Track agent's running modes for conflict detection
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 ==============
@@ -21,27 +22,38 @@ let agentHealthStatus = {}; // Cache of health status per agent ID
* Start periodic health monitoring for all agents. * Start periodic health monitoring for all agents.
* Runs every 30 seconds to check agent health status. * Runs every 30 seconds to check agent health status.
*/ */
function startHealthMonitoring() { 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) {
// Start periodic checks every 30 seconds clearTimeout(healthCheckKickoffTimer);
healthCheckInterval = setInterval(checkAllAgentsHealth, 30000); }
console.log('[AgentManager] Health monitoring started (30s interval)'); healthCheckKickoffTimer = setTimeout(() => {
} healthCheckKickoffTimer = null;
checkAllAgentsHealth();
}, 5000);
// Start periodic checks every 30 seconds
healthCheckInterval = setInterval(checkAllAgentsHealth, 30000);
console.log('[AgentManager] Health monitoring started (30s interval)');
}
/** /**
* Stop health monitoring. * Stop health monitoring.
*/ */
function stopHealthMonitoring() { function stopHealthMonitoring() {
if (healthCheckInterval) { if (healthCheckKickoffTimer) {
clearInterval(healthCheckInterval); clearTimeout(healthCheckKickoffTimer);
healthCheckInterval = null; healthCheckKickoffTimer = null;
console.log('[AgentManager] Health monitoring stopped'); }
} if (healthCheckInterval) {
clearInterval(healthCheckInterval);
healthCheckInterval = null;
console.log('[AgentManager] Health monitoring stopped');
}
} }
/** /**
+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();
} }
}); });
+4 -3
View File
@@ -17,9 +17,10 @@ const CheatSheets = (function () {
sstv: { title: 'ISS SSTV', icon: '🖼️', hardware: 'RTL-SDR + 145MHz antenna', description: 'Receives ISS SSTV images via slowrx.', whatToExpect: 'Color images during ISS SSTV events (PD180 mode).', tips: ['ISS SSTV: 145.800 MHz', 'Check ARISS for active event dates', 'ISS must be overhead — check pass times'] }, sstv: { title: 'ISS SSTV', icon: '🖼️', hardware: 'RTL-SDR + 145MHz antenna', description: 'Receives ISS SSTV images via slowrx.', whatToExpect: 'Color images during ISS SSTV events (PD180 mode).', tips: ['ISS SSTV: 145.800 MHz', 'Check ARISS for active event dates', 'ISS must be overhead — check pass times'] },
weathersat: { title: 'Weather Satellites', icon: '🌤️', hardware: 'RTL-SDR + 137MHz turnstile/QFH antenna', description: 'Decodes NOAA APT and Meteor LRPT weather imagery via SatDump.', whatToExpect: 'Infrared/visible cloud imagery.', tips: ['NOAA 15/18/19: 137.1137.9 MHz APT', 'Meteor M2-3: 137.9 MHz LRPT', 'Use circular polarized antenna (QFH or turnstile)'] }, weathersat: { title: 'Weather Satellites', icon: '🌤️', hardware: 'RTL-SDR + 137MHz turnstile/QFH antenna', description: 'Decodes NOAA APT and Meteor LRPT weather imagery via SatDump.', whatToExpect: 'Infrared/visible cloud imagery.', tips: ['NOAA 15/18/19: 137.1137.9 MHz APT', 'Meteor M2-3: 137.9 MHz LRPT', 'Use circular polarized antenna (QFH or turnstile)'] },
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'] },
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'] }, 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'] },
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'] },
subghz: { title: 'SubGHz Transceiver', icon: '📡', hardware: 'HackRF One', description: 'Transmit and receive sub-GHz RF signals for IoT and industrial protocols.', whatToExpect: 'Raw signal capture, replay, and protocol analysis.', tips: ['Only use on licensed frequencies', 'Capture mode records raw IQ for replay', 'Common: garage doors, keyfobs, 315/433/868/915 MHz'] }, subghz: { title: 'SubGHz Transceiver', icon: '📡', hardware: 'HackRF One', description: 'Transmit and receive sub-GHz RF signals for IoT and industrial protocols.', whatToExpect: 'Raw signal capture, replay, and protocol analysis.', tips: ['Only use on licensed frequencies', 'Capture mode records raw IQ for replay', 'Common: garage doors, keyfobs, 315/433/868/915 MHz'] },
+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();
+41 -23
View File
@@ -2,12 +2,13 @@
* Updater Module - GitHub update checking and notification system * Updater Module - GitHub update checking and notification system
*/ */
const Updater = { const Updater = {
// State // State
_checkInterval: null, _checkInterval: null,
_toastElement: null, _startupCheckTimer: null,
_modalElement: null, _toastElement: null,
_updateData: null, _modalElement: null,
_updateData: null,
// Configuration // Configuration
CHECK_INTERVAL_MS: 6 * 60 * 60 * 1000, // 6 hours in milliseconds CHECK_INTERVAL_MS: 6 * 60 * 60 * 1000, // 6 hours in milliseconds
@@ -15,18 +16,31 @@ const Updater = {
/** /**
* Initialize the updater module * Initialize the updater module
*/ */
init() { init() {
// 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();
// Set up periodic checks return;
this._checkInterval = setInterval(() => { }
this.checkForUpdates();
}, this.CHECK_INTERVAL_MS); // 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
if (!this._checkInterval) {
this._checkInterval = setInterval(() => {
this.checkForUpdates();
}, this.CHECK_INTERVAL_MS);
}
},
/** /**
* Ensure toast container exists in DOM * Ensure toast container exists in DOM
@@ -505,11 +519,15 @@ const Updater = {
/** /**
* Clean up on page unload * Clean up on page unload
*/ */
destroy() { destroy() {
if (this._checkInterval) { if (this._startupCheckTimer) {
clearInterval(this._checkInterval); clearTimeout(this._startupCheckTimer);
this._checkInterval = null; this._startupCheckTimer = null;
} }
if (this._checkInterval) {
clearInterval(this._checkInterval);
this._checkInterval = null;
}
this.hideToast(); this.hideToast();
this.hideModal(); this.hideModal();
} }
+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();
}
})();
+11 -7
View File
@@ -16,8 +16,9 @@ const Meshtastic = (function() {
// Map state // Map state
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
@@ -32,11 +33,14 @@ const Meshtastic = (function() {
/** /**
* Setup event delegation for dynamically created elements * Setup event delegation for dynamically created elements
*/ */
function setupEventDelegation() { function setupEventDelegation() {
// Handle button clicks in Leaflet popups and elsewhere if (clickDelegationAttached) return;
document.addEventListener('click', function(e) { clickDelegationAttached = true;
const tracerouteBtn = e.target.closest('.mesh-traceroute-btn');
if (tracerouteBtn) { // Handle button clicks in Leaflet popups and elsewhere
document.addEventListener('click', function(e) {
const tracerouteBtn = e.target.closest('.mesh-traceroute-btn');
if (tracerouteBtn) {
const nodeId = tracerouteBtn.dataset.nodeId; const nodeId = tracerouteBtn.dataset.nodeId;
if (nodeId) { if (nodeId) {
sendTraceroute(nodeId); sendTraceroute(nodeId);
+70 -31
View File
@@ -16,8 +16,13 @@ const SpaceWeather = (function () {
let _xrayChart = null; let _xrayChart = null;
// 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() {
@@ -48,33 +53,35 @@ const SpaceWeather = (function () {
_fetchData(); _fetchData();
} }
function selectSolarImage(key) { function selectSolarImage(key) {
_solarImageKey = key; _solarImageKey = key;
_updateSolarImageTabs(); _updateSolarImageTabs();
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>'
} );
} }
}
function selectDrapFreq(key) { function selectDrapFreq(key) {
_drapFreq = key; _drapFreq = key;
_updateDrapTabs(); _updateDrapTabs();
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>'
} );
} }
}
function toggleAutoRefresh() { function toggleAutoRefresh() {
const cb = document.getElementById('swAutoRefresh'); const cb = document.getElementById('swAutoRefresh');
@@ -94,9 +101,41 @@ const SpaceWeather = (function () {
} }
} }
function _stopAutoRefresh() { function _stopAutoRefresh() {
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')
+11 -8
View File
@@ -14,10 +14,11 @@ const SSTV = (function() {
let issMarker = null; let issMarker = null;
let issTrackLine = null; let issTrackLine = null;
let issPosition = null; let issPosition = null;
let issUpdateInterval = null; let issUpdateInterval = null;
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,10 +93,12 @@ 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;
}
}
/** /**
* Save location from input fields * Save location from input fields
+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 }))
)
); );
}); });
+345 -174
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,22 +19,20 @@
<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>
<header class="header"> <header class="header">
<div class="logo"> <div class="logo">
AIRCRAFT RADAR AIRCRAFT RADAR
<span>// INTERCEPT - See the Invisible</span> <span>// <span class="brand-i"><svg viewBox="36 14 28 68" width="1em" height="1em" xmlns="http://www.w3.org/2000/svg"><circle cx="50" cy="20" r="6" fill="#00ff88"/><rect x="44" y="33" width="12" height="45" rx="2" fill="#00d4ff"/><rect x="38" y="33" width="24" height="4" rx="1" fill="#00d4ff"/><rect x="38" y="74" width="24" height="4" rx="1" fill="#00d4ff"/></svg></span>NTERCEPT - See the Invisible</span>
</div> </div>
<div class="status-bar"> <div class="status-bar">
<!-- Agent Selector --> <!-- Agent Selector -->
@@ -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 {
+1 -1
View File
@@ -22,7 +22,7 @@
<header class="header"> <header class="header">
<div class="logo"> <div class="logo">
ADS-B HISTORY ADS-B HISTORY
<span>// INTERCEPT REPORTING</span> <span>// <span class="brand-i"><svg viewBox="36 14 28 68" width="1em" height="1em" xmlns="http://www.w3.org/2000/svg"><circle cx="50" cy="20" r="6" fill="#00ff88"/><rect x="44" y="33" width="12" height="45" rx="2" fill="#00d4ff"/><rect x="38" y="33" width="24" height="4" rx="1" fill="#00d4ff"/><rect x="38" y="74" width="24" height="4" rx="1" fill="#00d4ff"/></svg></span>NTERCEPT REPORTING</span>
</div> </div>
<div class="status-bar"> <div class="status-bar">
<a href="/adsb/dashboard" class="back-link">Live Radar</a> <a href="/adsb/dashboard" class="back-link">Live Radar</a>
+1 -1
View File
@@ -281,7 +281,7 @@
</svg> </svg>
</div> </div>
<h1 style="margin: 0;"> <h1 style="margin: 0;">
iNTERCEPT <span class="tagline">// Remote Agents</span> <span class="brand-i"><svg viewBox="36 14 28 68" width="1em" height="1em" xmlns="http://www.w3.org/2000/svg"><circle cx="50" cy="20" r="6" fill="#00ff88"/><rect x="44" y="33" width="12" height="45" rx="2" fill="#00d4ff"/><rect x="38" y="33" width="24" height="4" rx="1" fill="#00d4ff"/><rect x="38" y="74" width="24" height="4" rx="1" fill="#00d4ff"/></svg></span>NTERCEPT <span class="tagline">// Remote Agents</span>
</h1> </h1>
</header> </header>
+68 -35
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>
@@ -51,7 +32,7 @@
<header class="header"> <header class="header">
<div class="logo"> <div class="logo">
VESSEL RADAR VESSEL RADAR
<span>// INTERCEPT - AIS Tracking</span> <span>// <span class="brand-i"><svg viewBox="36 14 28 68" width="1em" height="1em" xmlns="http://www.w3.org/2000/svg"><circle cx="50" cy="20" r="6" fill="#00ff88"/><rect x="44" y="33" width="12" height="45" rx="2" fill="#00d4ff"/><rect x="38" y="33" width="24" height="4" rx="1" fill="#00d4ff"/><rect x="38" y="74" width="24" height="4" rx="1" fill="#00d4ff"/></svg></span>NTERCEPT - AIS Tracking</span>
</div> </div>
<div class="status-bar"> <div class="status-bar">
<!-- Agent Selector --> <!-- Agent Selector -->
@@ -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>
+341 -118
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 = {
@@ -293,7 +273,7 @@
<rect x="38" y="76" width="24" height="4" rx="1" fill="#00d4ff" /> <rect x="38" y="76" width="24" height="4" rx="1" fill="#00d4ff" />
</svg> </svg>
</div> </div>
<h1 class="welcome-title">iNTERCEPT</h1> <h1 class="welcome-title"><span class="brand-i"><svg viewBox="36 14 28 68" width="1em" height="1em" xmlns="http://www.w3.org/2000/svg"><circle cx="50" cy="20" r="6" fill="#00ff88"/><rect x="44" y="33" width="12" height="45" rx="2" fill="#00d4ff"/><rect x="38" y="33" width="24" height="4" rx="1" fill="#00d4ff"/><rect x="38" y="74" width="24" height="4" rx="1" fill="#00d4ff"/></svg></span>NTERCEPT</h1>
<p class="welcome-tagline">// See the Invisible</p> <p class="welcome-tagline">// See the Invisible</p>
<span class="welcome-version">v{{ version }}</span> <span class="welcome-version">v{{ version }}</span>
<button type="button" class="welcome-settings-btn" onclick="showSettings()" title="Settings" aria-label="Open settings"> <button type="button" class="welcome-settings-btn" onclick="showSettings()" title="Settings" aria-label="Open settings">
@@ -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>
@@ -589,7 +569,7 @@
<rect x="38" y="76" width="24" height="4" rx="1" fill="#00d4ff" /> <rect x="38" y="76" width="24" height="4" rx="1" fill="#00d4ff" />
</svg> </svg>
</a> </a>
<h1>iNTERCEPT <span class="tagline">// See the Invisible</span></h1> <h1><span class="brand-i"><svg viewBox="36 14 28 68" width="1em" height="1em" xmlns="http://www.w3.org/2000/svg"><circle cx="50" cy="20" r="6" fill="#00ff88"/><rect x="44" y="33" width="12" height="45" rx="2" fill="#00d4ff"/><rect x="38" y="33" width="24" height="4" rx="1" fill="#00d4ff"/><rect x="38" y="74" width="24" height="4" rx="1" fill="#00d4ff"/></svg></span>NTERCEPT <span class="tagline">// See the Invisible</span></h1>
</div> </div>
<div class="header-right"> <div class="header-right">
<span class="active-mode-indicator" id="activeModeIndicator"><span class="pulse-dot"></span>PAGER</span> <span class="active-mode-indicator" id="activeModeIndicator"><span class="pulse-dot"></span>PAGER</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>
+1 -1
View File
@@ -53,7 +53,7 @@
<rect x="38" y="76" width="24" height="4" rx="1" fill="var(--accent-cyan)"/> <rect x="38" y="76" width="24" height="4" rx="1" fill="var(--accent-cyan)"/>
</svg> </svg>
<span class="app-logo-text"> <span class="app-logo-text">
<span class="app-logo-title">iNTERCEPT</span> <span class="app-logo-title"><span class="brand-i"><svg viewBox="36 14 28 68" width="1em" height="1em" xmlns="http://www.w3.org/2000/svg"><circle cx="50" cy="20" r="6" fill="#00ff88"/><rect x="44" y="33" width="12" height="45" rx="2" fill="#00d4ff"/><rect x="38" y="33" width="24" height="4" rx="1" fill="#00d4ff"/><rect x="38" y="74" width="24" height="4" rx="1" fill="#00d4ff"/></svg></span>NTERCEPT</span>
<span class="app-logo-tagline">// See the Invisible</span> <span class="app-logo-tagline">// See the Invisible</span>
</span> </span>
</a> </a>

Some files were not shown because too many files have changed in this diff Show More