Compare commits

..

253 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
Smittix 90281b1535 fix(modes): deep-linked mode scripts fail when body not yet parsed
ensureModeScript() used document.body.appendChild() to load lazy mode
scripts, but the preload for ?mode= query params runs in <head> before
<body> exists, causing all deep-linked modes to silently fail.

Also fix cross-mode handoffs (BT→BT Locate, WiFi→WiFi Locate,
Spy Stations→Waterfall) that assumed target module was already loaded.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 20:49:08 +00:00
Smittix e687862043 feat: UI/UX overhaul — CSS cleanup, accessibility, error handling, inline style extraction
Phase 0 — CSS-only fixes:
- Fix --font-mono to use real monospace stack (JetBrains Mono, Fira Code, etc.)
- Replace hardcoded hex colors with CSS variables across 16+ files
- Merge global-nav.css (507 lines) into layout.css, delete original
- Reduce !important in responsive.css from 71 to 8 via .app-shell specificity
- Standardize breakpoints to 480/768/1024/1280px

Phase 1 — Loading states & SSE connection feedback:
- Add centralized SSEManager (sse-manager.js) with exponential backoff
- Add SSE status indicator dot in nav bar
- Add withLoadingButton() + .btn-loading CSS spinner
- Add mode section crossfade transitions

Phase 2 — Accessibility:
- Add aria-labels to icon-only buttons across mode partials
- Add for/id associations to 42 form labels in 5 mode partials
- Add aria-live on toast stack, enableListKeyNav() utility

Phase 3 — Destructive action guards & list overflow:
- Add confirmAction() styled modal, replace all 25 native confirm() calls
- Add toast cap at 5 simultaneous toasts
- Add list overflow indicator CSS

Phase 4 — Inline style extraction:
- Refactor switchMode() in app.js and index.html to use classList.toggle()
- Add CSS toggle rules for all switchMode-controlled elements
- Remove inline style="display:none" from 7+ HTML elements
- Add utility classes (.hidden, .d-flex, .d-grid, etc.)

Phase 5 — Mobile UX polish:
- pre/code overflow handling already in place
- Touch target sizing via --touch-min variable

Phase 6 — Error handling consistency:
- Add reportActionableError() to user-facing catch blocks in 5 mode JS files
- 28 error toast additions alongside existing console.error calls

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 13:04:36 +00:00
Smittix 05412fbfc3 fix(wifi_locate): read correct RSSI field from SSE network events
Backend sends rssi_current but frontend was reading net.signal || net.rssi,
causing RSSI to parse as NaN and silently skipping all meter/audio updates.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 10:27:12 +00:00
Smittix aa787f0b53 fix(setup): skip apt for acarsdec, build from source directly (#183)
acarsdec is not available in apt repos, so the apt_install attempt
always failed with a confusing error message before falling through
to the source build. Skip the apt attempt and go straight to compiling
from source on Linux.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-11 18:06:06 +00:00
Smittix ab033b35d3 feat: WiFi Locate mode, mobile nav groups, v2.24.0
Add WiFi Locate mode for locating access points by BSSID with real-time
signal meter, distance estimation, RSSI history chart, and audio
proximity tones. Includes hand-off from WiFi detail drawer, environment
presets (Free Space/Outdoor/Indoor), and signal-lost detection.

Also includes:
- Mobile navigation reorganized into labeled groups (SIG/TRK/SPC/WIFI/INTEL/SYS)
- flask-limiter made optional with graceful degradation
- Fix radiosonde setup missing semver Python dependency
- Documentation updates (FEATURES, USAGE, UI_GUIDE, GitHub Pages site)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-10 22:49:03 +00:00
Smittix e383575c80 fix(ook): use process group kill for reliable stop
The OOK subprocess was spawned without start_new_session=True, so
process.terminate() only signalled the parent — child processes kept
running. Now uses os.killpg() to terminate the entire process group,
matching the pattern used by all other routes (ADS-B, AIS, ACARS, etc.).

Also fixes silent error swallowing in the frontend stop handler so the
UI resets even if the backend request fails.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-10 12:32:29 +00:00
Smittix fd12d11fab fix(setup): add timeout to rtl_test health check to prevent hangs
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-10 08:59:08 +00:00
Smittix 0fbb446209 fix(airband): parse composite device value and send sdr_type to backend
The airband start function was calling parseInt() directly on composite
device selector values like "rtlsdr:0", which always returned NaN and
fell back to device 0. This also meant sdr_type was never sent to the
backend, and could result in int(None) TypeError on the server.

Now properly splits the composite value (matching ADS-B/ACARS/VDL2
pattern) and sends both device index and sdr_type. Also hardened
backend int() parsing to use explicit None checks.

Fixes: "Airband Error: Invalid parameter: int() argument must be a
string, a bytes-like object or a real number, not 'NoneType'"

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-07 19:37:22 +00:00
Smittix 4ea64bd7ef Merge pull request #178 from thatsatechnique/main
feat: add Generic OOK Signal Decoder module
2026-03-06 21:58:43 +00:00
thatsatechnique 7d9a220230 fix(ook): replace innerHTML with createElement/textContent in appendFrameEntry
Addresses final upstream review — all backend-derived values (timestamp,
bit_count, rssi, hex, ascii) now use DOM methods instead of innerHTML
interpolation, closing the last XSS surface. Bumps cache-buster to ook2.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 12:55:07 -08:00
thatsatechnique 0afa15bb16 Merge remote-tracking branch 'upstream/main' 2026-03-06 12:47:51 -08:00
Smittix d66ab01d34 fix: prefer apt package for SatDump on Ubuntu 24.10+
SatDump v1.2.2 has multiple GCC 15 build failures (sol2 templates,
libacars incompatible pointer types) that are difficult to patch
exhaustively. On distros where SatDump is available as a system
package (Ubuntu 24.10+, Debian Trixie+), install via apt instead
of building from source. Falls back to source build on older systems.

Closes #180

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 17:26:08 +00:00
thatsatechnique 91989a0216 fix(ook): address Copilot review — stale process, XSS presets, localStorage
- Detect crashed rtl_433 process via poll() and clean up stale state
  instead of permanently blocking restarts with 409
- Replace innerHTML+onclick preset rendering with createElement/addEventListener
  to prevent XSS via crafted localStorage frequency values
- Normalize preset frequencies to toFixed(3) on save and render
- Add try/catch + shape validation to loadPresets() for corrupted localStorage

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 17:21:14 -08:00
thatsatechnique 7b4ad20805 fix(ook): address upstream PR review — SDR tracking, validation, cleanup, XSS
Critical:
- Pass sdr_type_str to claim/release_sdr_device (was missing 3rd arg)
- Add ook_active_sdr_type module-level var for proper device registry tracking
- Add server-side range validation on all timing params via validate_positive_int

Major:
- Extract cleanup_ook() function for full teardown (stop_event, pipes, process,
  SDR release) — called from both stop_ook() and kill_all()
- Replace Popen monkey-patching with module-level _ook_stop_event/_ook_parser_thread
- Fix XSS: define local _esc() fallback in ook.js, never use raw innerHTML
- Remove dead inversion code path in utils/ook.py (bytes.fromhex on same
  string that already failed decode — could never produce a result)

Minor:
- Status event key 'status' → 'text' for consistency with other modules
- Parser thread logging: debug → warning for missing code field and errors
- Parser thread emits status:stopped on exit (normal EOF or crash)
- Add cache-busting ?v={{ version }}&r=ook1 to ook.js script include
- Fix gain/ppm comparison: != '0' (string) → != 0 (number)

Tests: 22 → 33 (added start success, stop with process, SSE stream,
timing range validation, stopped-on-exit event)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 16:32:31 -08:00
Smittix a1b0616ee6 feat: add military/civilian classification filter to ADS-B history
Add client-side and server-side military aircraft detection using ICAO
hex ranges and callsign prefixes (matching live dashboard logic). History
table shows MIL/CIV badges with filtering dropdown, and exports respect
the classification filter.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 15:21:05 +00:00
Smittix a146a21285 feat: add ADS-B history filtering, export, and UI improvements
Add date range filtering, CSV export, and enhanced history page styling
for the ADS-B aircraft tracking history feature.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 14:54:55 +00:00
Smittix 87a5715f30 fix: add progress messages to dump1090 install flow (#177)
Users reported setup.sh appearing stuck during dump1090 installation on
Ubuntu 25.10. Added progress messages before APT package checks, build
dependency installation, and fallback clone steps.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 14:54:49 +00:00
Smittix 52a28167c9 fix: SatDump build failure on GCC 15 (Ubuntu 25.10+)
Add -Wno-template-body to CMAKE_CXX_FLAGS to suppress GCC 15's
-Wtemplate-body warning that breaks SatDump's bundled sol2/sol.hpp.
The flag is silently ignored by older GCC versions.

Closes #180

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 14:27:54 +00:00
Smittix 1403d49049 fix: restore HackRF One/Pro detection when PATH is restricted 2026-03-05 09:31:21 +00:00
thatsatechnique 9090b415cc Merge remote-tracking branch 'upstream/main' 2026-03-04 14:54:56 -08:00
thatsatechnique 3f1606c38f Merge branch 'feature/ook-decoder' 2026-03-04 14:54:10 -08:00
thatsatechnique 18db66bce3 fix(ook): harden for upstream review — tests, cleanup, CSS extraction
- Add kill_all() handler for OOK process cleanup on global reset
- Fix stop_ook() to close pipes and join parser thread (prevents hangs)
- Add ook.css with CSS classes, replace inline styles in ook.html
- Register ook.css in lazy-load style map (INTERCEPT_MODE_STYLE_MAP)
- Fix frontend frequency min=24 to match backend validation
- Add 22 unit tests for decode_ook_frame, ook_parser_thread, and routes

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 14:52:32 -08:00
Smittix 10077eee60 fix: HackRF One support — detection, ADS-B, waterfall, and error handling
- Parse hackrf_info stderr (newer firmware) and handle non-zero exit codes
- Fix gain_max from 62 to 102 (combined LNA 40 + VGA 62)
- Apply resolved readsb binary path for all SDR types, not just RTL-SDR
- Add HackRF/SoapySDR-specific error messages in ADS-B startup
- Add HackRF waterfall support via rx_sdr IQ capture + FFT
- Add 17 tests for HackRF detection and command builder

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 22:31:51 +00:00
Chris Brown 14568f8cc7 Merge pull request #7 from thatsatechnique/feature/ook-decoder
Feature/ook decoder
2026-03-04 14:31:32 -08:00
thatsatechnique 93fb694e25 fix(ook): address code review findings from Copilot PR review
- Fix XSS: escape ASCII output in innerHTML via escapeHtml()
- Fix deadlock: use put_nowait() for queue ops under ook_lock
- Fix SSE leak: add ook to moduleDestroyMap so switching modes
  closes the EventSource
- Fix RSSI: explicit null check preserves valid zero values in
  JSON export
- Add frame cap: trim oldest frames at 5000 to prevent unbounded
  memory growth on busy bands
- Validate timing params: wrap int() casts in try/except, return
  400 instead of 500 on invalid input
- Fix PWM hint: correct to short=0/long=1 matching rtl_433
  OOK_PWM convention (UI, JS hints, and cheat sheet)
- Fix inversion docstring: clarify fallback only applies when
  primary hex parse fails, not for valid decoded frames

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 14:29:55 -08:00
thatsatechnique cde24642ac feat(ook): add persistent frequency presets with add/remove/reset
Replace hardcoded frequency buttons with localStorage-backed presets.
Default presets are standard ISM frequencies (433.920, 315, 868, 915 MHz).
Users can add custom frequencies, right-click to remove, and reset to
defaults — matching the pager module pattern.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 14:28:49 -08:00
thatsatechnique b4757b1589 feat(ook): add cheat sheet with modulation and timing guide
Covers identifying modulation type (PWM/PPM/Manchester), finding
pulse timing via rtl_433 -A, common ISM frequencies and timings,
and troubleshooting tips for tolerance and bit order.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 14:28:49 -08:00
thatsatechnique f771100a4c fix(ook): fix output panel layout, persist frames, wire global status bar
- Fix double-scroll by switching ookOutputPanel to flex layout
- Keep decoded frames visible after stopping (persist for review)
- Wire global Clear/CSV/JSON status bar buttons to OOK functions
- Hide default output pane in OOK mode (uses own panel)
- Add command display showing the active rtl_433 command
- Add JSON export and auto-scroll support
- Fix 0x prefix stripping in OOK hex decoder
- Fix PWM encoding hint text

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 14:28:49 -08:00
thatsatechnique 0c3ccac21c feat(ook): add timing presets, RSSI, bit-order suggest, pattern filter, TSCM link
- Timing presets: five quick-fill buttons (300/600, 300/900, 400/800, 500/1500, 500 MC)
  that populate all six pulse-timing fields at once — maps to CTF flag timing profiles
- RSSI per frame: add -M level to rtl_433 command; parse snr/rssi/level from JSON;
  display dB SNR inline with each frame; include rssi_db column in CSV export
- Auto bit-order suggest: "Suggest" button counts printable chars across all stored
  frames for MSB vs LSB, selects the winner, shows count — no decoder restart needed
- Pattern filter: live hex/ASCII filter input above the frame log; hides non-matching
  frames and highlights matches in green; respects current bit order
- TSCM integration: "Decode (OOK)" button in RF signal device details panel switches
  to OOK mode and pre-fills frequency — frontend-only, no backend changes needed

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-04 14:28:49 -08:00
thatsatechnique 4c282bb055 feat: add Generic OOK Signal Decoder module
New 'OOK Decoder' mode for capturing and decoding arbitrary OOK/ASK
signals using rtl_433's flex decoder with fully configurable pulse
timing. Covers PWM, PPM, and Manchester encoding schemes.

Backend (utils/ook.py, routes/ook.py):
- Configurable modulation: OOK_PWM, OOK_PPM, OOK_MC_ZEROBIT
- Full rtl_433 flex spec builder with user-supplied pulse timings
- Bit-inversion fallback for transmitters with swapped short/long mapping
- Optional frame deduplication for repeated transmissions
- SSE streaming via /ook/stream

Frontend (static/js/modes/ook.js, templates/partials/modes/ook.html):
- Live MSB/LSB bit-order toggle — re-renders all stored frames instantly
  without restarting the decoder
- Full-detail frame display: timestamp, bit count, hex, dotted ASCII
- Modulation selector buttons with encoding hint text
- Full timing grid: short, long, gap/reset, tolerance, min bits
- CSV export of captured frames
- Global SDR device panel injection (device, SDR type, rtl_tcp, bias-T)

Integration (app.py, routes/__init__.py, templates/):
- Globals: ook_process, ook_queue, ook_lock
- Registered blueprint, nav entries (desktop + mobile), welcome card
- ookOutputPanel in visuals area with bit-order toolbar

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-04 14:28:49 -08:00
thatsatechnique 4741124d94 Merge remote-tracking branch 'upstream/main' 2026-03-04 14:28:02 -08:00
Smittix 9afd99bf7c fix: add progress messages to setup.sh for long-running install steps
Users had no visibility into what was happening during silent apt/pip
installs. Added info messages before Python package installs, APT
package lists update, and PostgreSQL installation.
2026-03-04 21:18:39 +00:00
Smittix fef54e5276 Merge pull request #175 from thatsatechnique/fix/pager-display-classification
fix: improve pager message display and mute visibility
2026-03-04 18:00:33 +00:00
Smittix f62c9871c4 feat: rewrite setup.sh as menu-driven installer with profile system
Replace the linear setup.sh with an interactive menu-driven installer:
- First-time wizard with OS detection and profile selection
- Install profiles: Core SIGINT, Maritime, Weather, RF Security, Full, Custom
- System health check (tools, SDR devices, ports, permissions, venv, PostgreSQL)
- Automated PostgreSQL setup for ADS-B history (creates DB, user, tables, indexes)
- Environment configurator for interactive INTERCEPT_* variable editing
- Update tools (rebuild source-built binaries)
- Uninstall/cleanup with granular options and double-confirm for destructive ops
- View status table of all tools with installed/missing state
- CLI flags: --non-interactive, --profile=, --health-check, --postgres-setup, --menu
- .env file helpers (read/write) with start.sh auto-sourcing
- Bash 3.2 compatible (no associative arrays) for macOS support

Update all documentation to reflect the new menu system:
- README.md: installation section with profiles, CLI flags, env config, health check
- CLAUDE.md: entry points and local setup commands
- docs/index.html: GitHub Pages install cards with profile mentions
- docs/HARDWARE.md: setup script section with profile table
- docs/TROUBLESHOOTING.md: health check and profile-based install guidance
- docs/DISTRIBUTED_AGENTS.md: controller quick start

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 17:07:41 +00:00
Smittix 0e03b84260 chore: add data/radiosonde/ to .gitignore and remove duplicate section
Runtime data (station config, logs) should not be tracked in version control.
Also removes duplicate "Local data" block in .gitignore.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 11:32:51 +00:00
Smittix f73f3466fd fix: browser hangs when navigating from WeFax to ADS-B dashboard
SSE EventSources and running processes were not cleaned up during
dashboard navigation, saturating the browser's per-origin connection
limit. Extract moduleDestroyMap into shared getModuleDestroyFn() and
call destroyCurrentMode() before navigation. Also expand
stopActiveLocalScansForNavigation() to cover wefax, weathersat, sstv,
subghz, meshtastic, and gps modes.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 10:35:33 +00:00
Smittix 8d91c200a5 fix: HackRF users get misleading RTL-SDR error in rtlamr/sstv/weather-sat modes
Several modes didn't pass sdr_type to claim_sdr_device(), defaulting to
'rtlsdr' and triggering an rtl_test USB probe that fails for HackRF with
a confusing "check that the RTL-SDR is connected" message.

- Add sdr_type to frontend start requests for rtlamr, weather-sat, sstv-general
- Read sdr_type in backend routes and pass to claim/release_sdr_device()
- Add early guard returning clear "not yet supported" error for non-RTL-SDR
  hardware in modes that are hardcoded to RTL-SDR tools
- Make probe_rtlsdr_device error message device-type-agnostic

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 07:52:13 +00:00
Chris Brown 9bf75a069e Merge pull request #6 from thatsatechnique/fix/pager-display-classification
fix: improve pager message display and mute visibility
2026-03-03 16:13:01 -08:00
ribs ec62cd9083 fix: prevent silent muting from hiding pager messages
The "Mute" button on pager cards persists muted addresses to
localStorage with no visible indicator, making it easy to
accidentally hide an address and forget about it. This caused
flag fragment messages on RIC 1337 to silently disappear.

- Add "X muted source(s) — Unmute All" indicator to sidebar
- Stop persisting hideToneOnly filter across sessions so the
  default (show all) always applies on page load
- Remove default checked state from Tone Only filter checkbox

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-03 16:05:01 -08:00
ribs 302b150c36 fix: strip CRLF from shell scripts during Docker build
Safety net for Windows developers whose git config (core.autocrlf=true)
converts LF to CRLF on checkout. Even with .gitattributes forcing eol=lf,
some git configurations can still produce CRLF working copies. The sed
pass after COPY ensures start.sh and other scripts always have Unix
line endings inside the container.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-03 16:05:01 -08:00
ribs cf022ed1c0 fix: add .gitattributes to enforce LF line endings for shell scripts
Docker containers crash on startup when shell scripts have CRLF line
endings (from Windows git checkout with core.autocrlf=true). The
start.sh gunicorn entrypoint fails with "$'\r': command not found".

Add .gitattributes forcing eol=lf for *.sh and Dockerfile so Docker
builds work regardless of the developer's git line ending config.
Also normalizes two scripts that were committed with CRLF.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-03 16:05:01 -08:00
ribs d3326409bf test: add unit tests for pager multimon-ng output parser
Cover all parse_multimon_output code paths:
- Alpha and Numeric content types across POCSAG baud rates
- Empty content and special characters (base64, punctuation)
- Catch-all pattern for non-standard content type labels
- Address-only (Tone) messages with trailing whitespace
- FLEX simple format and unrecognized input lines

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-03 16:05:01 -08:00
ribs 3de5e68e68 fix: improve pager message display and encryption classification
Three issues caused POCSAG messages to be incorrectly hidden or
misclassified in the Device Intelligence panel:

1. detectEncryption used a narrow character class ([a-zA-Z0-9\s.,!?-])
   to measure "printable ratio". Messages containing common printable
   ASCII characters like : = / + @ fell below the 0.8 threshold and
   returned null ("Unknown") instead of false ("Plaintext"). Simplified
   to check all printable ASCII (\x20-\x7E) which correctly classifies
   base64, structured data, and punctuation-heavy content.

2. The default hideToneOnly filter was true, hiding all address-only
   (Tone) pager messages. When RF conditions cause multimon-ng to decode
   the address but not the message content, the resulting Tone card was
   silently filtered. Changed default to false so users see all traffic
   and can opt-in to filtering.

3. The multimon-ng output parser only recognized "Alpha" and "Numeric"
   content type labels. Added a catch-all pattern to capture any
   additional content type labels that future multimon-ng versions or
   forks might emit, rather than dropping them to raw output.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-03 16:05:01 -08:00
Smittix 325dafacbc fix: improve startup error reporting with full stderr logging and dependency pre-check
Radiosonde route now runs a quick import check before launching the full
subprocess, catching missing Python dependencies immediately with a clear
message instead of a truncated traceback. Error messages are context-aware:
import errors suggest re-running setup.sh rather than checking SDR connections.

Increased stderr truncation limit from 200 to 500 chars and added full stderr
logging via logger.error() across all affected routes (radiosonde, ais, aprs,
acars, vdl2) for easier debugging.

Closes #173

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-03 20:46:14 +00:00
Smittix 2f5f429e83 fix: airband start crash when device selector not yet populated
When the /devices fetch hasn't completed or fails, parseInt on an empty
select returns NaN which JSON-serializes to null. The backend then calls
int(None) and raises TypeError. Fix both layers: frontend falls back to
0 on NaN, backend uses `or` defaults so null values don't bypass the
fallback.

Also adds a short TTL cache to detect_all_devices() so multiple
concurrent callers on the same page load don't each spawn blocking
subprocess probes.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-03 18:56:38 +00:00
Smittix fb4482fac7 fix: setup.sh flask-sock install failures on Debian 13 / RPi (#170)
Three compounding bugs prevented flask-sock (and other C-extension
packages) from installing and hid the actual errors:

- Add python3-dev to Debian apt installs so Python.h is available for
  building gevent, cryptography, etc.
- Remove 2>/dev/null from optional packages pip loop so install errors
  are visible and diagnosable
- Surface pip/setuptools/wheel upgrade failures with a warning instead
  of silently swallowing them

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-03 18:32:32 +00:00
Smittix 32f04d4ed8 fix: morse decoder splitting dahs into dits due to mid-element signal dropout
Add dropout tolerance (2 blocks ~40ms) to bridge brief signal gaps that
caused the state machine to chop dahs into multiple dits. Also fix scope
SNR display to use actual noise_ref instead of noise_floor.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-03 13:24:40 +00:00
Smittix 38644bced6 fix: replace 100+ hardcoded colors with CSS variables for light theme
Add theme-aware severity/neon CSS variables and replace hardcoded hex
colors (#fff, #000, #00ff88, #ffcc00, etc.) with var() references
across 26 files so text remains readable in both dark and light themes.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-03 11:35:17 +00:00
Smittix f3d475d53a fix: light theme nav labels, run-state chips, and buttons
Nav active labels used color: var(--bg-primary) which resolved to
near-white on light backgrounds. Run-state chips and buttons had
hardcoded dark RGBA backgrounds. Added light-theme overrides for
readable text and appropriate light backgrounds.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-03 10:58:21 +00:00
Smittix 195c224189 fix: stop satellite position polling when mode is inactive
Use postMessage from parent page to notify the satellite dashboard
iframe of visibility changes, preventing unnecessary POST requests
to /satellite/position when the user isn't viewing satellite mode.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-03 10:50:32 +00:00
Smittix f07ec23da9 feat: add space weather image prefetch and stable cache-busting
Backend: Add /prefetch-images endpoint that warms the image cache in
parallel using a thread pool, skipping already-cached images.

Frontend: Trigger prefetch on mode init so images load instantly.
Replace per-request Date.now() cache-bust with a 5-minute rotating
key to allow browser caching aligned with backend max-age.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-02 22:21:55 +00:00
Smittix 4b64862eb4 fix: release SDR device when switching modes across pages
When navigating from another mode (e.g. pager) to the ADS-B dashboard,
the old process could still hold the USB device. Two fixes:

1. routes/adsb.py: If dump1090 starts but SBS port never comes up,
   kill the process and return a DEVICE_BUSY error instead of silently
   claiming success with no data.

2. templates/adsb_dashboard.html: Pre-flight conflict check in
   toggleTracking() queries /devices/status and auto-stops any
   conflicting mode before starting ADS-B, with a 1.5s USB release
   delay.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-02 22:21:30 +00:00
Smittix eea44f9a6b fix: move meteor scatter start button below antenna guide
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-02 22:02:15 +00:00
Smittix de3f972aa2 fix: detect bias-t support before passing -T to rtl_sdr/rtl_fm
Stock rtl-sdr packages don't support the -T bias-tee flag (only
RTL-SDR Blog builds do). Passing -T to stock rtl_sdr causes an
immediate exit, breaking meteor scatter and waterfall modes.

Now probes the tool's --help output before adding -T, with a regex
that avoids false-matching "DVB-T" in the description text.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-02 21:52:54 +00:00
Smittix 6a334c61df fix: resolve meteor WebSocket race condition and setup apt-get failure
Meteor: onopen callback used closure variable _ws instead of `this`,
so a double-click during CONNECTING state sent on the wrong socket.
Also clean up any in-progress connection on re-start, not just running ones.

Setup: make apt-get update non-fatal so third-party repo errors
(e.g. stale PPAs on Debian) don't abort the entire install.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-02 21:48:56 +00:00
Smittix 63994ec1d4 fix: suppress double monkey-patch warnings and fork hook assertions
Match gunicorn's patch_all() args exactly (remove subprocess=False),
filter the MonkeyPatchWarning from the unavoidable double-patch, and
wrap gevent's _ForkHooks.after_fork_in_child to catch the spurious
AssertionError that fires when subprocesses fork after double-patching.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-02 21:26:55 +00:00
Smittix 6011d6fb41 fix: apply gevent monkey-patch in post_fork to prevent ARM worker deadlock
Gunicorn's gevent worker deadlocks during init_process() on Raspberry Pi
(ARM) before it can apply its own monkey-patching. Patching in post_fork
runs immediately after fork and before worker init, avoiding the race.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-02 21:24:04 +00:00
Smittix 845629ea46 feat: enhance Meteor Scatter with sidebar fixes and visual effects
Move SDR Device below mode title, add sidebar Start/Stop buttons,
and add starfield canvas, meteor streak animations, particle bursts,
signal strength meter, and enhanced ping flash effects.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-02 21:04:35 +00:00
Smittix 7311dd10ab feat: add Meteor Scatter mode for VHF beacon ping detection
Full-stack meteor scatter monitoring mode that captures IQ data from
an RTL-SDR, computes FFT waterfall frames via WebSocket, and runs a
real-time detection engine to identify transient VHF reflections from
meteor ionization trails (e.g. GRAVES radar at 143.050 MHz).

Backend: MeteorDetector with EMA noise floor, SNR threshold state
machine (IDLE/DETECTING/ACTIVE/COOLDOWN), hysteresis, and CSV/JSON
export. WebSocket at /ws/meteor for binary waterfall frames, SSE at
/meteor/stream for detection events and stats.

Frontend: spectrum + waterfall + timeline canvases, event table with
SNR/duration/confidence, stats strip, turbo colour LUT. Uses shared
SDR device selection panel with conflict tracking.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-02 20:38:15 +00:00
Smittix e2e92b6b38 fix: cache ISS position/schedule and parallelize SSTV init API calls
SSTV mode was slow to populate next-pass countdown and ISS location map
due to uncached skyfield computation and sequential JS API calls.

- Cache ISS position (10s TTL) and schedule (15min TTL, keyed by rounded lat/lon)
- Cache skyfield timescale object (expensive to create on every request)
- Reduce external API timeouts from 5s to 3s
- Fire checkStatus, loadImages, loadIssSchedule, updateIssPosition in parallel via Promise.all

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-02 18:05:57 +00:00
Smittix 5534493bd1 fix: parallelize space weather API fetches to reduce cold-cache latency
The /space-weather/data endpoint made 13 sequential HTTP requests, each
with a 15s timeout, causing 30-195s load times on cold cache. Now uses
ThreadPoolExecutor to fetch all sources concurrently, reducing worst-case
latency to ~15s (single slowest request).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-02 17:51:31 +00:00
Smittix 86fa6326e9 fix: prevent radiosonde strip from stretching in flex column layout
Add flex-shrink: 0 so the strip holds its intrinsic height instead of
being distorted by the parent flex container.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-02 17:30:27 +00:00
Smittix be70d2e43b feat: move radiosonde status display to main pane stats strip
Move tracking state, balloon count, last update, and waveform from the
sidebar into a stats strip above the map, matching the APRS strip pattern.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-02 16:58:16 +00:00
Smittix e89a0ef486 fix: pass config file path (not directory) to radiosonde_auto_rx -c flag
Reverts the incorrect assumption from f8e5d61 that -c expects a
directory. The auto_rx -c flag expects the full path to station.cfg.
Passing the directory caused "Config file ... does not exist!" on start.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-02 16:35:25 +00:00
Smittix bcf447fe4e fix: prevent root-owned data files from breaking radiosonde start
Running via sudo creates data/radiosonde/ as root. On next run the
config write fails with an unhandled OSError, Flask returns an HTML 500,
and the frontend shows a cryptic JSON parse error.

Three-layer fix:
- start.sh: pre-create known data dirs before chown, add certs/ to the
  list, export INTERCEPT_SUDO_UID/GID for runtime use
- generate_station_cfg: catch OSError with actionable message, chown
  newly created files to the real user via _fix_data_ownership()
- start_radiosonde: wrap config generation in try/except so it returns
  JSON instead of letting Flask emit an HTML error page

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-02 16:27:20 +00:00
Smittix 90b455aa6c feat: add signal activity waveform component for radiosonde mode
Reusable SVG bar waveform (SignalWaveform.Live) that animates in response
to incoming SSE data — idle breathing when stopped, active oscillation
proportional to telemetry update frequency, smooth decay on signal loss.

Integrated into radiosonde Status section with ping() on each balloon
message and stop() on tracking stop. Also hardens the fetch error path
to show a readable message instead of a JSON parse error when the server
returns HTML.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-02 16:27:09 +00:00
Smittix f8e5d61fa9 fix: pass config directory (not file path) to radiosonde_auto_rx -c flag
The -c flag expects a directory containing station.cfg, but we were
passing the full file path, so auto_rx could never find its config.
Also fix sonde_type priority to prefer subtype over type.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-02 15:12:30 +00:00
Smittix bd67195238 fix: apply light theme to sidebar, nav, and visual refresh components (#168)
The visual refresh layer hardcoded dark rgba() gradients that overrode
variable-based backgrounds. Added [data-theme="light"] overrides for
visual refresh CSS variables and comprehensive component backgrounds
in index.css and global-nav.css.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-02 14:28:11 +00:00
Smittix d78ab5cc2c fix: install flask-sock individually to surface failures (#170)
Move flask-sock and websocket-client from the batch core install (where
failures are silently swallowed) to the optional packages loop so users
see a clear warning if either package fails to build on ARM.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-02 14:06:33 +00:00
Smittix d087780d9f fix: WeFax sidebar minor GUI issues (#167)
Remove section hover shift, fix broken NOAA PDF link, reorder sections
to match Weather Satellite pattern, and fix text alignment spacing.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-02 13:48:42 +00:00
Smittix 8379f42ec3 fix: close leaked file descriptors on mode switch (#169)
SSE EventSource connections for AIS, ACARS, VDL2, and radiosonde were
not closed when switching modes, causing fd exhaustion after repeated
switches. Also fixes socket leaks on exception paths in AIS/ADS-B
stream parsers, closes subprocess pipes in safe_terminate/cleanup, and
caches skyfield timescale at module level to avoid per-request fd churn.

Closes #169

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-02 13:38:21 +00:00
Smittix ff9961b846 fix: add missing METEOR-M2-4 TLE data for pass predictions
METEOR-M2-4 was defined as an active weather satellite but had no
orbital data, so pass predictions always returned empty. Added TLE
entry and CelesTrak name mapping for automatic refresh.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-02 11:45:20 +00:00
Smittix 5e99d19165 fix: suppress noisy SSL handshake errors in gunicorn logs
Add SSLZeroReturnError and SSLError to gevent's NOT_ERROR list so
dropped TLS handshakes (browser preflight, plain HTTP to HTTPS port)
don't print scary tracebacks to the console.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-02 10:39:52 +00:00
Smittix 0df412c014 feat: show LAN address instead of 0.0.0.0 in start.sh output
Resolves the machine's LAN IP via hostname -I so users see a
clickable URL they can use from other devices on the network.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-02 10:10:44 +00:00
Smittix e756a00cc9 fix: add proactive DB writability check before init_db writes
sqlite3.connect() opens read-only files without error — the failure
only surfaces on the first write (INSERT). Add an upfront os.access()
check on both the directory and file, with a clear error showing the
owner and the exact chown command to fix it.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-02 10:03:11 +00:00
Smittix c35131462e fix: prevent root-owned database files when running with sudo
When start.sh runs via sudo, chown instance/ and data/ back to the
invoking user so the SQLite DB stays accessible without sudo. Also
adds a clear error message in get_connection() when the DB can't be
opened due to permissions.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-02 09:59:41 +00:00
Smittix bad637591a fix: replace duplicate libfftw3-dev with libfftw3-bin for SatDump runtime
The FFTW3 dev package was listed twice in the build stage and both
copies were removed during cleanup, taking the runtime .so with them.
Switching the duplicate to libfftw3-bin ensures libfftw3f.so.3 persists.

Fixes #166

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-02 09:30:09 +00:00
Smittix 910b69594d Merge pull request #145 from mitchross/main
All review issues addressed. Merging with fixup commit for XSS escaping, import cleanup, VDL2 click behavior, frequency defaults, and misc fixes.
2026-03-01 20:42:50 +00:00
Smittix a154601e86 fix: address PR #145 review issues
- Escape ac.icao, callsign, typeCode with escapeHtml() in aircraft card (XSS)
- Add linking comments between duplicated IATA_TO_ICAO mappings
- VDL2 sidebar: single-click selects aircraft, double-click opens modal
- Remove stale ICAOs from acarsAircraftIcaos in cleanupOldAircraft()
- Add null guard to drawPolarPlot() in weather-satellite.js
- Move deferred imports (translate_message, get_flight_correlator) to module level
- Check all frequency checkboxes by default on initial load
- Remove extra blank lines and uncertain MC/MCO airline code entry
- Add TODO comments linking duplicated renderAcarsCard implementations

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-01 20:42:14 +00:00
Smittix bdeb32e723 feat: add rtl_tcp remote SDR support to weather satellite decoder
Extends the rtl_tcp support (added in c1339b6 for APRS, Morse, DSC) to
the weather satellite mode. When a remote SDR host is provided, SatDump
uses --source rtltcp instead of --source rtlsdr, local device claiming
is skipped, and the frontend sends rtl_tcp params via getRemoteSDRConfig().

Closes #166

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-01 16:55:04 +00:00
Smittix 2de592f798 fix: suppress noisy gevent SystemExit traceback on shutdown
When stopping gunicorn with Ctrl+C, the gevent worker's handle_quit()
calls sys.exit(0) inside a greenlet, causing gevent to print a
SystemExit traceback. Add a gunicorn config with post_worker_init hook
that marks SystemExit as a non-error in gevent's hub.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-28 21:31:01 +00:00
Mitch Ross b5c3d71247 Merge branch 'smittix:main' into main 2026-02-28 16:30:56 -05:00
Smittix c1339b6c65 feat: add rtl_tcp remote SDR support to aprs, morse, and dsc routes
Closes #164. Only pager and sensor routes supported rtl_tcp connections.
Now aprs, morse, and dsc routes follow the same pattern: extract
rtl_tcp_host/port from the request, skip local device claiming for
remote connections, and use SDRFactory.create_network_device(). DSC also
refactored from manual rtl_fm command building to use SDRFactory's
builder abstraction. Frontend wired up for all three modes.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-28 19:21:28 +00:00
Smittix 153aacba03 fix: defer heavy init so gunicorn worker serves requests immediately
Blueprint registration and database init run synchronously (essential
for routing). Process cleanup, database cleanup scheduling, and TLE
satellite updates are deferred to a background thread with a 1-second
delay so the gevent worker can start serving HTTP requests right away.

Previously all init ran synchronously during module import, blocking
the single gevent worker for minutes while TLE data was fetched from
CelesTrak.

Also removes duplicate TLE update — init_tle_auto_refresh() already
schedules its own background fetch.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-28 17:59:46 +00:00
Smittix bcbadac995 docs: add sudo to start.sh usage comments and USAGE.md examples
All other docs already reference sudo ./start.sh but the inline usage
comments in start.sh itself and the --help example in USAGE.md were
missing it, which could lead users to run without root privileges.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-28 17:51:02 +00:00
Smittix a6e62f4674 fix: skip custom signal handlers when running under gunicorn
The custom SIGINT/SIGTERM handler in utils/process.py overrode
gunicorn's own signal management, causing KeyboardInterrupt to fire
inside the gevent worker on Ctrl+C instead of allowing gunicorn's
graceful shutdown. Now detects if another signal manager (gunicorn)
has already installed handlers and defers to it.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-28 17:48:32 +00:00
Smittix 77255e015d fix: register blueprints at module level for gunicorn compatibility
Blueprint registration, database init, cleanup, and websocket setup
were all inside main() which only runs via 'python intercept.py'.
When gunicorn imports app:app, it got a bare Flask app with no routes,
causing every endpoint to return 404.

Extracted initialization into _init_app() called at module level with
a guard to prevent double-init when main() is also used.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-28 17:46:44 +00:00
Smittix 6cbe94cf20 fix: restore flask-limiter as mandatory dependency
Rate limiting on login is a security requirement, not optional.
Reverts the no-op fallback — if flask-limiter is missing, the app
will fail fast with a clear import error rather than silently
running without rate limiting.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-28 17:35:46 +00:00
Smittix cdf10e1d6a fix: add missing psutil to setup.sh and relax flask-limiter check
- psutil was in requirements.txt but missing from setup.sh optional list
- Verification check no longer hard-fails on flask-limiter since app.py
  now handles it as optional with a no-op fallback

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-28 17:34:39 +00:00
Smittix a22244a041 fix: make flask-limiter import optional to prevent worker boot failure
flask-limiter may not be installed (e.g. RPi venv). The hard import
crashed the gunicorn gevent worker on startup, causing all routes to
return 404 with no visible error. Now falls back to a no-op limiter.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-28 17:29:10 +00:00
Smittix 9371fccd62 fix: add graceful-timeout to gunicorn so Ctrl+C shuts down promptly
Long-lived SSE connections prevent the gevent worker from exiting on
SIGINT. --graceful-timeout 5 force-kills the worker after 5 seconds.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-28 17:25:26 +00:00
Smittix b4b6fdc0fc fix: remove manual gevent monkey-patch that blocked gunicorn worker boot
Gunicorn's gevent worker (-k gevent) handles monkey-patching internally.
The manual patch_all() in app.py ran in the master process before worker
fork, preventing the worker from booting (no 'Booting worker' log line,
server unreachable).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-28 17:24:22 +00:00
Smittix 9e3fcb8edd fix: use venv Python in start.sh instead of bare python
Resolves ModuleNotFoundError when running outside a venv by auto-detecting
the venv/bin/python relative to the script, falling back to VIRTUAL_ENV
or system python3.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-28 17:22:31 +00:00
Smittix 2c7909e502 fix: convert start.sh line endings from CRLF to LF
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-28 17:21:27 +00:00
Smittix 003c4d62cf feat: add gunicorn + gevent production server via start.sh
Add start.sh as the recommended production entry point with:
- gunicorn + gevent worker for concurrent SSE/WebSocket handling
- CLI flags for port, host, debug, HTTPS, and dependency checks
- Auto-fallback to Flask dev server if gunicorn not installed
- Conditional gevent monkey-patch in app.py via INTERCEPT_USE_GEVENT env var
- Docker CMD updated to use start.sh
- Updated all docs, setup.sh, and requirements.txt accordingly

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-28 17:18:40 +00:00
Smittix 10e4804e0a fix: bluetooth no results, audio waveform leak, and mode switch cleanup
- Change 'already_running' to 'already_scanning' status in bluetooth_v2
  so frontend recognizes the response and connects the SSE stream
- Hide pagerScopePanel and sensorScopePanel in switchMode() to prevent
  audio waveform bars leaking into other modes
- Clear devices Map, pendingDeviceIds Set, and UI in BluetoothMode.destroy()
  to prevent memory accumulation on repeated mode switches

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-28 17:16:55 +00:00
Smittix 05edfb93dc fix: parse actual board name from hackrf_info for HackRF Pro support
Previously all HackRF devices were hardcoded as "HackRF One" regardless
of actual hardware variant. Now parses the Board ID line from hackrf_info
to correctly identify HackRF Pro, HackRF One, and other variants.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-28 14:18:30 +00:00
mitchross 29873fb3c0 Merge upstream/main and resolve acars, vdl2, dashboard conflicts
Resolved conflicts:
- routes/acars.py: keep /messages and /clear endpoints for history reload
- routes/vdl2.py: keep /messages and /clear endpoints for history reload
- templates/adsb_dashboard.html: keep removal of hardcoded device-1
  defaults for ACARS/VDL2 selectors (users pick their own device)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-27 14:47:57 -05:00
mitchross 81a8f24e27 Merge upstream/main and resolve weather-satellite.js conflict
Keep allPasses assignment for satellite filtering support.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-26 18:37:09 -05:00
mitchross 4712616339 Fix ADS-B sidebar deselect bug, ACARS XSS, and classifier dead code
- Clear sidebar highlights and ACARS message timer when stale selected
  aircraft is removed in cleanupOldAircraft()
- Escape all user-controlled strings in renderAcarsCard(),
  addAcarsMessage(), and renderAcarsMainCard() before innerHTML insertion
- Remove dead duplicate H1 check in classify_message_type
- Move _d label from link_test set to handshake return path

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-26 18:34:35 -05:00
mitchross 5fcfa2f72f Add multi-SDR setup guide to hardware docs
Step-by-step instructions for running multiple RTL-SDR dongles:
serial burning, udev symlinks, USB power, and Docker passthrough.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-26 12:52:54 -05:00
mitchross 5f583e5718 Merge upstream/main and resolve weather-satellite.js conflict
Resolved conflict in static/js/modes/weather-satellite.js:
- Kept allPasses state variable and applyPassFilter() for satellite pass filtering
- Kept satellite select dropdown listener for filter feature
- Adopted upstream's optimistic stop() UI pattern for better responsiveness
- Kept optional chaining (pass?.trajectory) since drawPolarPlot can receive null

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-26 00:37:02 -05:00
mitchross 6a690abf82 Fix review issues: profiles, imports, clear reset, frequencies, VDL2 enrichment
- Remove profiles: [basic] from intercept service so docker compose up -d
  works without --profile flag (fixes breaking change for existing deployments)
- Add missing Any import to routes/acars.py and routes/vdl2.py
- Reset last_message_time to None in ACARS and VDL2 clear endpoints
- Restore 131.725 and 131.825 to default ACARS frequencies (major US carriers)
- Copy VDL2 ACARS enrichment fields to top-level data dict instead of mutating
  nested acars_payload (consistent with ACARS route pattern)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-23 23:31:20 -05:00
mitchross e19a990b64 Merge upstream/main and resolve adsb_dashboard.html conflict
Take upstream's crosshair animation system and updated selectAircraft(icao, source) signature.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-23 23:16:39 -05:00
Mitch Ross 1c681b6777 Merge branch 'smittix:main' into main 2026-02-22 21:35:05 -05:00
Mitch Ross ab064b4c91 fix 2026-02-21 15:50:58 -05:00
Mitch Ross 26ecd3dd93 Merge branch 'smittix:main' into main 2026-02-21 12:12:54 -05:00
Mitch Ross c2405bfe14 feat(adsb): improve ACARS/VDL2 panels with history, clear, smooth updates, and translation
- Persist ACARS/VDL2 messages across page refresh via new /acars/messages
  and /vdl2/messages endpoints backed by FlightCorrelator
- Add clear buttons to ACARS/VDL2 sidebars and right-panel datalink section
  with /acars/clear and /vdl2/clear endpoints
- Fix right-panel DATALINK MESSAGES flickering by diffing innerHTML before
  updating, with opacity transition for smooth refreshes
- Add aircraft deselect toggle (click selected aircraft again to deselect)
- Enrich VDL2 messages with ACARS label translation (label_description,
  message_type, parsed fields) matching existing ACARS translator

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-20 23:48:50 -05:00
mitchross 01409cfdea fix(adsb): use actual device index for ACARS/VDL2 SDR conflict checks
ACARS and VDL2 conflict warnings were hardcoded to check device === '0'
instead of comparing against the actual ADS-B device (adsbActiveDevice).
This caused false warnings when ADS-B used a different device index.

Also removes hardcoded device-1 defaults for ACARS/VDL2 selectors —
users should pick their own device based on their antenna setup.

Adds profiles: [basic] to the intercept service in docker-compose so it
doesn't port-conflict with intercept-history when using --profile history.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-20 17:29:47 -05:00
Mitch Ross 130f58d9cc feat(adsb): add IATA↔ICAO airline code translation for ACARS cross-linking
ACARS messages use IATA codes (e.g. UA2412) while ADS-B uses ICAO
callsigns (e.g. UAL2412). Add a translation layer so the two can
match, enabling click-to-highlight and datalink message correlation.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-20 16:39:00 -05:00
mitchross 15d5cb2272 feat(adsb): cross-link ACARS sidebar messages with tracked aircraft
Click an ACARS message in the left sidebar to zoom the map to the
matching aircraft and open its detail panel. Aircraft with ACARS
activity show a DLK badge in the tracked list. Default NA frequency
changed to only check 131.550 on initial load.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-20 13:24:00 -05:00
mitchross d28d8cb9ef feat(acars): add message translator and ADS-B datalink integration
Add ACARS label translation, message classification, and field parsers
so decoded messages show human-readable descriptions instead of raw
label codes (H1, DF, _d, 5Z, etc.). Integrate translated ACARS
messages into the ADS-B aircraft detail panel and add a live message
feed to the standalone ACARS mode.

- New utils/acars_translator.py with ~50 label codes, type classifier,
  and parsers for position reports, engine data, weather, and OOOI
- Enrich messages at ingest in routes/acars.py with translation fields
- Backfill translation in /adsb/aircraft/<icao>/messages endpoint
- ADS-B dashboard: DATALINK MESSAGES section in aircraft detail panel
  with auto-refresh, color-coded type badges, and parsed field display
- Standalone ACARS mode: scrollable live message feed (max 30 cards)
- Fix default N. America ACARS frequencies to 131.550/130.025/129.125
- Unit tests covering all translator functions

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-20 11:11:57 -05:00
331 changed files with 43911 additions and 19966 deletions
+20 -1
View File
@@ -1,6 +1,8 @@
# Git # Git & CI
.git .git
.gitignore .gitignore
.github
.claude
# Python # Python
__pycache__ __pycache__
@@ -29,6 +31,23 @@ tests/
.coverage .coverage
htmlcov/ htmlcov/
.mypy_cache/ .mypy_cache/
.ruff_cache
.DS_Store
tasks/
# Documentation
*.md
# Runtime data (mounted as volume)
instance/
# data/ is a Python package — only exclude non-code files
data/*.json
data/*.csv
data/*.db
# Build scripts
build-multiarch.sh
# Logs # Logs
*.log *.log
+39 -2
View File
@@ -1,2 +1,39 @@
# Uncomment and set to use external storage for ADS-B history # =============================================================================
# PGDATA_PATH=/mnt/external/intercept/pgdata # INTERCEPT CONTROLLER (.env)
# =============================================================================
# Copy to .env and edit for your setup
# Container timezone (e.g. America/New_York, Europe/London, Australia/Sydney)
TZ=UTC
# Flask secret key (auto-generated if not set)
# INTERCEPT_SECRET_KEY=your-secret-key-here
# Admin credentials (password auto-generated on first run if not set)
# INTERCEPT_ADMIN_USERNAME=admin
# INTERCEPT_ADMIN_PASSWORD=your-password-here
# Postgres password (default: intercept)
INTERCEPT_ADSB_DB_PASSWORD=intercept
# Auto-start ADS-B when dashboard loads
INTERCEPT_ADSB_AUTO_START=false
# Share observer location across all modules
INTERCEPT_SHARED_OBSERVER_LOCATION=true
# Observer coordinates (uncomment and set to skip GPS prompt)
# INTERCEPT_DEFAULT_LAT=40.7128
# INTERCEPT_DEFAULT_LON=-74.0060
# =============================================================================
# AGENT SETTINGS (for docker-compose.agent.yml on remote Pis)
# =============================================================================
# Agent identity
AGENT_NAME=sdr-agent-1
AGENT_PORT=8020
# Controller connection (IP of the machine running docker-compose.yml)
CONTROLLER_URL=http://192.168.1.100:5050
AGENT_API_KEY=changeme
+3
View File
@@ -0,0 +1,3 @@
# Force LF line endings for files that must run on Linux (Docker)
*.sh text eol=lf
Dockerfile text eol=lf
+26
View File
@@ -0,0 +1,26 @@
name: CI
on: [push, pull_request]
jobs:
lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: '3.11'
- run: pip install -r requirements-dev.txt
- run: ruff check .
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: '3.11'
- run: pip install -r requirements-dev.txt
- name: Run tests
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
+6 -4
View File
@@ -18,10 +18,6 @@ pager_messages.log
downloads/ downloads/
pgdata/ pgdata/
# Local data
downloads/
pgdata/
# IDE # IDE
.idea/ .idea/
.vscode/ .vscode/
@@ -58,6 +54,9 @@ intercept_agent_*.cfg
# Weather satellite runtime data (decoded images, samples, SatDump output) # Weather satellite runtime data (decoded images, samples, SatDump output)
data/weather_sat/ data/weather_sat/
# Radiosonde runtime data (station config, logs)
data/radiosonde/
# SDR capture files (large IQ recordings) # SDR capture files (large IQ recordings)
data/subghz/captures/ data/subghz/captures/
@@ -65,3 +64,6 @@ data/subghz/captures/
.env .env
.env.* .env.*
!.env.example !.env.example
# Local utility scripts
reset-sdr.*
+122
View File
@@ -2,6 +2,126 @@
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
### Added
- **WiFi Locate Mode** - Locate WiFi access points by BSSID with real-time signal meter, distance estimation, RSSI chart, and audio proximity tones. Hand-off from WiFi detail drawer, environment presets (Free Space/Outdoor/Indoor), and signal-lost detection.
### Changed
- Mobile navigation bar reorganized into labeled groups (SIG, TRK, SPC, WIFI, INTEL, SYS) for better usability
- flask-limiter made optional — rate limiting degrades gracefully if package is missing
### Fixed
- Radiosonde setup missing `semver` Python dependency — `setup.sh` now explicitly installs it alongside `requirements.txt`
## [2.23.0] - 2026-02-27 ## [2.23.0] - 2026-02-27
### Added ### Added
@@ -15,11 +135,13 @@ All notable changes to iNTERCEPT will be documented in this file.
- **Multi-SDR WeFax** - Multiple SDR hardware support for WeFax decoder - **Multi-SDR WeFax** - Multiple SDR hardware support for WeFax decoder
- **Tool Path Overrides** - `INTERCEPT_*_PATH` environment variables for custom tool locations - **Tool Path Overrides** - `INTERCEPT_*_PATH` environment variables for custom tool locations
- **Homebrew Tool Detection** - Native path detection for Apple Silicon Homebrew installations - **Homebrew Tool Detection** - Native path detection for Apple Silicon Homebrew installations
- **Production Server** - `start.sh` with gunicorn + gevent for concurrent SSE/WebSocket handling — eliminates multi-client page load delays
### Changed ### Changed
- Morse decoder rebuilt with custom Goertzel decoder, replacing multimon-ng dependency - Morse decoder rebuilt with custom Goertzel decoder, replacing multimon-ng dependency
- GPS mode upgraded to textured 3D globe visualization - GPS mode upgraded to textured 3D globe visualization
- Destroy lifecycle added to all mode modules to prevent resource leaks - Destroy lifecycle added to all mode modules to prevent resource leaks
- Docker container now uses gunicorn + gevent by default via `start.sh`
### Fixed ### Fixed
- ADS-B device release leak and startup performance regression - ADS-B device release leak and startup performance regression
+21 -9
View File
@@ -25,15 +25,25 @@ docker compose --profile basic up -d --build
### Local Setup (Alternative) ### Local Setup (Alternative)
```bash ```bash
# Initial setup (installs dependencies and configures SDR tools) # First-time setup (interactive wizard with install profiles)
./setup.sh ./setup.sh
# Run the application (requires sudo for SDR/network access) # Or headless full install
./setup.sh --non-interactive
# Or install specific profiles
./setup.sh --profile=core,weather
# Run with production server (gunicorn + gevent, handles concurrent SSE/WebSocket)
sudo ./start.sh
# Or for quick local dev (Flask dev server)
sudo -E venv/bin/python intercept.py sudo -E venv/bin/python intercept.py
# Or activate venv first # Other setup utilities
source venv/bin/activate ./setup.sh --health-check # Verify installation
sudo -E python intercept.py ./setup.sh --postgres-setup # Set up ADS-B history database
./setup.sh --menu # Force interactive menu
``` ```
### Testing ### Testing
@@ -69,8 +79,10 @@ mypy .
## Architecture ## Architecture
### Entry Points ### Entry Points
- `intercept.py` - Main entry point script - `setup.sh` - Menu-driven installer with profile system (wizard, health check, PostgreSQL setup, env configurator, update, uninstall). Sources `.env` on startup via `start.sh`.
- `app.py` - Flask application initialization, global state management, process lifecycle, SSE streaming infrastructure - `start.sh` - Production startup script (gunicorn + gevent auto-detection, CLI flags, HTTPS, `.env` sourcing, fallback to Flask dev server)
- `intercept.py` - Direct Flask dev server entry point (quick local development)
- `app.py` - Flask application initialization, global state management, process lifecycle, SSE streaming infrastructure, conditional gevent monkey-patch
### Route Blueprints (routes/) ### Route Blueprints (routes/)
Each signal type has its own Flask blueprint: Each signal type has its own Flask blueprint:
@@ -121,7 +133,7 @@ Each signal type has its own Flask blueprint:
### Key Patterns ### Key Patterns
**Server-Sent Events (SSE)**: All real-time features stream via SSE endpoints (`/stream_pager`, `/stream_sensor`, etc.). Pattern uses `queue.Queue` with timeout and keepalive messages. **Server-Sent Events (SSE)**: All real-time features stream via SSE endpoints (`/stream_pager`, `/stream_sensor`, etc.). Pattern uses `queue.Queue` with timeout and keepalive messages. Under gunicorn + gevent, each SSE connection is a lightweight greenlet instead of an OS thread.
**Process Management**: External decoders run as subprocesses with output threads feeding queues. Use `safe_terminate()` for cleanup. Global locks prevent race conditions. **Process Management**: External decoders run as subprocesses with output threads feeding queues. Use `safe_terminate()` for cleanup. Global locks prevent race conditions.
@@ -152,7 +164,7 @@ Each signal type has its own Flask blueprint:
- **Mode Integration**: Each mode needs entries in `index.html` at ~12 points: CSS include, welcome card, partial include, visuals container, JS include, `validModes` set, `modeGroups` map, classList toggle, `modeNames`, visuals display toggle, titles, and init call in `switchMode()` - **Mode Integration**: Each mode needs entries in `index.html` at ~12 points: CSS include, welcome card, partial include, visuals container, JS include, `validModes` set, `modeGroups` map, classList toggle, `modeNames`, visuals display toggle, titles, and init call in `switchMode()`
### Docker ### Docker
- `Dockerfile` - Single-stage build with all SDR tools compiled from source (dump1090, AIS-catcher, slowrx, SatDump, etc.) - `Dockerfile` - Single-stage build with all SDR tools compiled from source (dump1090, AIS-catcher, slowrx, SatDump, etc.). CMD runs `start.sh` (gunicorn + gevent)
- `docker-compose.yml` - Two profiles: `basic` (standalone) and `history` (with Postgres for ADS-B) - `docker-compose.yml` - Two profiles: `basic` (standalone) and `history` (with Postgres for ADS-B)
- `build-multiarch.sh` - Multi-arch build script for amd64 + arm64 (RPi5) - `build-multiarch.sh` - Multi-arch build script for amd64 + arm64 (RPi5)
- Data persisted via `./data:/app/data` volume mount - Data persisted via `./data:/app/data` volume mount
+207 -191
View File
@@ -1,6 +1,197 @@
# INTERCEPT - Signal Intelligence Platform # INTERCEPT - Signal Intelligence Platform
# Docker container for running the web interface # Docker container for running the web interface
# Multi-stage build: builder compiles tools, runtime keeps only what's needed
###############################################################################
# Stage 1: Builder — compile all tools from source
###############################################################################
FROM python:3.11-slim AS builder
WORKDIR /tmp/build
# Install ALL build dependencies
RUN apt-get update && apt-get install -y --no-install-recommends \
build-essential \
git \
pkg-config \
cmake \
librtlsdr-dev \
libusb-1.0-0-dev \
libncurses-dev \
libsndfile1-dev \
libgtk-3-dev \
libasound2-dev \
libsoapysdr-dev \
libhackrf-dev \
liblimesuite-dev \
libfftw3-dev \
libpng-dev \
libtiff-dev \
libjemalloc-dev \
libvolk-dev \
libnng-dev \
libzstd-dev \
libsqlite3-dev \
libcurl4-openssl-dev \
zlib1g-dev \
libzmq3-dev \
libpulse-dev \
libfftw3-bin \
liblapack-dev \
libglib2.0-dev \
libxml2-dev \
curl \
&& rm -rf /var/lib/apt/lists/*
# Create staging directory for all built artifacts
RUN mkdir -p /staging/usr/bin /staging/usr/local/bin /staging/usr/local/lib /staging/opt
# Build dump1090
RUN cd /tmp \
&& git clone --depth 1 https://github.com/flightaware/dump1090.git \
&& cd dump1090 \
&& sed -i 's/-Werror//g' Makefile \
&& make BLADERF=no RTLSDR=yes \
&& cp dump1090 /staging/usr/bin/dump1090-fa \
&& ln -s /usr/bin/dump1090-fa /staging/usr/bin/dump1090 \
&& rm -rf /tmp/dump1090
# Build AIS-catcher
RUN cd /tmp \
&& git clone https://github.com/jvde-github/AIS-catcher.git \
&& cd AIS-catcher \
&& mkdir build && cd build \
&& cmake .. \
&& make \
&& cp AIS-catcher /staging/usr/bin/AIS-catcher \
&& rm -rf /tmp/AIS-catcher
# Build readsb
RUN cd /tmp \
&& git clone --depth 1 https://github.com/wiedehopf/readsb.git \
&& cd readsb \
&& make BLADERF=no PLUTOSDR=no SOAPYSDR=yes \
&& cp readsb /staging/usr/bin/readsb \
&& rm -rf /tmp/readsb
# Build rx_tools
RUN cd /tmp \
&& git clone https://github.com/rxseger/rx_tools.git \
&& cd rx_tools \
&& mkdir build && cd build \
&& cmake .. \
&& make \
&& DESTDIR=/staging make install \
&& rm -rf /tmp/rx_tools
# Build acarsdec
RUN cd /tmp \
&& git clone --depth 1 https://github.com/TLeconte/acarsdec.git \
&& cd acarsdec \
&& mkdir build && cd build \
&& cmake .. -Drtl=ON -DCMAKE_POLICY_VERSION_MINIMUM=3.5 \
&& make \
&& cp acarsdec /staging/usr/bin/acarsdec \
&& rm -rf /tmp/acarsdec
# Build libacars (required by dumpvdl2)
RUN cd /tmp \
&& git clone --depth 1 https://github.com/szpajder/libacars.git \
&& cd libacars \
&& mkdir build && cd build \
&& cmake .. \
&& make \
&& make install \
&& ldconfig \
&& cp -a /usr/local/lib/libacars* /staging/usr/local/lib/ \
&& rm -rf /tmp/libacars
# Build dumpvdl2 (VDL2 aircraft datalink decoder)
RUN cd /tmp \
&& git clone --depth 1 https://github.com/szpajder/dumpvdl2.git \
&& cd dumpvdl2 \
&& mkdir build && cd build \
&& cmake .. \
&& make \
&& cp src/dumpvdl2 /staging/usr/bin/dumpvdl2 \
&& rm -rf /tmp/dumpvdl2
# Build slowrx (SSTV decoder) — pinned to known-good commit
RUN cd /tmp \
&& git clone https://github.com/windytan/slowrx.git \
&& cd slowrx \
&& git checkout ca6d7012 \
&& make \
&& install -m 0755 slowrx /staging/usr/local/bin/slowrx \
&& rm -rf /tmp/slowrx
# Build SatDump (weather satellite decoder - NOAA APT & Meteor LRPT) — pinned to v1.2.2
RUN cd /tmp \
&& git clone --depth 1 --branch 1.2.2 https://github.com/SatDump/SatDump.git \
&& cd SatDump \
&& mkdir build && cd build \
&& 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 install \
&& ldconfig \
# Ensure SatDump plugins are in the expected path (handles multiarch differences)
&& mkdir -p /usr/local/lib/satdump/plugins \
&& if [ -z "$(ls /usr/local/lib/satdump/plugins/*.so 2>/dev/null)" ]; then \
for dir in /usr/local/lib/*/satdump/plugins /usr/lib/*/satdump/plugins /usr/lib/satdump/plugins; do \
if [ -d "$dir" ] && [ -n "$(ls "$dir"/*.so 2>/dev/null)" ]; then \
ln -sf "$dir"/*.so /usr/local/lib/satdump/plugins/; \
break; \
fi; \
done; \
fi \
# Copy SatDump install artifacts to staging
&& cp -a /usr/local/bin/satdump /staging/usr/local/bin/ 2>/dev/null || true \
&& cp -a /usr/local/lib/libsatdump* /staging/usr/local/lib/ 2>/dev/null || true \
&& cp -a /usr/local/lib/satdump /staging/usr/local/lib/ 2>/dev/null || true \
&& cp -a /usr/local/share/satdump /staging/usr/local/share/ 2>/dev/null; mkdir -p /staging/usr/local/share \
&& cp -a /usr/local/share/satdump /staging/usr/local/share/ 2>/dev/null || true \
&& rm -rf /tmp/SatDump
# Build hackrf CLI tools from source — avoids libhackrf0 version conflict
# between the 'hackrf' apt package and soapysdr-module-hackrf's newer libhackrf0
RUN cd /tmp \
&& git clone --depth 1 https://github.com/greatscottgadgets/hackrf.git \
&& cd hackrf/host \
&& mkdir build && cd build \
&& cmake .. \
&& make \
&& make install \
&& ldconfig \
&& cp -a /usr/local/bin/hackrf_* /staging/usr/local/bin/ 2>/dev/null || true \
&& cp -a /usr/local/lib/libhackrf* /staging/usr/local/lib/ 2>/dev/null || true \
&& rm -rf /tmp/hackrf
# Install radiosonde_auto_rx (weather balloon decoder)
RUN cd /tmp \
&& git clone --depth 1 https://github.com/projecthorus/radiosonde_auto_rx.git \
&& cd radiosonde_auto_rx/auto_rx \
&& pip install --no-cache-dir -r requirements.txt semver \
&& bash build.sh \
&& mkdir -p /staging/opt/radiosonde_auto_rx/auto_rx \
&& cp -r . /staging/opt/radiosonde_auto_rx/auto_rx/ \
&& chmod +x /staging/opt/radiosonde_auto_rx/auto_rx/auto_rx.py \
&& rm -rf /tmp/radiosonde_auto_rx
# Build rtlamr (utility meter decoder - requires Go)
RUN cd /tmp \
&& curl -fsSL "https://go.dev/dl/go1.22.5.linux-$(dpkg --print-architecture).tar.gz" | tar -C /usr/local -xz \
&& export PATH="$PATH:/usr/local/go/bin" \
&& export GOPATH=/tmp/gopath \
&& go install github.com/bemasher/rtlamr@latest \
&& cp /tmp/gopath/bin/rtlamr /staging/usr/bin/rtlamr \
&& rm -rf /usr/local/go /tmp/gopath
###############################################################################
# Stage 2: Runtime — lean image with only runtime dependencies
###############################################################################
FROM python:3.11-slim FROM python:3.11-slim
LABEL maintainer="INTERCEPT Project" LABEL maintainer="INTERCEPT Project"
@@ -12,12 +203,10 @@ WORKDIR /app
# Pre-accept tshark non-root capture prompt for non-interactive install # Pre-accept tshark non-root capture prompt for non-interactive install
RUN echo 'wireshark-common wireshark-common/install-setuid boolean true' | debconf-set-selections RUN echo 'wireshark-common wireshark-common/install-setuid boolean true' | debconf-set-selections
# Install system dependencies for SDR tools # Install ONLY runtime dependencies (no -dev packages, no build tools)
RUN apt-get update && apt-get install -y --no-install-recommends \ RUN apt-get update && apt-get install -y --no-install-recommends \
# RTL-SDR tools # RTL-SDR tools
rtl-sdr \ rtl-sdr \
librtlsdr-dev \
libusb-1.0-0-dev \
# 433MHz decoder # 433MHz decoder
rtl-433 \ rtl-433 \
# Pager decoder # Pager decoder
@@ -43,7 +232,6 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
# GPS support # GPS support
gpsd \ gpsd \
gpsd-clients \ gpsd-clients \
# Utilities
# APRS # APRS
direwolf \ direwolf \
# WiFi Extra # WiFi Extra
@@ -62,192 +250,17 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
procps \ procps \
&& rm -rf /var/lib/apt/lists/* && rm -rf /var/lib/apt/lists/*
# Build dump1090-fa and acarsdec from source (packages not available in slim repos) # Copy compiled binaries and libraries from builder stage
RUN apt-get update && apt-get install -y --no-install-recommends \ COPY --from=builder /staging/usr/bin/ /usr/bin/
build-essential \ COPY --from=builder /staging/usr/local/bin/ /usr/local/bin/
git \ COPY --from=builder /staging/usr/local/lib/ /usr/local/lib/
pkg-config \ COPY --from=builder /staging/opt/ /opt/
cmake \
libncurses-dev \ # Copy radiosonde Python dependencies installed during builder stage
libsndfile1-dev \ COPY --from=builder /usr/local/lib/python3.11/site-packages/ /usr/local/lib/python3.11/site-packages/
# GTK is required for slowrx (SSTV decoder GUI dependency).
# Note: slowrx is kept for backwards compatibility, but the pure Python # Refresh shared library cache for custom-built libraries
# SSTV decoder in utils/sstv/ is now the primary implementation. RUN ldconfig
# GTK can be removed if slowrx is deprecated in future releases.
libgtk-3-dev \
libasound2-dev \
libsoapysdr-dev \
libhackrf-dev \
liblimesuite-dev \
libfftw3-dev \
libpng-dev \
libtiff-dev \
libjemalloc-dev \
libvolk-dev \
libnng-dev \
libzstd-dev \
libsqlite3-dev \
libcurl4-openssl-dev \
zlib1g-dev \
libzmq3-dev \
libpulse-dev \
libfftw3-dev \
liblapack-dev \
libglib2.0-dev \
libxml2-dev \
# Build dump1090
&& cd /tmp \
&& git clone --depth 1 https://github.com/flightaware/dump1090.git \
&& cd dump1090 \
&& sed -i 's/-Werror//g' Makefile \
&& make BLADERF=no RTLSDR=yes \
&& cp dump1090 /usr/bin/dump1090-fa \
&& ln -s /usr/bin/dump1090-fa /usr/bin/dump1090 \
&& rm -rf /tmp/dump1090 \
# Build AIS-catcher
&& cd /tmp \
&& git clone https://github.com/jvde-github/AIS-catcher.git \
&& cd AIS-catcher \
&& mkdir build && cd build \
&& cmake .. \
&& make \
&& cp AIS-catcher /usr/bin/AIS-catcher \
&& cd /tmp \
&& rm -rf /tmp/AIS-catcher \
# Build readsb
&& cd /tmp \
&& git clone --depth 1 https://github.com/wiedehopf/readsb.git \
&& cd readsb \
&& make BLADERF=no PLUTOSDR=no SOAPYSDR=yes \
&& cp readsb /usr/bin/readsb \
&& cd /tmp \
&& rm -rf /tmp/readsb \
# Build rx_tools
&& cd /tmp \
&& git clone https://github.com/rxseger/rx_tools.git \
&& cd rx_tools \
&& mkdir build && cd build \
&& cmake .. \
&& make \
&& make install \
&& cd /tmp \
&& rm -rf /tmp/rx_tools \
# Build acarsdec
&& cd /tmp \
&& git clone --depth 1 https://github.com/TLeconte/acarsdec.git \
&& cd acarsdec \
&& mkdir build && cd build \
&& cmake .. -Drtl=ON -DCMAKE_POLICY_VERSION_MINIMUM=3.5 \
&& make \
&& cp acarsdec /usr/bin/acarsdec \
&& rm -rf /tmp/acarsdec \
# Build libacars (required by dumpvdl2)
&& cd /tmp \
&& git clone --depth 1 https://github.com/szpajder/libacars.git \
&& cd libacars \
&& mkdir build && cd build \
&& cmake .. \
&& make \
&& make install \
&& ldconfig \
&& rm -rf /tmp/libacars \
# Build dumpvdl2 (VDL2 aircraft datalink decoder)
&& cd /tmp \
&& git clone --depth 1 https://github.com/szpajder/dumpvdl2.git \
&& cd dumpvdl2 \
&& mkdir build && cd build \
&& cmake .. \
&& make \
&& cp src/dumpvdl2 /usr/bin/dumpvdl2 \
&& rm -rf /tmp/dumpvdl2 \
# Build slowrx (SSTV decoder) — pinned to known-good commit
&& cd /tmp \
&& git clone https://github.com/windytan/slowrx.git \
&& cd slowrx \
&& git checkout ca6d7012 \
&& make \
&& install -m 0755 slowrx /usr/local/bin/slowrx \
&& rm -rf /tmp/slowrx \
# Build SatDump (weather satellite decoder - NOAA APT & Meteor LRPT) — pinned to v1.2.2
&& cd /tmp \
&& git clone --depth 1 --branch 1.2.2 https://github.com/SatDump/SatDump.git \
&& cd SatDump \
&& mkdir build && cd build \
&& cmake -DCMAKE_BUILD_TYPE=Release -DBUILD_GUI=OFF -DCMAKE_INSTALL_LIBDIR=lib .. \
&& make -j$(nproc) \
&& make install \
&& ldconfig \
# Ensure SatDump plugins are in the expected path (handles multiarch differences)
&& mkdir -p /usr/local/lib/satdump/plugins \
&& if [ -z "$(ls /usr/local/lib/satdump/plugins/*.so 2>/dev/null)" ]; then \
for dir in /usr/local/lib/*/satdump/plugins /usr/lib/*/satdump/plugins /usr/lib/satdump/plugins; do \
if [ -d "$dir" ] && [ -n "$(ls "$dir"/*.so 2>/dev/null)" ]; then \
ln -sf "$dir"/*.so /usr/local/lib/satdump/plugins/; \
break; \
fi; \
done; \
fi \
&& cd /tmp \
&& rm -rf /tmp/SatDump \
# Build hackrf CLI tools from source — avoids libhackrf0 version conflict
# between the 'hackrf' apt package and soapysdr-module-hackrf's newer libhackrf0
&& cd /tmp \
&& git clone --depth 1 https://github.com/greatscottgadgets/hackrf.git \
&& cd hackrf/host \
&& mkdir build && cd build \
&& cmake .. \
&& make \
&& make install \
&& ldconfig \
&& rm -rf /tmp/hackrf \
# Install radiosonde_auto_rx (weather balloon decoder)
&& cd /tmp \
&& git clone --depth 1 https://github.com/projecthorus/radiosonde_auto_rx.git \
&& cd radiosonde_auto_rx/auto_rx \
&& pip install --no-cache-dir -r requirements.txt \
&& bash build.sh \
&& mkdir -p /opt/radiosonde_auto_rx/auto_rx \
&& cp -r . /opt/radiosonde_auto_rx/auto_rx/ \
&& chmod +x /opt/radiosonde_auto_rx/auto_rx/auto_rx.py \
&& cd /tmp \
&& rm -rf /tmp/radiosonde_auto_rx \
# Build rtlamr (utility meter decoder - requires Go)
&& cd /tmp \
&& curl -fsSL "https://go.dev/dl/go1.22.5.linux-$(dpkg --print-architecture).tar.gz" | tar -C /usr/local -xz \
&& export PATH="$PATH:/usr/local/go/bin" \
&& export GOPATH=/tmp/gopath \
&& go install github.com/bemasher/rtlamr@latest \
&& cp /tmp/gopath/bin/rtlamr /usr/bin/rtlamr \
&& rm -rf /usr/local/go /tmp/gopath \
# Cleanup build tools to reduce image size
# libgtk-3-dev is explicitly removed; runtime GTK libs remain for slowrx
&& apt-get remove -y \
build-essential \
git \
pkg-config \
cmake \
libncurses-dev \
libsndfile1-dev \
libgtk-3-dev \
libasound2-dev \
libpng-dev \
libtiff-dev \
libjemalloc-dev \
libvolk-dev \
libnng-dev \
libzstd-dev \
libsoapysdr-dev \
libhackrf-dev \
liblimesuite-dev \
libsqlite3-dev \
libcurl4-openssl-dev \
zlib1g-dev \
libzmq3-dev \
libpulse-dev \
libfftw3-dev \
liblapack-dev \
&& apt-get autoremove -y \
&& rm -rf /var/lib/apt/lists/*
# Copy requirements first for better caching # Copy requirements first for better caching
COPY requirements.txt . COPY requirements.txt .
@@ -256,6 +269,9 @@ RUN pip install --no-cache-dir -r requirements.txt
# Copy application code # Copy application code
COPY . . COPY . .
# Strip Windows CRLF from shell scripts (git autocrlf can re-introduce them)
RUN find . -name '*.sh' -exec sed -i 's/\r$//' {} +
# Create data directory for persistence # Create data directory for persistence
RUN mkdir -p /app/data /app/data/weather_sat /app/data/radiosonde/logs RUN mkdir -p /app/data /app/data/weather_sat /app/data/radiosonde/logs
@@ -274,4 +290,4 @@ HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \
CMD curl -sf http://localhost:5050/health || exit 1 CMD curl -sf http://localhost:5050/health || exit 1
# Run the application # Run the application
CMD ["python", "intercept.py"] CMD ["/bin/bash", "start.sh"]
+92 -33
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+">
@@ -45,6 +47,7 @@ Support the developer of this open-source project
- **WiFi Scanning** - Monitor mode reconnaissance via aircrack-ng - **WiFi Scanning** - Monitor mode reconnaissance via aircrack-ng
- **Bluetooth Scanning** - Device discovery and tracker detection (with Ubertooth support) - **Bluetooth Scanning** - Device discovery and tracker detection (with Ubertooth support)
- **BT Locate** - SAR Bluetooth device location with GPS-tagged signal trail mapping and proximity alerts - **BT Locate** - SAR Bluetooth device location with GPS-tagged signal trail mapping and proximity alerts
- **WiFi Locate** - Locate WiFi access points by BSSID with real-time signal meter, distance estimation, and proximity audio
- **GPS** - Real-time GPS position tracking with live map, speed, altitude, and satellite info - **GPS** - Real-time GPS position tracking with live map, speed, altitude, and satellite info
- **TSCM** - Counter-surveillance with RF baseline comparison and threat detection - **TSCM** - Counter-surveillance with RF baseline comparison and threat detection
- **Meshtastic** - LoRa mesh network integration - **Meshtastic** - LoRa mesh network integration
@@ -81,14 +84,59 @@ Troubleshooting (no decode / noisy decode):
--- ---
## Installation / Debian / Ubuntu / MacOS ## Installation / Debian / Ubuntu / macOS
### Quick Start
**1. Clone and run:**
```bash ```bash
git clone https://github.com/smittix/intercept.git git clone https://github.com/smittix/intercept.git
cd intercept cd intercept
./setup.sh ./setup.sh # Interactive menu (first run launches setup wizard)
sudo -E venv/bin/python intercept.py sudo ./start.sh
```
On first run, `setup.sh` launches a **guided wizard** that detects your OS, lets you choose install profiles, sets up the Python environment, and optionally configures environment variables and PostgreSQL.
On subsequent runs, it opens an **interactive menu**:
```
INTERCEPT Setup Menu
════════════════════════════════════════
1) Install / Add Modules
2) System Health Check
3) Database Setup (ADS-B History)
4) Update Tools
5) Environment Configurator
6) Uninstall / Cleanup
7) View Status
0) Exit
```
> **Production vs Dev server:** `start.sh` auto-detects gunicorn + gevent and runs a production server with cooperative greenlets — handles multiple SSE/WebSocket clients without blocking. Falls back to Flask dev server if gunicorn is not installed. For quick local development, you can still use `sudo -E venv/bin/python intercept.py` directly.
### Install Profiles
Choose what to install during the wizard or via menu option 1:
| # | Profile | Tools |
|---|---------|-------|
| 1 | Core SIGINT | rtl_sdr, multimon-ng, rtl_433, dump1090, acarsdec, dumpvdl2, ffmpeg, gpsd |
| 2 | Maritime & Radio | AIS-catcher, direwolf |
| 3 | Weather & Space | SatDump, radiosonde_auto_rx |
| 4 | RF Security | aircrack-ng, HackRF, BlueZ, hcxtools, Ubertooth, SoapySDR |
| 5 | Full SIGINT | All of the above |
| 6 | Custom | Per-tool checklist |
Multiple profiles can be combined (e.g. enter `1 3` for Core + Weather).
### CLI Flags
```bash
./setup.sh --non-interactive # Headless full install (same as legacy behavior)
./setup.sh --profile=core,weather # Install specific profiles
./setup.sh --health-check # Check system health and exit
./setup.sh --postgres-setup # Run PostgreSQL setup and exit
./setup.sh --menu # Force interactive menu
``` ```
### Docker ### Docker
@@ -140,16 +188,40 @@ INTERCEPT_IMAGE=ghcr.io/youruser/intercept:latest
docker compose --profile basic up -d docker compose --profile basic up -d
``` ```
### ADS-B History (Optional) ### Environment Configuration
The ADS-B history feature persists aircraft messages to Postgres for long-term analysis. Use the **Environment Configurator** (menu option 5) to interactively set any `INTERCEPT_*` variable. Settings are saved to a `.env` file that `start.sh` sources automatically on startup.
You can also create or edit `.env` manually:
```bash
# .env (auto-loaded by start.sh)
INTERCEPT_PORT=5050
INTERCEPT_ADSB_AUTO_START=true
INTERCEPT_DEFAULT_LAT=51.5074
INTERCEPT_DEFAULT_LON=-0.1278
```
### ADS-B History (Optional)
The ADS-B history feature persists aircraft messages to PostgreSQL for long-term analysis.
**Automated setup (local install):**
```bash
./setup.sh --postgres-setup
# Or use menu option 3: Database Setup
```
This will install PostgreSQL if needed, create the database/user/tables, and write the connection settings to `.env`.
**Docker:**
```bash ```bash
# Start with ADS-B history and Postgres
docker compose --profile history up -d docker compose --profile history up -d
``` ```
Set the following environment variables (for example in a `.env` file): Set the following environment variables (in `.env`):
```bash ```bash
INTERCEPT_ADSB_HISTORY_ENABLED=true INTERCEPT_ADSB_HISTORY_ENABLED=true
@@ -160,30 +232,6 @@ INTERCEPT_ADSB_DB_USER=intercept
INTERCEPT_ADSB_DB_PASSWORD=intercept INTERCEPT_ADSB_DB_PASSWORD=intercept
``` ```
### Other ADS-B Settings
Set these as environment variables for either local installs or Docker:
| Variable | Default | Description |
|----------|---------|-------------|
| `INTERCEPT_ADSB_AUTO_START` | `false` | Auto-start ADS-B tracking when the dashboard loads |
| `INTERCEPT_SHARED_OBSERVER_LOCATION` | `true` | Share observer location across ADS-B/AIS/SSTV/Satellite modules |
**Local install example**
```bash
INTERCEPT_ADSB_AUTO_START=true \
INTERCEPT_SHARED_OBSERVER_LOCATION=false \
sudo -E venv/bin/python intercept.py
```
**Docker example (.env)**
```bash
INTERCEPT_ADSB_AUTO_START=true
INTERCEPT_SHARED_OBSERVER_LOCATION=false
```
To store Postgres data on external storage, set `PGDATA_PATH` (defaults to `./pgdata`): To store Postgres data on external storage, set `PGDATA_PATH` (defaults to `./pgdata`):
```bash ```bash
@@ -192,6 +240,17 @@ PGDATA_PATH=/mnt/usbpi1/intercept/pgdata
Then open **/adsb/history** for the reporting dashboard. Then open **/adsb/history** for the reporting dashboard.
### System Health Check
Verify your installation is complete and working:
```bash
./setup.sh --health-check
# Or use menu option 2
```
Checks installed tools, SDR devices, port availability, permissions, Python venv, `.env` configuration, and PostgreSQL connectivity.
### Open the Interface ### Open the Interface
After starting, open **http://localhost:5050** in your browser. The username and password is <b>admin</b>:<b>admin</b> After starting, open **http://localhost:5050** in your browser. The username and password is <b>admin</b>:<b>admin</b>
+336 -104
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,48 +17,122 @@ 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 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 flask_limiter import Limiter from utils.process import cleanup_stale_dump1090, cleanup_stale_processes
from flask_limiter.util import get_remote_address from utils.sdr import SDRFactory
try:
from flask_limiter import Limiter
from flask_limiter.util import get_remote_address
_has_limiter = True
except ImportError:
_has_limiter = False
try:
from flask_compress import Compress
_has_compress = True
except ImportError:
_has_compress = False
try:
from flask_wtf.csrf import CSRFProtect
_has_csrf = True
except ImportError:
_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')
# Create Flask app # Create Flask app
app = Flask(__name__) app = Flask(__name__)
app.secret_key = "signals_intelligence_secret" # Required for flash messages def _load_or_generate_secret_key():
"""Load secret key from env var or instance file, generating if needed."""
env_key = os.environ.get('INTERCEPT_SECRET_KEY')
if env_key:
return env_key
key_path = Path('instance/secret.key')
if key_path.exists():
return key_path.read_text().strip()
key_path.parent.mkdir(exist_ok=True)
key = os.urandom(32).hex()
key_path.write_text(key)
return key
app.secret_key = _load_or_generate_secret_key()
# Set up HTTP compression (gzip/brotli for HTML, CSS, JS, JSON)
if _has_compress:
Compress(app)
else:
logging.getLogger('intercept').warning(
"flask-compress not installed HTTP compression disabled. "
"Install with: pip install flask-compress"
)
# Set up rate limiting # Set up rate limiting
limiter = Limiter( if _has_limiter:
key_func=get_remote_address, # Identifies the user by their IP limiter = Limiter(
app=app, key_func=get_remote_address,
storage_uri="memory://", # Use RAM memory (change to redis:// etc. for distributed setups) app=app,
) storage_uri="memory://",
)
else:
logging.getLogger('intercept').warning(
"flask-limiter not installed rate limiting disabled. "
"Install with: pip install flask-limiter"
)
class _NoopLimiter:
"""Stub so @limiter.limit() decorators are silently ignored."""
def limit(self, *a, **kw):
def decorator(f):
return f
return decorator
limiter = _NoopLimiter()
# Set up CSRF protection
if _has_csrf:
csrf = CSRFProtect(app)
else:
logging.getLogger('intercept').warning(
"flask-wtf not installed CSRF protection disabled. "
"Install with: pip install flask-wtf"
)
csrf = None
# Disable Werkzeug debugger PIN (not needed for local development tool) # Disable Werkzeug debugger PIN (not needed for local development tool)
os.environ['WERKZEUG_DEBUG_PIN'] = 'off' os.environ['WERKZEUG_DEBUG_PIN'] = 'off'
@@ -89,6 +163,12 @@ def add_security_headers(response):
response.headers['Referrer-Policy'] = 'strict-origin-when-cross-origin' response.headers['Referrer-Policy'] = 'strict-origin-when-cross-origin'
# Permissions policy (disable unnecessary features) # Permissions policy (disable unnecessary features)
response.headers['Permissions-Policy'] = 'geolocation=(self), microphone=()' response.headers['Permissions-Policy'] = 'geolocation=(self), microphone=()'
# Cache-Control for static assets
if request.path.startswith('/static/'):
if '/vendor/' in request.path:
response.headers['Cache-Control'] = 'public, max-age=604800' # 7 days for vendored libs
else:
response.headers['Cache-Control'] = 'public, max-age=86400' # 24h for app assets
return response return response
@@ -194,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()
@@ -208,6 +291,16 @@ morse_process = None
morse_queue = queue.Queue(maxsize=QUEUE_MAX_SIZE) morse_queue = queue.Queue(maxsize=QUEUE_MAX_SIZE)
morse_lock = threading.Lock() morse_lock = threading.Lock()
# Meteor scatter detection
meteor_process = None
meteor_queue = queue.Queue(maxsize=QUEUE_MAX_SIZE)
meteor_lock = threading.Lock()
# Generic OOK signal decoder
ook_process = None
ook_queue = queue.Queue(maxsize=QUEUE_MAX_SIZE)
ook_lock = threading.Lock()
# Deauth Attack Detection # Deauth Attack Detection
deauth_detector = None deauth_detector = None
deauth_detector_queue = queue.Queue(maxsize=QUEUE_MAX_SIZE) deauth_detector_queue = queue.Queue(maxsize=QUEUE_MAX_SIZE)
@@ -387,6 +480,9 @@ def login():
@app.route('/') @app.route('/')
def index() -> str: def index() -> str:
if request.args.get('mode') == 'satellite':
return redirect(url_for('satellite.satellite_dashboard'))
tools = { tools = {
'rtl_fm': check_tool('rtl_fm'), 'rtl_fm': check_tool('rtl_fm'),
'multimon': check_tool('multimon-ng'), 'multimon': check_tool('multimon-ng'),
@@ -696,6 +792,29 @@ def _get_subghz_active() -> bool:
return False return False
def _get_singleton_running(module_path: str, getter_name: str, attr: str) -> bool:
"""Safely check if a singleton-based mode is running without creating instances."""
try:
import importlib
mod = importlib.import_module(module_path)
getter = getattr(mod, getter_name)
instance = getter()
if instance is None:
return False
return bool(getattr(instance, attr, False))
except Exception:
return False
def _get_tscm_active() -> bool:
"""Check if a TSCM sweep is running."""
try:
from routes.tscm import _sweep_running
return bool(_sweep_running)
except Exception:
return False
def _get_bluetooth_health() -> tuple[bool, int]: def _get_bluetooth_health() -> tuple[bool, int]:
"""Return Bluetooth active state and best-effort device count.""" """Return Bluetooth active state and best-effort device count."""
legacy_running = bt_process is not None and (bt_process.poll() is None if bt_process else False) legacy_running = bt_process is not None and (bt_process.poll() is None if bt_process else False)
@@ -753,13 +872,43 @@ def _get_wifi_health() -> tuple[bool, int, int]:
@app.route('/health') @app.route('/health')
def health_check() -> Response: def health_check() -> Response:
"""Health check endpoint for monitoring.""" """Health check endpoint for monitoring."""
import platform
import time import time
bt_active, bt_device_count = _get_bluetooth_health() bt_active, bt_device_count = _get_bluetooth_health()
wifi_active, wifi_network_count, wifi_client_count = _get_wifi_health() wifi_active, wifi_network_count, wifi_client_count = _get_wifi_health()
return jsonify({
'status': 'healthy', # Database health check
db_ok = True
try:
from utils.database import get_connection
get_connection().execute('SELECT 1')
except Exception:
db_ok = False
# SDR device count (cached, non-blocking)
sdr_count = 0
try:
from utils.sdr.detection import get_cached_devices
cached = get_cached_devices()
if cached is not None:
sdr_count = len(cached)
except (ImportError, Exception):
pass
overall_status = 'healthy' if db_ok else 'degraded'
status_code = 200 if db_ok else 503
response = jsonify({
'status': overall_status,
'version': VERSION, 'version': VERSION,
'uptime_seconds': round(time.time() - _app_start_time, 2), 'uptime_seconds': round(time.time() - _app_start_time, 2),
'system': {
'python_version': platform.python_version(),
'platform': platform.platform(),
},
'database': db_ok,
'sdr_devices': sdr_count,
'rate_limiting': _has_limiter,
'processes': { 'processes': {
'pager': current_process is not None and (current_process.poll() is None if current_process else False), 'pager': current_process is not None and (current_process.poll() is None if current_process else False),
'sensor': sensor_process is not None and (sensor_process.poll() is None if sensor_process else False), 'sensor': sensor_process is not None and (sensor_process.poll() is None if sensor_process else False),
@@ -774,6 +923,15 @@ def health_check() -> Response:
'radiosonde': radiosonde_process is not None and (radiosonde_process.poll() is None if radiosonde_process else False), 'radiosonde': radiosonde_process is not None and (radiosonde_process.poll() is None if radiosonde_process else False),
'morse': morse_process is not None and (morse_process.poll() is None if morse_process else False), 'morse': morse_process is not None and (morse_process.poll() is None if morse_process else False),
'subghz': _get_subghz_active(), 'subghz': _get_subghz_active(),
'rtlamr': rtlamr_process is not None and (rtlamr_process.poll() is None if rtlamr_process else False),
'meshtastic': _get_singleton_running('utils.meshtastic', 'get_meshtastic_client', 'is_running'),
'sstv': _get_singleton_running('utils.sstv', 'get_sstv_decoder', 'is_running'),
'weathersat': _get_singleton_running('utils.weather_sat', 'get_weather_sat_decoder', 'is_running'),
'wefax': _get_singleton_running('utils.wefax', 'get_wefax_decoder', 'is_running'),
'sstv_general': _get_singleton_running('utils.sstv', 'get_general_sstv_decoder', 'is_running'),
'tscm': _get_tscm_active(),
'gps': _get_singleton_running('utils.gps', 'get_gps_reader', 'is_running'),
'bt_locate': _get_singleton_running('utils.bt_locate', 'get_locate_session', 'is_active'),
}, },
'data': { 'data': {
'aircraft_count': len(adsb_aircraft), 'aircraft_count': len(adsb_aircraft),
@@ -784,13 +942,16 @@ def health_check() -> Response:
'dsc_messages_count': len(dsc_messages), 'dsc_messages_count': len(dsc_messages),
} }
}) })
response.status_code = status_code
return response
@app.route('/killall', methods=['POST']) @app.route('/killall', methods=['POST'])
@(csrf.exempt if csrf else lambda f: f)
def kill_all() -> Response: def kill_all() -> Response:
"""Kill all decoder, WiFi, and Bluetooth processes.""" """Kill all decoder, WiFi, and Bluetooth processes."""
global current_process, sensor_process, wifi_process, adsb_process, ais_process, acars_process global current_process, sensor_process, wifi_process, adsb_process, ais_process, acars_process
global vdl2_process, morse_process, radiosonde_process global vdl2_process, morse_process, radiosonde_process, ook_process
global aprs_process, aprs_rtl_process, dsc_process, dsc_rtl_process, bt_process global aprs_process, aprs_rtl_process, dsc_process, dsc_rtl_process, bt_process
# Import modules to reset their state # Import modules to reset their state
@@ -854,6 +1015,17 @@ def kill_all() -> Response:
with morse_lock: with morse_lock:
morse_process = None morse_process = None
# Reset OOK state (full cleanup: parser thread, pipes, SDR release)
with ook_lock:
try:
from routes.ook import cleanup_ook
cleanup_ook(emit_status=False)
except Exception:
if ook_process:
safe_terminate(ook_process)
unregister_process(ook_process)
ook_process = None
# Reset APRS state # Reset APRS state
with aprs_lock: with aprs_lock:
aprs_process = None aprs_process = None
@@ -871,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
@@ -928,9 +1098,144 @@ def _ensure_self_signed_cert(cert_dir: str) -> tuple:
return cert_path, key_path return cert_path, key_path
_app_initialized = False
def _init_app() -> None:
"""Initialize blueprints, database, and websockets.
Safe to call multiple times — subsequent calls are no-ops.
Called automatically at module level for gunicorn, and also
from main() for the Flask dev server path.
Heavy/network operations (TLE updates, process cleanup) are
deferred to a background thread so the worker can serve
requests immediately.
"""
global _app_initialized
if _app_initialized:
return
_app_initialized = True
import os
# Initialize database for settings storage
from utils.database import init_db
init_db()
# Register blueprints (essential — without these, all routes 404)
from routes import register_blueprints
register_blueprints(app)
# Initialize WebSocket for audio streaming
try:
from routes.audio_websocket import init_audio_websocket
init_audio_websocket(app)
except Exception:
pass
# Initialize KiwiSDR WebSocket audio proxy
try:
from routes.websdr import init_websdr_audio
init_websdr_audio(app)
except Exception:
pass
# Initialize WebSocket for waterfall streaming
try:
from routes.waterfall_websocket import init_waterfall_websocket
init_waterfall_websocket(app)
except Exception:
pass
# Initialize WebSocket for meteor scatter monitoring
try:
from routes.meteor_websocket import init_meteor_websocket
init_meteor_websocket(app)
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
# Defer heavy/network operations so the worker can serve requests immediately
import threading
def _deferred_init():
"""Run heavy initialization after a short delay."""
import time
time.sleep(1) # Let the worker start serving first
# Clean up stale processes from previous runs
try:
cleanup_stale_processes()
cleanup_stale_dump1090()
except Exception as e:
logger.warning(f"Stale process cleanup failed: {e}")
# Register and start database cleanup
try:
from utils.database import (
cleanup_old_dsc_alerts,
cleanup_old_payloads,
cleanup_old_signal_history,
cleanup_old_timeline_entries,
)
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_dsc_alerts, interval_multiplier=1440)
cleanup_manager.register_db_cleanup(cleanup_old_payloads, interval_multiplier=1440)
cleanup_manager.start()
except Exception as e:
logger.warning(f"Cleanup manager init failed: {e}")
# Initialize TLE auto-refresh (must be after blueprint registration)
try:
from routes.satellite import init_tle_auto_refresh
if not os.environ.get('TESTING'):
init_tle_auto_refresh()
except Exception as 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()
# Auto-initialize when imported (e.g. by gunicorn)
_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(
@@ -972,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():
@@ -1009,81 +1314,8 @@ def main() -> None:
print("Running as root - full capabilities enabled") print("Running as root - full capabilities enabled")
print() print()
# Clean up any stale processes from previous runs # Ensure app is initialized (no-op if already done by module-level call)
cleanup_stale_processes() _init_app()
cleanup_stale_dump1090()
# Initialize database for settings storage
from utils.database import init_db
init_db()
# Register database cleanup functions
from utils.database import (
cleanup_old_signal_history,
cleanup_old_timeline_entries,
cleanup_old_dsc_alerts,
cleanup_old_payloads
)
cleanup_manager.register_db_cleanup(cleanup_old_signal_history, interval_multiplier=1440) # Every 24 hours
cleanup_manager.register_db_cleanup(cleanup_old_timeline_entries, interval_multiplier=1440) # Every 24 hours
cleanup_manager.register_db_cleanup(cleanup_old_dsc_alerts, interval_multiplier=1440) # Every 24 hours
cleanup_manager.register_db_cleanup(cleanup_old_payloads, interval_multiplier=1440) # Every 24 hours
# Start automatic cleanup of stale data entries
cleanup_manager.start()
# Register blueprints
from routes import register_blueprints
register_blueprints(app)
# Initialize TLE auto-refresh (must be after blueprint registration)
try:
from routes.satellite import init_tle_auto_refresh
import os
if not os.environ.get('TESTING'):
init_tle_auto_refresh()
except Exception as e:
logger.warning(f"Failed to initialize TLE auto-refresh: {e}")
# Update TLE data in background thread (non-blocking)
def update_tle_background():
try:
from routes.satellite import refresh_tle_data
print("Updating satellite TLE data from CelesTrak...")
updated = refresh_tle_data()
if updated:
print(f"TLE data updated for: {', '.join(updated)}")
else:
print("TLE update: No satellites updated (may be offline)")
except Exception as e:
print(f"TLE update failed (will use cached data): {e}")
tle_thread = threading.Thread(target=update_tle_background, daemon=True)
tle_thread.start()
# Initialize WebSocket for audio streaming
try:
from routes.audio_websocket import init_audio_websocket
init_audio_websocket(app)
print("WebSocket audio streaming enabled")
except ImportError as e:
print(f"WebSocket audio disabled (install flask-sock): {e}")
# Initialize KiwiSDR WebSocket audio proxy
try:
from routes.websdr import init_websdr_audio
init_websdr_audio(app)
print("KiwiSDR audio proxy enabled")
except ImportError as e:
print(f"KiwiSDR audio proxy disabled: {e}")
# Initialize WebSocket for waterfall streaming
try:
from routes.waterfall_websocket import init_waterfall_websocket
init_waterfall_websocket(app)
print("WebSocket waterfall streaming enabled")
except ImportError as e:
print(f"WebSocket waterfall disabled: {e}")
# Configure SSL if HTTPS is enabled # Configure SSL if HTTPS is enabled
ssl_context = None ssl_context = None
+102 -1
View File
@@ -7,10 +7,110 @@ import os
import sys import sys
# Application version # Application version
VERSION = "2.23.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",
"date": "March 2026",
"highlights": [
"WiFi Locate mode — locate access points by BSSID with real-time signal meter, distance estimation, RSSI chart, and audio proximity tones",
"Mobile navigation reorganized into labeled groups for better usability",
"flask-limiter made optional for graceful degradation",
"Radiosonde setup fix — missing semver dependency",
]
},
{ {
"version": "2.23.0", "version": "2.23.0",
"date": "February 2026", "date": "February 2026",
@@ -20,6 +120,7 @@ CHANGELOG = [
"WeFax (Weather Fax) decoder with auto-scheduler and broadcast timeline", "WeFax (Weather Fax) decoder with auto-scheduler and broadcast timeline",
"System Health monitoring mode with telemetry dashboard", "System Health monitoring mode with telemetry dashboard",
"HTTPS support, HackRF TSCM RF scan, ADS-B voice alerts", "HTTPS support, HackRF TSCM RF scan, ADS-B voice alerts",
"Production server (start.sh) with gunicorn + gevent for concurrent multi-client support",
"Multi-SDR support for WeFax, tool path overrides, native Homebrew detection", "Multi-SDR support for WeFax, tool path overrides, native Homebrew detection",
"GPS mode upgraded to textured 3D globe", "GPS mode upgraded to textured 3D globe",
"Destroy lifecycle added to all mode modules to prevent resource leaks", "Destroy lifecycle added to all mode modules to prevent resource leaks",
+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
View File
@@ -26,4 +26,7 @@ TLE_SATELLITES = {
'METEOR-M2-3': ('METEOR-M2 3', 'METEOR-M2-3': ('METEOR-M2 3',
'1 57166U 23091A 25028.81539352 .00000157 00000+0 94432-4 0 9993', '1 57166U 23091A 25028.81539352 .00000157 00000+0 94432-4 0 9993',
'2 57166 98.7690 91.9652 0001790 107.4859 252.6519 14.23646028 77844'), '2 57166 98.7690 91.9652 0001790 107.4859 252.6519 14.23646028 77844'),
'METEOR-M2-4': ('METEOR-M2 4',
'1 59051U 24039A 26061.19281216 .00000032 00000+0 34037-4 0 9998',
'2 59051 98.6892 21.9068 0008025 115.2158 244.9852 14.22415711104050'),
} }
+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
+5
View File
@@ -1,6 +1,8 @@
# INTERCEPT - Signal Intelligence Platform # INTERCEPT - Signal Intelligence Platform
# Docker Compose configuration for easy deployment # Docker Compose configuration for easy deployment
# #
# Uses gunicorn + gevent production server via start.sh (handles concurrent SSE/WebSocket)
#
# Basic usage (build locally): # Basic usage (build locally):
# docker compose --profile basic up -d --build # docker compose --profile basic up -d --build
# #
@@ -31,6 +33,7 @@ services:
# Optional: mount logs directory # Optional: mount logs directory
# - ./logs:/app/logs # - ./logs:/app/logs
environment: environment:
- TZ=${TZ:-UTC}
- INTERCEPT_HOST=0.0.0.0 - INTERCEPT_HOST=0.0.0.0
- INTERCEPT_PORT=5050 - INTERCEPT_PORT=5050
- INTERCEPT_LOG_LEVEL=INFO - INTERCEPT_LOG_LEVEL=INFO
@@ -85,6 +88,7 @@ services:
volumes: volumes:
- ./data:/app/data - ./data:/app/data
environment: environment:
- TZ=${TZ:-UTC}
- INTERCEPT_HOST=0.0.0.0 - INTERCEPT_HOST=0.0.0.0
- INTERCEPT_PORT=5050 - INTERCEPT_PORT=5050
- INTERCEPT_LOG_LEVEL=INFO - INTERCEPT_LOG_LEVEL=INFO
@@ -118,6 +122,7 @@ services:
profiles: profiles:
- history - history
environment: environment:
- TZ=${TZ:-UTC}
- POSTGRES_DB=intercept_adsb - POSTGRES_DB=intercept_adsb
- POSTGRES_USER=intercept - POSTGRES_USER=intercept
- POSTGRES_PASSWORD=intercept - POSTGRES_PASSWORD=intercept
+2 -2
View File
@@ -38,8 +38,8 @@ The controller is the main Intercept application:
```bash ```bash
cd intercept cd intercept
python app.py ./setup.sh # First-time setup (choose install profiles)
# Runs on http://localhost:5050 sudo ./start.sh # Production server on http://localhost:5050
``` ```
### 2. Configure an Agent ### 2. Configure an Agent
+31
View File
@@ -276,6 +276,34 @@ Search and rescue Bluetooth device location with GPS-tagged signal trail mapping
- Bluetooth adapter (built-in or USB) - Bluetooth adapter (built-in or USB)
- GPS receiver (optional, falls back to manual coordinates) - GPS receiver (optional, falls back to manual coordinates)
## WiFi Locate
Locate a WiFi access point by BSSID using real-time signal strength tracking.
### Core Features
- **Target by BSSID** - Enter any MAC address or hand off from the WiFi scanner
- **Real-time signal meter** - Large dBm display with color-coded strength (good/medium/weak)
- **20-segment signal bar** - Visual proximity indicator with red/yellow/green segments
- **RSSI history chart** - Canvas sparkline showing signal trend over time
- **Distance estimation** - Log-distance path loss model with configurable environment presets
- **Audio proximity alerts** - Web Audio API tones that increase in pitch and frequency as signal strengthens
- **Signal lost detection** - 30-second timeout with visual overlay when target disappears
- **Hand-off from WiFi mode** - One-click transfer from WiFi detail drawer to WiFi Locate
- **Stats tracking** - Current, min, max, and average RSSI across session
### Environment Presets
- **Open Field** (n=2.0) - Free space path loss
- **Outdoor** (n=2.8) - Typical outdoor environment (default)
- **Indoor** (n=3.5) - Indoor with walls and obstacles
### Mode Transition
- WiFi scan is preserved when switching between WiFi and WiFi Locate modes
- Deep scan auto-starts if not already running
### Requirements
- WiFi adapter capable of monitor mode
- aircrack-ng suite for deep scanning
## GPS Mode ## GPS Mode
Real-time GPS position tracking with live map visualization. Real-time GPS position tracking with live map visualization.
@@ -410,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
@@ -466,6 +496,7 @@ The settings modal shows availability status for each bundled asset:
## General ## General
- **Web-based interface** - no desktop app needed - **Web-based interface** - no desktop app needed
- **Production server** - gunicorn + gevent via `start.sh` for concurrent SSE/WebSocket handling (falls back to Flask dev server)
- **Live message streaming** via Server-Sent Events (SSE) - **Live message streaming** via Server-Sent Events (SSE)
- **Audio alerts** with mute toggle - **Audio alerts** with mute toggle
- **Message export** to CSV/JSON - **Message export** to CSV/JSON
+172 -9
View File
@@ -14,7 +14,39 @@ INTERCEPT automatically detects connected devices.
## Quick Install ## Quick Install
### macOS (Homebrew) ### Recommended: Use the Setup Script
The setup script provides an interactive menu with install profiles for selective installation:
```bash
git clone https://github.com/smittix/intercept.git
cd intercept
./setup.sh
```
On first run, a guided wizard walks you through profile selection:
| Profile | What it installs |
|---------|-----------------|
| Core SIGINT | rtl_sdr, multimon-ng, rtl_433, dump1090, acarsdec, dumpvdl2, ffmpeg, gpsd |
| Maritime & Radio | AIS-catcher, direwolf |
| Weather & Space | SatDump, radiosonde_auto_rx |
| RF Security | aircrack-ng, HackRF, BlueZ, hcxtools, Ubertooth, SoapySDR |
| Full SIGINT | All of the above |
For headless/CI installs:
```bash
./setup.sh --non-interactive # Install everything
./setup.sh --profile=core,maritime # Install specific profiles
```
After installation, use the menu to manage your setup:
```bash
./setup.sh # Opens interactive menu
./setup.sh --health-check # Verify installation
```
### Manual Install: macOS (Homebrew)
```bash ```bash
# Install Homebrew if needed # Install Homebrew if needed
@@ -36,7 +68,7 @@ brew install soapysdr limesuite soapylms7
brew install hackrf soapyhackrf brew install hackrf soapyhackrf
``` ```
### Debian / Ubuntu / Raspberry Pi OS ### Manual Install: Debian / Ubuntu / Raspberry Pi OS
```bash ```bash
# Update package lists # Update package lists
@@ -94,6 +126,126 @@ sudo modprobe -r dvb_usb_rtl28xxu
--- ---
## Multiple RTL-SDR Dongles
If you're running two (or more) RTL-SDR dongles on the same machine, they ship with the same default serial number so Linux can't tell them apart reliably. Follow these steps to give each a unique identity.
### Step 1: Blacklist the DVB-T driver
Already covered above, but make sure this is done first — the kernel's DVB driver will grab the dongles before librtlsdr can:
```bash
echo "blacklist dvb_usb_rtl28xxu" | sudo tee /etc/modprobe.d/blacklist-rtl.conf
sudo modprobe -r dvb_usb_rtl28xxu
```
### Step 2: Burn unique serial numbers
Each dongle has an EEPROM that stores a serial number. By default they're all `00000001`. You need to give each one a unique serial.
**Plug in only the first dongle**, then:
```bash
rtl_eeprom -d 0 -s 00000001
```
**Unplug it, plug in the second dongle**, then:
```bash
rtl_eeprom -d 0 -s 00000002
```
> Pick any 8-digit hex serials you like. The `-d 0` means "device index 0" (the only one plugged in).
Unplug and replug both dongles after writing.
### Step 3: Verify
With both plugged in:
```bash
rtl_test -t
```
You should see:
```
0: Realtek, RTL2838UHIDIR, SN: 00000001
1: Realtek, RTL2838UHIDIR, SN: 00000002
```
**Tip:** If you don't know which physical dongle has which serial, unplug one and run `rtl_test -t` — the one still detected is the one still plugged in.
### Step 4: Udev rules with stable symlinks
Create rules that give each dongle a persistent name based on its serial:
```bash
sudo bash -c 'cat > /etc/udev/rules.d/20-rtlsdr.rules << EOF
# RTL-SDR dongles - permissions and stable symlinks by serial
SUBSYSTEM=="usb", ATTR{idVendor}=="0bda", ATTR{idProduct}=="2838", MODE="0666"
SUBSYSTEM=="usb", ATTR{idVendor}=="0bda", ATTR{idProduct}=="2832", MODE="0666"
# Symlinks by serial — change names/serials to match your hardware
SUBSYSTEM=="usb", ATTR{idVendor}=="0bda", ATTRS{serial}=="00000001", SYMLINK+="sdr-dongle1"
SUBSYSTEM=="usb", ATTR{idVendor}=="0bda", ATTRS{serial}=="00000002", SYMLINK+="sdr-dongle2"
EOF'
sudo udevadm control --reload-rules
sudo udevadm trigger
```
After replugging, you'll have `/dev/sdr-dongle1` and `/dev/sdr-dongle2`.
### Step 5: USB power (Raspberry Pi)
Two dongles can draw more current than the Pi allows by default:
```bash
# In /boot/firmware/config.txt, add:
usb_max_current_enable=1
```
Disable USB autosuspend so dongles don't get powered off:
```bash
# In /etc/default/grub or kernel cmdline, add:
usbcore.autosuspend=-1
```
Or via udev:
```bash
echo 'ACTION=="add", SUBSYSTEM=="usb", ATTR{power/autosuspend}="-1"' | \
sudo tee /etc/udev/rules.d/50-usb-autosuspend.rules
```
### Step 6: Docker access
Your `docker-compose.yml` needs privileged mode and USB passthrough:
```yaml
services:
intercept:
privileged: true
volumes:
- /dev/bus/usb:/dev/bus/usb
```
INTERCEPT auto-detects both dongles inside the container via `rtl_test -t` and addresses them by device index (`-d 0`, `-d 1`).
### Quick reference
| Step | What | Why |
|------|------|-----|
| Blacklist DVB | `/etc/modprobe.d/blacklist-rtl.conf` | Kernel won't steal the dongles |
| Burn serials | `rtl_eeprom -d 0 -s <serial>` | Unique identity per dongle |
| Udev rules | `/etc/udev/rules.d/20-rtlsdr.rules` | Permissions + stable `/dev/sdr-*` names |
| USB power | `config.txt` + autosuspend off | Enough current for two dongles on a Pi |
| Docker | `privileged: true` + USB volume | Container sees both dongles |
---
## Verify Installation ## Verify Installation
### Check dependencies ### Check dependencies
@@ -119,11 +271,19 @@ SoapySDRUtil --find
./setup.sh ./setup.sh
``` ```
This automatically: The setup wizard automatically:
- Detects your OS - Detects your OS (macOS, Debian/Ubuntu, DragonOS)
- Creates a virtual environment if needed (for PEP 668 systems) - Lets you choose install profiles (Core, Maritime, Weather, Security, Full, Custom)
- Installs Python dependencies - Creates a virtual environment with system site-packages
- Checks for required tools - Installs Python dependencies (core + optional)
- Runs a health check to verify everything works
After initial setup, use the menu to manage your environment:
- **Install / Add Modules** — add tools you didn't install initially
- **System Health Check** — verify all tools and dependencies
- **Environment Configurator** — set `INTERCEPT_*` variables interactively
- **Update Tools** — rebuild source-built tools (dump1090, SatDump, etc.)
- **View Status** — see what's installed at a glance
### Manual setup ### Manual setup
```bash ```bash
@@ -139,10 +299,13 @@ pip install -r requirements.txt
After installation: After installation:
```bash ```bash
sudo -E venv/bin/python intercept.py sudo ./start.sh
# Custom port # Custom port
INTERCEPT_PORT=8080 sudo -E venv/bin/python intercept.py sudo ./start.sh -p 8080
# HTTPS
sudo ./start.sh --https
``` ```
Open **http://localhost:5050** in your browser. Open **http://localhost:5050** in your browser.
+2 -3
View File
@@ -18,10 +18,9 @@ By default, INTERCEPT binds to `0.0.0.0:5050`, making it accessible from any net
echo "block in on en0 proto tcp from any to any port 5050" | sudo pfctl -ef - echo "block in on en0 proto tcp from any to any port 5050" | sudo pfctl -ef -
``` ```
2. **Bind to Localhost**: For local-only access, set the host environment variable: 2. **Bind to Localhost**: For local-only access, set the host or use the CLI flag:
```bash ```bash
export INTERCEPT_HOST=127.0.0.1 sudo ./start.sh -H 127.0.0.1
python intercept.py
``` ```
3. **Trusted Networks Only**: Only run INTERCEPT on networks you trust. The application has no authentication mechanism. 3. **Trusted Networks Only**: Only run INTERCEPT on networks you trust. The application has no authentication mechanism.
+17 -7
View File
@@ -25,7 +25,7 @@ sudo apt install python3-flask python3-requests python3-serial python3-skyfield
# Then create venv with system packages # Then create venv with system packages
python3 -m venv --system-site-packages venv python3 -m venv --system-site-packages venv
source venv/bin/activate source venv/bin/activate
sudo venv/bin/python intercept.py sudo ./start.sh
``` ```
### "error: externally-managed-environment" (pip blocked) ### "error: externally-managed-environment" (pip blocked)
@@ -61,18 +61,21 @@ sudo apt install python3.11 python3.11-venv python3-pip
python3.11 -m venv venv python3.11 -m venv venv
source venv/bin/activate source venv/bin/activate
pip install -r requirements.txt pip install -r requirements.txt
sudo venv/bin/python intercept.py sudo ./start.sh
``` ```
### Alternative: Use the setup script ### Alternative: Use the setup script
The setup script handles all installation automatically, including apt packages: The setup script handles all installation automatically, including apt packages and source builds:
```bash ```bash
chmod +x setup.sh ./setup.sh # Interactive wizard (first run) or menu
./setup.sh ./setup.sh --non-interactive # Headless full install
./setup.sh --health-check # Diagnose installation issues
``` ```
The setup menu also includes a **System Health Check** (option 2) that verifies all tools, SDR devices, ports, permissions, and Python packages — useful for diagnosing installation problems.
### "pip: command not found" ### "pip: command not found"
```bash ```bash
@@ -336,7 +339,7 @@ rtl_fm -M am -f 118000000 -s 24000 -r 24000 -g 40 2>/dev/null | \
Run INTERCEPT with sudo: Run INTERCEPT with sudo:
```bash ```bash
sudo -E venv/bin/python intercept.py sudo ./start.sh
``` ```
### Interface not found after enabling monitor mode ### Interface not found after enabling monitor mode
@@ -373,7 +376,14 @@ sudo usermod -a -G bluetooth $USER
### Cannot install dump1090 in Debian (ADS-B mode) ### Cannot install dump1090 in Debian (ADS-B mode)
On newer Debian versions, dump1090 may not be in repositories. The recommended action is to build from source or use the setup.sh script which will do it for you. On newer Debian versions, dump1090 may not be in repositories. Use the setup script which builds it from source automatically:
```bash
./setup.sh # Select Core SIGINT profile, or
./setup.sh --profile=core # Install core tools including dump1090
```
The setup menu's **Install / Add Modules** option also lets you install dump1090 individually via the Custom tool checklist.
### No aircraft appearing (ADS-B mode) ### No aircraft appearing (ADS-B mode)
+2 -1
View File
@@ -212,6 +212,7 @@ Extended base for full-screen dashboards (maps, visualizations).
| `websdr` | WebSDR | | `websdr` | WebSDR |
| `subghz` | Sub-GHz analyzer | | `subghz` | Sub-GHz analyzer |
| `bt_locate` | BT Locate | | `bt_locate` | BT Locate |
| `wifi_locate` | WiFi Locate |
| `analytics` | Analytics dashboard | | `analytics` | Analytics dashboard |
| `spaceweather` | Space weather | | `spaceweather` | Space weather |
### Navigation Groups ### Navigation Groups
@@ -220,7 +221,7 @@ The navigation is organized into groups:
- **Signals**: Pager, 433MHz, Meters, Listening Post, SubGHz - **Signals**: Pager, 433MHz, Meters, Listening Post, SubGHz
- **Tracking**: Aircraft, Vessels, APRS, GPS - **Tracking**: Aircraft, Vessels, APRS, GPS
- **Space**: Satellite, ISS SSTV, Weather Sat, HF SSTV, Space Weather - **Space**: Satellite, ISS SSTV, Weather Sat, HF SSTV, Space Weather
- **Wireless**: WiFi, Bluetooth, BT Locate, Meshtastic - **Wireless**: WiFi, Bluetooth, BT Locate, WiFi Locate, Meshtastic
- **Intel**: TSCM, Analytics, Spy Stations, WebSDR - **Intel**: TSCM, Analytics, Spy Stations, WebSDR
--- ---
+53 -2
View File
@@ -172,7 +172,7 @@ Set the following environment variables (Docker recommended):
```bash ```bash
INTERCEPT_ADSB_AUTO_START=true \ INTERCEPT_ADSB_AUTO_START=true \
INTERCEPT_SHARED_OBSERVER_LOCATION=false \ INTERCEPT_SHARED_OBSERVER_LOCATION=false \
sudo -E venv/bin/python intercept.py sudo ./start.sh
``` ```
**Docker example (.env)** **Docker example (.env)**
@@ -377,6 +377,39 @@ Digital Selective Calling monitoring runs alongside AIS:
- The RSSI chart shows signal trend over time — use it to determine if you're getting closer - The RSSI chart shows signal trend over time — use it to determine if you're getting closer
- Clear the trail when starting a new search area - Clear the trail when starting a new search area
## WiFi Locate Mode
1. **Set Target** - Enter a BSSID (MAC address) in AA:BB:CC:DD:EE:FF format, or hand off from WiFi mode
2. **Choose Environment** - Select the RF environment preset:
- **Open Field** (n=2.0) - Best for open areas with line-of-sight
- **Outdoor** (n=2.8) - Default, works well in most outdoor settings
- **Indoor** (n=3.5) - For buildings with walls and obstacles
3. **Start Locate** - Click "Start Locate" to begin tracking
4. **Monitor Signal** - The HUD shows:
- Large dBm reading with color coding (green/yellow/red)
- 20-segment signal bar for quick visual reference
- Estimated distance based on path loss model
- RSSI history chart for trend analysis
- Current/min/max/average statistics
5. **Follow the Signal** - Move towards stronger signal (higher RSSI / closer distance)
6. **Audio Alerts** - Enable audio for proximity tones that speed up as signal strengthens
### Hand-off from WiFi Mode
1. Open WiFi scanning mode and start a deep scan
2. Click any network to open the detail drawer
3. Click the "Locate" button in the drawer header
4. WiFi Locate opens with the BSSID and SSID pre-filled
5. Click "Start Locate" to begin tracking
### Tips
- Deep scan is required for continuous RSSI updates — WiFi Locate auto-starts it if needed
- The WiFi scan is preserved when switching between WiFi and WiFi Locate modes
- Signal lost overlay appears after 30 seconds without an update from the target
- The distance estimate is approximate — environment preset significantly affects accuracy
- Indoor environments with walls attenuate signal more than open field
## GPS Mode ## GPS Mode
1. **Start GPS** - Click "Start" to connect to gpsd and begin position tracking 1. **Start GPS** - Click "Start" to connect to gpsd and begin position tracking
@@ -518,10 +551,28 @@ INTERCEPT can be configured via environment variables:
| `INTERCEPT_LOG_LEVEL` | `WARNING` | Log level (DEBUG, INFO, WARNING, ERROR) | | `INTERCEPT_LOG_LEVEL` | `WARNING` | Log level (DEBUG, INFO, WARNING, ERROR) |
| `INTERCEPT_DEFAULT_GAIN` | `40` | Default RTL-SDR gain | | `INTERCEPT_DEFAULT_GAIN` | `40` | Default RTL-SDR gain |
Example: `INTERCEPT_PORT=8080 sudo -E venv/bin/python intercept.py` Example: `INTERCEPT_PORT=8080 sudo ./start.sh`
## Command-line Options ## Command-line Options
### Production server (recommended)
```
sudo ./start.sh --help
-p, --port PORT Port to listen on (default: 5050)
-H, --host HOST Host to bind to (default: 0.0.0.0)
-d, --debug Run in debug mode (Flask dev server)
--https Enable HTTPS with self-signed certificate
--check-deps Check dependencies and exit
```
> **Note:** `sudo` is required for SDR hardware access, WiFi monitor mode, and Bluetooth low-level operations.
`start.sh` auto-detects gunicorn + gevent and runs a production WSGI server with cooperative greenlets — this handles multiple SSE streams and WebSocket connections concurrently without blocking. Falls back to the Flask dev server if gunicorn is not installed.
### Development server
``` ```
python3 intercept.py --help python3 intercept.py --help
+13 -7
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">
@@ -192,6 +192,11 @@
<h3>BT Locate</h3> <h3>BT Locate</h3>
<p>SAR Bluetooth device location with GPS-tagged signal trail mapping, IRK-based RPA resolution, and proximity audio alerts.</p> <p>SAR Bluetooth device location with GPS-tagged signal trail mapping, IRK-based RPA resolution, and proximity audio alerts.</p>
</div> </div>
<div class="feature-card" data-category="wireless">
<div class="feature-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M5 12.55a11 11 0 0 1 14.08 0"/><path d="M8.53 16.11a6 6 0 0 1 6.95 0"/><circle cx="12" cy="20" r="1" fill="currentColor" stroke="none"/><circle cx="12" cy="10" r="2"/><path d="M12 14v-2"/></svg></div>
<h3>WiFi Locate</h3>
<p>Locate WiFi access points by BSSID with real-time signal meter, distance estimation, RSSI chart, and audio proximity tones.</p>
</div>
<div class="feature-card" data-category="intel"> <div class="feature-card" data-category="intel">
<div class="feature-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M12 22s-8-4.5-8-11.8A8 8 0 0 1 12 2a8 8 0 0 1 8 8.2c0 7.3-8 11.8-8 11.8z"/><circle cx="12" cy="10" r="3"/><path d="M12 2v3"/><path d="M4.93 4.93l2.12 2.12"/><path d="M20 12h-3"/></svg></div> <div class="feature-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M12 22s-8-4.5-8-11.8A8 8 0 0 1 12 2a8 8 0 0 1 8 8.2c0 7.3-8 11.8-8 11.8z"/><circle cx="12" cy="10" r="3"/><path d="M12 2v3"/><path d="M4.93 4.93l2.12 2.12"/><path d="M20 12h-3"/></svg></div>
<h3>TSCM</h3> <h3>TSCM</h3>
@@ -330,10 +335,10 @@
<div class="code-block"> <div class="code-block">
<pre><code>git clone https://github.com/smittix/intercept.git <pre><code>git clone https://github.com/smittix/intercept.git
cd intercept cd intercept
./setup.sh ./setup.sh # Interactive wizard with install profiles
sudo -E venv/bin/python intercept.py</code></pre> sudo ./start.sh</code></pre>
</div> </div>
<p class="install-note">Requires Python 3.9+ and RTL-SDR drivers</p> <p class="install-note">Menu-driven setup: choose Core, Maritime, Weather, Security, or Full SIGINT profiles. Headless mode: <code>./setup.sh --non-interactive</code></p>
</div> </div>
<div class="install-card"> <div class="install-card">
@@ -350,6 +355,7 @@ docker compose --profile basic up -d --build</code></pre>
<div class="post-install"> <div class="post-install">
<p>After starting, open <code>http://localhost:5050</code> in your browser.</p> <p>After starting, open <code>http://localhost:5050</code> in your browser.</p>
<p>Default credentials: <code>admin</code> / <code>admin</code></p> <p>Default credentials: <code>admin</code> / <code>admin</code></p>
<p>Run <code>./setup.sh --health-check</code> to verify your installation, or use menu option 2 for a full system health check.</p>
</div> </div>
</div> </div>
</section> </section>
@@ -429,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,
)
+64
View File
@@ -0,0 +1,64 @@
"""Gunicorn configuration for INTERCEPT."""
import contextlib
import warnings
warnings.filterwarnings(
'ignore',
message='Patching more than once',
category=DeprecationWarning,
)
def post_fork(server, worker):
"""Apply gevent monkey-patching immediately after fork.
Gunicorn's built-in gevent worker is supposed to handle this, but on
some platforms (notably Raspberry Pi / ARM) the worker deadlocks during
its own init_process() before it gets to patch. Doing it here — right
after fork, before any worker initialisation — avoids the race.
Gunicorn's gevent worker will call patch_all() again in init_process();
the duplicate call is harmless (gevent unions the flags) and the
MonkeyPatchWarning is suppressed above.
"""
try:
from gevent import monkey
monkey.patch_all()
except Exception:
pass
# Silence the spurious AssertionError in gevent's fork hooks that fires
# when subprocesses fork after a double monkey-patch.
try:
from gevent.threading import _ForkHooks
_orig = _ForkHooks.after_fork_in_child
def _safe_after_fork(self):
with contextlib.suppress(AssertionError):
_orig(self)
_ForkHooks.after_fork_in_child = _safe_after_fork
except Exception:
pass
def post_worker_init(worker):
"""Suppress noisy SystemExit tracebacks during gevent worker shutdown.
When gunicorn receives SIGINT, the gevent worker's handle_quit()
calls sys.exit(0) inside a greenlet. Gevent treats SystemExit as
an error by default and prints a traceback. Adding it to NOT_ERROR
silences this harmless noise.
"""
try:
import ssl
from gevent import get_hub
hub = get_hub()
suppress = (SystemExit, ssl.SSLZeroReturnError, ssl.SSLError)
for exc in suppress:
if exc not in hub.NOT_ERROR:
hub.NOT_ERROR = hub.NOT_ERROR + (exc,)
except Exception:
pass
-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.23.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"]
+7
View File
@@ -1,5 +1,7 @@
# Core dependencies # Core dependencies
flask>=3.0.0 flask>=3.0.0
flask-wtf>=1.2.0
flask-compress>=1.15
flask-limiter>=2.5.4 flask-limiter>=2.5.4
requests>=2.28.0 requests>=2.28.0
Werkzeug>=3.1.5 Werkzeug>=3.1.5
@@ -43,7 +45,12 @@ 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)
psutil>=5.9.0 psutil>=5.9.0
# Production WSGI server (optional - falls back to Flask dev server)
gunicorn>=21.2.0
gevent>=23.9.0
+18 -1
View File
@@ -1,7 +1,13 @@
# Routes package - registers all blueprints with the Flask app # Routes package - registers all blueprints with the Flask app
def register_blueprints(app): def register_blueprints(app):
"""Register all route blueprints with the Flask app.""" """Register all route blueprints with the Flask app."""
# Import CSRF to exempt API blueprints (they use JSON, not form tokens)
try:
from app import csrf as _csrf
except ImportError:
_csrf = None
from .acars import acars_bp from .acars import acars_bp
from .adsb import adsb_bp from .adsb import adsb_bp
from .ais import ais_bp from .ais import ais_bp
@@ -14,10 +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 .morse import morse_bp from .morse import morse_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
@@ -36,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
@@ -76,9 +85,17 @@ def register_blueprints(app):
app.register_blueprint(space_weather_bp) # Space weather monitoring app.register_blueprint(space_weather_bp) # Space weather monitoring
app.register_blueprint(signalid_bp) # External signal ID enrichment app.register_blueprint(signalid_bp) # External signal ID enrichment
app.register_blueprint(wefax_bp) # WeFax HF weather fax decoder app.register_blueprint(wefax_bp) # WeFax HF weather fax decoder
app.register_blueprint(meteor_bp) # Meteor scatter detection
app.register_blueprint(morse_bp) # CW/Morse code decoder app.register_blueprint(morse_bp) # CW/Morse code decoder
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(ground_station_bp) # Ground station automation
# Exempt all API blueprints from CSRF (they use JSON, not form tokens)
if _csrf:
for bp in app.blueprints.values():
_csrf.exempt(bp)
# Initialize TSCM state with queue and lock from app # Initialize TSCM state with queue and lock from app
import app as app_module import app as app_module
+61 -43
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,30 +13,36 @@ import subprocess
import threading import threading
import time import time
from datetime import datetime from datetime import datetime
from typing import Generator from typing import Any
from flask import Blueprint, jsonify, request, Response from flask import Blueprint, Response, jsonify, request
import app as app_module import app as app_module
from utils.logging import sensor_logger as logger from utils.acars_translator import translate_message
from utils.validation import validate_device_index, validate_gain, validate_ppm
from utils.sdr import SDRFactory, SDRType
from utils.sse import sse_stream_fanout
from utils.event_pipeline import process_event
from utils.constants import ( from utils.constants import (
PROCESS_START_WAIT,
PROCESS_TERMINATE_TIMEOUT, PROCESS_TERMINATE_TIMEOUT,
SSE_KEEPALIVE_INTERVAL, SSE_KEEPALIVE_INTERVAL,
SSE_QUEUE_TIMEOUT, SSE_QUEUE_TIMEOUT,
PROCESS_START_WAIT,
) )
from utils.event_pipeline import process_event
from utils.flight_correlator import get_flight_correlator
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.sse import sse_stream_fanout
from utils.validation import validate_device_index, validate_gain, validate_ppm
acars_bp = Blueprint('acars', __name__, url_prefix='/acars') acars_bp = Blueprint('acars', __name__, url_prefix='/acars')
# Default VHF ACARS frequencies (MHz) - common worldwide # Default VHF ACARS frequencies (MHz) - North America primary
DEFAULT_ACARS_FREQUENCIES = [ DEFAULT_ACARS_FREQUENCIES = [
'131.725', # North America '131.550', # Primary worldwide / North America
'131.825', # North America '130.025', # North America secondary
'129.125', # North America tertiary
'131.725', # North America (major US carriers)
'131.825', # North America (major US carriers)
] ]
# Message counter for statistics # Message counter for statistics
@@ -121,6 +127,15 @@ def stream_acars_output(process: subprocess.Popen, is_text_mode: bool = False) -
data['type'] = 'acars' data['type'] = 'acars'
data['timestamp'] = datetime.utcnow().isoformat() + 'Z' data['timestamp'] = datetime.utcnow().isoformat() + 'Z'
# Enrich with translated label and parsed fields
try:
translation = translate_message(data)
data['label_description'] = translation['label_description']
data['message_type'] = translation['message_type']
data['parsed'] = translation['parsed']
except Exception:
pass
# Update stats # Update stats
acars_message_count += 1 acars_message_count += 1
acars_last_message_time = time.time() acars_last_message_time = time.time()
@@ -128,11 +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):
from utils.flight_correlator import get_flight_correlator
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:
@@ -158,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:
@@ -206,18 +216,12 @@ def start_acars() -> Response:
with app_module.acars_lock: with app_module.acars_lock:
if app_module.acars_process and app_module.acars_process.poll() is None: if app_module.acars_process and app_module.acars_process.poll() is None:
return jsonify({ return api_error('ACARS decoder already running', 409)
'status': 'error',
'message': 'ACARS decoder already running'
}), 409
# Check for acarsdec # Check for acarsdec
acarsdec_path = find_acarsdec() acarsdec_path = find_acarsdec()
if not acarsdec_path: if not acarsdec_path:
return jsonify({ return api_error('acarsdec not found. Install with: sudo apt install acarsdec', 400)
'status': 'error',
'message': 'acarsdec not found. Install with: sudo apt install acarsdec'
}), 400
data = request.json or {} data = request.json or {}
@@ -227,7 +231,7 @@ def start_acars() -> Response:
gain = validate_gain(data.get('gain', '40')) gain = validate_gain(data.get('gain', '40'))
ppm = validate_ppm(data.get('ppm', '0')) ppm = validate_ppm(data.get('ppm', '0'))
except ValueError as e: except ValueError as e:
return jsonify({'status': 'error', 'message': str(e)}), 400 return api_error(str(e), 400)
# Resolve SDR type for device selection # Resolve SDR type for device selection
sdr_type_str = data.get('sdr_type', 'rtlsdr') sdr_type_str = data.get('sdr_type', 'rtlsdr')
@@ -236,11 +240,7 @@ def start_acars() -> Response:
device_int = int(device) device_int = int(device)
error = app_module.claim_sdr_device(device_int, 'acars', sdr_type_str) error = app_module.claim_sdr_device(device_int, 'acars', sdr_type_str)
if error: if error:
return jsonify({ return api_error(error, 409, error_type='DEVICE_BUSY')
'status': 'error',
'error_type': 'DEVICE_BUSY',
'message': error
}), 409
acars_active_device = device_int acars_active_device = device_int
acars_active_sdr_type = sdr_type_str acars_active_sdr_type = sdr_type_str
@@ -331,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(
@@ -353,11 +353,13 @@ def start_acars() -> Response:
stderr = '' stderr = ''
if process.stderr: if process.stderr:
stderr = process.stderr.read().decode('utf-8', errors='replace') stderr = process.stderr.read().decode('utf-8', errors='replace')
error_msg = f'acarsdec failed to start'
if stderr: if stderr:
error_msg += f': {stderr[:200]}' logger.error(f"acarsdec stderr:\n{stderr}")
error_msg = 'acarsdec failed to start'
if stderr:
error_msg += f': {stderr[:500]}'
logger.error(error_msg) logger.error(error_msg)
return jsonify({'status': 'error', 'message': error_msg}), 500 return api_error(error_msg, 500)
app_module.acars_process = process app_module.acars_process = process
register_process(process) register_process(process)
@@ -384,7 +386,7 @@ def start_acars() -> Response:
acars_active_device = None acars_active_device = None
acars_active_sdr_type = None acars_active_sdr_type = None
logger.error(f"Failed to start ACARS decoder: {e}") logger.error(f"Failed to start ACARS decoder: {e}")
return jsonify({'status': 'error', 'message': str(e)}), 500 return api_error(str(e), 500)
@acars_bp.route('/stop', methods=['POST']) @acars_bp.route('/stop', methods=['POST'])
@@ -394,10 +396,7 @@ def stop_acars() -> Response:
with app_module.acars_lock: with app_module.acars_lock:
if not app_module.acars_process: if not app_module.acars_process:
return jsonify({ return api_error('ACARS decoder not running', 400)
'status': 'error',
'message': 'ACARS decoder not running'
}), 400
try: try:
app_module.acars_process.terminate() app_module.acars_process.terminate()
@@ -439,13 +438,32 @@ def stream_acars() -> Response:
return response return response
@acars_bp.route('/messages')
def get_acars_messages() -> Response:
"""Get recent ACARS messages from correlator (for history reload)."""
limit = request.args.get('limit', 50, type=int)
limit = max(1, min(limit, 200))
msgs = get_flight_correlator().get_recent_messages('acars', limit)
return jsonify(msgs)
@acars_bp.route('/clear', methods=['POST'])
def clear_acars_messages() -> Response:
"""Clear stored ACARS messages and reset counter."""
global acars_message_count, acars_last_message_time
get_flight_correlator().clear_acars()
acars_message_count = 0
acars_last_message_time = None
return jsonify({'status': 'cleared'})
@acars_bp.route('/frequencies') @acars_bp.route('/frequencies')
def get_frequencies() -> Response: def get_frequencies() -> Response:
"""Get default ACARS frequencies.""" """Get default ACARS frequencies."""
return jsonify({ return jsonify({
'default': DEFAULT_ACARS_FREQUENCIES, 'default': DEFAULT_ACARS_FREQUENCIES,
'regions': { 'regions': {
'north_america': ['131.725', '131.825'], 'north_america': ['131.550', '130.025', '129.125', '131.725', '131.825'],
'europe': ['131.525', '131.725', '131.550'], 'europe': ['131.525', '131.725', '131.550'],
'asia_pacific': ['131.550', '131.450'], 'asia_pacific': ['131.550', '131.450'],
} }
+583 -136
View File
File diff suppressed because it is too large Load Diff
+33 -36
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,29 +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
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')
@@ -80,6 +80,7 @@ def parse_ais_stream(port: int):
_ais_error_logged = True _ais_error_logged = True
while ais_running: while ais_running:
sock = None
try: try:
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
sock.settimeout(AIS_SOCKET_TIMEOUT) sock.settimeout(AIS_SOCKET_TIMEOUT)
@@ -126,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')
@@ -152,7 +151,6 @@ def parse_ais_stream(port: int):
except socket.timeout: except socket.timeout:
continue continue
sock.close()
ais_connected = False ais_connected = False
except OSError as e: except OSError as e:
ais_connected = False ais_connected = False
@@ -160,6 +158,10 @@ def parse_ais_stream(port: int):
logger.warning(f"AIS connection error: {e}, reconnecting...") logger.warning(f"AIS connection error: {e}, reconnecting...")
_ais_error_logged = True _ais_error_logged = True
time.sleep(AIS_RECONNECT_DELAY) time.sleep(AIS_RECONNECT_DELAY)
finally:
if sock:
with contextlib.suppress(OSError):
sock.close()
ais_connected = False ais_connected = False
logger.info("AIS stream parser stopped") logger.info("AIS stream parser stopped")
@@ -355,7 +357,7 @@ def start_ais():
with app_module.ais_lock: with app_module.ais_lock:
if ais_running: if ais_running:
return jsonify({'status': 'already_running', 'message': 'AIS tracking already active'}), 409 return api_error('AIS tracking already active', 409)
data = request.json or {} data = request.json or {}
@@ -364,15 +366,12 @@ def start_ais():
gain = int(validate_gain(data.get('gain', '40'))) gain = int(validate_gain(data.get('gain', '40')))
device = validate_device_index(data.get('device', '0')) device = validate_device_index(data.get('device', '0'))
except ValueError as e: except ValueError as e:
return jsonify({'status': 'error', 'message': str(e)}), 400 return api_error(str(e), 400)
# Find AIS-catcher # Find AIS-catcher
ais_catcher_path = find_ais_catcher() ais_catcher_path = find_ais_catcher()
if not ais_catcher_path: if not ais_catcher_path:
return jsonify({ return api_error('AIS-catcher not found. Install from https://github.com/jvde-github/AIS-catcher/releases', 400)
'status': 'error',
'message': 'AIS-catcher not found. Install from https://github.com/jvde-github/AIS-catcher/releases'
}), 400
# Get SDR type from request # Get SDR type from request
sdr_type_str = data.get('sdr_type', 'rtlsdr') sdr_type_str = data.get('sdr_type', 'rtlsdr')
@@ -400,11 +399,7 @@ def start_ais():
device_int = int(device) device_int = int(device)
error = app_module.claim_sdr_device(device_int, 'ais', sdr_type_str) error = app_module.claim_sdr_device(device_int, 'ais', sdr_type_str)
if error: if error:
return jsonify({ return api_error(error, 409, error_type='DEVICE_BUSY')
'status': 'error',
'error_type': 'DEVICE_BUSY',
'message': error
}), 409
# Build command using SDR abstraction # Build command using SDR abstraction
sdr_device = SDRFactory.create_default_device(sdr_type, index=device) sdr_device = SDRFactory.create_default_device(sdr_type, index=device)
@@ -440,14 +435,14 @@ 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: if stderr_output:
pass 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.'
if stderr_output: if stderr_output:
error_msg += f' Error: {stderr_output[:200]}' error_msg += f' Error: {stderr_output[:500]}'
return jsonify({'status': 'error', 'message': error_msg}), 500 return api_error(error_msg, 500)
ais_running = True ais_running = True
ais_active_device = device ais_active_device = device
@@ -467,7 +462,7 @@ def start_ais():
# Release device on failure # Release device on failure
app_module.release_sdr_device(device_int, sdr_type_str) app_module.release_sdr_device(device_int, sdr_type_str)
logger.error(f"Failed to start AIS-catcher: {e}") logger.error(f"Failed to start AIS-catcher: {e}")
return jsonify({'status': 'error', 'message': str(e)}), 500 return api_error(str(e), 500)
@ais_bp.route('/stop', methods=['POST']) @ais_bp.route('/stop', methods=['POST'])
@@ -527,17 +522,17 @@ def stream_ais():
def get_vessel_dsc(mmsi: str): def get_vessel_dsc(mmsi: str):
"""Get DSC messages associated with a vessel MMSI.""" """Get DSC messages associated with a vessel MMSI."""
if not mmsi or not mmsi.isdigit(): if not mmsi or not mmsi.isdigit():
return jsonify({'status': 'error', 'message': 'Invalid MMSI'}), 400 return api_error('Invalid MMSI', 400)
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:
pass pass
return jsonify({'status': 'success', 'mmsi': mmsi, 'dsc_messages': matches}) return api_success(data={'mmsi': mmsi, 'dsc_messages': matches})
@ais_bp.route('/dashboard') @ais_bp.route('/dashboard')
@@ -547,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,
) )
+11 -12
View File
@@ -2,13 +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_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')
@@ -18,18 +17,18 @@ alerts_bp = Blueprint('alerts', __name__, url_prefix='/alerts')
def list_rules(): def list_rules():
manager = get_alert_manager() manager = get_alert_manager()
include_disabled = request.args.get('all') in ('1', 'true', 'yes') include_disabled = request.args.get('all') in ('1', 'true', 'yes')
return jsonify({'status': 'success', 'rules': manager.list_rules(include_disabled=include_disabled)}) return api_success(data={'rules': manager.list_rules(include_disabled=include_disabled)})
@alerts_bp.route('/rules', methods=['POST']) @alerts_bp.route('/rules', methods=['POST'])
def create_rule(): def create_rule():
data = request.get_json() or {} data = request.get_json() or {}
if not isinstance(data.get('match', {}), dict): if not isinstance(data.get('match', {}), dict):
return jsonify({'status': 'error', 'message': 'match must be a JSON object'}), 400 return api_error('match must be a JSON object', 400)
manager = get_alert_manager() manager = get_alert_manager()
rule_id = manager.add_rule(data) rule_id = manager.add_rule(data)
return jsonify({'status': 'success', 'rule_id': rule_id}) return api_success(data={'rule_id': rule_id})
@alerts_bp.route('/rules/<int:rule_id>', methods=['PUT', 'PATCH']) @alerts_bp.route('/rules/<int:rule_id>', methods=['PUT', 'PATCH'])
@@ -38,8 +37,8 @@ def update_rule(rule_id: int):
manager = get_alert_manager() manager = get_alert_manager()
ok = manager.update_rule(rule_id, data) ok = manager.update_rule(rule_id, data)
if not ok: if not ok:
return jsonify({'status': 'error', 'message': 'Rule not found or no changes'}), 404 return api_error('Rule not found or no changes', 404)
return jsonify({'status': 'success'}) return api_success()
@alerts_bp.route('/rules/<int:rule_id>', methods=['DELETE']) @alerts_bp.route('/rules/<int:rule_id>', methods=['DELETE'])
@@ -47,8 +46,8 @@ def delete_rule(rule_id: int):
manager = get_alert_manager() manager = get_alert_manager()
ok = manager.delete_rule(rule_id) ok = manager.delete_rule(rule_id)
if not ok: if not ok:
return jsonify({'status': 'error', 'message': 'Rule not found'}), 404 return api_error('Rule not found', 404)
return jsonify({'status': 'success'}) return api_success()
@alerts_bp.route('/events', methods=['GET']) @alerts_bp.route('/events', methods=['GET'])
@@ -58,7 +57,7 @@ def list_events():
mode = request.args.get('mode') mode = request.args.get('mode')
severity = request.args.get('severity') severity = request.args.get('severity')
events = manager.list_events(limit=limit, mode=mode, severity=severity) events = manager.list_events(limit=limit, mode=mode, severity=severity)
return jsonify({'status': 'success', 'events': events}) return api_success(data={'events': events})
@alerts_bp.route('/stream', methods=['GET']) @alerts_bp.route('/stream', methods=['GET'])
+120 -132
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,22 +16,29 @@ 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
import app as app_module import app as app_module
from utils.logging import sensor_logger as logger
from utils.validation import validate_device_index, validate_gain, validate_ppm
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 (
PROCESS_START_WAIT,
PROCESS_TERMINATE_TIMEOUT, PROCESS_TERMINATE_TIMEOUT,
SSE_KEEPALIVE_INTERVAL, SSE_KEEPALIVE_INTERVAL,
SSE_QUEUE_TIMEOUT, SSE_QUEUE_TIMEOUT,
PROCESS_START_WAIT, )
from utils.event_pipeline import process_event
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 (
validate_device_index,
validate_gain,
validate_ppm,
validate_rtl_tcp_host,
validate_rtl_tcp_port,
) )
aprs_bp = Blueprint('aprs', __name__, url_prefix='/aprs') aprs_bp = Blueprint('aprs', __name__, url_prefix='/aprs')
@@ -68,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')
@@ -135,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:
@@ -424,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)
@@ -584,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
@@ -642,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
@@ -823,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:
@@ -966,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
@@ -1050,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
@@ -1115,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.
@@ -1167,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
@@ -1210,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.
@@ -1224,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.
@@ -1253,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:
@@ -1297,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)
@@ -1323,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...
@@ -1345,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,...
@@ -1374,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 $.
@@ -1402,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:
@@ -1572,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]:
@@ -1583,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')
@@ -1645,8 +1649,7 @@ def aprs_data() -> Response:
if app_module.aprs_process: if app_module.aprs_process:
running = app_module.aprs_process.poll() is None running = app_module.aprs_process.poll() is None
return jsonify({ return api_success(data={
'status': 'success',
'running': running, 'running': running,
'stations': list(aprs_stations.values()), 'stations': list(aprs_stations.values()),
'count': len(aprs_stations), 'count': len(aprs_stations),
@@ -1664,20 +1667,14 @@ def start_aprs() -> Response:
with app_module.aprs_lock: with app_module.aprs_lock:
if app_module.aprs_process and app_module.aprs_process.poll() is None: if app_module.aprs_process and app_module.aprs_process.poll() is None:
return jsonify({ return api_error('APRS decoder already running', 409)
'status': 'error',
'message': 'APRS decoder already running'
}), 409
# Check for decoder (prefer direwolf, fallback to multimon-ng) # Check for decoder (prefer direwolf, fallback to multimon-ng)
direwolf_path = find_direwolf() direwolf_path = find_direwolf()
multimon_path = find_multimon_ng() multimon_path = find_multimon_ng()
if not direwolf_path and not multimon_path: if not direwolf_path and not multimon_path:
return jsonify({ return api_error('No APRS decoder found. Install direwolf or multimon-ng', 400)
'status': 'error',
'message': 'No APRS decoder found. Install direwolf or multimon-ng'
}), 400
data = request.json or {} data = request.json or {}
@@ -1687,7 +1684,11 @@ def start_aprs() -> Response:
gain = validate_gain(data.get('gain', '40')) gain = validate_gain(data.get('gain', '40'))
ppm = validate_ppm(data.get('ppm', '0')) ppm = validate_ppm(data.get('ppm', '0'))
except ValueError as e: except ValueError as e:
return jsonify({'status': 'error', 'message': str(e)}), 400 return api_error(str(e), 400)
# Check for rtl_tcp (remote SDR) connection
rtl_tcp_host = data.get('rtl_tcp_host')
rtl_tcp_port = data.get('rtl_tcp_port', 1234)
sdr_type_str = str(data.get('sdr_type', 'rtlsdr')).lower() sdr_type_str = str(data.get('sdr_type', 'rtlsdr')).lower()
try: try:
@@ -1697,27 +1698,18 @@ def start_aprs() -> Response:
if sdr_type == SDRType.RTL_SDR: if sdr_type == SDRType.RTL_SDR:
if find_rtl_fm() is None: if find_rtl_fm() is None:
return jsonify({ return api_error('rtl_fm not found. Install with: sudo apt install rtl-sdr', 400)
'status': 'error',
'message': 'rtl_fm not found. Install with: sudo apt install rtl-sdr'
}), 400
else: else:
if find_rx_fm() is None: if find_rx_fm() is None:
return jsonify({ return api_error(f'rx_fm not found. Install SoapySDR tools for {sdr_type.value}.', 400)
'status': 'error',
'message': f'rx_fm not found. Install SoapySDR tools for {sdr_type.value}.'
}), 400
# Reserve SDR device to prevent conflicts with other modes # Reserve SDR device to prevent conflicts (skip for remote rtl_tcp)
error = app_module.claim_sdr_device(device, 'aprs', sdr_type_str) if not rtl_tcp_host:
if error: error = app_module.claim_sdr_device(device, 'aprs', sdr_type_str)
return jsonify({ if error:
'status': 'error', return api_error(error, 409, error_type='DEVICE_BUSY')
'error_type': 'DEVICE_BUSY', aprs_active_device = device
'message': error aprs_active_sdr_type = sdr_type_str
}), 409
aprs_active_device = device
aprs_active_sdr_type = sdr_type_str
# Get frequency for region # Get frequency for region
region = data.get('region', 'north_america') region = data.get('region', 'north_america')
@@ -1741,8 +1733,17 @@ def start_aprs() -> Response:
# Build FM demod command for APRS (AFSK1200 @ 22050 Hz) via SDR abstraction. # Build FM demod command for APRS (AFSK1200 @ 22050 Hz) via SDR abstraction.
try: try:
sdr_device = SDRFactory.create_default_device(sdr_type, index=device) if rtl_tcp_host:
builder = SDRFactory.get_builder(sdr_type) try:
rtl_tcp_host = validate_rtl_tcp_host(rtl_tcp_host)
rtl_tcp_port = validate_rtl_tcp_port(rtl_tcp_port)
except ValueError as e:
return api_error(str(e), 400)
sdr_device = SDRFactory.create_network_device(rtl_tcp_host, rtl_tcp_port)
logger.info(f"Using remote SDR: rtl_tcp://{rtl_tcp_host}:{rtl_tcp_port}")
else:
sdr_device = SDRFactory.create_default_device(sdr_type, index=device)
builder = SDRFactory.get_builder(sdr_device.sdr_type)
rtl_cmd = builder.build_fm_demod_command( rtl_cmd = builder.build_fm_demod_command(
device=sdr_device, device=sdr_device,
frequency_mhz=float(frequency), frequency_mhz=float(frequency),
@@ -1762,7 +1763,7 @@ def start_aprs() -> Response:
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
aprs_active_sdr_type = None aprs_active_sdr_type = None
return jsonify({'status': 'error', 'message': f'Failed to build SDR command: {e}'}), 500 return api_error(f'Failed to build SDR command: {e}', 500)
# Build decoder command # Build decoder command
if direwolf_path: if direwolf_path:
@@ -1850,23 +1851,21 @@ def start_aprs() -> Response:
stderr_output = remaining.decode('utf-8', errors='replace').strip() stderr_output = remaining.decode('utf-8', errors='replace').strip()
except Exception: except Exception:
pass pass
if stderr_output:
logger.error(f"rtl_fm stderr:\n{stderr_output}")
error_msg = f'rtl_fm failed to start (exit code {rtl_process.returncode})' error_msg = f'rtl_fm failed to start (exit code {rtl_process.returncode})'
if stderr_output: if stderr_output:
error_msg += f': {stderr_output[:200]}' 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
aprs_active_sdr_type = None aprs_active_sdr_type = None
return jsonify({'status': 'error', 'message': error_msg}), 500 return api_error(error_msg, 500)
if decoder_process.poll() is not None: if decoder_process.poll() is not None:
# Decoder exited early - capture any output from PTY # Decoder exited early - capture any output from PTY
@@ -1882,19 +1881,15 @@ 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
aprs_active_sdr_type = None aprs_active_sdr_type = None
return jsonify({'status': 'error', 'message': error_msg}), 500 return api_error(error_msg, 500)
# Store references for status checks and cleanup # Store references for status checks and cleanup
app_module.aprs_process = decoder_process app_module.aprs_process = decoder_process
@@ -1924,12 +1919,18 @@ def start_aprs() -> Response:
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
aprs_active_sdr_type = None aprs_active_sdr_type = None
return jsonify({'status': 'error', 'message': str(e)}), 500 return api_error(str(e), 500)
@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:
@@ -1942,11 +1943,30 @@ def stop_aprs() -> Response:
processes_to_stop.append(app_module.aprs_process) processes_to_stop.append(app_module.aprs_process)
if not processes_to_stop: if not processes_to_stop:
return jsonify({ return api_error('APRS decoder not running', 400)
'status': 'error',
'message': '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()
@@ -1956,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'})
@@ -2023,10 +2027,7 @@ def scan_aprs_spectrum() -> Response:
""" """
rtl_power_path = find_rtl_power() rtl_power_path = find_rtl_power()
if not rtl_power_path: if not rtl_power_path:
return jsonify({ return api_error('rtl_power not found. Install with: sudo apt install rtl-sdr', 400)
'status': 'error',
'message': 'rtl_power not found. Install with: sudo apt install rtl-sdr'
}), 400
# Get parameters from JSON body or query args # Get parameters from JSON body or query args
if request.is_json: if request.is_json:
@@ -2046,7 +2047,7 @@ def scan_aprs_spectrum() -> Response:
gain = validate_gain(gain) gain = validate_gain(gain)
duration = min(max(int(duration), 5), 60) # Clamp 5-60 seconds duration = min(max(int(duration), 5), 60) # Clamp 5-60 seconds
except ValueError as e: except ValueError as e:
return jsonify({'status': 'error', 'message': str(e)}), 400 return api_error(str(e), 400)
# Get center frequency # Get center frequency
if frequency: if frequency:
@@ -2091,21 +2092,15 @@ def scan_aprs_spectrum() -> Response:
if result.returncode != 0: if result.returncode != 0:
error_msg = result.stderr[:200] if result.stderr else f'Exit code {result.returncode}' error_msg = result.stderr[:200] if result.stderr else f'Exit code {result.returncode}'
return jsonify({ return api_error(f'rtl_power failed: {error_msg}', 500)
'status': 'error',
'message': f'rtl_power failed: {error_msg}'
}), 500
# Parse rtl_power CSV output # Parse rtl_power CSV output
# Format: date, time, start_hz, end_hz, step_hz, samples, db1, db2, db3, ... # Format: date, time, start_hz, end_hz, step_hz, samples, db1, db2, db3, ...
if not os.path.exists(tmp_file): if not os.path.exists(tmp_file):
return jsonify({ return api_error('rtl_power did not produce output file', 500)
'status': 'error',
'message': '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:
@@ -2122,10 +2117,7 @@ def scan_aprs_spectrum() -> Response:
continue continue
if not bins: if not bins:
return jsonify({ return api_error('No spectrum data collected. Check SDR connection and antenna.', 500)
'status': 'error',
'message': 'No spectrum data collected. Check SDR connection and antenna.'
}), 500
# Calculate statistics # Calculate statistics
db_values = [b['db'] for b in bins] db_values = [b['db'] for b in bins]
@@ -2155,8 +2147,7 @@ def scan_aprs_spectrum() -> Response:
else: else:
advice = "Good signal detected. Decoding should work well." advice = "Good signal detected. Decoding should work well."
return jsonify({ return api_success(data={
'status': 'success',
'scan_params': { 'scan_params': {
'center_freq_mhz': center_freq_mhz, 'center_freq_mhz': center_freq_mhz,
'start_freq_mhz': start_freq_mhz, 'start_freq_mhz': start_freq_mhz,
@@ -2182,13 +2173,10 @@ def scan_aprs_spectrum() -> Response:
}) })
except subprocess.TimeoutExpired: except subprocess.TimeoutExpired:
return jsonify({ return api_error(f'Spectrum scan timed out after {duration + 15} seconds', 500)
'status': 'error',
'message': f'Spectrum scan timed out after {duration + 15} seconds'
}), 500
except Exception as e: except Exception as e:
logger.error(f"Spectrum scan error: {e}") logger.error(f"Spectrum scan error: {e}")
return jsonify({'status': 'error', 'message': str(e)}), 500 return api_error(str(e), 500)
finally: finally:
# Cleanup temp file # Cleanup temp file
try: try:
+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")
+46 -42
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,32 +12,42 @@ 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
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')
# --- v1 deprecation ---
# These endpoints are deprecated in favor of /api/bluetooth/*.
# Frontend still uses v1, so they remain active.
# Migration: switch frontend to v2 endpoints, then remove this file.
_v1_deprecation_logged = set()
@bluetooth_bp.after_request
def _add_deprecation_header(response):
"""Add X-Deprecated header to all v1 Bluetooth responses."""
response.headers['X-Deprecated'] = 'Use /api/bluetooth/* endpoints instead'
endpoint = request.endpoint or ''
if endpoint not in _v1_deprecation_logged:
_v1_deprecation_logged.add(endpoint)
logger.warning(f"Deprecated v1 Bluetooth endpoint called: {request.path} — migrate to /api/bluetooth/*")
return response
def classify_bt_device(name, device_class, services, manufacturer=None): def classify_bt_device(name, device_class, services, manufacturer=None):
"""Classify Bluetooth device type based on available info.""" """Classify Bluetooth device type based on available info."""
@@ -310,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)})
@@ -331,8 +338,8 @@ def reload_oui_database_route():
if new_db: if new_db:
OUI_DATABASE.clear() OUI_DATABASE.clear()
OUI_DATABASE.update(new_db) OUI_DATABASE.update(new_db)
return jsonify({'status': 'success', 'entries': len(OUI_DATABASE)}) return api_success(data={'entries': len(OUI_DATABASE)})
return jsonify({'status': 'error', 'message': 'Could not load oui_database.json'}) return api_error('Could not load oui_database.json')
@bluetooth_bp.route('/interfaces') @bluetooth_bp.route('/interfaces')
@@ -359,7 +366,7 @@ def start_bt_scan():
with app_module.bt_lock: with app_module.bt_lock:
if app_module.bt_process: if app_module.bt_process:
if app_module.bt_process.poll() is None: if app_module.bt_process.poll() is None:
return jsonify({'status': 'error', 'message': 'Scan already running'}) return api_error('Scan already running')
else: else:
app_module.bt_process = None app_module.bt_process = None
@@ -371,7 +378,7 @@ def start_bt_scan():
try: try:
interface = validate_bluetooth_interface(data.get('interface', 'hci0')) interface = validate_bluetooth_interface(data.get('interface', 'hci0'))
except ValueError as e: except ValueError as e:
return jsonify({'status': 'error', 'message': str(e)}), 400 return api_error(str(e), 400)
app_module.bt_interface = interface app_module.bt_interface = interface
app_module.bt_devices = {} app_module.bt_devices = {}
@@ -413,14 +420,14 @@ def start_bt_scan():
os.write(master_fd, b'scan on\n') os.write(master_fd, b'scan on\n')
else: else:
return jsonify({'status': 'error', 'message': f'Unknown scan mode: {scan_mode}'}) return api_error(f'Unknown scan mode: {scan_mode}')
time.sleep(0.5) time.sleep(0.5)
if app_module.bt_process.poll() is not None: if app_module.bt_process.poll() is not None:
stderr_output = app_module.bt_process.stderr.read().decode('utf-8', errors='replace').strip() stderr_output = app_module.bt_process.stderr.read().decode('utf-8', errors='replace').strip()
app_module.bt_process = None app_module.bt_process = None
return jsonify({'status': 'error', 'message': stderr_output or 'Process failed to start'}) return api_error(stderr_output or 'Process failed to start')
thread = threading.Thread(target=stream_bt_scan, args=(app_module.bt_process, scan_mode)) thread = threading.Thread(target=stream_bt_scan, args=(app_module.bt_process, scan_mode))
thread.daemon = True thread.daemon = True
@@ -430,9 +437,9 @@ def start_bt_scan():
return jsonify({'status': 'started', 'mode': scan_mode, 'interface': interface}) return jsonify({'status': 'started', 'mode': scan_mode, 'interface': interface})
except FileNotFoundError as e: except FileNotFoundError as e:
return jsonify({'status': 'error', 'message': f'Tool not found: {e.filename}'}) return api_error(f'Tool not found: {e.filename}')
except Exception as e: except Exception as e:
return jsonify({'status': 'error', 'message': str(e)}) return api_error(str(e))
@bluetooth_bp.route('/scan/stop', methods=['POST']) @bluetooth_bp.route('/scan/stop', methods=['POST'])
@@ -459,7 +466,7 @@ def reset_bt_adapter():
try: try:
interface = validate_bluetooth_interface(data.get('interface', 'hci0')) interface = validate_bluetooth_interface(data.get('interface', 'hci0'))
except ValueError as e: except ValueError as e:
return jsonify({'status': 'error', 'message': str(e)}), 400 return api_error(str(e), 400)
with app_module.bt_lock: with app_module.bt_lock:
if app_module.bt_process: if app_module.bt_process:
@@ -467,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:
@@ -489,12 +494,12 @@ 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
}) })
except Exception as e: except Exception as e:
return jsonify({'status': 'error', 'message': str(e)}) return api_error(str(e))
@bluetooth_bp.route('/enum', methods=['POST']) @bluetooth_bp.route('/enum', methods=['POST'])
@@ -504,7 +509,7 @@ def enum_bt_services():
target_mac = data.get('mac') target_mac = data.get('mac')
if not target_mac: if not target_mac:
return jsonify({'status': 'error', 'message': 'Target MAC required'}) return api_error('Target MAC required')
try: try:
result = subprocess.run( result = subprocess.run(
@@ -529,18 +534,17 @@ def enum_bt_services():
app_module.bt_services[target_mac] = services app_module.bt_services[target_mac] = services
return jsonify({ return api_success(data={
'status': 'success',
'mac': target_mac, 'mac': target_mac,
'services': services 'services': services
}) })
except subprocess.TimeoutExpired: except subprocess.TimeoutExpired:
return jsonify({'status': 'error', 'message': 'Connection timed out'}) return api_error('Connection timed out')
except FileNotFoundError: except FileNotFoundError:
return jsonify({'status': 'error', 'message': 'sdptool not found'}) return api_error('sdptool not found')
except Exception as e: except Exception as e:
return jsonify({'status': 'error', 'message': str(e)}) return api_error(str(e))
@bluetooth_bp.route('/devices') @bluetooth_bp.route('/devices')
+11 -17
View File
@@ -7,30 +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.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')
@@ -231,7 +228,7 @@ def start_scan():
# Validate mode # Validate mode
valid_modes = ('auto', 'dbus', 'bleak', 'hcitool', 'bluetoothctl', 'ubertooth') valid_modes = ('auto', 'dbus', 'bleak', 'hcitool', 'bluetoothctl', 'ubertooth')
if mode not in valid_modes: if mode not in valid_modes:
return jsonify({'error': f'Invalid mode. Must be one of: {valid_modes}'}), 400 return api_error(f'Invalid mode. Must be one of: {valid_modes}', 400)
# Get scanner instance # Get scanner instance
scanner = get_bluetooth_scanner(adapter_id) scanner = get_bluetooth_scanner(adapter_id)
@@ -261,7 +258,7 @@ def start_scan():
# Check if already scanning # Check if already scanning
if scanner.is_scanning: if scanner.is_scanning:
return jsonify({ return jsonify({
'status': 'already_running', 'status': 'already_scanning',
'scan_status': scanner.get_status().to_dict() 'scan_status': scanner.get_status().to_dict()
}) })
@@ -389,7 +386,7 @@ def get_device(device_id: str):
device = scanner.get_device(device_id) device = scanner.get_device(device_id)
if not device: if not device:
return jsonify({'error': 'Device not found'}), 404 return api_error('Device not found', 404)
return jsonify(device.to_dict()) return jsonify(device.to_dict())
@@ -529,7 +526,7 @@ def get_tracker_detail(device_id: str):
device = scanner.get_device(device_id) device = scanner.get_device(device_id)
if not device: if not device:
return jsonify({'error': 'Device not found'}), 404 return api_error('Device not found', 404)
# Get RSSI history for timeline # Get RSSI history for timeline
rssi_history = device.get_rssi_history(max_points=100) rssi_history = device.get_rssi_history(max_points=100)
@@ -900,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(
@@ -971,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')
+17 -23
View File
@@ -21,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')
@@ -73,26 +74,25 @@ def start_session():
target.device_key, target.device_key,
target.fingerprint_id, target.fingerprint_id,
]): ]):
return jsonify({ return api_error(
'error': ( 'At least one target identifier required '
'At least one target identifier required ' '(mac_address, name_pattern, irk_hex, device_id, device_key, or fingerprint_id)',
'(mac_address, name_pattern, irk_hex, device_id, device_key, or fingerprint_id)' 400
) )
}), 400
# Parse environment # Parse environment
env_str = data.get('environment', 'OUTDOOR').upper() env_str = data.get('environment', 'OUTDOOR').upper()
try: try:
environment = Environment[env_str] environment = Environment[env_str]
except KeyError: except KeyError:
return jsonify({'error': f'Invalid environment: {env_str}'}), 400 return api_error(f'Invalid environment: {env_str}', 400)
custom_exponent = data.get('custom_exponent') custom_exponent = data.get('custom_exponent')
if custom_exponent is not None: if custom_exponent is not None:
try: try:
custom_exponent = float(custom_exponent) custom_exponent = float(custom_exponent)
except (ValueError, TypeError): except (ValueError, TypeError):
return jsonify({'error': 'custom_exponent must be a number'}), 400 return api_error('custom_exponent must be a number', 400)
# Fallback coordinates when GPS is unavailable (from user settings) # Fallback coordinates when GPS is unavailable (from user settings)
fallback_lat = None fallback_lat = None
@@ -115,16 +115,10 @@ def start_session():
) )
except RuntimeError as exc: except RuntimeError as exc:
logger.warning(f"Unable to start BT Locate session: {exc}") logger.warning(f"Unable to start BT Locate session: {exc}")
return jsonify({ return api_error('Bluetooth scanner could not be started. Check adapter permissions/capabilities.', 503)
'status': 'error',
'error': 'Bluetooth scanner could not be started. Check adapter permissions/capabilities.',
}), 503
except Exception as exc: except Exception as exc:
logger.exception(f"Unexpected error starting BT Locate session: {exc}") logger.exception(f"Unexpected error starting BT Locate session: {exc}")
return jsonify({ return api_error('Failed to start locate session', 500)
'status': 'error',
'error': 'Failed to start locate session',
}), 500
return jsonify({ return jsonify({
'status': 'started', 'status': 'started',
@@ -216,15 +210,15 @@ def test_resolve_rpa():
address = data.get('address', '') address = data.get('address', '')
if not irk_hex or not address: if not irk_hex or not address:
return jsonify({'error': 'irk_hex and address are required'}), 400 return api_error('irk_hex and address are required', 400)
try: try:
irk = bytes.fromhex(irk_hex) irk = bytes.fromhex(irk_hex)
except ValueError: except ValueError:
return jsonify({'error': 'Invalid IRK hex string'}), 400 return api_error('Invalid IRK hex string', 400)
if len(irk) != 16: if len(irk) != 16:
return jsonify({'error': 'IRK must be exactly 16 bytes (32 hex characters)'}), 400 return api_error('IRK must be exactly 16 bytes (32 hex characters)', 400)
result = resolve_rpa(irk, address) result = resolve_rpa(irk, address)
return jsonify({ return jsonify({
@@ -239,14 +233,14 @@ def set_environment():
"""Update the environment on the active session.""" """Update the environment on the active session."""
session = get_locate_session() session = get_locate_session()
if not session: if not session:
return jsonify({'error': 'no active session'}), 400 return api_error('no active session', 400)
data = request.get_json() or {} data = request.get_json() or {}
env_str = data.get('environment', '').upper() env_str = data.get('environment', '').upper()
try: try:
environment = Environment[env_str] environment = Environment[env_str]
except KeyError: except KeyError:
return jsonify({'error': f'Invalid environment: {env_str}'}), 400 return api_error(f'Invalid environment: {env_str}', 400)
custom_exponent = data.get('custom_exponent') custom_exponent = data.get('custom_exponent')
if custom_exponent is not None: if custom_exponent is not None:
@@ -268,11 +262,11 @@ def debug_matching():
"""Debug endpoint showing scanner devices and match results.""" """Debug endpoint showing scanner devices and match results."""
session = get_locate_session() session = get_locate_session()
if not session: if not session:
return jsonify({'error': 'no session'}) return api_error('no session')
scanner = session._scanner scanner = session._scanner
if not scanner: if not scanner:
return jsonify({'error': 'no scanner'}) return api_error('no scanner')
devices = scanner.get_devices(max_age_seconds=30) devices = scanner.get_devices(max_age_seconds=30)
return jsonify({ return jsonify({
+78 -96
View File
@@ -10,34 +10,41 @@ 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.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
AGENT_STATUS_TIMEOUT_SECONDS = 2.5
# Multi-agent SSE fanout state (per-client queues). # Multi-agent SSE fanout state (per-client queues).
_agent_stream_subscribers: set[queue.Queue] = set() _agent_stream_subscribers: set[queue.Queue] = set()
@@ -76,7 +83,11 @@ def get_agents():
if refresh: if refresh:
for agent in agents: for agent in agents:
try: try:
client = create_client_from_agent(agent) client = AgentClient(
agent['base_url'],
api_key=agent.get('api_key'),
timeout=AGENT_HEALTH_TIMEOUT_SECONDS,
)
agent['healthy'] = client.health_check() agent['healthy'] = client.health_check()
except Exception: except Exception:
agent['healthy'] = False agent['healthy'] = False
@@ -108,28 +119,25 @@ def register_agent():
base_url = data.get('base_url', '').strip() base_url = data.get('base_url', '').strip()
if not name: if not name:
return jsonify({'status': 'error', 'message': 'Agent name is required'}), 400 return api_error('Agent name is required', 400)
if not base_url: if not base_url:
return jsonify({'status': 'error', 'message': 'Base URL is required'}), 400 return api_error('Base URL is required', 400)
# Validate URL format # Validate URL format
from urllib.parse import urlparse from urllib.parse import urlparse
try: try:
parsed = urlparse(base_url) parsed = urlparse(base_url)
if parsed.scheme not in ('http', 'https'): if parsed.scheme not in ('http', 'https'):
return jsonify({'status': 'error', 'message': 'URL must start with http:// or https://'}), 400 return api_error('URL must start with http:// or https://', 400)
if not parsed.netloc: if not parsed.netloc:
return jsonify({'status': 'error', 'message': 'Invalid URL format'}), 400 return api_error('Invalid URL format', 400)
except Exception: except Exception:
return jsonify({'status': 'error', 'message': 'Invalid URL format'}), 400 return api_error('Invalid URL format', 400)
# Check if agent already exists # Check if agent already exists
existing = get_agent_by_name(name) existing = get_agent_by_name(name)
if existing: if existing:
return jsonify({ return api_error(f'Agent with name "{name}" already exists', 409)
'status': 'error',
'message': f'Agent with name "{name}" already exists'
}), 409
# Try to connect and get capabilities # Try to connect and get capabilities
api_key = data.get('api_key', '').strip() or None api_key = data.get('api_key', '').strip() or None
@@ -171,7 +179,7 @@ def register_agent():
except Exception as e: except Exception as e:
logger.exception("Failed to create agent") logger.exception("Failed to create agent")
return jsonify({'status': 'error', 'message': str(e)}), 500 return api_error(str(e), 500)
@controller_bp.route('/agents/<int:agent_id>', methods=['GET']) @controller_bp.route('/agents/<int:agent_id>', methods=['GET'])
@@ -179,7 +187,7 @@ def get_agent_detail(agent_id: int):
"""Get details of a specific agent.""" """Get details of a specific agent."""
agent = get_agent(agent_id) agent = get_agent(agent_id)
if not agent: if not agent:
return jsonify({'status': 'error', 'message': 'Agent not found'}), 404 return api_error('Agent not found', 404)
# Optionally refresh from agent # Optionally refresh from agent
refresh = request.args.get('refresh', 'false').lower() == 'true' refresh = request.args.get('refresh', 'false').lower() == 'true'
@@ -215,7 +223,7 @@ def update_agent_detail(agent_id: int):
"""Update an agent's details.""" """Update an agent's details."""
agent = get_agent(agent_id) agent = get_agent(agent_id)
if not agent: if not agent:
return jsonify({'status': 'error', 'message': 'Agent not found'}), 404 return api_error('Agent not found', 404)
data = request.json or {} data = request.json or {}
@@ -237,7 +245,7 @@ def remove_agent(agent_id: int):
"""Delete an agent.""" """Delete an agent."""
agent = get_agent(agent_id) agent = get_agent(agent_id)
if not agent: if not agent:
return jsonify({'status': 'error', 'message': 'Agent not found'}), 404 return api_error('Agent not found', 404)
delete_agent(agent_id) delete_agent(agent_id)
return jsonify({'status': 'success', 'message': 'Agent deleted'}) return jsonify({'status': 'success', 'message': 'Agent deleted'})
@@ -248,7 +256,7 @@ def refresh_agent_metadata(agent_id: int):
"""Refresh an agent's capabilities and status.""" """Refresh an agent's capabilities and status."""
agent = get_agent(agent_id) agent = get_agent(agent_id)
if not agent: if not agent:
return jsonify({'status': 'error', 'message': 'Agent not found'}), 404 return api_error('Agent not found', 404)
try: try:
client = create_client_from_agent(agent) client = create_client_from_agent(agent)
@@ -274,16 +282,10 @@ def refresh_agent_metadata(agent_id: int):
'metadata': metadata 'metadata': metadata
}) })
else: else:
return jsonify({ return api_error('Agent is not reachable', 503)
'status': 'error',
'message': 'Agent is not reachable'
}), 503
except (AgentHTTPError, AgentConnectionError) as e: except (AgentHTTPError, AgentConnectionError) as e:
return jsonify({ return api_error(f'Failed to reach agent: {e}', 503)
'status': 'error',
'message': f'Failed to reach agent: {e}'
}), 503
# ============================================================================= # =============================================================================
@@ -295,7 +297,7 @@ def get_agent_status(agent_id: int):
"""Get an agent's current status including running modes.""" """Get an agent's current status including running modes."""
agent = get_agent(agent_id) agent = get_agent(agent_id)
if not agent: if not agent:
return jsonify({'status': 'error', 'message': 'Agent not found'}), 404 return api_error('Agent not found', 404)
try: try:
client = create_client_from_agent(agent) client = create_client_from_agent(agent)
@@ -307,10 +309,7 @@ def get_agent_status(agent_id: int):
'agent_status': status 'agent_status': status
}) })
except (AgentHTTPError, AgentConnectionError) as e: except (AgentHTTPError, AgentConnectionError) as e:
return jsonify({ return api_error(f'Failed to reach agent: {e}', 503)
'status': 'error',
'message': f'Failed to reach agent: {e}'
}), 503
@controller_bp.route('/agents/health', methods=['GET']) @controller_bp.route('/agents/health', methods=['GET'])
@@ -335,7 +334,11 @@ def check_all_agents_health():
} }
try: try:
client = create_client_from_agent(agent) client = AgentClient(
agent['base_url'],
api_key=agent.get('api_key'),
timeout=AGENT_HEALTH_TIMEOUT_SECONDS,
)
# Time the health check # Time the health check
start_time = time.time() start_time = time.time()
@@ -351,7 +354,12 @@ def check_all_agents_health():
# Also fetch running modes # Also fetch running modes
try: try:
status = client.get_status() status_client = AgentClient(
agent['base_url'],
api_key=agent.get('api_key'),
timeout=AGENT_STATUS_TIMEOUT_SECONDS,
)
status = status_client.get_status()
result['running_modes'] = status.get('running_modes', []) result['running_modes'] = status.get('running_modes', [])
result['running_modes_detail'] = status.get('running_modes_detail', {}) result['running_modes_detail'] = status.get('running_modes_detail', {})
except Exception: except Exception:
@@ -384,7 +392,7 @@ def proxy_start_mode(agent_id: int, mode: str):
"""Start a mode on a remote agent.""" """Start a mode on a remote agent."""
agent = get_agent(agent_id) agent = get_agent(agent_id)
if not agent: if not agent:
return jsonify({'status': 'error', 'message': 'Agent not found'}), 404 return api_error('Agent not found', 404)
params = request.json or {} params = request.json or {}
@@ -403,15 +411,9 @@ def proxy_start_mode(agent_id: int, mode: str):
}) })
except AgentConnectionError as e: except AgentConnectionError as e:
return jsonify({ return api_error(f'Cannot connect to agent: {e}', 503)
'status': 'error',
'message': f'Cannot connect to agent: {e}'
}), 503
except AgentHTTPError as e: except AgentHTTPError as e:
return jsonify({ return api_error(f'Agent error: {e}', 502)
'status': 'error',
'message': f'Agent error: {e}'
}), 502
@controller_bp.route('/agents/<int:agent_id>/<mode>/stop', methods=['POST']) @controller_bp.route('/agents/<int:agent_id>/<mode>/stop', methods=['POST'])
@@ -419,7 +421,7 @@ def proxy_stop_mode(agent_id: int, mode: str):
"""Stop a mode on a remote agent.""" """Stop a mode on a remote agent."""
agent = get_agent(agent_id) agent = get_agent(agent_id)
if not agent: if not agent:
return jsonify({'status': 'error', 'message': 'Agent not found'}), 404 return api_error('Agent not found', 404)
try: try:
client = create_client_from_agent(agent) client = create_client_from_agent(agent)
@@ -435,15 +437,9 @@ def proxy_stop_mode(agent_id: int, mode: str):
}) })
except AgentConnectionError as e: except AgentConnectionError as e:
return jsonify({ return api_error(f'Cannot connect to agent: {e}', 503)
'status': 'error',
'message': f'Cannot connect to agent: {e}'
}), 503
except AgentHTTPError as e: except AgentHTTPError as e:
return jsonify({ return api_error(f'Agent error: {e}', 502)
'status': 'error',
'message': f'Agent error: {e}'
}), 502
@controller_bp.route('/agents/<int:agent_id>/<mode>/status', methods=['GET']) @controller_bp.route('/agents/<int:agent_id>/<mode>/status', methods=['GET'])
@@ -451,7 +447,7 @@ def proxy_mode_status(agent_id: int, mode: str):
"""Get mode status from a remote agent.""" """Get mode status from a remote agent."""
agent = get_agent(agent_id) agent = get_agent(agent_id)
if not agent: if not agent:
return jsonify({'status': 'error', 'message': 'Agent not found'}), 404 return api_error('Agent not found', 404)
try: try:
client = create_client_from_agent(agent) client = create_client_from_agent(agent)
@@ -465,10 +461,7 @@ def proxy_mode_status(agent_id: int, mode: str):
}) })
except (AgentHTTPError, AgentConnectionError) as e: except (AgentHTTPError, AgentConnectionError) as e:
return jsonify({ return api_error(f'Agent error: {e}', 502)
'status': 'error',
'message': f'Agent error: {e}'
}), 502
@controller_bp.route('/agents/<int:agent_id>/<mode>/data', methods=['GET']) @controller_bp.route('/agents/<int:agent_id>/<mode>/data', methods=['GET'])
@@ -476,7 +469,7 @@ def proxy_mode_data(agent_id: int, mode: str):
"""Get current data from a remote agent.""" """Get current data from a remote agent."""
agent = get_agent(agent_id) agent = get_agent(agent_id)
if not agent: if not agent:
return jsonify({'status': 'error', 'message': 'Agent not found'}), 404 return api_error('Agent not found', 404)
try: try:
client = create_client_from_agent(agent) client = create_client_from_agent(agent)
@@ -495,10 +488,7 @@ def proxy_mode_data(agent_id: int, mode: str):
}) })
except (AgentHTTPError, AgentConnectionError) as e: except (AgentHTTPError, AgentConnectionError) as e:
return jsonify({ return api_error(f'Agent error: {e}', 502)
'status': 'error',
'message': f'Agent error: {e}'
}), 502
@controller_bp.route('/agents/<int:agent_id>/<mode>/stream') @controller_bp.route('/agents/<int:agent_id>/<mode>/stream')
@@ -506,7 +496,7 @@ def proxy_mode_stream(agent_id: int, mode: str):
"""Proxy SSE stream from a remote agent.""" """Proxy SSE stream from a remote agent."""
agent = get_agent(agent_id) agent = get_agent(agent_id)
if not agent: if not agent:
return jsonify({'status': 'error', 'message': 'Agent not found'}), 404 return api_error('Agent not found', 404)
client = create_client_from_agent(agent) client = create_client_from_agent(agent)
query = request.query_string.decode('utf-8') query = request.query_string.decode('utf-8')
@@ -547,7 +537,7 @@ def proxy_wifi_monitor(agent_id: int):
"""Toggle monitor mode on a remote agent's WiFi interface.""" """Toggle monitor mode on a remote agent's WiFi interface."""
agent = get_agent(agent_id) agent = get_agent(agent_id)
if not agent: if not agent:
return jsonify({'status': 'error', 'message': 'Agent not found'}), 404 return api_error('Agent not found', 404)
data = request.json or {} data = request.json or {}
@@ -582,15 +572,9 @@ def proxy_wifi_monitor(agent_id: int):
}) })
except AgentConnectionError as e: except AgentConnectionError as e:
return jsonify({ return api_error(f'Cannot connect to agent: {e}', 503)
'status': 'error',
'message': f'Cannot connect to agent: {e}'
}), 503
except AgentHTTPError as e: except AgentHTTPError as e:
return jsonify({ return api_error(f'Agent error: {e}', 502)
'status': 'error',
'message': f'Agent error: {e}'
}), 502
# ============================================================================= # =============================================================================
@@ -616,23 +600,23 @@ def ingest_push_data():
""" """
data = request.json data = request.json
if not data: if not data:
return jsonify({'status': 'error', 'message': 'No data provided'}), 400 return api_error('No data provided', 400)
agent_name = data.get('agent_name') agent_name = data.get('agent_name')
if not agent_name: if not agent_name:
return jsonify({'status': 'error', 'message': 'agent_name required'}), 400 return api_error('agent_name required', 400)
# Find agent # Find agent
agent = get_agent_by_name(agent_name) agent = get_agent_by_name(agent_name)
if not agent: if not agent:
return jsonify({'status': 'error', 'message': 'Unknown agent'}), 401 return api_error('Unknown agent', 401)
# Validate API key if configured # Validate API key if configured
if agent.get('api_key'): if agent.get('api_key'):
provided_key = request.headers.get('X-API-Key', '') provided_key = request.headers.get('X-API-Key', '')
if provided_key != agent['api_key']: if provided_key != agent['api_key']:
logger.warning(f"Invalid API key from agent {agent_name}") logger.warning(f"Invalid API key from agent {agent_name}")
return jsonify({'status': 'error', 'message': 'Invalid API key'}), 401 return api_error('Invalid API key', 401)
# Store payload # Store payload
try: try:
@@ -662,7 +646,7 @@ def ingest_push_data():
except Exception as e: except Exception as e:
logger.exception("Failed to store push payload") logger.exception("Failed to store push payload")
return jsonify({'status': 'error', 'message': str(e)}), 500 return api_error(str(e), 500)
@controller_bp.route('/api/payloads', methods=['GET']) @controller_bp.route('/api/payloads', methods=['GET'])
@@ -704,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:
@@ -735,6 +720,7 @@ 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)
@@ -743,7 +729,9 @@ def agent_management_page():
def network_monitor_page(): def network_monitor_page():
"""Render the network monitor page for multi-agent aggregated view.""" """Render the network monitor page for multi-agent aggregated view."""
from flask import render_template from flask import render_template
return render_template('network_monitor.html')
from config import VERSION
return render_template('network_monitor.html', version=VERSION)
# ============================================================================= # =============================================================================
@@ -783,7 +771,7 @@ def add_location_observation():
required = ['device_id', 'agent_name', 'agent_lat', 'agent_lon', 'rssi'] required = ['device_id', 'agent_name', 'agent_lat', 'agent_lon', 'rssi']
for field in required: for field in required:
if field not in data: if field not in data:
return jsonify({'status': 'error', 'message': f'Missing required field: {field}'}), 400 return api_error(f'Missing required field: {field}', 400)
# Look up agent GPS from database if not provided # Look up agent GPS from database if not provided
agent_lat = data.get('agent_lat') agent_lat = data.get('agent_lat')
@@ -797,10 +785,7 @@ def add_location_observation():
agent_lon = coords.get('lon') or coords.get('longitude') agent_lon = coords.get('lon') or coords.get('longitude')
if agent_lat is None or agent_lon is None: if agent_lat is None or agent_lon is None:
return jsonify({ return api_error('Agent GPS coordinates required', 400)
'status': 'error',
'message': 'Agent GPS coordinates required'
}), 400
estimate = device_tracker.add_observation( estimate = device_tracker.add_observation(
device_id=data['device_id'], device_id=data['device_id'],
@@ -837,10 +822,7 @@ def estimate_location():
observations = data.get('observations', []) observations = data.get('observations', [])
if len(observations) < 2: if len(observations) < 2:
return jsonify({ return api_error('At least 2 observations required', 400)
'status': 'error',
'message': 'At least 2 observations required'
}), 400
environment = data.get('environment', 'outdoor') environment = data.get('environment', 'outdoor')
@@ -852,7 +834,7 @@ def estimate_location():
}) })
except Exception as e: except Exception as e:
logger.exception("Location estimation failed") logger.exception("Location estimation failed")
return jsonify({'status': 'error', 'message': str(e)}), 500 return api_error(str(e), 500)
@controller_bp.route('/api/location/<device_id>', methods=['GET']) @controller_bp.route('/api/location/<device_id>', methods=['GET'])
@@ -904,7 +886,7 @@ def get_devices_near():
lon = float(request.args.get('lon', 0)) lon = float(request.args.get('lon', 0))
radius = float(request.args.get('radius', 100)) radius = float(request.args.get('radius', 100))
except (ValueError, TypeError): except (ValueError, TypeError):
return jsonify({'status': 'error', 'message': 'Invalid coordinates'}), 400 return api_error('Invalid coordinates', 400)
results = device_tracker.get_devices_near(lat, lon, radius) results = device_tracker.get_devices_near(lat, lon, radius)
+10 -32
View File
@@ -2,11 +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.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')
@@ -39,18 +40,14 @@ def get_device_correlations() -> Response:
include_historical=include_historical include_historical=include_historical
) )
return jsonify({ return api_success(data={
'status': 'success',
'correlations': correlations, 'correlations': correlations,
'wifi_count': len(wifi_devices), 'wifi_count': len(wifi_devices),
'bt_count': len(bt_devices) 'bt_count': len(bt_devices)
}) })
except Exception as e: except Exception as e:
logger.error(f"Error calculating correlations: {e}") logger.error(f"Error calculating correlations: {e}")
return jsonify({ return api_error(str(e), 500)
'status': 'error',
'message': str(e)
}), 500
@correlation_bp.route('/analyze', methods=['POST']) @correlation_bp.route('/analyze', methods=['POST'])
@@ -67,10 +64,7 @@ def analyze_correlation() -> Response:
bt_mac = data.get('bt_mac') bt_mac = data.get('bt_mac')
if not wifi_mac or not bt_mac: if not wifi_mac or not bt_mac:
return jsonify({ return api_error('wifi_mac and bt_mac are required', 400)
'status': 'error',
'message': 'wifi_mac and bt_mac are required'
}), 400
try: try:
# Get device data # Get device data
@@ -81,16 +75,10 @@ def analyze_correlation() -> Response:
bt_device = app_module.bt_devices.get(bt_mac) bt_device = app_module.bt_devices.get(bt_mac)
if not wifi_device: if not wifi_device:
return jsonify({ return api_error(f'WiFi device {wifi_mac} not found', 404)
'status': 'error',
'message': f'WiFi device {wifi_mac} not found'
}), 404
if not bt_device: if not bt_device:
return jsonify({ return api_error(f'Bluetooth device {bt_mac} not found', 404)
'status': 'error',
'message': f'Bluetooth device {bt_mac} not found'
}), 404
# Calculate correlation for this specific pair # Calculate correlation for this specific pair
correlations = get_correlations( correlations = get_correlations(
@@ -101,19 +89,9 @@ def analyze_correlation() -> Response:
) )
if correlations: if correlations:
return jsonify({ return api_success(data={'correlation': correlations[0]})
'status': 'success',
'correlation': correlations[0]
})
else: else:
return jsonify({ return api_success(data={'correlation': None}, message='No correlation detected between these devices')
'status': 'success',
'correlation': None,
'message': 'No correlation detected between these devices'
})
except Exception as e: except Exception as e:
logger.error(f"Error analyzing correlation: {e}") logger.error(f"Error analyzing correlation: {e}")
return jsonify({ return api_error(str(e), 500)
'status': 'error',
'message': str(e)
}), 500
+71 -58
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,31 +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
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.dsc.parser import parse_dsc_message
from utils.sse import sse_stream_fanout
from utils.event_pipeline import process_event
from utils.validation import validate_device_index, validate_gain
from utils.sdr import SDRFactory, SDRType
from utils.dependencies import get_tool_path from utils.dependencies import get_tool_path
from utils.dsc.parser import parse_dsc_message
from utils.event_pipeline import process_event
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.sse import sse_stream_fanout
from utils.validation import (
validate_device_index,
validate_gain,
validate_rtl_tcp_host,
validate_rtl_tcp_port,
)
logger = logging.getLogger('intercept.dsc') logger = logging.getLogger('intercept.dsc')
@@ -77,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
@@ -173,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:
@@ -187,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:
@@ -336,19 +337,29 @@ def start_decoding() -> Response:
# Get SDR type from request # Get SDR type from request
sdr_type_str = data.get('sdr_type', 'rtlsdr') sdr_type_str = data.get('sdr_type', 'rtlsdr')
# Check if device is available using centralized registry # Check for rtl_tcp (remote SDR) connection
global dsc_active_device, dsc_active_sdr_type rtl_tcp_host = data.get('rtl_tcp_host')
device_int = int(device) rtl_tcp_port = data.get('rtl_tcp_port', 1234)
error = app_module.claim_sdr_device(device_int, 'dsc', sdr_type_str)
if error:
return jsonify({
'status': 'error',
'error_type': 'DEVICE_BUSY',
'message': error
}), 409
dsc_active_device = device_int try:
dsc_active_sdr_type = sdr_type_str sdr_type = SDRType(sdr_type_str)
except ValueError:
sdr_type = SDRType.RTL_SDR
# Check if device is available using centralized registry (skip for remote rtl_tcp)
global dsc_active_device, dsc_active_sdr_type
if not rtl_tcp_host:
device_int = int(device)
error = app_module.claim_sdr_device(device_int, 'dsc', sdr_type_str)
if error:
return jsonify({
'status': 'error',
'error_type': 'DEVICE_BUSY',
'message': error
}), 409
dsc_active_device = device_int
dsc_active_sdr_type = sdr_type_str
# Clear queue # Clear queue
while not app_module.dsc_queue.empty(): while not app_module.dsc_queue.empty():
@@ -357,22 +368,32 @@ def start_decoding() -> Response:
except queue.Empty: except queue.Empty:
break break
# Build rtl_fm command # Build rtl_fm command via SDR abstraction layer
rtl_fm_path = tools['rtl_fm']['path']
decoder_path = tools['dsc_decoder']['path'] decoder_path = tools['dsc_decoder']['path']
# rtl_fm command for DSC decoding if rtl_tcp_host:
# DSC uses narrow FM at 156.525 MHz with 48kHz sample rate try:
rtl_cmd = [ rtl_tcp_host = validate_rtl_tcp_host(rtl_tcp_host)
rtl_fm_path, rtl_tcp_port = validate_rtl_tcp_port(rtl_tcp_port)
'-f', f'{DSC_VHF_FREQUENCY_MHZ}M', except ValueError as e:
'-s', str(DSC_SAMPLE_RATE), return api_error(str(e), 400)
'-d', str(device), sdr_device = SDRFactory.create_network_device(rtl_tcp_host, rtl_tcp_port)
'-g', str(gain), logger.info(f"Using remote SDR: rtl_tcp://{rtl_tcp_host}:{rtl_tcp_port}")
'-M', 'fm', # FM demodulation else:
'-l', '0', # No squelch for DSC sdr_device = SDRFactory.create_default_device(sdr_type, index=int(device))
'-E', 'dc' # DC blocking filter
] builder = SDRFactory.get_builder(sdr_device.sdr_type)
rtl_cmd = list(builder.build_fm_demod_command(
device=sdr_device,
frequency_mhz=DSC_VHF_FREQUENCY_MHZ,
sample_rate=DSC_SAMPLE_RATE,
gain=float(gain) if gain and str(gain) != '0' else None,
modulation='fm',
squelch=0,
))
# Ensure trailing '-' for stdin piping and add DC blocking filter
if rtl_cmd and rtl_cmd[-1] == '-':
rtl_cmd = rtl_cmd[:-1] + ['-E', 'dc', '-']
# Decoder command # Decoder command
decoder_cmd = [decoder_path] decoder_cmd = [decoder_path]
@@ -440,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')
@@ -459,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')
@@ -492,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
@@ -505,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
-2
View File
@@ -3,8 +3,6 @@
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
+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))}")
File diff suppressed because it is too large Load Diff
+523
View File
@@ -0,0 +1,523 @@
"""Receiver routes for radio monitoring and frequency scanning.
This package splits the listening post into sub-modules:
scanner - /scanner/*, /presets routes
audio - /audio/* routes
waterfall - /waterfall/* routes
tools - /tools, /signal/guess routes
"""
from __future__ import annotations
import os
import queue
import shutil
import signal
import struct
import subprocess
import threading
import time
from datetime import datetime
from typing import Dict, List, Optional
from flask import Blueprint
from utils.constants import (
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.sse import sse_stream_fanout
logger = get_logger('intercept.receiver')
receiver_bp = Blueprint('receiver', __name__, url_prefix='/receiver')
# Deferred import to avoid circular import at module load time.
# app.py -> register_blueprints -> from .listening_post import receiver_bp
# must find receiver_bp already defined (above) before this import runs.
import contextlib
import app as app_module # noqa: E402
# ============================================
# GLOBAL STATE
# ============================================
# Audio demodulation state
audio_process = None
audio_rtl_process = None
audio_lock = threading.Lock()
audio_start_lock = threading.Lock()
audio_running = False
audio_frequency = 0.0
audio_modulation = 'fm'
audio_source = 'process'
audio_start_token = 0
# Scanner state
scanner_thread: threading.Thread | None = None
scanner_running = False
scanner_lock = threading.Lock()
scanner_paused = False
scanner_current_freq = 0.0
scanner_active_device: int | None = None
scanner_active_sdr_type: str = 'rtlsdr'
receiver_active_device: int | None = None
receiver_active_sdr_type: str = 'rtlsdr'
scanner_power_process: subprocess.Popen | None = None
scanner_config = {
'start_freq': 88.0,
'end_freq': 108.0,
'step': 0.1,
'modulation': 'wfm',
'squelch': 0,
'dwell_time': 10.0, # Seconds to stay on active frequency
'scan_delay': 0.1, # Seconds between frequency hops (keep low for fast scanning)
'device': 0,
'gain': 40,
'bias_t': False, # Bias-T power for external LNA
'sdr_type': 'rtlsdr', # SDR type: rtlsdr, hackrf, airspy, limesdr, sdrplay
'scan_method': 'power', # power (rtl_power) or classic (rtl_fm hop)
'snr_threshold': 8,
}
# Activity log
activity_log: list[dict] = []
activity_log_lock = threading.Lock()
MAX_LOG_ENTRIES = 500
# SSE queue for scanner events
scanner_queue: queue.Queue = queue.Queue(maxsize=100)
# Flag to trigger skip from API
scanner_skip_signal = False
# Waterfall / spectrogram state
waterfall_process: subprocess.Popen | None = None
waterfall_thread: threading.Thread | None = None
waterfall_running = False
waterfall_lock = threading.Lock()
waterfall_queue: queue.Queue = queue.Queue(maxsize=200)
waterfall_active_device: int | None = None
waterfall_active_sdr_type: str = 'rtlsdr'
waterfall_config = {
'start_freq': 88.0,
'end_freq': 108.0,
'bin_size': 10000,
'gain': 40,
'device': 0,
'max_bins': 1024,
'interval': 0.4,
}
# ============================================
# HELPER FUNCTIONS (shared across sub-modules)
# ============================================
VALID_MODULATIONS = ['fm', 'wfm', 'am', 'usb', 'lsb']
def find_rtl_fm() -> str | None:
"""Find rtl_fm binary."""
return shutil.which('rtl_fm')
def find_rtl_power() -> str | None:
"""Find rtl_power binary."""
return shutil.which('rtl_power')
def find_rx_fm() -> str | None:
"""Find rx_fm binary (SoapySDR FM demodulator for HackRF/Airspy/LimeSDR)."""
return shutil.which('rx_fm')
def find_ffmpeg() -> str | None:
"""Find ffmpeg for audio encoding."""
return shutil.which('ffmpeg')
def normalize_modulation(value: str) -> str:
"""Normalize and validate modulation string."""
mod = str(value or '').lower().strip()
if mod not in VALID_MODULATIONS:
raise ValueError(f'Invalid modulation. Use: {", ".join(VALID_MODULATIONS)}')
return mod
def _rtl_fm_demod_mode(modulation: str) -> str:
"""Map UI modulation names to rtl_fm demod tokens."""
mod = str(modulation or '').lower().strip()
return 'wbfm' if mod == 'wfm' else mod
def _wav_header(sample_rate: int = 48000, bits_per_sample: int = 16, channels: int = 1) -> bytes:
"""Create a streaming WAV header with unknown data length."""
bytes_per_sample = bits_per_sample // 8
byte_rate = sample_rate * channels * bytes_per_sample
block_align = channels * bytes_per_sample
return (
b'RIFF'
+ struct.pack('<I', 0xFFFFFFFF)
+ b'WAVE'
+ b'fmt '
+ struct.pack('<IHHIIHH', 16, 1, channels, sample_rate, byte_rate, block_align, bits_per_sample)
+ b'data'
+ struct.pack('<I', 0xFFFFFFFF)
)
def add_activity_log(event_type: str, frequency: float, details: str = ''):
"""Add entry to activity log."""
with activity_log_lock:
entry = {
'timestamp': datetime.utcnow().isoformat() + 'Z',
'type': event_type,
'frequency': frequency,
'details': details,
}
activity_log.insert(0, entry)
# Trim log
while len(activity_log) > MAX_LOG_ENTRIES:
activity_log.pop()
# Also push to SSE queue
with contextlib.suppress(queue.Full):
scanner_queue.put_nowait({
'type': 'log',
'entry': entry
})
def _start_audio_stream(
frequency: float,
modulation: str,
*,
device: int | None = None,
sdr_type: str | None = None,
gain: int | None = None,
squelch: int | None = None,
bias_t: bool | None = None,
):
"""Start audio streaming at given frequency."""
global audio_process, audio_rtl_process, audio_running, audio_frequency, audio_modulation
# Stop existing stream and snapshot config under lock
with audio_lock:
_stop_audio_stream_internal()
ffmpeg_path = find_ffmpeg()
if not ffmpeg_path:
logger.error("ffmpeg not found")
return
# Snapshot runtime tuning config so the spawned demod command cannot
# drift if shared scanner_config changes while startup is in-flight.
device_index = int(device if device is not None else scanner_config.get('device', 0))
gain_value = int(gain if gain is not None else scanner_config.get('gain', 40))
squelch_value = int(squelch if squelch is not None else scanner_config.get('squelch', 0))
bias_t_enabled = bool(scanner_config.get('bias_t', False) if bias_t is None else bias_t)
sdr_type_str = str(sdr_type if sdr_type is not None else scanner_config.get('sdr_type', 'rtlsdr')).lower()
# Build commands outside lock (no blocking I/O, just command construction)
try:
resolved_sdr_type = SDRType(sdr_type_str)
except ValueError:
resolved_sdr_type = SDRType.RTL_SDR
# Set sample rates based on modulation
if modulation == 'wfm':
sample_rate = 170000
resample_rate = 32000
elif modulation in ['usb', 'lsb']:
sample_rate = 12000
resample_rate = 12000
else:
sample_rate = 24000
resample_rate = 24000
# Build the SDR command based on device type
if resolved_sdr_type == SDRType.RTL_SDR:
rtl_fm_path = find_rtl_fm()
if not rtl_fm_path:
logger.error("rtl_fm not found")
return
freq_hz = int(frequency * 1e6)
sdr_cmd = [
rtl_fm_path,
'-M', _rtl_fm_demod_mode(modulation),
'-f', str(freq_hz),
'-s', str(sample_rate),
'-r', str(resample_rate),
'-g', str(gain_value),
'-d', str(device_index),
'-l', str(squelch_value),
]
if bias_t_enabled:
sdr_cmd.append('-T')
else:
rx_fm_path = find_rx_fm()
if not rx_fm_path:
logger.error(f"rx_fm not found - required for {resolved_sdr_type.value}. Install SoapySDR utilities.")
return
sdr_device = SDRFactory.create_default_device(resolved_sdr_type, index=device_index)
builder = SDRFactory.get_builder(resolved_sdr_type)
sdr_cmd = builder.build_fm_demod_command(
device=sdr_device,
frequency_mhz=frequency,
sample_rate=resample_rate,
gain=float(gain_value),
modulation=modulation,
squelch=squelch_value,
bias_t=bias_t_enabled,
)
sdr_cmd[0] = rx_fm_path
encoder_cmd = [
ffmpeg_path,
'-hide_banner',
'-loglevel', 'error',
'-fflags', 'nobuffer',
'-flags', 'low_delay',
'-probesize', '32',
'-analyzeduration', '0',
'-f', 's16le',
'-ar', str(resample_rate),
'-ac', '1',
'-i', 'pipe:0',
'-acodec', 'pcm_s16le',
'-ar', '44100',
'-f', 'wav',
'pipe:1'
]
# Retry loop outside lock — spawning + health check sleeps don't block
# other operations. audio_start_lock already serializes callers.
try:
rtl_stderr_log = '/tmp/rtl_fm_stderr.log'
ffmpeg_stderr_log = '/tmp/ffmpeg_stderr.log'
logger.info(f"Starting audio: {frequency} MHz, mod={modulation}, device={device_index}")
new_rtl_proc = None
new_audio_proc = None
max_attempts = 3
for attempt in range(max_attempts):
new_rtl_proc = None
new_audio_proc = None
rtl_err_handle = None
ffmpeg_err_handle = None
try:
rtl_err_handle = open(rtl_stderr_log, 'w')
ffmpeg_err_handle = open(ffmpeg_stderr_log, 'w')
new_rtl_proc = subprocess.Popen(
sdr_cmd,
stdout=subprocess.PIPE,
stderr=rtl_err_handle,
bufsize=0,
start_new_session=True
)
new_audio_proc = subprocess.Popen(
encoder_cmd,
stdin=new_rtl_proc.stdout,
stdout=subprocess.PIPE,
stderr=ffmpeg_err_handle,
bufsize=0,
start_new_session=True
)
if new_rtl_proc.stdout:
new_rtl_proc.stdout.close()
finally:
if rtl_err_handle:
rtl_err_handle.close()
if ffmpeg_err_handle:
ffmpeg_err_handle.close()
# Brief delay to check if process started successfully
time.sleep(0.3)
if (new_rtl_proc and new_rtl_proc.poll() is not None) or (
new_audio_proc and new_audio_proc.poll() is not None
):
rtl_stderr = ''
ffmpeg_stderr = ''
try:
with open(rtl_stderr_log) as f:
rtl_stderr = f.read().strip()
except Exception:
pass
try:
with open(ffmpeg_stderr_log) as f:
ffmpeg_stderr = f.read().strip()
except Exception:
pass
if 'usb_claim_interface' in rtl_stderr and attempt < max_attempts - 1:
logger.warning(f"USB device busy (attempt {attempt + 1}/{max_attempts}), waiting for release...")
if new_audio_proc:
try:
new_audio_proc.terminate()
new_audio_proc.wait(timeout=0.5)
except Exception:
pass
if new_rtl_proc:
try:
new_rtl_proc.terminate()
new_rtl_proc.wait(timeout=0.5)
except Exception:
pass
time.sleep(1.0)
continue
if new_audio_proc and new_audio_proc.poll() is None:
try:
new_audio_proc.terminate()
new_audio_proc.wait(timeout=0.5)
except Exception:
pass
if new_rtl_proc and new_rtl_proc.poll() is None:
try:
new_rtl_proc.terminate()
new_rtl_proc.wait(timeout=0.5)
except Exception:
pass
new_audio_proc = None
new_rtl_proc = None
logger.error(
f"Audio pipeline exited immediately. rtl_fm stderr: {rtl_stderr}, ffmpeg stderr: {ffmpeg_stderr}"
)
return
# Pipeline started successfully
break
# Verify pipeline is still alive, then install under lock
if (
not new_audio_proc
or not new_rtl_proc
or new_audio_proc.poll() is not None
or new_rtl_proc.poll() is not None
):
logger.warning("Audio pipeline did not remain alive after startup")
# Clean up failed processes
if new_audio_proc:
try:
new_audio_proc.terminate()
new_audio_proc.wait(timeout=0.5)
except Exception:
pass
if new_rtl_proc:
try:
new_rtl_proc.terminate()
new_rtl_proc.wait(timeout=0.5)
except Exception:
pass
return
# Install processes under lock
with audio_lock:
audio_rtl_process = new_rtl_proc
audio_process = new_audio_proc
audio_running = True
audio_frequency = frequency
audio_modulation = modulation
logger.info(f"Audio stream started: {frequency} MHz ({modulation}) via {resolved_sdr_type.value}")
except Exception as e:
logger.error(f"Failed to start audio stream: {e}")
def _stop_audio_stream():
"""Stop audio streaming."""
with audio_lock:
_stop_audio_stream_internal()
def _stop_audio_stream_internal():
"""Internal stop (must hold lock)."""
global audio_process, audio_rtl_process, audio_running, audio_frequency, audio_source
# Set flag first to stop any streaming
audio_running = False
audio_frequency = 0.0
previous_source = audio_source
audio_source = 'process'
if previous_source == 'waterfall':
try:
from routes.waterfall_websocket import stop_shared_monitor_from_capture
stop_shared_monitor_from_capture()
except Exception:
pass
had_processes = audio_process is not None or audio_rtl_process is not None
# Kill the pipeline processes and their groups
if audio_process:
try:
# Kill entire process group (SDR demod + ffmpeg)
try:
os.killpg(os.getpgid(audio_process.pid), signal.SIGKILL)
except (ProcessLookupError, PermissionError):
audio_process.kill()
audio_process.wait(timeout=0.5)
except Exception:
pass
if audio_rtl_process:
try:
try:
os.killpg(os.getpgid(audio_rtl_process.pid), signal.SIGKILL)
except (ProcessLookupError, PermissionError):
audio_rtl_process.kill()
audio_rtl_process.wait(timeout=0.5)
except Exception:
pass
audio_process = None
audio_rtl_process = None
# Brief pause for SDR device USB interface to be released by kernel.
# The _start_audio_stream retry loop handles longer contention windows
# so only a minimal delay is needed here.
if had_processes:
time.sleep(0.15)
def _stop_waterfall_internal() -> None:
"""Stop the waterfall display and release resources."""
global waterfall_running, waterfall_process, waterfall_active_device, waterfall_active_sdr_type
waterfall_running = False
if waterfall_process and waterfall_process.poll() is None:
try:
waterfall_process.terminate()
waterfall_process.wait(timeout=1)
except Exception:
with contextlib.suppress(Exception):
waterfall_process.kill()
waterfall_process = None
if waterfall_active_device is not None:
app_module.release_sdr_device(waterfall_active_device, waterfall_active_sdr_type)
waterfall_active_device = None
waterfall_active_sdr_type = 'rtlsdr'
# ============================================
# Import sub-modules to register routes on receiver_bp
# ============================================
from . import (
audio, # noqa: E402, F401
scanner, # noqa: E402, F401
tools, # noqa: E402, F401
waterfall, # noqa: E402, F401
)
+496
View File
@@ -0,0 +1,496 @@
"""Audio routes for manual listening and audio streaming."""
from __future__ import annotations
import contextlib
import os
import select
import subprocess
import time
from flask import Response, jsonify, request
import routes.listening_post as _state
from . import (
_start_audio_stream,
_stop_audio_stream,
_stop_waterfall_internal,
_wav_header,
app_module,
logger,
normalize_modulation,
receiver_bp,
scanner_config,
)
# ============================================
# MANUAL AUDIO ENDPOINTS (for direct listening)
# ============================================
@receiver_bp.route('/audio/start', methods=['POST'])
def start_audio() -> Response:
"""Start audio at specific frequency (manual mode)."""
data = request.json or {}
try:
frequency = float(data.get('frequency', 0))
modulation = normalize_modulation(data.get('modulation', 'wfm'))
squelch = int(data['squelch']) if data.get('squelch') is not None else 0
gain = int(data['gain']) if data.get('gain') is not None else 40
device = int(data['device']) if data.get('device') is not None else 0
sdr_type = str(data.get('sdr_type', 'rtlsdr')).lower()
request_token_raw = data.get('request_token')
request_token = int(request_token_raw) if request_token_raw is not None else None
bias_t_raw = data.get('bias_t', scanner_config.get('bias_t', False))
if isinstance(bias_t_raw, str):
bias_t = bias_t_raw.strip().lower() in {'1', 'true', 'yes', 'on'}
else:
bias_t = bool(bias_t_raw)
except (ValueError, TypeError) as e:
return jsonify({
'status': 'error',
'message': f'Invalid parameter: {e}'
}), 400
if frequency <= 0:
return jsonify({
'status': 'error',
'message': 'frequency is required'
}), 400
valid_sdr_types = ['rtlsdr', 'hackrf', 'airspy', 'limesdr', 'sdrplay']
if sdr_type not in valid_sdr_types:
return jsonify({
'status': 'error',
'message': f'Invalid sdr_type. Use: {", ".join(valid_sdr_types)}'
}), 400
with _state.audio_start_lock:
if request_token is not None:
if request_token < _state.audio_start_token:
return jsonify({
'status': 'stale',
'message': 'Superseded audio start request',
'source': _state.audio_source,
'superseded': True,
'current_token': _state.audio_start_token,
}), 409
_state.audio_start_token = request_token
else:
_state.audio_start_token += 1
request_token = _state.audio_start_token
# Grab scanner refs inside lock, signal stop, clear state
need_scanner_teardown = False
scanner_thread_ref = None
scanner_proc_ref = None
if _state.scanner_running:
_state.scanner_running = False
if _state.scanner_active_device is not None:
app_module.release_sdr_device(_state.scanner_active_device, _state.scanner_active_sdr_type)
_state.scanner_active_device = None
_state.scanner_active_sdr_type = 'rtlsdr'
scanner_thread_ref = _state.scanner_thread
scanner_proc_ref = _state.scanner_power_process
_state.scanner_power_process = None
need_scanner_teardown = True
# Update config for audio
scanner_config['squelch'] = squelch
scanner_config['gain'] = gain
scanner_config['device'] = device
scanner_config['sdr_type'] = sdr_type
scanner_config['bias_t'] = bias_t
# Scanner teardown outside lock (blocking: thread join, process wait, pkill, sleep)
if need_scanner_teardown:
if scanner_thread_ref and scanner_thread_ref.is_alive():
with contextlib.suppress(Exception):
scanner_thread_ref.join(timeout=2.0)
if scanner_proc_ref and scanner_proc_ref.poll() is None:
try:
scanner_proc_ref.terminate()
scanner_proc_ref.wait(timeout=1)
except Exception:
with contextlib.suppress(Exception):
scanner_proc_ref.kill()
with contextlib.suppress(Exception):
subprocess.run(['pkill', '-9', 'rtl_power'], capture_output=True, timeout=0.5)
time.sleep(0.5)
# Re-acquire lock for waterfall check and device claim
with _state.audio_start_lock:
# Preferred path: when waterfall WebSocket is active on the same SDR,
# derive monitor audio from that IQ stream instead of spawning rtl_fm.
try:
from routes.waterfall_websocket import (
get_shared_capture_status,
start_shared_monitor_from_capture,
)
shared = get_shared_capture_status()
if shared.get('running') and shared.get('device') == device:
_stop_audio_stream()
ok, msg = start_shared_monitor_from_capture(
device=device,
frequency_mhz=frequency,
modulation=modulation,
squelch=squelch,
)
if ok:
_state.audio_running = True
_state.audio_frequency = frequency
_state.audio_modulation = modulation
_state.audio_source = 'waterfall'
# Shared monitor uses the waterfall's existing SDR claim.
if _state.receiver_active_device is not None:
app_module.release_sdr_device(_state.receiver_active_device, _state.receiver_active_sdr_type)
_state.receiver_active_device = None
_state.receiver_active_sdr_type = 'rtlsdr'
return jsonify({
'status': 'started',
'frequency': frequency,
'modulation': modulation,
'source': 'waterfall',
'request_token': request_token,
})
logger.warning(f"Shared waterfall monitor unavailable: {msg}")
except Exception as e:
logger.debug(f"Shared waterfall monitor probe failed: {e}")
# Stop waterfall if it's using the same SDR (SSE path)
if _state.waterfall_running and _state.waterfall_active_device == device:
_stop_waterfall_internal()
time.sleep(0.2)
# Claim device for listening audio. The WebSocket waterfall handler
# may still be tearing down its IQ capture process (thread join +
# safe_terminate can take several seconds), so we retry with back-off
# to give the USB device time to be fully released.
if _state.receiver_active_device is None or _state.receiver_active_device != device:
if _state.receiver_active_device is not None:
app_module.release_sdr_device(_state.receiver_active_device, _state.receiver_active_sdr_type)
_state.receiver_active_device = None
_state.receiver_active_sdr_type = 'rtlsdr'
error = None
max_claim_attempts = 6
for attempt in range(max_claim_attempts):
error = app_module.claim_sdr_device(device, 'receiver', sdr_type)
if not error:
break
if attempt < max_claim_attempts - 1:
logger.debug(
f"Device claim attempt {attempt + 1}/{max_claim_attempts} "
f"failed, retrying in 0.5s: {error}"
)
time.sleep(0.5)
if error:
return jsonify({
'status': 'error',
'error_type': 'DEVICE_BUSY',
'message': error
}), 409
_state.receiver_active_device = device
_state.receiver_active_sdr_type = sdr_type
_start_audio_stream(
frequency,
modulation,
device=device,
sdr_type=sdr_type,
gain=gain,
squelch=squelch,
bias_t=bias_t,
)
if _state.audio_running:
_state.audio_source = 'process'
return jsonify({
'status': 'started',
'frequency': _state.audio_frequency,
'modulation': _state.audio_modulation,
'source': 'process',
'request_token': request_token,
})
# Avoid leaving a stale device claim after startup failure.
if _state.receiver_active_device is not None:
app_module.release_sdr_device(_state.receiver_active_device, _state.receiver_active_sdr_type)
_state.receiver_active_device = None
_state.receiver_active_sdr_type = 'rtlsdr'
start_error = ''
for log_path in ('/tmp/rtl_fm_stderr.log', '/tmp/ffmpeg_stderr.log'):
try:
with open(log_path) as handle:
content = handle.read().strip()
if content:
start_error = content.splitlines()[-1]
break
except Exception:
continue
message = 'Failed to start audio. Check SDR device.'
if start_error:
message = f'Failed to start audio: {start_error}'
return jsonify({
'status': 'error',
'message': message
}), 500
@receiver_bp.route('/audio/stop', methods=['POST'])
def stop_audio() -> Response:
"""Stop audio."""
_stop_audio_stream()
if _state.receiver_active_device is not None:
app_module.release_sdr_device(_state.receiver_active_device, _state.receiver_active_sdr_type)
_state.receiver_active_device = None
_state.receiver_active_sdr_type = 'rtlsdr'
return jsonify({'status': 'stopped'})
@receiver_bp.route('/audio/status')
def audio_status() -> Response:
"""Get audio status."""
running = _state.audio_running
if _state.audio_source == 'waterfall':
try:
from routes.waterfall_websocket import get_shared_capture_status
shared = get_shared_capture_status()
running = bool(shared.get('running') and shared.get('monitor_enabled'))
except Exception:
running = False
return jsonify({
'running': running,
'frequency': _state.audio_frequency,
'modulation': _state.audio_modulation,
'source': _state.audio_source,
})
@receiver_bp.route('/audio/debug')
def audio_debug() -> Response:
"""Get audio debug status and recent stderr logs."""
rtl_log_path = '/tmp/rtl_fm_stderr.log'
ffmpeg_log_path = '/tmp/ffmpeg_stderr.log'
sample_path = '/tmp/audio_probe.bin'
def _read_log(path: str) -> str:
try:
with open(path) as handle:
return handle.read().strip()
except Exception:
return ''
shared = {}
if _state.audio_source == 'waterfall':
try:
from routes.waterfall_websocket import get_shared_capture_status
shared = get_shared_capture_status()
except Exception:
shared = {}
return jsonify({
'running': _state.audio_running,
'frequency': _state.audio_frequency,
'modulation': _state.audio_modulation,
'source': _state.audio_source,
'sdr_type': scanner_config.get('sdr_type', 'rtlsdr'),
'device': scanner_config.get('device', 0),
'gain': scanner_config.get('gain', 0),
'squelch': scanner_config.get('squelch', 0),
'audio_process_alive': bool(_state.audio_process and _state.audio_process.poll() is None),
'shared_capture': shared,
'rtl_fm_stderr': _read_log(rtl_log_path),
'ffmpeg_stderr': _read_log(ffmpeg_log_path),
'audio_probe_bytes': os.path.getsize(sample_path) if os.path.exists(sample_path) else 0,
})
@receiver_bp.route('/audio/probe')
def audio_probe() -> Response:
"""Grab a small chunk of audio bytes from the pipeline for debugging."""
if _state.audio_source == 'waterfall':
try:
from routes.waterfall_websocket import read_shared_monitor_audio_chunk
data = read_shared_monitor_audio_chunk(timeout=2.0)
if not data:
return jsonify({'status': 'error', 'message': 'no shared audio data available'}), 504
sample_path = '/tmp/audio_probe.bin'
with open(sample_path, 'wb') as handle:
handle.write(data)
return jsonify({'status': 'ok', 'bytes': len(data), 'source': 'waterfall'})
except Exception as e:
return jsonify({'status': 'error', 'message': str(e)}), 500
if not _state.audio_process or not _state.audio_process.stdout:
return jsonify({'status': 'error', 'message': 'audio process not running'}), 400
sample_path = '/tmp/audio_probe.bin'
size = 0
try:
ready, _, _ = select.select([_state.audio_process.stdout], [], [], 2.0)
if not ready:
return jsonify({'status': 'error', 'message': 'no data available'}), 504
data = _state.audio_process.stdout.read(4096)
if not data:
return jsonify({'status': 'error', 'message': 'no data read'}), 504
with open(sample_path, 'wb') as handle:
handle.write(data)
size = len(data)
except Exception as e:
return jsonify({'status': 'error', 'message': str(e)}), 500
return jsonify({'status': 'ok', 'bytes': size})
@receiver_bp.route('/audio/stream')
def stream_audio() -> Response:
"""Stream WAV audio."""
request_token_raw = request.args.get('request_token')
request_token = None
if request_token_raw is not None:
try:
request_token = int(request_token_raw)
except (ValueError, TypeError):
request_token = None
if request_token is not None and request_token < _state.audio_start_token:
return Response(b'', mimetype='audio/wav', status=204)
if _state.audio_source == 'waterfall':
for _ in range(40):
if _state.audio_running:
break
time.sleep(0.05)
if not _state.audio_running:
return Response(b'', mimetype='audio/wav', status=204)
def generate_shared():
try:
from routes.waterfall_websocket import (
get_shared_capture_status,
read_shared_monitor_audio_chunk,
)
except Exception:
return
# Browser expects an immediate WAV header.
yield _wav_header(sample_rate=48000)
inactive_since: float | None = None
while _state.audio_running and _state.audio_source == 'waterfall':
if request_token is not None and request_token < _state.audio_start_token:
break
chunk = read_shared_monitor_audio_chunk(timeout=1.0)
if chunk:
inactive_since = None
yield chunk
continue
shared = get_shared_capture_status()
if shared.get('running') and shared.get('monitor_enabled'):
inactive_since = None
continue
if inactive_since is None:
inactive_since = time.monotonic()
continue
if (time.monotonic() - inactive_since) < 4.0:
continue
if not shared.get('running') or not shared.get('monitor_enabled'):
_state.audio_running = False
_state.audio_source = 'process'
break
return Response(
generate_shared(),
mimetype='audio/wav',
headers={
'Content-Type': 'audio/wav',
'Cache-Control': 'no-cache, no-store',
'X-Accel-Buffering': 'no',
'Transfer-Encoding': 'chunked',
}
)
# Wait for audio process to be ready (up to 2 seconds).
for _ in range(40):
if _state.audio_running and _state.audio_process:
break
time.sleep(0.05)
if not _state.audio_running or not _state.audio_process:
return Response(b'', mimetype='audio/wav', status=204)
def generate():
# Capture local reference to avoid race condition with stop
proc = _state.audio_process
if not proc or not proc.stdout:
return
try:
# Drain stale audio that accumulated in the pipe buffer
# between pipeline start and stream connection. Keep the
# first chunk (contains WAV header) and discard the rest
# so the browser starts close to real-time.
header_chunk = None
while True:
ready, _, _ = select.select([proc.stdout], [], [], 0)
if not ready:
break
chunk = proc.stdout.read(8192)
if not chunk:
break
if header_chunk is None:
header_chunk = chunk
if header_chunk:
yield header_chunk
# Stream real-time audio
first_chunk_deadline = time.time() + 20.0
warned_wait = False
while _state.audio_running and proc.poll() is None:
if request_token is not None and request_token < _state.audio_start_token:
break
# Use select to avoid blocking forever
ready, _, _ = select.select([proc.stdout], [], [], 2.0)
if ready:
chunk = proc.stdout.read(8192)
if chunk:
warned_wait = False
yield chunk
else:
break
else:
# Keep connection open while demodulator settles.
if time.time() > first_chunk_deadline:
if not warned_wait:
logger.warning("Audio stream still waiting for first chunk")
warned_wait = True
continue
# Timeout - check if process died
if proc.poll() is not None:
break
except GeneratorExit:
pass
except Exception as e:
logger.error(f"Audio stream error: {e}")
return Response(
generate(),
mimetype='audio/wav',
headers={
'Content-Type': 'audio/wav',
'Cache-Control': 'no-cache, no-store',
'X-Accel-Buffering': 'no',
'Transfer-Encoding': 'chunked',
}
)
+804
View File
@@ -0,0 +1,804 @@
"""Scanner routes and implementation for frequency scanning."""
from __future__ import annotations
import contextlib
import math
import queue
import struct
import subprocess
import threading
import time
from typing import Any
from flask import Response, jsonify, request
import routes.listening_post as _state
from . import (
SSE_KEEPALIVE_INTERVAL,
SSE_QUEUE_TIMEOUT,
_rtl_fm_demod_mode,
_start_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,
receiver_bp,
scanner_config,
scanner_lock,
scanner_queue,
sse_stream_fanout,
)
# ============================================
# SCANNER IMPLEMENTATION
# ============================================
def scanner_loop():
"""Main scanner loop - scans frequencies looking for signals."""
logger.info("Scanner thread started")
add_activity_log('scanner_start', scanner_config['start_freq'],
f"Scanning {scanner_config['start_freq']}-{scanner_config['end_freq']} MHz")
rtl_fm_path = find_rtl_fm()
if not rtl_fm_path:
logger.error("rtl_fm not found")
add_activity_log('error', 0, 'rtl_fm not found')
_state.scanner_running = False
return
current_freq = scanner_config['start_freq']
last_signal_time = 0
signal_detected = False
try:
while _state.scanner_running:
# Check if paused
if _state.scanner_paused:
time.sleep(0.1)
continue
# Read config values on each iteration (allows live updates)
step_mhz = scanner_config['step'] / 1000.0
squelch = scanner_config['squelch']
mod = scanner_config['modulation']
gain = scanner_config['gain']
device = scanner_config['device']
_state.scanner_current_freq = current_freq
# Notify clients of frequency change
with contextlib.suppress(queue.Full):
scanner_queue.put_nowait({
'type': 'freq_change',
'frequency': current_freq,
'scanning': not signal_detected,
'range_start': scanner_config['start_freq'],
'range_end': scanner_config['end_freq']
})
# Start rtl_fm at this frequency
freq_hz = int(current_freq * 1e6)
# Sample rates
if mod == 'wfm':
sample_rate = 170000
resample_rate = 32000
elif mod in ['usb', 'lsb']:
sample_rate = 12000
resample_rate = 12000
else:
sample_rate = 24000
resample_rate = 24000
# Don't use squelch in rtl_fm - we want to analyze raw audio
rtl_cmd = [
rtl_fm_path,
'-M', _rtl_fm_demod_mode(mod),
'-f', str(freq_hz),
'-s', str(sample_rate),
'-r', str(resample_rate),
'-g', str(gain),
'-d', str(device),
]
# Add bias-t flag if enabled (for external LNA power)
if scanner_config.get('bias_t', False):
rtl_cmd.append('-T')
try:
# Start rtl_fm
rtl_proc = subprocess.Popen(
rtl_cmd,
stdout=subprocess.PIPE,
stderr=subprocess.DEVNULL
)
# Read audio data for analysis
audio_data = b''
# Read audio samples for a short period
sample_duration = 0.25 # 250ms - balance between speed and detection
bytes_needed = int(resample_rate * 2 * sample_duration) # 16-bit mono
while len(audio_data) < bytes_needed and _state.scanner_running:
chunk = rtl_proc.stdout.read(4096)
if not chunk:
break
audio_data += chunk
# Clean up rtl_fm
rtl_proc.terminate()
try:
rtl_proc.wait(timeout=1)
except subprocess.TimeoutExpired:
rtl_proc.kill()
# Analyze audio level
audio_detected = False
rms = 0
threshold = 500
if len(audio_data) > 100:
samples = struct.unpack(f'{len(audio_data)//2}h', audio_data)
# Calculate RMS level (root mean square)
rms = (sum(s*s for s in samples) / len(samples)) ** 0.5
# Threshold based on squelch setting
# Lower squelch = more sensitive (lower threshold)
# squelch 0 = very sensitive, squelch 100 = only strong signals
if mod == 'wfm':
# WFM: threshold 500-10000 based on squelch
threshold = 500 + (squelch * 95)
min_threshold = 1500
else:
# AM/NFM: threshold 300-6500 based on squelch
threshold = 300 + (squelch * 62)
min_threshold = 900
effective_threshold = max(threshold, min_threshold)
audio_detected = rms > effective_threshold
# Send level info to clients
with contextlib.suppress(queue.Full):
scanner_queue.put_nowait({
'type': 'scan_update',
'frequency': current_freq,
'level': int(rms),
'threshold': int(effective_threshold) if 'effective_threshold' in dir() else 0,
'detected': audio_detected,
'range_start': scanner_config['start_freq'],
'range_end': scanner_config['end_freq']
})
if audio_detected and _state.scanner_running:
if not signal_detected:
# New signal found!
signal_detected = True
last_signal_time = time.time()
add_activity_log('signal_found', current_freq,
f'Signal detected on {current_freq:.3f} MHz ({mod.upper()})')
logger.info(f"Signal found at {current_freq} MHz")
# Start audio streaming for user
_start_audio_stream(current_freq, mod)
try:
snr_db = round(10 * math.log10(rms / effective_threshold), 1) if rms > 0 and effective_threshold > 0 else 0.0
scanner_queue.put_nowait({
'type': 'signal_found',
'frequency': current_freq,
'modulation': mod,
'audio_streaming': True,
'level': int(rms),
'threshold': int(effective_threshold),
'snr': snr_db,
'range_start': scanner_config['start_freq'],
'range_end': scanner_config['end_freq']
})
except queue.Full:
pass
# Check for skip signal
if _state.scanner_skip_signal:
_state.scanner_skip_signal = False
signal_detected = False
_stop_audio_stream()
with contextlib.suppress(queue.Full):
scanner_queue.put_nowait({
'type': 'signal_skipped',
'frequency': current_freq
})
# Move to next frequency (step is in kHz, convert to MHz)
current_freq += step_mhz
if current_freq > scanner_config['end_freq']:
current_freq = scanner_config['start_freq']
continue
# Stay on this frequency (dwell) but check periodically
dwell_start = time.time()
while (time.time() - dwell_start) < scanner_config['dwell_time'] and _state.scanner_running:
if _state.scanner_skip_signal:
break
time.sleep(0.2)
last_signal_time = time.time()
# After dwell, move on to keep scanning
if _state.scanner_running and not _state.scanner_skip_signal:
signal_detected = False
_stop_audio_stream()
with contextlib.suppress(queue.Full):
scanner_queue.put_nowait({
'type': 'signal_lost',
'frequency': current_freq,
'range_start': scanner_config['start_freq'],
'range_end': scanner_config['end_freq']
})
current_freq += step_mhz
if current_freq > scanner_config['end_freq']:
current_freq = scanner_config['start_freq']
add_activity_log('scan_cycle', current_freq, 'Scan cycle complete')
time.sleep(scanner_config['scan_delay'])
else:
# No signal at this frequency
if signal_detected:
# Signal lost
duration = time.time() - last_signal_time + scanner_config['dwell_time']
add_activity_log('signal_lost', current_freq,
f'Signal lost after {duration:.1f}s')
signal_detected = False
# Stop audio
_stop_audio_stream()
with contextlib.suppress(queue.Full):
scanner_queue.put_nowait({
'type': 'signal_lost',
'frequency': current_freq
})
# Move to next frequency (step is in kHz, convert to MHz)
current_freq += step_mhz
if current_freq > scanner_config['end_freq']:
current_freq = scanner_config['start_freq']
add_activity_log('scan_cycle', current_freq, 'Scan cycle complete')
time.sleep(scanner_config['scan_delay'])
except Exception as e:
logger.error(f"Scanner error at {current_freq} MHz: {e}")
time.sleep(0.5)
except Exception as e:
logger.error(f"Scanner loop error: {e}")
finally:
_state.scanner_running = False
_stop_audio_stream()
add_activity_log('scanner_stop', _state.scanner_current_freq, 'Scanner stopped')
logger.info("Scanner thread stopped")
def scanner_loop_power():
"""Power sweep scanner using rtl_power to detect peaks."""
logger.info("Power sweep scanner thread started")
add_activity_log('scanner_start', scanner_config['start_freq'],
f"Power sweep {scanner_config['start_freq']}-{scanner_config['end_freq']} MHz")
rtl_power_path = find_rtl_power()
if not rtl_power_path:
logger.error("rtl_power not found")
add_activity_log('error', 0, 'rtl_power not found')
_state.scanner_running = False
return
try:
while _state.scanner_running:
if _state.scanner_paused:
time.sleep(0.1)
continue
start_mhz = scanner_config['start_freq']
end_mhz = scanner_config['end_freq']
step_khz = scanner_config['step']
gain = scanner_config['gain']
device = scanner_config['device']
scanner_config['squelch']
mod = scanner_config['modulation']
# Configure sweep
bin_hz = max(1000, int(step_khz * 1000))
start_hz = int(start_mhz * 1e6)
end_hz = int(end_mhz * 1e6)
# Integration time per sweep (seconds)
integration = max(0.3, min(1.0, scanner_config.get('scan_delay', 0.5)))
cmd = [
rtl_power_path,
'-f', f'{start_hz}:{end_hz}:{bin_hz}',
'-i', f'{integration}',
'-1',
'-g', str(gain),
'-d', str(device),
]
try:
proc = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.DEVNULL)
_state.scanner_power_process = proc
stdout, _ = proc.communicate(timeout=15)
except subprocess.TimeoutExpired:
proc.kill()
stdout = b''
finally:
_state.scanner_power_process = None
if not _state.scanner_running:
break
if not stdout:
add_activity_log('error', start_mhz, 'Power sweep produced no data')
with contextlib.suppress(queue.Full):
scanner_queue.put_nowait({
'type': 'scan_update',
'frequency': end_mhz,
'level': 0,
'threshold': int(float(scanner_config.get('snr_threshold', 12)) * 100),
'detected': False,
'range_start': scanner_config['start_freq'],
'range_end': scanner_config['end_freq']
})
time.sleep(0.2)
continue
lines = stdout.decode(errors='ignore').splitlines()
segments = []
for line in lines:
if not line or line.startswith('#'):
continue
parts = [p.strip() for p in line.split(',')]
# Find start_hz token
start_idx = None
for i, tok in enumerate(parts):
try:
val = float(tok)
except ValueError:
continue
if val > 1e5:
start_idx = i
break
if start_idx is None or len(parts) < start_idx + 6:
continue
try:
sweep_start = float(parts[start_idx])
sweep_end = float(parts[start_idx + 1])
sweep_bin = float(parts[start_idx + 2])
raw_values = []
for v in parts[start_idx + 3:]:
try:
raw_values.append(float(v))
except ValueError:
continue
# rtl_power may include a samples field before the power list
if raw_values and raw_values[0] >= 0 and any(val < 0 for val in raw_values[1:]):
raw_values = raw_values[1:]
bin_values = raw_values
except ValueError:
continue
if not bin_values:
continue
segments.append((sweep_start, sweep_end, sweep_bin, bin_values))
if not segments:
add_activity_log('error', start_mhz, 'Power sweep bins missing')
with contextlib.suppress(queue.Full):
scanner_queue.put_nowait({
'type': 'scan_update',
'frequency': end_mhz,
'level': 0,
'threshold': int(float(scanner_config.get('snr_threshold', 12)) * 100),
'detected': False,
'range_start': scanner_config['start_freq'],
'range_end': scanner_config['end_freq']
})
time.sleep(0.2)
continue
# Process segments in ascending frequency order to avoid backtracking in UI
segments.sort(key=lambda s: s[0])
total_bins = sum(len(seg[3]) for seg in segments)
if total_bins <= 0:
time.sleep(0.2)
continue
segment_offset = 0
for sweep_start, sweep_end, sweep_bin, bin_values in segments:
# Noise floor (median)
sorted_vals = sorted(bin_values)
mid = len(sorted_vals) // 2
noise_floor = sorted_vals[mid]
# SNR threshold (dB)
snr_threshold = float(scanner_config.get('snr_threshold', 12))
# Emit progress updates (throttled)
emit_stride = max(1, len(bin_values) // 60)
for idx, val in enumerate(bin_values):
if idx % emit_stride != 0 and idx != len(bin_values) - 1:
continue
freq_hz = sweep_start + sweep_bin * idx
_state.scanner_current_freq = freq_hz / 1e6
snr = val - noise_floor
level = int(max(0, snr) * 100)
threshold = int(snr_threshold * 100)
progress = min(1.0, (segment_offset + idx) / max(1, total_bins - 1))
with contextlib.suppress(queue.Full):
scanner_queue.put_nowait({
'type': 'scan_update',
'frequency': _state.scanner_current_freq,
'level': level,
'threshold': threshold,
'detected': snr >= snr_threshold,
'progress': progress,
'range_start': scanner_config['start_freq'],
'range_end': scanner_config['end_freq']
})
segment_offset += len(bin_values)
# Detect peaks (clusters above threshold)
peaks = []
in_cluster = False
peak_idx = None
peak_val = None
for idx, val in enumerate(bin_values):
snr = val - noise_floor
if snr >= snr_threshold:
if not in_cluster:
in_cluster = True
peak_idx = idx
peak_val = val
else:
if val > peak_val:
peak_val = val
peak_idx = idx
else:
if in_cluster and peak_idx is not None:
peaks.append((peak_idx, peak_val))
in_cluster = False
peak_idx = None
peak_val = None
if in_cluster and peak_idx is not None:
peaks.append((peak_idx, peak_val))
for idx, val in peaks:
freq_hz = sweep_start + sweep_bin * (idx + 0.5)
freq_mhz = freq_hz / 1e6
snr = val - noise_floor
level = int(max(0, snr) * 100)
threshold = int(snr_threshold * 100)
add_activity_log('signal_found', freq_mhz,
f'Peak detected at {freq_mhz:.3f} MHz ({mod.upper()})')
with contextlib.suppress(queue.Full):
scanner_queue.put_nowait({
'type': 'signal_found',
'frequency': freq_mhz,
'modulation': mod,
'audio_streaming': False,
'level': level,
'threshold': threshold,
'snr': round(snr, 1),
'range_start': scanner_config['start_freq'],
'range_end': scanner_config['end_freq']
})
add_activity_log('scan_cycle', start_mhz, 'Power sweep complete')
time.sleep(max(0.1, scanner_config.get('scan_delay', 0.5)))
except Exception as e:
logger.error(f"Power sweep scanner error: {e}")
finally:
_state.scanner_running = False
add_activity_log('scanner_stop', _state.scanner_current_freq, 'Scanner stopped')
logger.info("Power sweep scanner thread stopped")
# ============================================
# SCANNER API ENDPOINTS
# ============================================
@receiver_bp.route('/scanner/start', methods=['POST'])
def start_scanner() -> Response:
"""Start the frequency scanner."""
with scanner_lock:
if _state.scanner_running:
return jsonify({
'status': 'error',
'message': 'Scanner already running'
}), 409
# Clear stale queue entries so UI updates immediately
try:
while True:
scanner_queue.get_nowait()
except queue.Empty:
pass
data = request.json or {}
# Update scanner config
try:
scanner_config['start_freq'] = float(data.get('start_freq', 88.0))
scanner_config['end_freq'] = float(data.get('end_freq', 108.0))
scanner_config['step'] = float(data.get('step', 0.1))
scanner_config['modulation'] = normalize_modulation(data.get('modulation', 'wfm'))
scanner_config['squelch'] = int(data.get('squelch', 0))
scanner_config['dwell_time'] = float(data.get('dwell_time', 3.0))
scanner_config['scan_delay'] = float(data.get('scan_delay', 0.5))
scanner_config['device'] = int(data.get('device', 0))
scanner_config['gain'] = int(data.get('gain', 40))
scanner_config['bias_t'] = bool(data.get('bias_t', False))
scanner_config['sdr_type'] = str(data.get('sdr_type', 'rtlsdr')).lower()
scanner_config['scan_method'] = str(data.get('scan_method', '')).lower().strip()
if data.get('snr_threshold') is not None:
scanner_config['snr_threshold'] = float(data.get('snr_threshold'))
except (ValueError, TypeError) as e:
return jsonify({
'status': 'error',
'message': f'Invalid parameter: {e}'
}), 400
# Validate
if scanner_config['start_freq'] >= scanner_config['end_freq']:
return jsonify({
'status': 'error',
'message': 'start_freq must be less than end_freq'
}), 400
# Decide scan method
if not scanner_config['scan_method']:
scanner_config['scan_method'] = 'power' if find_rtl_power() else 'classic'
sdr_type = scanner_config['sdr_type']
# Power scan only supports RTL-SDR for now
if scanner_config['scan_method'] == 'power' and (sdr_type != 'rtlsdr' or not find_rtl_power()):
scanner_config['scan_method'] = 'classic'
# Check tools based on chosen method
if scanner_config['scan_method'] == 'power':
if not find_rtl_power():
return jsonify({
'status': 'error',
'message': 'rtl_power not found. Install rtl-sdr tools.'
}), 503
# Release listening device if active
if _state.receiver_active_device is not None:
app_module.release_sdr_device(_state.receiver_active_device, _state.receiver_active_sdr_type)
_state.receiver_active_device = None
_state.receiver_active_sdr_type = 'rtlsdr'
# Claim device for scanner
error = app_module.claim_sdr_device(scanner_config['device'], 'scanner', scanner_config['sdr_type'])
if error:
return jsonify({
'status': 'error',
'error_type': 'DEVICE_BUSY',
'message': error
}), 409
_state.scanner_active_device = scanner_config['device']
_state.scanner_active_sdr_type = scanner_config['sdr_type']
_state.scanner_running = True
_state.scanner_thread = threading.Thread(target=scanner_loop_power, daemon=True)
_state.scanner_thread.start()
else:
if sdr_type == 'rtlsdr':
if not find_rtl_fm():
return jsonify({
'status': 'error',
'message': 'rtl_fm not found. Install rtl-sdr tools.'
}), 503
else:
if not find_rx_fm():
return jsonify({
'status': 'error',
'message': f'rx_fm not found. Install SoapySDR utilities for {sdr_type}.'
}), 503
if _state.receiver_active_device is not None:
app_module.release_sdr_device(_state.receiver_active_device, _state.receiver_active_sdr_type)
_state.receiver_active_device = None
_state.receiver_active_sdr_type = 'rtlsdr'
error = app_module.claim_sdr_device(scanner_config['device'], 'scanner', scanner_config['sdr_type'])
if error:
return jsonify({
'status': 'error',
'error_type': 'DEVICE_BUSY',
'message': error
}), 409
_state.scanner_active_device = scanner_config['device']
_state.scanner_active_sdr_type = scanner_config['sdr_type']
_state.scanner_running = True
_state.scanner_thread = threading.Thread(target=scanner_loop, daemon=True)
_state.scanner_thread.start()
return jsonify({
'status': 'started',
'config': scanner_config
})
@receiver_bp.route('/scanner/stop', methods=['POST'])
def stop_scanner() -> Response:
"""Stop the frequency scanner."""
_state.scanner_running = False
_stop_audio_stream()
if _state.scanner_power_process and _state.scanner_power_process.poll() is None:
try:
_state.scanner_power_process.terminate()
_state.scanner_power_process.wait(timeout=1)
except Exception:
with contextlib.suppress(Exception):
_state.scanner_power_process.kill()
_state.scanner_power_process = None
if _state.scanner_active_device is not None:
app_module.release_sdr_device(_state.scanner_active_device, _state.scanner_active_sdr_type)
_state.scanner_active_device = None
_state.scanner_active_sdr_type = 'rtlsdr'
return jsonify({'status': 'stopped'})
@receiver_bp.route('/scanner/pause', methods=['POST'])
def pause_scanner() -> Response:
"""Pause/resume the scanner."""
_state.scanner_paused = not _state.scanner_paused
if _state.scanner_paused:
add_activity_log('scanner_pause', _state.scanner_current_freq, 'Scanner paused')
else:
add_activity_log('scanner_resume', _state.scanner_current_freq, 'Scanner resumed')
return jsonify({
'status': 'paused' if _state.scanner_paused else 'resumed',
'paused': _state.scanner_paused
})
@receiver_bp.route('/scanner/skip', methods=['POST'])
def skip_signal() -> Response:
"""Skip current signal and continue scanning."""
if not _state.scanner_running:
return jsonify({
'status': 'error',
'message': 'Scanner not running'
}), 400
_state.scanner_skip_signal = True
add_activity_log('signal_skip', _state.scanner_current_freq, f'Skipped signal at {_state.scanner_current_freq:.3f} MHz')
return jsonify({
'status': 'skipped',
'frequency': _state.scanner_current_freq
})
@receiver_bp.route('/scanner/config', methods=['POST'])
def update_scanner_config() -> Response:
"""Update scanner config while running (step, squelch, gain, dwell)."""
data = request.json or {}
updated = []
if 'step' in data:
scanner_config['step'] = float(data['step'])
updated.append(f"step={data['step']}kHz")
if 'squelch' in data:
scanner_config['squelch'] = int(data['squelch'])
updated.append(f"squelch={data['squelch']}")
if 'gain' in data:
scanner_config['gain'] = int(data['gain'])
updated.append(f"gain={data['gain']}")
if 'dwell_time' in data:
scanner_config['dwell_time'] = int(data['dwell_time'])
updated.append(f"dwell={data['dwell_time']}s")
if 'modulation' in data:
try:
scanner_config['modulation'] = normalize_modulation(data['modulation'])
updated.append(f"mod={data['modulation']}")
except (ValueError, TypeError) as e:
return jsonify({
'status': 'error',
'message': str(e)
}), 400
if updated:
logger.info(f"Scanner config updated: {', '.join(updated)}")
return jsonify({
'status': 'updated',
'config': scanner_config
})
@receiver_bp.route('/scanner/status')
def scanner_status() -> Response:
"""Get scanner status."""
return jsonify({
'running': _state.scanner_running,
'paused': _state.scanner_paused,
'current_freq': _state.scanner_current_freq,
'config': scanner_config,
'audio_streaming': _state.audio_running,
'audio_frequency': _state.audio_frequency
})
@receiver_bp.route('/scanner/stream')
def stream_scanner_events() -> Response:
"""SSE stream for scanner events."""
def _on_msg(msg: dict[str, Any]) -> None:
process_event('receiver_scanner', msg, msg.get('type'))
response = Response(
sse_stream_fanout(
source_queue=scanner_queue,
channel_key='receiver_scanner',
timeout=SSE_QUEUE_TIMEOUT,
keepalive_interval=SSE_KEEPALIVE_INTERVAL,
on_message=_on_msg,
),
mimetype='text/event-stream',
)
response.headers['Cache-Control'] = 'no-cache'
response.headers['X-Accel-Buffering'] = 'no'
return response
@receiver_bp.route('/scanner/log')
def get_activity_log() -> Response:
"""Get activity log."""
limit = request.args.get('limit', 100, type=int)
with activity_log_lock:
return jsonify({
'log': activity_log[:limit],
'total': len(activity_log)
})
@receiver_bp.route('/scanner/log/clear', methods=['POST'])
def clear_activity_log() -> Response:
"""Clear activity log."""
with activity_log_lock:
activity_log.clear()
return jsonify({'status': 'cleared'})
@receiver_bp.route('/presets')
def get_presets() -> Response:
"""Get scanner presets."""
presets = [
{'name': 'FM Broadcast', 'start': 88.0, 'end': 108.0, 'step': 0.2, 'mod': 'wfm'},
{'name': 'Air Band', 'start': 118.0, 'end': 137.0, 'step': 0.025, 'mod': 'am'},
{'name': 'Marine VHF', 'start': 156.0, 'end': 163.0, 'step': 0.025, 'mod': 'fm'},
{'name': 'Amateur 2m', 'start': 144.0, 'end': 148.0, 'step': 0.0125, 'mod': 'fm'},
{'name': 'Amateur 70cm', 'start': 430.0, 'end': 440.0, 'step': 0.025, 'mod': 'fm'},
{'name': 'PMR446', 'start': 446.0, 'end': 446.2, 'step': 0.0125, 'mod': 'fm'},
{'name': 'FRS/GMRS', 'start': 462.5, 'end': 467.7, 'step': 0.025, 'mod': 'fm'},
{'name': 'Weather Radio', 'start': 162.4, 'end': 162.55, 'step': 0.025, 'mod': 'fm'},
]
return jsonify({'presets': presets})
+90
View File
@@ -0,0 +1,90 @@
"""Tool check and signal identification routes."""
from __future__ import annotations
from flask import Response, jsonify, request
from . import (
find_ffmpeg,
find_rtl_fm,
find_rtl_power,
find_rx_fm,
logger,
receiver_bp,
)
# ============================================
# TOOL CHECK ENDPOINT
# ============================================
@receiver_bp.route('/tools')
def check_tools() -> Response:
"""Check for required tools."""
rtl_fm = find_rtl_fm()
rtl_power = find_rtl_power()
rx_fm = find_rx_fm()
ffmpeg = find_ffmpeg()
# Determine which SDR types are supported
supported_sdr_types = []
if rtl_fm:
supported_sdr_types.append('rtlsdr')
if rx_fm:
# rx_fm from SoapySDR supports these types
supported_sdr_types.extend(['hackrf', 'airspy', 'limesdr', 'sdrplay'])
return jsonify({
'rtl_fm': rtl_fm is not None,
'rtl_power': rtl_power is not None,
'rx_fm': rx_fm is not None,
'ffmpeg': ffmpeg is not None,
'available': (rtl_fm is not None or rx_fm is not None) and ffmpeg is not None,
'supported_sdr_types': supported_sdr_types
})
# ============================================
# SIGNAL IDENTIFICATION ENDPOINT
# ============================================
@receiver_bp.route('/signal/guess', methods=['POST'])
def guess_signal() -> Response:
"""Identify a signal based on frequency, modulation, and other parameters."""
data = request.json or {}
freq_mhz = data.get('frequency_mhz')
if freq_mhz is None:
return jsonify({'status': 'error', 'message': 'frequency_mhz is required'}), 400
try:
freq_mhz = float(freq_mhz)
except (ValueError, TypeError):
return jsonify({'status': 'error', 'message': 'Invalid frequency_mhz'}), 400
if freq_mhz <= 0:
return jsonify({'status': 'error', 'message': 'frequency_mhz must be positive'}), 400
frequency_hz = int(freq_mhz * 1e6)
modulation = data.get('modulation')
bandwidth_hz = data.get('bandwidth_hz')
if bandwidth_hz is not None:
try:
bandwidth_hz = int(bandwidth_hz)
except (ValueError, TypeError):
bandwidth_hz = None
region = data.get('region', 'UK/EU')
try:
from utils.signal_guess import guess_signal_type_dict
result = guess_signal_type_dict(
frequency_hz=frequency_hz,
modulation=modulation,
bandwidth_hz=bandwidth_hz,
region=region,
)
return jsonify({'status': 'ok', **result})
except Exception as e:
logger.error(f"Signal guess error: {e}")
return jsonify({'status': 'error', 'message': str(e)}), 500
+493
View File
@@ -0,0 +1,493 @@
"""Waterfall / spectrogram routes and implementation."""
from __future__ import annotations
import contextlib
import math
import queue
import struct
import subprocess
import threading
import time
from datetime import datetime
from typing import Any
from flask import Response, jsonify, request
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
# ============================================
def _parse_rtl_power_line(line: str) -> tuple[str | None, float | None, float | None, list[float]]:
"""Parse a single rtl_power CSV line into bins."""
if not line or line.startswith('#'):
return None, None, None, []
parts = [p.strip() for p in line.split(',')]
if len(parts) < 6:
return None, None, None, []
# Timestamp in first two fields (YYYY-MM-DD, HH:MM:SS)
timestamp = f"{parts[0]} {parts[1]}" if len(parts) >= 2 else parts[0]
start_idx = None
for i, tok in enumerate(parts):
try:
val = float(tok)
except ValueError:
continue
if val > 1e5:
start_idx = i
break
if start_idx is None or len(parts) < start_idx + 4:
return timestamp, None, None, []
try:
seg_start = float(parts[start_idx])
seg_end = float(parts[start_idx + 1])
raw_values = []
for v in parts[start_idx + 3:]:
try:
raw_values.append(float(v))
except ValueError:
continue
if raw_values and raw_values[0] >= 0 and any(val < 0 for val in raw_values[1:]):
raw_values = raw_values[1:]
return timestamp, seg_start, seg_end, raw_values
except ValueError:
return timestamp, None, None, []
def _queue_waterfall_error(message: str) -> None:
"""Push an error message onto the waterfall SSE queue."""
with contextlib.suppress(queue.Full):
_state.waterfall_queue.put_nowait({
'type': 'waterfall_error',
'message': message,
'timestamp': datetime.now().isoformat(),
})
def _downsample_bins(values: list[float], target: int) -> list[float]:
"""Downsample bins to a target length using simple averaging."""
if target <= 0 or len(values) <= target:
return values
out: list[float] = []
step = len(values) / target
for i in range(target):
start = int(i * step)
end = int((i + 1) * step)
if end <= start:
end = min(start + 1, len(values))
chunk = values[start:end]
if not chunk:
continue
out.append(sum(chunk) / len(chunk))
return out
# ============================================
# WATERFALL LOOP IMPLEMENTATIONS
# ============================================
def _waterfall_loop():
"""Continuous waterfall sweep loop emitting FFT data."""
sdr_type_str = _state.waterfall_config.get('sdr_type', 'rtlsdr')
try:
sdr_type = SDRType(sdr_type_str)
except ValueError:
sdr_type = SDRType.RTL_SDR
if sdr_type == SDRType.RTL_SDR:
_waterfall_loop_rtl_power()
else:
_waterfall_loop_iq(sdr_type)
def _waterfall_loop_iq(sdr_type: SDRType):
"""Waterfall loop using rx_sdr IQ capture + FFT for HackRF/SoapySDR devices."""
start_freq = _state.waterfall_config['start_freq']
end_freq = _state.waterfall_config['end_freq']
gain = _state.waterfall_config['gain']
device = _state.waterfall_config['device']
interval = float(_state.waterfall_config.get('interval', 0.4))
# Use center frequency and sample rate to cover the requested span
center_mhz = (start_freq + end_freq) / 2.0
span_hz = (end_freq - start_freq) * 1e6
# Pick a sample rate that covers the span (minimum 2 MHz for HackRF)
sample_rate = max(2000000, int(span_hz))
# Cap to sensible maximum
sample_rate = min(sample_rate, 20000000)
sdr_device = SDRFactory.create_default_device(sdr_type, index=device)
builder = SDRFactory.get_builder(sdr_type)
cmd = builder.build_iq_capture_command(
device=sdr_device,
frequency_mhz=center_mhz,
sample_rate=sample_rate,
gain=float(gain),
)
fft_size = min(int(_state.waterfall_config.get('max_bins') or 1024), 4096)
try:
_state.waterfall_process = subprocess.Popen(
cmd,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
)
# Detect immediate startup failures
time.sleep(0.35)
if _state.waterfall_process.poll() is not None:
stderr_text = ''
try:
if _state.waterfall_process.stderr:
stderr_text = _state.waterfall_process.stderr.read().decode('utf-8', errors='ignore').strip()
except Exception:
stderr_text = ''
msg = stderr_text or f'IQ capture exited early (code {_state.waterfall_process.returncode})'
logger.error(f"Waterfall startup failed: {msg}")
_queue_waterfall_error(msg)
return
if not _state.waterfall_process.stdout:
_queue_waterfall_error('IQ capture stdout unavailable')
return
# Read IQ samples and compute FFT
# CU8 format: interleaved unsigned 8-bit I/Q pairs
bytes_per_sample = 2 # 1 byte I + 1 byte Q
chunk_bytes = fft_size * bytes_per_sample
received_any = False
while _state.waterfall_running:
raw = _state.waterfall_process.stdout.read(chunk_bytes)
if not raw or len(raw) < chunk_bytes:
if _state.waterfall_process.poll() is not None:
break
continue
received_any = True
# Convert CU8 to complex float: center at 127.5
iq = struct.unpack(f'{fft_size * 2}B', raw)
# Compute power spectrum via FFT
real_parts = [(iq[i * 2] - 127.5) / 127.5 for i in range(fft_size)]
imag_parts = [(iq[i * 2 + 1] - 127.5) / 127.5 for i in range(fft_size)]
bins: list[float] = []
try:
# Try numpy if available for efficient FFT
import numpy as np
samples = np.array(real_parts, dtype=np.float32) + 1j * np.array(imag_parts, dtype=np.float32)
# Apply Hann window
window = np.hanning(fft_size)
samples *= window
spectrum = np.fft.fftshift(np.fft.fft(samples))
power_db = 10.0 * np.log10(np.abs(spectrum) ** 2 + 1e-10)
bins = power_db.tolist()
except ImportError:
# Fallback: compute magnitude without full FFT
# Just report raw magnitudes per sample as approximate power
for i in range(fft_size):
mag = math.sqrt(real_parts[i] ** 2 + imag_parts[i] ** 2)
power = 10.0 * math.log10(mag ** 2 + 1e-10)
bins.append(power)
max_bins = int(_state.waterfall_config.get('max_bins') or 0)
if max_bins > 0 and len(bins) > max_bins:
bins = _downsample_bins(bins, max_bins)
msg = {
'type': 'waterfall_sweep',
'start_freq': start_freq,
'end_freq': end_freq,
'bins': bins,
'timestamp': datetime.now().isoformat(),
}
try:
_state.waterfall_queue.put_nowait(msg)
except queue.Full:
with contextlib.suppress(queue.Empty):
_state.waterfall_queue.get_nowait()
with contextlib.suppress(queue.Full):
_state.waterfall_queue.put_nowait(msg)
# Throttle to respect interval
time.sleep(interval)
if _state.waterfall_running and not received_any:
_queue_waterfall_error(f'No IQ data received from {sdr_type.value}')
except Exception as e:
logger.error(f"Waterfall IQ loop error: {e}")
_queue_waterfall_error(f"Waterfall loop error: {e}")
finally:
_state.waterfall_running = False
if _state.waterfall_process and _state.waterfall_process.poll() is None:
try:
_state.waterfall_process.terminate()
_state.waterfall_process.wait(timeout=1)
except Exception:
with contextlib.suppress(Exception):
_state.waterfall_process.kill()
_state.waterfall_process = None
logger.info("Waterfall IQ loop stopped")
def _waterfall_loop_rtl_power():
"""Continuous rtl_power sweep loop emitting waterfall data."""
rtl_power_path = find_rtl_power()
if not rtl_power_path:
logger.error("rtl_power not found for waterfall")
_queue_waterfall_error('rtl_power not found')
_state.waterfall_running = False
return
start_hz = int(_state.waterfall_config['start_freq'] * 1e6)
end_hz = int(_state.waterfall_config['end_freq'] * 1e6)
bin_hz = int(_state.waterfall_config['bin_size'])
gain = _state.waterfall_config['gain']
device = _state.waterfall_config['device']
interval = float(_state.waterfall_config.get('interval', 0.4))
cmd = [
rtl_power_path,
'-f', f'{start_hz}:{end_hz}:{bin_hz}',
'-i', str(interval),
'-g', str(gain),
'-d', str(device),
]
try:
_state.waterfall_process = subprocess.Popen(
cmd,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
bufsize=1,
text=True,
)
# Detect immediate startup failures (e.g. device busy / no device).
time.sleep(0.35)
if _state.waterfall_process.poll() is not None:
stderr_text = ''
try:
if _state.waterfall_process.stderr:
stderr_text = _state.waterfall_process.stderr.read().strip()
except Exception:
stderr_text = ''
msg = stderr_text or f'rtl_power exited early (code {_state.waterfall_process.returncode})'
logger.error(f"Waterfall startup failed: {msg}")
_queue_waterfall_error(msg)
return
current_ts = None
all_bins: list[float] = []
sweep_start_hz = start_hz
sweep_end_hz = end_hz
received_any = False
if not _state.waterfall_process.stdout:
_queue_waterfall_error('rtl_power stdout unavailable')
return
for line in _state.waterfall_process.stdout:
if not _state.waterfall_running:
break
ts, seg_start, seg_end, bins = _parse_rtl_power_line(line)
if ts is None or not bins:
continue
received_any = True
if current_ts is None:
current_ts = ts
if ts != current_ts and all_bins:
max_bins = int(_state.waterfall_config.get('max_bins') or 0)
bins_to_send = all_bins
if max_bins > 0 and len(bins_to_send) > max_bins:
bins_to_send = _downsample_bins(bins_to_send, max_bins)
msg = {
'type': 'waterfall_sweep',
'start_freq': sweep_start_hz / 1e6,
'end_freq': sweep_end_hz / 1e6,
'bins': bins_to_send,
'timestamp': datetime.now().isoformat(),
}
try:
_state.waterfall_queue.put_nowait(msg)
except queue.Full:
with contextlib.suppress(queue.Empty):
_state.waterfall_queue.get_nowait()
with contextlib.suppress(queue.Full):
_state.waterfall_queue.put_nowait(msg)
all_bins = []
sweep_start_hz = start_hz
sweep_end_hz = end_hz
current_ts = ts
all_bins.extend(bins)
if seg_start is not None:
sweep_start_hz = min(sweep_start_hz, seg_start)
if seg_end is not None:
sweep_end_hz = max(sweep_end_hz, seg_end)
# Flush any remaining bins
if all_bins and _state.waterfall_running:
max_bins = int(_state.waterfall_config.get('max_bins') or 0)
bins_to_send = all_bins
if max_bins > 0 and len(bins_to_send) > max_bins:
bins_to_send = _downsample_bins(bins_to_send, max_bins)
msg = {
'type': 'waterfall_sweep',
'start_freq': sweep_start_hz / 1e6,
'end_freq': sweep_end_hz / 1e6,
'bins': bins_to_send,
'timestamp': datetime.now().isoformat(),
}
with contextlib.suppress(queue.Full):
_state.waterfall_queue.put_nowait(msg)
if _state.waterfall_running and not received_any:
_queue_waterfall_error('No waterfall FFT data received from rtl_power')
except Exception as e:
logger.error(f"Waterfall loop error: {e}")
_queue_waterfall_error(f"Waterfall loop error: {e}")
finally:
_state.waterfall_running = False
if _state.waterfall_process and _state.waterfall_process.poll() is None:
try:
_state.waterfall_process.terminate()
_state.waterfall_process.wait(timeout=1)
except Exception:
with contextlib.suppress(Exception):
_state.waterfall_process.kill()
_state.waterfall_process = None
logger.info("Waterfall loop stopped")
# ============================================
# WATERFALL API ENDPOINTS
# ============================================
@receiver_bp.route('/waterfall/start', methods=['POST'])
def start_waterfall() -> Response:
"""Start the waterfall/spectrogram display."""
with _state.waterfall_lock:
if _state.waterfall_running:
return jsonify({
'status': 'started',
'already_running': True,
'message': 'Waterfall already running',
'config': _state.waterfall_config,
})
data = request.json or {}
# Determine SDR type
sdr_type_str = data.get('sdr_type', 'rtlsdr')
try:
sdr_type = SDRType(sdr_type_str)
except ValueError:
sdr_type = SDRType.RTL_SDR
sdr_type_str = sdr_type.value
# RTL-SDR uses rtl_power; other types use rx_sdr via IQ capture
if sdr_type == SDRType.RTL_SDR and not find_rtl_power():
return jsonify({'status': 'error', 'message': 'rtl_power not found'}), 503
try:
_state.waterfall_config['start_freq'] = float(data.get('start_freq', 88.0))
_state.waterfall_config['end_freq'] = float(data.get('end_freq', 108.0))
_state.waterfall_config['bin_size'] = int(data.get('bin_size', 10000))
_state.waterfall_config['gain'] = int(data.get('gain', 40))
_state.waterfall_config['device'] = int(data.get('device', 0))
_state.waterfall_config['sdr_type'] = sdr_type_str
if data.get('interval') is not None:
interval = float(data.get('interval', _state.waterfall_config['interval']))
if interval < 0.1 or interval > 5:
return jsonify({'status': 'error', 'message': 'interval must be between 0.1 and 5 seconds'}), 400
_state.waterfall_config['interval'] = interval
if data.get('max_bins') is not None:
max_bins = int(data.get('max_bins', _state.waterfall_config['max_bins']))
if max_bins < 64 or max_bins > 4096:
return jsonify({'status': 'error', 'message': 'max_bins must be between 64 and 4096'}), 400
_state.waterfall_config['max_bins'] = max_bins
except (ValueError, TypeError) as e:
return jsonify({'status': 'error', 'message': f'Invalid parameter: {e}'}), 400
if _state.waterfall_config['start_freq'] >= _state.waterfall_config['end_freq']:
return jsonify({'status': 'error', 'message': 'start_freq must be less than end_freq'}), 400
# Clear stale queue
try:
while True:
_state.waterfall_queue.get_nowait()
except queue.Empty:
pass
# Claim SDR device
error = app_module.claim_sdr_device(_state.waterfall_config['device'], 'waterfall', sdr_type_str)
if error:
return jsonify({'status': 'error', 'error_type': 'DEVICE_BUSY', 'message': error}), 409
_state.waterfall_active_device = _state.waterfall_config['device']
_state.waterfall_active_sdr_type = sdr_type_str
_state.waterfall_running = True
_state.waterfall_thread = threading.Thread(target=_waterfall_loop, daemon=True)
_state.waterfall_thread.start()
return jsonify({'status': 'started', 'config': _state.waterfall_config})
@receiver_bp.route('/waterfall/stop', methods=['POST'])
def stop_waterfall() -> Response:
"""Stop the waterfall display."""
_stop_waterfall_internal()
return jsonify({'status': 'stopped'})
@receiver_bp.route('/waterfall/stream')
def stream_waterfall() -> Response:
"""SSE stream for waterfall data."""
def _on_msg(msg: dict[str, Any]) -> None:
process_event('waterfall', msg, msg.get('type'))
response = Response(
sse_stream_fanout(
source_queue=_state.waterfall_queue,
channel_key='receiver_waterfall',
timeout=SSE_QUEUE_TIMEOUT,
keepalive_interval=SSE_KEEPALIVE_INTERVAL,
on_message=_on_msg,
),
mimetype='text/event-stream',
)
response.headers['Cache-Control'] = 'no-cache'
response.headers['X-Accel-Buffering'] = 'no'
return response
+7 -8
View File
@@ -11,20 +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.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')
@@ -1050,11 +1049,11 @@ def request_store_forward():
def mesh_topology(): def mesh_topology():
"""Return mesh network topology graph.""" """Return mesh network topology graph."""
if not is_meshtastic_available(): if not is_meshtastic_available():
return jsonify({'status': 'error', 'message': 'Meshtastic SDK not installed'}), 400 return api_error('Meshtastic SDK not installed', 400)
client = get_meshtastic_client() client = get_meshtastic_client()
if not client or not client.is_running: if not client or not client.is_running:
return jsonify({'status': 'error', 'message': 'Not connected'}), 400 return api_error('Not connected', 400)
return jsonify({ return jsonify({
'status': 'success', 'status': 'success',
+599
View File
@@ -0,0 +1,599 @@
"""WebSocket-based meteor scatter monitoring with waterfall display and ping detection.
Provides:
- WebSocket at /ws/meteor for binary waterfall frames (reuses waterfall_fft pipeline)
- SSE at /meteor/stream for detection events and stats
- REST endpoints for status, events, and export
"""
from __future__ import annotations
import json
import queue
import shutil
import socket
import subprocess
import threading
import time
from contextlib import suppress
from typing import Any
from flask import Blueprint, Flask, Response, jsonify, request
from utils.responses import api_error
try:
from flask_sock import Sock
WEBSOCKET_AVAILABLE = True
except ImportError:
WEBSOCKET_AVAILABLE = False
Sock = None
from utils.logging import get_logger
from utils.meteor_detector import MeteorDetector
from utils.process import register_process, safe_terminate, unregister_process
from utils.sdr import SDRFactory, SDRType
from utils.sdr.base import SDRCapabilities, SDRDevice
from utils.sse import sse_stream_fanout
from utils.validation import validate_device_index, validate_frequency, validate_gain
from utils.waterfall_fft import (
build_binary_frame,
compute_power_spectrum,
cu8_to_complex,
quantize_to_uint8,
)
logger = get_logger('intercept.meteor')
# Module-level shared state
_state_lock = threading.Lock()
_state: dict[str, Any] = {
'running': False,
'device': None,
'frequency_mhz': 0.0,
'sample_rate': 0,
}
_detector: MeteorDetector | None = None
_sse_queue: queue.Queue = queue.Queue(maxsize=500)
# Maximum bandwidth per SDR type (Hz)
MAX_BANDWIDTH = {
SDRType.RTL_SDR: 2400000,
SDRType.HACKRF: 20000000,
SDRType.LIME_SDR: 20000000,
SDRType.AIRSPY: 10000000,
SDRType.SDRPLAY: 2000000,
}
def _push_sse(data: dict[str, Any]) -> None:
"""Push a message to the SSE queue, dropping oldest if full."""
try:
_sse_queue.put_nowait(data)
except queue.Full:
try:
_sse_queue.get_nowait()
_sse_queue.put_nowait(data)
except (queue.Empty, queue.Full):
pass
def _resolve_sdr_type(sdr_type_str: str) -> SDRType:
mapping = {
'rtlsdr': SDRType.RTL_SDR,
'rtl_sdr': SDRType.RTL_SDR,
'hackrf': SDRType.HACKRF,
'limesdr': SDRType.LIME_SDR,
'airspy': SDRType.AIRSPY,
'sdrplay': SDRType.SDRPLAY,
}
return mapping.get(sdr_type_str.lower(), SDRType.RTL_SDR)
def _build_dummy_device(device_index: int, sdr_type: SDRType) -> SDRDevice:
builder = SDRFactory.get_builder(sdr_type)
caps = builder.get_capabilities()
return SDRDevice(
sdr_type=sdr_type,
index=device_index,
name=f'{sdr_type.value}-{device_index}',
serial='N/A',
driver=sdr_type.value,
capabilities=caps,
)
def _pick_sample_rate(span_hz: int, caps: SDRCapabilities, sdr_type: SDRType) -> int:
valid_rates = sorted({int(r) for r in caps.sample_rates if int(r) > 0})
if valid_rates:
return min(valid_rates, key=lambda rate: abs(rate - span_hz))
max_bw = MAX_BANDWIDTH.get(sdr_type, 2400000)
return max(62500, min(span_hz, max_bw))
# ── Blueprint for REST/SSE endpoints ──
meteor_bp = Blueprint('meteor', __name__, url_prefix='/meteor')
@meteor_bp.route('/status')
def meteor_status():
"""Return current meteor monitoring status."""
with _state_lock:
running = _state['running']
freq = _state['frequency_mhz']
device = _state['device']
sr = _state['sample_rate']
detector = _detector
stats = None
if detector:
stats = detector._build_stats(time.time())
return jsonify({
'running': running,
'frequency_mhz': freq,
'device': device,
'sample_rate': sr,
'stats': stats,
})
@meteor_bp.route('/stream')
def meteor_stream():
"""SSE endpoint for meteor detection events and stats."""
response = Response(
sse_stream_fanout(
source_queue=_sse_queue,
channel_key='meteor',
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
@meteor_bp.route('/events')
def meteor_events():
"""Return detected events as JSON."""
detector = _detector
if not detector:
return jsonify({'events': []})
limit = request.args.get('limit', 500, type=int)
return jsonify({'events': detector.get_events(limit=limit)})
@meteor_bp.route('/events/export')
def meteor_events_export():
"""Export events as CSV or JSON."""
detector = _detector
if not detector:
return api_error('No active session', 400)
fmt = request.args.get('format', 'json').lower()
if fmt == 'csv':
csv_data = detector.export_events_csv()
return Response(
csv_data,
mimetype='text/csv',
headers={'Content-Disposition': 'attachment; filename=meteor_events.csv'},
)
else:
json_data = detector.export_events_json()
return Response(
json_data,
mimetype='application/json',
headers={'Content-Disposition': 'attachment; filename=meteor_events.json'},
)
@meteor_bp.route('/events/clear', methods=['POST'])
def meteor_events_clear():
"""Clear all detected events."""
detector = _detector
if not detector:
return jsonify({'cleared': 0})
count = detector.clear_events()
return jsonify({'cleared': count})
# ── WebSocket handler ──
def init_meteor_websocket(app: Flask):
"""Initialize WebSocket meteor scatter streaming."""
global _detector
if not WEBSOCKET_AVAILABLE:
logger.warning("flask-sock not installed, WebSocket meteor disabled")
return
sock = Sock(app)
@sock.route('/ws/meteor')
def meteor_stream_ws(ws):
"""WebSocket endpoint for meteor scatter waterfall + detection."""
global _detector
logger.info("WebSocket meteor client connected")
import app as app_module
iq_process = None
reader_thread = None
stop_event = threading.Event()
claimed_device = None
claimed_sdr_type = 'rtlsdr'
send_queue: queue.Queue = queue.Queue(maxsize=120)
try:
while True:
# Drain send queue
while True:
try:
outgoing = send_queue.get_nowait()
except queue.Empty:
break
try:
ws.send(outgoing)
except Exception:
stop_event.set()
break
try:
msg = ws.receive(timeout=0.01)
except Exception as e:
err = str(e).lower()
if "closed" in err:
break
if "timed out" not in err:
logger.error(f"WebSocket receive error: {e}")
continue
if msg is None:
if not ws.connected:
break
if stop_event.is_set():
break
continue
try:
data = json.loads(msg)
except (json.JSONDecodeError, TypeError):
continue
cmd = data.get('cmd')
if cmd == 'start':
# Stop any existing capture
was_restarting = iq_process is not None
stop_event.set()
if reader_thread and reader_thread.is_alive():
reader_thread.join(timeout=2)
if iq_process:
safe_terminate(iq_process)
unregister_process(iq_process)
iq_process = None
if claimed_device is not None:
app_module.release_sdr_device(claimed_device, claimed_sdr_type)
claimed_device = None
with _state_lock:
_state['running'] = False
stop_event.clear()
while not send_queue.empty():
try:
send_queue.get_nowait()
except queue.Empty:
break
if was_restarting:
time.sleep(0.5)
# Parse config
try:
frequency_mhz = float(data.get('frequency_mhz', 143.05))
validate_frequency(frequency_mhz)
gain_raw = data.get('gain')
if gain_raw is None or str(gain_raw).lower() == 'auto':
gain = None
else:
gain = validate_gain(float(gain_raw))
device_index = validate_device_index(int(data.get('device', 0)))
sdr_type_str = data.get('sdr_type', 'rtlsdr')
sample_rate_req = int(data.get('sample_rate', 250000))
fft_size = int(data.get('fft_size', 1024))
fps = int(data.get('fps', 20))
avg_count = int(data.get('avg_count', 4))
ppm = data.get('ppm')
if ppm is not None:
ppm = int(ppm)
bias_t = bool(data.get('bias_t', False))
# Detection settings
snr_threshold = float(data.get('snr_threshold', 6.0))
min_duration = float(data.get('min_duration_ms', 50.0))
cooldown = float(data.get('cooldown_ms', 200.0))
freq_drift = float(data.get('freq_drift_tolerance_hz', 500.0))
except (TypeError, ValueError) as exc:
ws.send(json.dumps({
'status': 'error',
'message': f'Invalid configuration: {exc}',
}))
continue
# Clamp values
fft_size = max(256, min(4096, fft_size))
fps = max(5, min(30, fps))
avg_count = max(1, min(16, avg_count))
# Resolve SDR type and sample rate
sdr_type = _resolve_sdr_type(sdr_type_str)
builder = SDRFactory.get_builder(sdr_type)
caps = builder.get_capabilities()
sample_rate = _pick_sample_rate(sample_rate_req, caps, sdr_type)
# Compute frequency range
span_mhz = sample_rate / 1e6
start_freq = frequency_mhz - span_mhz / 2
end_freq = frequency_mhz + span_mhz / 2
# Claim SDR device
max_claim_attempts = 4 if was_restarting else 1
claim_err = None
for _attempt in range(max_claim_attempts):
claim_err = app_module.claim_sdr_device(device_index, 'meteor', sdr_type_str)
if not claim_err:
break
if _attempt < max_claim_attempts - 1:
time.sleep(0.4)
if claim_err:
ws.send(json.dumps({
'status': 'error',
'message': claim_err,
'error_type': 'DEVICE_BUSY',
}))
continue
claimed_device = device_index
claimed_sdr_type = sdr_type_str
# Build I/Q capture command
try:
device = _build_dummy_device(device_index, sdr_type)
iq_cmd = builder.build_iq_capture_command(
device=device,
frequency_mhz=frequency_mhz,
sample_rate=sample_rate,
gain=gain,
ppm=ppm,
bias_t=bias_t,
)
except NotImplementedError as e:
app_module.release_sdr_device(device_index, sdr_type_str)
claimed_device = None
ws.send(json.dumps({'status': 'error', 'message': str(e)}))
continue
# Check binary exists
if not shutil.which(iq_cmd[0]):
app_module.release_sdr_device(device_index, sdr_type_str)
claimed_device = None
ws.send(json.dumps({
'status': 'error',
'message': f'Required tool "{iq_cmd[0]}" not found.',
}))
continue
# Spawn I/Q capture
max_attempts = 3 if was_restarting else 1
try:
for attempt in range(max_attempts):
logger.info(
f"Starting meteor I/Q capture: {frequency_mhz:.6f} MHz, "
f"sr={sample_rate}, fft={fft_size}"
)
iq_process = subprocess.Popen(
iq_cmd,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
bufsize=0,
)
register_process(iq_process)
time.sleep(0.3)
if iq_process.poll() is not None:
stderr_out = ''
if iq_process.stderr:
with suppress(Exception):
stderr_out = iq_process.stderr.read().decode('utf-8', errors='replace').strip()
unregister_process(iq_process)
iq_process = None
if attempt < max_attempts - 1:
time.sleep(0.5)
continue
detail = f": {stderr_out}" if stderr_out else ""
raise RuntimeError(f"I/Q process exited immediately{detail}")
break
except Exception as e:
logger.error(f"Failed to start meteor I/Q capture: {e}")
if iq_process:
safe_terminate(iq_process)
unregister_process(iq_process)
iq_process = None
app_module.release_sdr_device(device_index, sdr_type_str)
claimed_device = None
ws.send(json.dumps({
'status': 'error',
'message': f'Failed to start I/Q capture: {e}',
}))
continue
# Initialize detector
_detector = MeteorDetector(
snr_threshold_db=snr_threshold,
min_duration_ms=min_duration,
cooldown_ms=cooldown,
freq_drift_tolerance_hz=freq_drift,
)
with _state_lock:
_state['running'] = True
_state['device'] = device_index
_state['frequency_mhz'] = frequency_mhz
_state['sample_rate'] = sample_rate
# Send confirmation
ws.send(json.dumps({
'status': 'started',
'frequency_mhz': frequency_mhz,
'start_freq': start_freq,
'end_freq': end_freq,
'fft_size': fft_size,
'sample_rate': sample_rate,
'span_mhz': span_mhz,
}))
# Start FFT reader + detection thread
def fft_reader(
proc, _send_q, stop_evt, detector,
_fft_size, _avg_count, _fps, _sample_rate,
_start_freq, _end_freq, _freq_mhz,
):
required_fft_samples = _fft_size * _avg_count
timeslice_samples = max(required_fft_samples, int(_sample_rate / max(1, _fps)))
bytes_per_frame = timeslice_samples * 2
frame_interval = 1.0 / _fps
start_freq_hz = _start_freq * 1e6
end_freq_hz = _end_freq * 1e6
last_stats_push = 0.0
try:
while not stop_evt.is_set():
if proc.poll() is not None:
break
frame_start = time.monotonic()
# Read raw I/Q
raw = b''
remaining = bytes_per_frame
while remaining > 0 and not stop_evt.is_set():
chunk = proc.stdout.read(min(remaining, 65536))
if not chunk:
break
raw += chunk
remaining -= len(chunk)
if len(raw) < _fft_size * 2:
break
# FFT pipeline
samples = cu8_to_complex(raw)
fft_samples = samples[-required_fft_samples:] if len(samples) > required_fft_samples else samples
power_db = compute_power_spectrum(
fft_samples,
fft_size=_fft_size,
avg_count=_avg_count,
)
quantized = quantize_to_uint8(power_db)
frame = build_binary_frame(_start_freq, _end_freq, quantized)
# Send waterfall frame via WS
with suppress(queue.Full):
_send_q.put_nowait(frame)
# Run detection on raw dB spectrum
now = time.time()
stats, event = detector.process_frame(
power_db, start_freq_hz, end_freq_hz, now,
)
# Push event immediately via SSE
if event:
_push_sse({
'type': 'event',
'event': event.to_dict(),
})
# Also send as JSON via WS for immediate UI update
event_msg = json.dumps({
'type': 'detection',
'event': event.to_dict(),
})
with suppress(queue.Full):
_send_q.put_nowait(event_msg)
# Push stats every ~1s via SSE
if now - last_stats_push >= 1.0:
_push_sse(stats)
last_stats_push = now
# Pace to target FPS
elapsed = time.monotonic() - frame_start
sleep_time = frame_interval - elapsed
if sleep_time > 0:
stop_evt.wait(sleep_time)
except Exception as e:
logger.debug(f"Meteor FFT reader stopped: {e}")
reader_thread = threading.Thread(
target=fft_reader,
args=(
iq_process, send_queue, stop_event, _detector,
fft_size, avg_count, fps, sample_rate,
start_freq, end_freq, frequency_mhz,
),
daemon=True,
)
reader_thread.start()
elif cmd == 'update_threshold':
detector = _detector
if detector:
detector.update_settings(
snr_threshold_db=data.get('snr_threshold'),
min_duration_ms=data.get('min_duration_ms'),
cooldown_ms=data.get('cooldown_ms'),
freq_drift_tolerance_hz=data.get('freq_drift_tolerance_hz'),
)
ws.send(json.dumps({'status': 'threshold_updated'}))
elif cmd == 'stop':
stop_event.set()
if reader_thread and reader_thread.is_alive():
reader_thread.join(timeout=2)
reader_thread = None
if iq_process:
safe_terminate(iq_process)
unregister_process(iq_process)
iq_process = None
if claimed_device is not None:
app_module.release_sdr_device(claimed_device, claimed_sdr_type)
claimed_device = None
with _state_lock:
_state['running'] = False
_state['device'] = None
stop_event.clear()
ws.send(json.dumps({'status': 'stopped'}))
except Exception as e:
logger.info(f"WebSocket meteor closed: {e}")
finally:
stop_event.set()
if reader_thread and reader_thread.is_alive():
reader_thread.join(timeout=2)
if iq_process:
safe_terminate(iq_process)
unregister_process(iq_process)
if claimed_device is not None:
app_module.release_sdr_device(claimed_device, claimed_sdr_type)
with _state_lock:
_state['running'] = False
_state['device'] = None
with suppress(Exception):
ws.close()
with suppress(Exception):
ws.sock.shutdown(socket.SHUT_RDWR)
with suppress(Exception):
ws.sock.close()
logger.info("WebSocket meteor client disconnected")
+54 -33
View File
@@ -21,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 (
@@ -28,6 +29,8 @@ from utils.validation import (
validate_frequency, validate_frequency,
validate_gain, validate_gain,
validate_ppm, validate_ppm,
validate_rtl_tcp_host,
validate_rtl_tcp_port,
) )
morse_bp = Blueprint('morse', __name__) morse_bp = Blueprint('morse', __name__)
@@ -250,7 +253,7 @@ def start_morse() -> Response:
try: try:
detect_mode = _validate_detect_mode(data.get('detect_mode', 'goertzel')) detect_mode = _validate_detect_mode(data.get('detect_mode', 'goertzel'))
except ValueError as e: except ValueError as e:
return jsonify({'status': 'error', 'message': str(e)}), 400 return api_error(str(e), 400)
freq_max = 1766.0 if detect_mode == 'envelope' else 30.0 freq_max = 1766.0 if detect_mode == 'envelope' else 30.0
try: try:
@@ -259,7 +262,7 @@ def start_morse() -> Response:
ppm = validate_ppm(data.get('ppm', '0')) ppm = validate_ppm(data.get('ppm', '0'))
device = validate_device_index(data.get('device', '0')) device = validate_device_index(data.get('device', '0'))
except ValueError as e: except ValueError as e:
return jsonify({'status': 'error', 'message': str(e)}), 400 return api_error(str(e), 400)
try: try:
tone_freq = _validate_tone_freq(data.get('tone_freq', '700')) tone_freq = _validate_tone_freq(data.get('tone_freq', '700'))
@@ -275,10 +278,14 @@ def start_morse() -> Response:
tone_lock = _bool_value(data.get('tone_lock', False), False) tone_lock = _bool_value(data.get('tone_lock', False), False)
wpm_lock = _bool_value(data.get('wpm_lock', False), False) wpm_lock = _bool_value(data.get('wpm_lock', False), False)
except ValueError as e: except ValueError as e:
return jsonify({'status': 'error', 'message': str(e)}), 400 return api_error(str(e), 400)
sdr_type_str = data.get('sdr_type', 'rtlsdr') sdr_type_str = data.get('sdr_type', 'rtlsdr')
# Check for rtl_tcp (remote SDR) connection
rtl_tcp_host = data.get('rtl_tcp_host')
rtl_tcp_port = data.get('rtl_tcp_port', 1234)
with app_module.morse_lock: with app_module.morse_lock:
if morse_state in {MORSE_STARTING, MORSE_RUNNING, MORSE_STOPPING}: if morse_state in {MORSE_STARTING, MORSE_RUNNING, MORSE_STOPPING}:
return jsonify({ return jsonify({
@@ -287,17 +294,19 @@ def start_morse() -> Response:
'state': morse_state, 'state': morse_state,
}), 409 }), 409
device_int = int(device) # Reserve SDR device (skip for remote rtl_tcp)
error = app_module.claim_sdr_device(device_int, 'morse', sdr_type_str) if not rtl_tcp_host:
if error: device_int = int(device)
return jsonify({ error = app_module.claim_sdr_device(device_int, 'morse', sdr_type_str)
'status': 'error', if error:
'error_type': 'DEVICE_BUSY', return jsonify({
'message': error, 'status': 'error',
}), 409 'error_type': 'DEVICE_BUSY',
'message': error,
}), 409
morse_active_device = device_int morse_active_device = device_int
morse_active_sdr_type = sdr_type_str morse_active_sdr_type = sdr_type_str
morse_last_error = '' morse_last_error = ''
morse_session_id += 1 morse_session_id += 1
@@ -320,23 +329,35 @@ def start_morse() -> Response:
except ValueError: except ValueError:
sdr_type = SDRType.RTL_SDR sdr_type = SDRType.RTL_SDR
# Create network or local SDR device
network_sdr_device = None
if rtl_tcp_host:
try:
rtl_tcp_host = validate_rtl_tcp_host(rtl_tcp_host)
rtl_tcp_port = validate_rtl_tcp_port(rtl_tcp_port)
except ValueError as e:
return api_error(str(e), 400)
network_sdr_device = SDRFactory.create_network_device(rtl_tcp_host, rtl_tcp_port)
logger.info(f"Using remote SDR: rtl_tcp://{rtl_tcp_host}:{rtl_tcp_port}")
requested_device_index = int(device) requested_device_index = int(device)
active_device_index = requested_device_index active_device_index = requested_device_index
builder = SDRFactory.get_builder(sdr_type) builder = SDRFactory.get_builder(network_sdr_device.sdr_type if network_sdr_device else sdr_type)
device_catalog: dict[int, dict[str, str]] = {} device_catalog: dict[int, dict[str, str]] = {}
candidate_device_indices: list[int] = [requested_device_index] candidate_device_indices: list[int] = [requested_device_index]
with contextlib.suppress(Exception): if not network_sdr_device:
detected_devices = SDRFactory.detect_devices() with contextlib.suppress(Exception):
same_type_devices = [d for d in detected_devices if d.sdr_type == sdr_type] detected_devices = SDRFactory.detect_devices()
for d in same_type_devices: same_type_devices = [d for d in detected_devices if d.sdr_type == sdr_type]
device_catalog[d.index] = { for d in same_type_devices:
'name': str(d.name or f'SDR {d.index}'), device_catalog[d.index] = {
'serial': str(d.serial or 'Unknown'), 'name': str(d.name or f'SDR {d.index}'),
} 'serial': str(d.serial or 'Unknown'),
for d in sorted(same_type_devices, key=lambda dev: dev.index): }
if d.index not in candidate_device_indices: for d in sorted(same_type_devices, key=lambda dev: dev.index):
candidate_device_indices.append(d.index) if d.index not in candidate_device_indices:
candidate_device_indices.append(d.index)
def _device_label(device_index: int) -> str: def _device_label(device_index: int) -> str:
meta = device_catalog.get(device_index, {}) meta = device_catalog.get(device_index, {})
@@ -350,7 +371,7 @@ def start_morse() -> Response:
tuned_frequency_mhz = max(0.5, float(freq)) tuned_frequency_mhz = max(0.5, float(freq))
else: else:
tuned_frequency_mhz = max(0.5, float(freq) - (float(tone_freq) / 1_000_000.0)) tuned_frequency_mhz = max(0.5, float(freq) - (float(tone_freq) / 1_000_000.0))
sdr_device = SDRFactory.create_default_device(sdr_type, index=device_index) sdr_device = network_sdr_device or SDRFactory.create_default_device(sdr_type, index=device_index)
fm_kwargs: dict[str, Any] = { fm_kwargs: dict[str, Any] = {
'device': sdr_device, 'device': sdr_device,
'frequency_mhz': tuned_frequency_mhz, 'frequency_mhz': tuned_frequency_mhz,
@@ -676,7 +697,7 @@ def start_morse() -> Response:
morse_last_error = msg morse_last_error = msg
_set_state(MORSE_ERROR, msg) _set_state(MORSE_ERROR, msg)
_set_state(MORSE_IDLE, 'Idle') _set_state(MORSE_IDLE, 'Idle')
return jsonify({'status': 'error', 'message': msg}), 500 return api_error(msg, 500)
with app_module.morse_lock: with app_module.morse_lock:
app_module.morse_process = active_rtl_process app_module.morse_process = active_rtl_process
@@ -720,7 +741,7 @@ def start_morse() -> Response:
morse_last_error = f'Tool not found: {e.filename}' morse_last_error = f'Tool not found: {e.filename}'
_set_state(MORSE_ERROR, morse_last_error) _set_state(MORSE_ERROR, morse_last_error)
_set_state(MORSE_IDLE, 'Idle') _set_state(MORSE_IDLE, 'Idle')
return jsonify({'status': 'error', 'message': morse_last_error}), 400 return api_error(morse_last_error, 400)
except Exception as e: except Exception as e:
_cleanup_attempt( _cleanup_attempt(
@@ -738,7 +759,7 @@ def start_morse() -> Response:
morse_last_error = str(e) morse_last_error = str(e)
_set_state(MORSE_ERROR, morse_last_error) _set_state(MORSE_ERROR, morse_last_error)
_set_state(MORSE_IDLE, 'Idle') _set_state(MORSE_IDLE, 'Idle')
return jsonify({'status': 'error', 'message': str(e)}), 500 return api_error(str(e), 500)
@morse_bp.route('/morse/stop', methods=['POST']) @morse_bp.route('/morse/stop', methods=['POST'])
@@ -888,11 +909,11 @@ def calibrate_morse() -> Response:
def decode_morse_file() -> Response: def decode_morse_file() -> Response:
"""Decode Morse from an uploaded WAV file.""" """Decode Morse from an uploaded WAV file."""
if 'audio' not in request.files: if 'audio' not in request.files:
return jsonify({'status': 'error', 'message': 'No audio file provided'}), 400 return api_error('No audio file provided', 400)
audio_file = request.files['audio'] audio_file = request.files['audio']
if not audio_file.filename: if not audio_file.filename:
return jsonify({'status': 'error', 'message': 'No file selected'}), 400 return api_error('No file selected', 400)
# Parse optional tuning/decoder parameters from form fields. # Parse optional tuning/decoder parameters from form fields.
form = request.form or {} form = request.form or {}
@@ -910,7 +931,7 @@ def decode_morse_file() -> Response:
tone_lock = _bool_value(form.get('tone_lock', 'false'), False) tone_lock = _bool_value(form.get('tone_lock', 'false'), False)
wpm_lock = _bool_value(form.get('wpm_lock', 'false'), False) wpm_lock = _bool_value(form.get('wpm_lock', 'false'), False)
except ValueError as e: except ValueError as e:
return jsonify({'status': 'error', 'message': str(e)}), 400 return api_error(str(e), 400)
with tempfile.NamedTemporaryFile(suffix='.wav', delete=False) as tmp: with tempfile.NamedTemporaryFile(suffix='.wav', delete=False) as tmp:
audio_file.save(tmp.name) audio_file.save(tmp.name)
@@ -948,7 +969,7 @@ def decode_morse_file() -> Response:
}) })
except Exception as e: except Exception as e:
logger.error(f'Morse decode-file error: {e}') logger.error(f'Morse decode-file error: {e}')
return jsonify({'status': 'error', 'message': str(e)}), 500 return api_error(str(e), 500)
finally: finally:
with contextlib.suppress(Exception): with contextlib.suppress(Exception):
tmp_path.unlink(missing_ok=True) tmp_path.unlink(missing_ok=True)
+14 -26
View File
@@ -2,10 +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
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
@@ -64,10 +67,7 @@ def get_offline_settings():
def get_settings(): def get_settings():
"""Get current offline settings.""" """Get current offline settings."""
settings = get_offline_settings() settings = get_offline_settings()
return jsonify({ return api_success(data={'settings': settings})
'status': 'success',
'settings': settings
})
@offline_bp.route('/settings', methods=['POST']) @offline_bp.route('/settings', methods=['POST'])
@@ -75,14 +75,14 @@ def save_setting():
"""Save an offline setting.""" """Save an offline setting."""
data = request.get_json() data = request.get_json()
if not data or 'key' not in data or 'value' not in data: if not data or 'key' not in data or 'value' not in data:
return jsonify({'status': 'error', 'message': 'Missing key or value'}), 400 return api_error('Missing key or value', 400)
key = data['key'] key = data['key']
value = data['value'] value = data['value']
# Validate key is an allowed setting # Validate key is an allowed setting
if key not in OFFLINE_DEFAULTS: if key not in OFFLINE_DEFAULTS:
return jsonify({'status': 'error', 'message': f'Unknown setting: {key}'}), 400 return api_error(f'Unknown setting: {key}', 400)
# Validate value type matches default # Validate value type matches default
default_type = type(OFFLINE_DEFAULTS[key]) default_type = type(OFFLINE_DEFAULTS[key])
@@ -94,18 +94,11 @@ def save_setting():
else: else:
value = default_type(value) value = default_type(value)
except (ValueError, TypeError): except (ValueError, TypeError):
return jsonify({ return api_error(f'Invalid value type for {key}', 400)
'status': 'error',
'message': f'Invalid value type for {key}'
}), 400
set_setting(key, value) set_setting(key, value)
return jsonify({ return api_success(data={'key': key, 'value': value})
'status': 'success',
'key': key,
'value': value
})
@offline_bp.route('/status', methods=['GET']) @offline_bp.route('/status', methods=['GET'])
@@ -134,8 +127,7 @@ def get_status():
if not available: if not available:
all_available = False all_available = False
return jsonify({ return api_success(data={
'status': 'success',
'all_available': all_available, 'all_available': all_available,
'assets': results, 'assets': results,
'offline_enabled': get_setting('offline.enabled', False) 'offline_enabled': get_setting('offline.enabled', False)
@@ -147,11 +139,11 @@ def check_asset():
"""Check if a specific asset file exists.""" """Check if a specific asset file exists."""
path = request.args.get('path', '') path = request.args.get('path', '')
if not path: if not path:
return jsonify({'status': 'error', 'message': 'Missing path parameter'}), 400 return api_error('Missing path parameter', 400)
# Security: only allow checking within static/vendor # Security: only allow checking within static/vendor
if not path.startswith('/static/vendor/'): if not path.startswith('/static/vendor/'):
return jsonify({'status': 'error', 'message': 'Invalid path'}), 400 return api_error('Invalid path', 400)
# Remove leading slash and construct full path # Remove leading slash and construct full path
relative_path = path.lstrip('/') relative_path = path.lstrip('/')
@@ -160,8 +152,4 @@ def check_asset():
exists = os.path.exists(full_path) exists = os.path.exists(full_path)
return jsonify({ return api_success(data={'path': path, 'exists': exists})
'status': 'success',
'path': path,
'exists': exists
})
+353
View File
@@ -0,0 +1,353 @@
"""Generic OOK signal decoder routes.
Captures raw OOK frames using rtl_433's flex decoder and streams decoded
bit/hex data to the browser for live ASCII interpretation. Supports
PWM, PPM, and Manchester modulation with fully configurable pulse timing.
"""
from __future__ import annotations
import contextlib
import os
import queue
import signal
import subprocess
import threading
from typing import Any
from flask import Blueprint, Response, jsonify, request
import app as app_module
from utils.event_pipeline import process_event
from utils.logging import sensor_logger as logger
from utils.ook import ook_parser_thread
from utils.process import register_process, safe_terminate, 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_positive_int,
validate_ppm,
validate_rtl_tcp_host,
validate_rtl_tcp_port,
)
ook_bp = Blueprint('ook', __name__)
# Track which device / SDR type is being used
ook_active_device: int | None = None
ook_active_sdr_type: str | None = None
# Parser thread state (avoids monkey-patching subprocess.Popen)
_ook_stop_event: threading.Event | None = None
_ook_parser_thread: threading.Thread | None = None
# Supported modulation schemes → rtl_433 flex decoder modulation string
_MODULATION_MAP = {
'pwm': 'OOK_PWM',
'ppm': 'OOK_PPM',
'manchester': 'OOK_MC_ZEROBIT',
}
def _validate_encoding(value: Any) -> str:
enc = str(value).lower().strip()
if enc not in _MODULATION_MAP:
raise ValueError(f"encoding must be one of: {', '.join(_MODULATION_MAP)}")
return enc
@ook_bp.route('/ook/start', methods=['POST'])
def start_ook() -> Response:
global ook_active_device, ook_active_sdr_type, _ook_stop_event, _ook_parser_thread
with app_module.ook_lock:
if app_module.ook_process:
# If the process exited/crashed, clean up stale state and allow restart
if app_module.ook_process.poll() is not None:
cleanup_ook(emit_status=False)
else:
return api_error('OOK decoder already running', 409)
data = request.json or {}
try:
freq = validate_frequency(data.get('frequency', '433.920'))
gain = validate_gain(data.get('gain', '0'))
ppm = validate_ppm(data.get('ppm', '0'))
device = validate_device_index(data.get('device', '0'))
except ValueError as e:
return api_error(str(e), 400)
try:
encoding = _validate_encoding(data.get('encoding', 'pwm'))
except ValueError as e:
return api_error(str(e), 400)
# OOK flex decoder timing parameters (server-side range validation)
try:
short_pulse = validate_positive_int(data.get('short_pulse', 300), 'short_pulse', max_val=100000)
long_pulse = validate_positive_int(data.get('long_pulse', 600), 'long_pulse', max_val=100000)
reset_limit = validate_positive_int(data.get('reset_limit', 8000), 'reset_limit', max_val=1000000)
gap_limit = validate_positive_int(data.get('gap_limit', 5000), 'gap_limit', max_val=1000000)
tolerance = validate_positive_int(data.get('tolerance', 150), 'tolerance', max_val=50000)
min_bits = validate_positive_int(data.get('min_bits', 8), 'min_bits', max_val=4096)
except ValueError as e:
return api_error(f'Invalid timing parameter: {e}', 400)
if min_bits < 1:
return api_error('min_bits must be >= 1', 400)
if short_pulse < 1 or long_pulse < 1:
return api_error('Pulse widths must be >= 1', 400)
deduplicate = bool(data.get('deduplicate', False))
# Parse SDR type early — needed for device claim
sdr_type_str = data.get('sdr_type', 'rtlsdr')
try:
sdr_type = SDRType(sdr_type_str)
except ValueError:
sdr_type = SDRType.RTL_SDR
sdr_type_str = 'rtlsdr'
rtl_tcp_host = data.get('rtl_tcp_host') or None
rtl_tcp_port = data.get('rtl_tcp_port', 1234)
if not rtl_tcp_host:
device_int = int(device)
error = app_module.claim_sdr_device(device_int, 'ook', sdr_type_str)
if error:
return api_error(error, 409, error_type='DEVICE_BUSY')
ook_active_device = device_int
ook_active_sdr_type = sdr_type_str
while not app_module.ook_queue.empty():
try:
app_module.ook_queue.get_nowait()
except queue.Empty:
break
if rtl_tcp_host:
try:
rtl_tcp_host = validate_rtl_tcp_host(rtl_tcp_host)
rtl_tcp_port = validate_rtl_tcp_port(rtl_tcp_port)
except ValueError as e:
return api_error(str(e), 400)
sdr_device = SDRFactory.create_network_device(rtl_tcp_host, rtl_tcp_port)
logger.info(f'Using remote SDR: rtl_tcp://{rtl_tcp_host}:{rtl_tcp_port}')
else:
sdr_device = SDRFactory.create_default_device(sdr_type, index=device)
builder = SDRFactory.get_builder(sdr_device.sdr_type)
bias_t = data.get('bias_t', False)
# Build base ISM command then replace protocol flags with flex decoder
cmd = builder.build_ism_command(
device=sdr_device,
frequency_mhz=freq,
gain=float(gain) if gain and gain != 0 else None,
ppm=int(ppm) if ppm and ppm != 0 else None,
bias_t=bias_t,
)
modulation = _MODULATION_MAP[encoding]
flex_spec = (
f'n=ook,m={modulation},'
f's={short_pulse},l={long_pulse},'
f'r={reset_limit},g={gap_limit},'
f't={tolerance},bits>={min_bits}'
)
# Strip any existing -R flags from the base command
filtered_cmd: list[str] = []
skip_next = False
for arg in cmd:
if skip_next:
skip_next = False
continue
if arg == '-R':
skip_next = True
continue
filtered_cmd.append(arg)
filtered_cmd.extend(['-M', 'level', '-R', '0', '-X', flex_spec])
full_cmd = ' '.join(filtered_cmd)
logger.info(f'OOK decoder running: {full_cmd}')
try:
rtl_process = subprocess.Popen(
filtered_cmd,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
start_new_session=True,
)
register_process(rtl_process)
_stderr_noise = ('bitbuffer_add_bit', 'row count limit')
def monitor_stderr() -> None:
for line in rtl_process.stderr:
err_text = line.decode('utf-8', errors='replace').strip()
if err_text and not any(n in err_text for n in _stderr_noise):
logger.debug(f'[rtl_433/ook] {err_text}')
stderr_thread = threading.Thread(target=monitor_stderr)
stderr_thread.daemon = True
stderr_thread.start()
stop_event = threading.Event()
parser_thread = threading.Thread(
target=ook_parser_thread,
args=(
rtl_process.stdout,
app_module.ook_queue,
stop_event,
encoding,
deduplicate,
),
)
parser_thread.daemon = True
parser_thread.start()
app_module.ook_process = rtl_process
_ook_stop_event = stop_event
_ook_parser_thread = parser_thread
try:
app_module.ook_queue.put_nowait({'type': 'status', 'text': 'started'})
except queue.Full:
logger.warning("OOK 'started' status dropped — queue full")
return jsonify({
'status': 'started',
'command': full_cmd,
'encoding': encoding,
'modulation': modulation,
'flex_spec': flex_spec,
'deduplicate': deduplicate,
})
except FileNotFoundError as e:
if ook_active_device is not None:
app_module.release_sdr_device(ook_active_device, ook_active_sdr_type or 'rtlsdr')
ook_active_device = None
ook_active_sdr_type = None
return api_error(f'Tool not found: {e.filename}', 400)
except Exception as e:
try:
rtl_process.terminate()
rtl_process.wait(timeout=2)
except Exception:
with contextlib.suppress(Exception):
rtl_process.kill()
unregister_process(rtl_process)
if ook_active_device is not None:
app_module.release_sdr_device(ook_active_device, ook_active_sdr_type or 'rtlsdr')
ook_active_device = None
ook_active_sdr_type = None
return api_error(str(e), 500)
def _close_pipe(pipe_obj) -> None:
"""Close a subprocess pipe, suppressing errors."""
if pipe_obj is not None:
with contextlib.suppress(Exception):
pipe_obj.close()
def cleanup_ook(*, emit_status: bool = True) -> None:
"""Full OOK cleanup: stop parser, terminate process, release SDR device.
Safe to call from ``stop_ook()`` and ``kill_all()``. Caller must hold
``app_module.ook_lock``.
"""
global ook_active_device, ook_active_sdr_type, _ook_stop_event, _ook_parser_thread
proc = app_module.ook_process
if not proc:
return
# Signal parser thread to stop
if _ook_stop_event:
_ook_stop_event.set()
# Close pipes so parser thread unblocks from readline()
_close_pipe(getattr(proc, 'stdout', None))
_close_pipe(getattr(proc, 'stderr', None))
# Kill the entire process group so child processes are cleaned up
try:
pgid = os.getpgid(proc.pid)
os.killpg(pgid, signal.SIGTERM)
try:
proc.wait(timeout=2)
except subprocess.TimeoutExpired:
os.killpg(pgid, signal.SIGKILL)
proc.wait(timeout=3)
except (ProcessLookupError, OSError):
# Process already dead — fall back to normal terminate
safe_terminate(proc)
unregister_process(proc)
app_module.ook_process = None
# Join parser thread with timeout
if _ook_parser_thread:
_ook_parser_thread.join(timeout=0.5)
_ook_stop_event = None
_ook_parser_thread = None
if ook_active_device is not None:
app_module.release_sdr_device(ook_active_device, ook_active_sdr_type or 'rtlsdr')
ook_active_device = None
ook_active_sdr_type = None
if emit_status:
try:
app_module.ook_queue.put_nowait({'type': 'status', 'text': 'stopped'})
except queue.Full:
logger.warning("OOK 'stopped' status dropped — queue full")
@ook_bp.route('/ook/stop', methods=['POST'])
def stop_ook() -> Response:
with app_module.ook_lock:
if app_module.ook_process:
cleanup_ook()
return jsonify({'status': 'stopped'})
return jsonify({'status': 'not_running'})
@ook_bp.route('/ook/status')
def ook_status() -> Response:
with app_module.ook_lock:
running = (
app_module.ook_process is not None
and app_module.ook_process.poll() is None
)
return jsonify({'running': running})
@ook_bp.route('/ook/stream')
def ook_stream() -> Response:
def _on_msg(msg: dict[str, Any]) -> None:
process_event('ook', msg, msg.get('type'))
response = Response(
sse_stream_fanout(
source_queue=app_module.ook_queue,
channel_key='ook',
timeout=1.0,
keepalive_interval=30.0,
on_message=_on_msg,
),
mimetype='text/event-stream',
)
response.headers['Cache-Control'] = 'no-cache'
response.headers['X-Accel-Buffering'] = 'no'
response.headers['Connection'] = 'keep-alive'
return response
+51 -49
View File
@@ -2,33 +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
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__)
@@ -55,6 +61,20 @@ def parse_multimon_output(line: str) -> dict[str, str] | None:
'message': pocsag_match.group(5).strip() or '[No Message]' 'message': pocsag_match.group(5).strip() or '[No Message]'
} }
# POCSAG parsing - other content types (catch-all for non-Alpha/Numeric labels)
pocsag_other_match = re.match(
r'(POCSAG\d+):\s*Address:\s*(\d+)\s+Function:\s*(\d+)\s+(\w+):\s*(.*)',
line
)
if pocsag_other_match:
return {
'protocol': pocsag_other_match.group(1),
'address': pocsag_other_match.group(2),
'function': pocsag_other_match.group(3),
'msg_type': pocsag_other_match.group(4),
'message': pocsag_other_match.group(5).strip() or '[No Message]'
}
# POCSAG parsing - address only (no message content) # POCSAG parsing - address only (no message content)
pocsag_addr_match = re.match( pocsag_addr_match = re.match(
r'(POCSAG\d+):\s*Address:\s*(\d+)\s+Function:\s*(\d+)\s*$', r'(POCSAG\d+):\s*Address:\s*(\d+)\s+Function:\s*(\d+)\s*$',
@@ -174,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:
@@ -222,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)
@@ -240,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:
@@ -261,7 +275,7 @@ def start_decoding() -> Response:
with app_module.process_lock: with app_module.process_lock:
if app_module.current_process: if app_module.current_process:
return jsonify({'status': 'error', 'message': 'Already running'}), 409 return api_error('Already running', 409)
data = request.json or {} data = request.json or {}
@@ -272,7 +286,7 @@ def start_decoding() -> Response:
ppm = validate_ppm(data.get('ppm', '0')) ppm = validate_ppm(data.get('ppm', '0'))
device = validate_device_index(data.get('device', '0')) device = validate_device_index(data.get('device', '0'))
except ValueError as e: except ValueError as e:
return jsonify({'status': 'error', 'message': str(e)}), 400 return api_error(str(e), 400)
squelch = data.get('squelch', '0') squelch = data.get('squelch', '0')
try: try:
@@ -280,7 +294,7 @@ def start_decoding() -> Response:
if not 0 <= squelch <= 1000: if not 0 <= squelch <= 1000:
raise ValueError("Squelch must be between 0 and 1000") raise ValueError("Squelch must be between 0 and 1000")
except (ValueError, TypeError): except (ValueError, TypeError):
return jsonify({'status': 'error', 'message': 'Invalid squelch value'}), 400 return api_error('Invalid squelch value', 400)
# Check for rtl_tcp (remote SDR) connection # Check for rtl_tcp (remote SDR) connection
rtl_tcp_host = data.get('rtl_tcp_host') rtl_tcp_host = data.get('rtl_tcp_host')
@@ -294,11 +308,7 @@ def start_decoding() -> Response:
device_int = int(device) device_int = int(device)
error = app_module.claim_sdr_device(device_int, 'pager', sdr_type_str) error = app_module.claim_sdr_device(device_int, 'pager', sdr_type_str)
if error: if error:
return jsonify({ return api_error(error, 409, error_type='DEVICE_BUSY')
'status': 'error',
'error_type': 'DEVICE_BUSY',
'message': error
}), 409
pager_active_device = device_int pager_active_device = device_int
pager_active_sdr_type = sdr_type_str pager_active_sdr_type = sdr_type_str
@@ -310,7 +320,7 @@ def start_decoding() -> Response:
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')
pager_active_device = None pager_active_device = None
pager_active_sdr_type = None pager_active_sdr_type = None
return jsonify({'status': 'error', 'message': 'Protocols must be a list'}), 400 return api_error('Protocols must be a list', 400)
protocols = [p for p in protocols if p in valid_protocols] protocols = [p for p in protocols if p in valid_protocols]
if not protocols: if not protocols:
protocols = valid_protocols protocols = valid_protocols
@@ -346,7 +356,7 @@ def start_decoding() -> Response:
rtl_tcp_host = validate_rtl_tcp_host(rtl_tcp_host) rtl_tcp_host = validate_rtl_tcp_host(rtl_tcp_host)
rtl_tcp_port = validate_rtl_tcp_port(rtl_tcp_port) rtl_tcp_port = validate_rtl_tcp_port(rtl_tcp_port)
except ValueError as e: except ValueError as e:
return jsonify({'status': 'error', 'message': str(e)}), 400 return api_error(str(e), 400)
sdr_device = SDRFactory.create_network_device(rtl_tcp_host, rtl_tcp_port) sdr_device = SDRFactory.create_network_device(rtl_tcp_host, rtl_tcp_port)
logger.info(f"Using remote SDR: rtl_tcp://{rtl_tcp_host}:{rtl_tcp_port}") logger.info(f"Using remote SDR: rtl_tcp://{rtl_tcp_host}:{rtl_tcp_port}")
@@ -371,7 +381,7 @@ def start_decoding() -> Response:
multimon_path = get_tool_path('multimon-ng') multimon_path = get_tool_path('multimon-ng')
if not multimon_path: if not multimon_path:
return jsonify({'status': 'error', 'message': 'multimon-ng not found'}), 400 return api_error('multimon-ng not found', 400)
multimon_cmd = [multimon_path, '-t', 'raw'] + decoders + ['-f', 'alpha', '-'] multimon_cmd = [multimon_path, '-t', 'raw'] + decoders + ['-f', 'alpha', '-']
full_cmd = ' '.join(rtl_cmd) + ' | ' + ' '.join(multimon_cmd) full_cmd = ' '.join(rtl_cmd) + ' | ' + ' '.join(multimon_cmd)
@@ -443,32 +453,28 @@ 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')
pager_active_device = None pager_active_device = None
pager_active_sdr_type = None pager_active_sdr_type = None
return jsonify({'status': 'error', 'message': f'Tool not found: {e.filename}'}) return api_error(f'Tool not found: {e.filename}')
except Exception as e: except Exception as e:
# Kill orphaned rtl_fm process if it was started # Kill orphaned rtl_fm process if it was started
try: try:
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')
pager_active_device = None pager_active_device = None
pager_active_sdr_type = None pager_active_sdr_type = None
return jsonify({'status': 'error', 'message': str(e)}) return api_error(str(e))
@pager_bp.route('/stop', methods=['POST']) @pager_bp.route('/stop', methods=['POST'])
@@ -487,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()
@@ -548,16 +550,16 @@ def toggle_logging() -> Response:
is_in_logs = str(requested_path).startswith(str(logs_dir)) is_in_logs = str(requested_path).startswith(str(logs_dir))
if not (is_in_cwd or is_in_logs): if not (is_in_cwd or is_in_logs):
return jsonify({'status': 'error', 'message': 'Invalid log file path'}), 400 return api_error('Invalid log file path', 400)
# Ensure it's not a directory # Ensure it's not a directory
if requested_path.is_dir(): if requested_path.is_dir():
return jsonify({'status': 'error', 'message': 'Log file path must be a file, not a directory'}), 400 return api_error('Log file path must be a file, not a directory', 400)
app_module.log_file_path = str(requested_path) app_module.log_file_path = str(requested_path)
except (ValueError, OSError) as e: except (ValueError, OSError) as e:
logger.warning(f"Invalid log file path: {e}") logger.warning(f"Invalid log file path: {e}")
return jsonify({'status': 'error', 'message': 'Invalid log file path'}), 400 return api_error('Invalid log file path', 400)
return jsonify({'logging': app_module.logging_enabled, 'log_file': app_module.log_file_path}) return jsonify({'logging': app_module.logging_enabled, 'log_file': app_module.log_file_path})
+223 -81
View File
@@ -7,9 +7,11 @@ telemetry (position, altitude, temperature, humidity, pressure) on the
from __future__ import annotations from __future__ import annotations
import contextlib
import json import json
import os import os
import queue import queue
import shlex
import shutil import shutil
import socket import socket
import subprocess import subprocess
@@ -31,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 (
@@ -43,6 +46,7 @@ from utils.validation import (
logger = get_logger('intercept.radiosonde') logger = get_logger('intercept.radiosonde')
radiosonde_bp = Blueprint('radiosonde', __name__, url_prefix='/radiosonde') radiosonde_bp = Blueprint('radiosonde', __name__, url_prefix='/radiosonde')
PROJECT_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
# Track radiosonde state # Track radiosonde state
radiosonde_running = False radiosonde_running = False
@@ -81,6 +85,119 @@ def find_auto_rx() -> str | None:
return None return None
def _resolve_shebang_interpreter(script_path: str) -> str | None:
"""Resolve a Python interpreter from a script shebang if possible."""
try:
with open(script_path, encoding='utf-8', errors='ignore') as handle:
first_line = handle.readline().strip()
except OSError:
return None
if not first_line.startswith('#!'):
return None
parts = shlex.split(first_line[2:].strip())
if not parts:
return None
if os.path.basename(parts[0]) == 'env' and len(parts) > 1:
return shutil.which(parts[1])
return parts[0]
def _resolve_pip_python(pip_bin: str | None) -> str | None:
"""Resolve the Python interpreter used by a pip executable."""
if not pip_bin:
return None
return _resolve_shebang_interpreter(pip_bin)
def _build_auto_rx_env(auto_rx_dir: str) -> dict[str, str]:
"""Build environment for radiosonde_auto_rx with compatibility shims."""
env = os.environ.copy()
python_path_entries = [PROJECT_ROOT, auto_rx_dir]
existing_pythonpath = env.get('PYTHONPATH', '')
if existing_pythonpath:
python_path_entries.append(existing_pythonpath)
env['PYTHONPATH'] = os.pathsep.join(entry for entry in python_path_entries if entry)
return env
def _iter_auto_rx_python_candidates(auto_rx_path: str):
"""Yield plausible Python interpreters for radiosonde_auto_rx."""
auto_rx_abs = os.path.abspath(auto_rx_path)
auto_rx_dir = os.path.dirname(auto_rx_abs)
install_root = os.path.dirname(auto_rx_dir)
install_parent = os.path.dirname(install_root)
candidates = [
_resolve_shebang_interpreter(auto_rx_abs),
sys.executable,
os.path.join(install_root, 'venv', 'bin', 'python'),
os.path.join(install_root, 'venv', 'bin', 'python3'),
os.path.join(install_root, '.venv', 'bin', 'python'),
os.path.join(install_root, '.venv', 'bin', 'python3'),
os.path.join(auto_rx_dir, 'venv', 'bin', 'python'),
os.path.join(auto_rx_dir, 'venv', 'bin', 'python3'),
os.path.join(auto_rx_dir, '.venv', 'bin', 'python'),
os.path.join(auto_rx_dir, '.venv', 'bin', 'python3'),
os.path.join(install_parent, 'venv', 'bin', 'python'),
os.path.join(install_parent, 'venv', 'bin', 'python3'),
os.path.join(install_parent, '.venv', 'bin', 'python'),
os.path.join(install_parent, '.venv', 'bin', 'python3'),
_resolve_pip_python(shutil.which('pip3')),
_resolve_pip_python(shutil.which('pip')),
shutil.which('python3'),
shutil.which('python'),
'/usr/local/bin/python3',
'/usr/local/bin/python',
'/usr/bin/python3',
]
seen: set[str] = set()
for candidate in candidates:
if not candidate:
continue
candidate_abs = os.path.abspath(candidate)
if candidate_abs in seen:
continue
seen.add(candidate_abs)
if os.path.isfile(candidate_abs) and os.access(candidate_abs, os.X_OK):
yield candidate_abs
def _resolve_auto_rx_python(auto_rx_path: str) -> tuple[str | None, str, list[str]]:
"""Pick a Python interpreter that can import autorx.scan successfully."""
auto_rx_dir = os.path.dirname(os.path.abspath(auto_rx_path))
auto_rx_env = _build_auto_rx_env(auto_rx_dir)
checked: list[str] = []
last_error = 'No usable Python interpreter found'
for python_bin in _iter_auto_rx_python_candidates(auto_rx_path):
checked.append(python_bin)
try:
dep_check = subprocess.run(
[python_bin, '-c', 'import autorx.scan'],
cwd=auto_rx_dir,
env=auto_rx_env,
capture_output=True,
timeout=10,
)
except Exception as exc:
last_error = str(exc)
continue
if dep_check.returncode == 0:
return python_bin, '', checked
stderr_output = dep_check.stderr.decode('utf-8', errors='ignore').strip()
stdout_output = dep_check.stdout.decode('utf-8', errors='ignore').strip()
last_error = stderr_output or stdout_output or f'Interpreter exited with code {dep_check.returncode}'
return None, last_error, checked
def generate_station_cfg( def generate_station_cfg(
freq_min: float = 400.0, freq_min: float = 400.0,
freq_max: float = 406.0, freq_max: float = 406.0,
@@ -243,13 +360,40 @@ radius_temporary_block = False
sonde_time_threshold = 3 sonde_time_threshold = 3
""" """
with open(cfg_path, 'w') as f: try:
f.write(cfg) with open(cfg_path, 'w') as f:
f.write(cfg)
except OSError as e:
logger.error(f"Cannot write station.cfg to {cfg_path}: {e}")
raise RuntimeError(
f"Cannot write radiosonde config to {cfg_path}: {e}. "
f"Fix permissions with: sudo chown -R $(whoami) {cfg_dir}"
) from e
# When running as root via sudo, fix ownership so next non-root run
# can still read/write these files.
_fix_data_ownership(cfg_dir)
logger.info(f"Generated station.cfg at {cfg_path}") logger.info(f"Generated station.cfg at {cfg_path}")
return cfg_path return cfg_path
def _fix_data_ownership(path: str) -> None:
"""Recursively chown a path to the real (non-root) user when running via sudo."""
uid = os.environ.get('INTERCEPT_SUDO_UID')
gid = os.environ.get('INTERCEPT_SUDO_GID')
if uid is None or gid is None:
return
try:
uid_int, gid_int = int(uid), int(gid)
for dirpath, _dirnames, filenames in os.walk(path):
os.chown(dirpath, uid_int, gid_int)
for fname in filenames:
os.chown(os.path.join(dirpath, fname), uid_int, gid_int)
except OSError as e:
logger.warning(f"Could not fix ownership of {path}: {e}")
def parse_radiosonde_udp(udp_port: int) -> None: def parse_radiosonde_udp(udp_port: int) -> None:
"""Thread function: listen for radiosonde_auto_rx UDP JSON telemetry.""" """Thread function: listen for radiosonde_auto_rx UDP JSON telemetry."""
global radiosonde_running, _udp_socket global radiosonde_running, _udp_socket
@@ -287,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")
@@ -313,11 +453,11 @@ def _process_telemetry(msg: dict) -> dict | None:
balloon: dict[str, Any] = {'id': str(serial)} balloon: dict[str, Any] = {'id': str(serial)}
# Sonde type (RS41, RS92, DFM, M10, etc.) # Sonde type (RS41, RS92, DFM, M10, etc.) — prefer subtype if available
if 'type' in msg:
balloon['sonde_type'] = msg['type']
if 'subtype' in msg: if 'subtype' in msg:
balloon['sonde_type'] = msg['subtype'] balloon['sonde_type'] = msg['subtype']
elif 'type' in msg:
balloon['sonde_type'] = msg['type']
# Timestamp # Timestamp
if 'datetime' in msg: if 'datetime' in msg:
@@ -326,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
@@ -452,10 +572,7 @@ def start_radiosonde():
with app_module.radiosonde_lock: with app_module.radiosonde_lock:
if radiosonde_running: if radiosonde_running:
return jsonify({ return api_error('Radiosonde tracking already active', 409)
'status': 'already_running',
'message': 'Radiosonde tracking already active',
}), 409
data = request.json or {} data = request.json or {}
@@ -464,7 +581,7 @@ def start_radiosonde():
gain = float(validate_gain(data.get('gain', '40'))) gain = float(validate_gain(data.get('gain', '40')))
device = validate_device_index(data.get('device', '0')) device = validate_device_index(data.get('device', '0'))
except ValueError as e: except ValueError as e:
return jsonify({'status': 'error', 'message': str(e)}), 400 return api_error(str(e), 400)
freq_min = data.get('freq_min', 400.0) freq_min = data.get('freq_min', 400.0)
freq_max = data.get('freq_max', 406.0) freq_max = data.get('freq_max', 406.0)
@@ -476,7 +593,7 @@ def start_radiosonde():
if freq_min >= freq_max: if freq_min >= freq_max:
raise ValueError("Min frequency must be less than max") raise ValueError("Min frequency must be less than max")
except (ValueError, TypeError) as e: except (ValueError, TypeError) as e:
return jsonify({'status': 'error', 'message': f'Invalid frequency range: {e}'}), 400 return api_error(f'Invalid frequency range: {e}', 400)
bias_t = data.get('bias_t', False) bias_t = data.get('bias_t', False)
ppm = int(data.get('ppm', 0)) ppm = int(data.get('ppm', 0))
@@ -498,10 +615,7 @@ def start_radiosonde():
# Find auto_rx # Find auto_rx
auto_rx_path = find_auto_rx() auto_rx_path = find_auto_rx()
if not auto_rx_path: if not auto_rx_path:
return jsonify({ return api_error('radiosonde_auto_rx not found. Install from https://github.com/projecthorus/radiosonde_auto_rx', 400)
'status': 'error',
'message': 'radiosonde_auto_rx not found. Install from https://github.com/projecthorus/radiosonde_auto_rx',
}), 400
# Get SDR type # Get SDR type
sdr_type_str = data.get('sdr_type', 'rtlsdr') sdr_type_str = data.get('sdr_type', 'rtlsdr')
@@ -525,34 +639,52 @@ def start_radiosonde():
device_int = int(device) device_int = int(device)
error = app_module.claim_sdr_device(device_int, 'radiosonde', sdr_type_str) error = app_module.claim_sdr_device(device_int, 'radiosonde', sdr_type_str)
if error: if error:
return jsonify({ return api_error(error, 409, error_type='DEVICE_BUSY')
'status': 'error',
'error_type': 'DEVICE_BUSY',
'message': error,
}), 409
# Generate config # Generate config
cfg_path = generate_station_cfg( try:
freq_min=freq_min, cfg_path = generate_station_cfg(
freq_max=freq_max, freq_min=freq_min,
gain=gain, freq_max=freq_max,
device_index=device_int, gain=gain,
ppm=ppm, device_index=device_int,
bias_t=bias_t, ppm=ppm,
latitude=latitude, bias_t=bias_t,
longitude=longitude, latitude=latitude,
gpsd_enabled=gpsd_enabled, longitude=longitude,
) gpsd_enabled=gpsd_enabled,
)
except (OSError, RuntimeError) as e:
app_module.release_sdr_device(device_int, sdr_type_str)
logger.error(f"Failed to generate radiosonde config: {e}")
return api_error(str(e), 500)
# Build command - auto_rx -c expects a file path, not a directory # Build command - auto_rx -c expects the path to station.cfg
cfg_abs = os.path.abspath(cfg_path) cfg_abs = os.path.abspath(cfg_path)
if auto_rx_path.endswith('.py'): if auto_rx_path.endswith('.py'):
cmd = [sys.executable, auto_rx_path, '-c', cfg_abs] selected_python, dep_error, checked_interpreters = _resolve_auto_rx_python(auto_rx_path)
if not selected_python:
logger.error(
"radiosonde_auto_rx dependency check failed across interpreters %s: %s",
checked_interpreters,
dep_error,
)
app_module.release_sdr_device(device_int, sdr_type_str)
checked_msg = ', '.join(checked_interpreters) if checked_interpreters else 'none'
return api_error(
'radiosonde_auto_rx dependencies not satisfied. '
'Install or repair its Python environment (missing packages such as semver). '
f'Checked interpreters: {checked_msg}. '
f'Last error: {dep_error[:500]}',
500,
)
cmd = [selected_python, auto_rx_path, '-c', cfg_abs]
else: else:
cmd = [auto_rx_path, '-c', cfg_abs] cmd = [auto_rx_path, '-c', cfg_abs]
# Set cwd to the auto_rx directory so 'from autorx.scan import ...' works # Set cwd to the auto_rx directory so 'from autorx.scan import ...' works
auto_rx_dir = os.path.dirname(os.path.abspath(auto_rx_path)) auto_rx_dir = os.path.dirname(os.path.abspath(auto_rx_path))
auto_rx_env = _build_auto_rx_env(auto_rx_dir)
try: try:
logger.info(f"Starting radiosonde_auto_rx: {' '.join(cmd)}") logger.info(f"Starting radiosonde_auto_rx: {' '.join(cmd)}")
@@ -562,6 +694,7 @@ def start_radiosonde():
stderr=subprocess.PIPE, stderr=subprocess.PIPE,
start_new_session=True, start_new_session=True,
cwd=auto_rx_dir, cwd=auto_rx_dir,
env=auto_rx_env,
) )
# Wait briefly for process to start # Wait briefly for process to start
@@ -571,16 +704,28 @@ 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
error_msg = 'radiosonde_auto_rx failed to start. Check SDR device connection.'
if stderr_output: if stderr_output:
error_msg += f' Error: {stderr_output[:200]}' logger.error(f"radiosonde_auto_rx stderr:\n{stderr_output}")
return jsonify({'status': 'error', 'message': error_msg}), 500 if stderr_output and (
'ImportError' in stderr_output
or 'ModuleNotFoundError' in stderr_output
):
error_msg = (
'radiosonde_auto_rx failed to start due to missing Python '
'dependencies. Re-run setup.sh or reinstall radiosonde_auto_rx.'
)
else:
error_msg = (
'radiosonde_auto_rx failed to start. '
'Check SDR device connection.'
)
if stderr_output:
error_msg += f' Error: {stderr_output[:500]}'
return api_error(error_msg, 500)
radiosonde_running = True radiosonde_running = True
radiosonde_active_device = device_int radiosonde_active_device = device_int
@@ -606,7 +751,7 @@ def start_radiosonde():
except Exception as e: except Exception as e:
app_module.release_sdr_device(device_int, sdr_type_str) app_module.release_sdr_device(device_int, sdr_type_str)
logger.error(f"Failed to start radiosonde_auto_rx: {e}") logger.error(f"Failed to start radiosonde_auto_rx: {e}")
return jsonify({'status': 'error', 'message': str(e)}), 500 return api_error(str(e), 500)
@radiosonde_bp.route('/stop', methods=['POST']) @radiosonde_bp.route('/stop', methods=['POST'])
@@ -631,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
@@ -675,8 +818,7 @@ def stream_radiosonde():
def get_balloons(): def get_balloons():
"""Get current balloon data.""" """Get current balloon data."""
with _balloons_lock: with _balloons_lock:
return jsonify({ return api_success(data={
'status': 'success',
'count': len(radiosonde_balloons), 'count': len(radiosonde_balloons),
'balloons': dict(radiosonde_balloons), 'balloons': dict(radiosonde_balloons),
}) })
+34 -41
View File
@@ -5,9 +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_error, api_success
recordings_bp = Blueprint('recordings', __name__, url_prefix='/recordings') recordings_bp = Blueprint('recordings', __name__, url_prefix='/recordings')
@@ -17,7 +18,7 @@ def start_recording():
data = request.get_json() or {} data = request.get_json() or {}
mode = (data.get('mode') or '').strip() mode = (data.get('mode') or '').strip()
if not mode: if not mode:
return jsonify({'status': 'error', 'message': 'mode is required'}), 400 return api_error('mode is required', 400)
label = data.get('label') label = data.get('label')
metadata = data.get('metadata') if isinstance(data.get('metadata'), dict) else {} metadata = data.get('metadata') if isinstance(data.get('metadata'), dict) else {}
@@ -25,16 +26,13 @@ def start_recording():
manager = get_recording_manager() manager = get_recording_manager()
session = manager.start_recording(mode=mode, label=label, metadata=metadata) session = manager.start_recording(mode=mode, label=label, metadata=metadata)
return jsonify({ return api_success(data={'session': {
'status': 'success', 'id': session.id,
'session': { 'mode': session.mode,
'id': session.id, 'label': session.label,
'mode': session.mode, 'started_at': session.started_at.isoformat(),
'label': session.label, 'file_path': str(session.file_path),
'started_at': session.started_at.isoformat(), }})
'file_path': str(session.file_path),
}
})
@recordings_bp.route('/stop', methods=['POST']) @recordings_bp.route('/stop', methods=['POST'])
@@ -46,29 +44,25 @@ def stop_recording():
manager = get_recording_manager() manager = get_recording_manager()
session = manager.stop_recording(mode=mode, session_id=session_id) session = manager.stop_recording(mode=mode, session_id=session_id)
if not session: if not session:
return jsonify({'status': 'error', 'message': 'No active recording found'}), 404 return api_error('No active recording found', 404)
return jsonify({ return api_success(data={'session': {
'status': 'success', 'id': session.id,
'session': { 'mode': session.mode,
'id': session.id, 'label': session.label,
'mode': session.mode, 'started_at': session.started_at.isoformat(),
'label': session.label, 'stopped_at': session.stopped_at.isoformat() if session.stopped_at else None,
'started_at': session.started_at.isoformat(), 'event_count': session.event_count,
'stopped_at': session.stopped_at.isoformat() if session.stopped_at else None, 'size_bytes': session.size_bytes,
'event_count': session.event_count, 'file_path': str(session.file_path),
'size_bytes': session.size_bytes, }})
'file_path': str(session.file_path),
}
})
@recordings_bp.route('', methods=['GET']) @recordings_bp.route('', methods=['GET'])
def list_recordings(): def list_recordings():
manager = get_recording_manager() manager = get_recording_manager()
limit = request.args.get('limit', default=50, type=int) limit = request.args.get('limit', default=50, type=int)
return jsonify({ return api_success(data={
'status': 'success',
'recordings': manager.list_recordings(limit=limit), 'recordings': manager.list_recordings(limit=limit),
'active': manager.get_active(), 'active': manager.get_active(),
}) })
@@ -79,8 +73,8 @@ def get_recording(session_id: str):
manager = get_recording_manager() manager = get_recording_manager()
rec = manager.get_recording(session_id) rec = manager.get_recording(session_id)
if not rec: if not rec:
return jsonify({'status': 'error', 'message': 'Recording not found'}), 404 return api_error('Recording not found', 404)
return jsonify({'status': 'success', 'recording': rec}) return api_success(data={'recording': rec})
@recordings_bp.route('/<session_id>/download', methods=['GET']) @recordings_bp.route('/<session_id>/download', methods=['GET'])
@@ -88,19 +82,19 @@ def download_recording(session_id: str):
manager = get_recording_manager() manager = get_recording_manager()
rec = manager.get_recording(session_id) rec = manager.get_recording(session_id)
if not rec: if not rec:
return jsonify({'status': 'error', 'message': 'Recording not found'}), 404 return api_error('Recording not found', 404)
file_path = Path(rec['file_path']) file_path = Path(rec['file_path'])
try: try:
resolved_root = RECORDING_ROOT.resolve() resolved_root = RECORDING_ROOT.resolve()
resolved_file = file_path.resolve() resolved_file = file_path.resolve()
if resolved_root not in resolved_file.parents: if resolved_root not in resolved_file.parents:
return jsonify({'status': 'error', 'message': 'Invalid recording path'}), 400 return api_error('Invalid recording path', 400)
except Exception: except Exception:
return jsonify({'status': 'error', 'message': 'Invalid recording path'}), 400 return api_error('Invalid recording path', 400)
if not file_path.exists(): if not file_path.exists():
return jsonify({'status': 'error', 'message': 'Recording file missing'}), 404 return api_error('Recording file missing', 404)
return send_file( return send_file(
file_path, file_path,
@@ -116,19 +110,19 @@ def get_recording_events(session_id: str):
manager = get_recording_manager() manager = get_recording_manager()
rec = manager.get_recording(session_id) rec = manager.get_recording(session_id)
if not rec: if not rec:
return jsonify({'status': 'error', 'message': 'Recording not found'}), 404 return api_error('Recording not found', 404)
file_path = Path(rec['file_path']) file_path = Path(rec['file_path'])
try: try:
resolved_root = RECORDING_ROOT.resolve() resolved_root = RECORDING_ROOT.resolve()
resolved_file = file_path.resolve() resolved_file = file_path.resolve()
if resolved_root not in resolved_file.parents: if resolved_root not in resolved_file.parents:
return jsonify({'status': 'error', 'message': 'Invalid recording path'}), 400 return api_error('Invalid recording path', 400)
except Exception: except Exception:
return jsonify({'status': 'error', 'message': 'Invalid recording path'}), 400 return api_error('Invalid recording path', 400)
if not file_path.exists(): if not file_path.exists():
return jsonify({'status': 'error', 'message': 'Recording file missing'}), 404 return api_error('Recording file missing', 404)
limit = max(1, min(5000, request.args.get('limit', default=500, type=int))) limit = max(1, min(5000, request.args.get('limit', default=500, type=int)))
offset = max(0, request.args.get('offset', default=0, type=int)) offset = max(0, request.args.get('offset', default=0, type=int))
@@ -150,8 +144,7 @@ def get_recording_events(session_id: str):
except json.JSONDecodeError: except json.JSONDecodeError:
continue continue
return jsonify({ return api_success(data={
'status': 'success',
'recording': { 'recording': {
'id': rec['id'], 'id': rec['id'],
'mode': rec['mode'], 'mode': rec['mode'],
+30 -33
View File
@@ -2,24 +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
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__)
@@ -29,6 +28,7 @@ rtl_tcp_lock = threading.Lock()
# Track which device is being used # Track which device is being used
rtlamr_active_device: int | None = None rtlamr_active_device: int | None = None
rtlamr_active_sdr_type: str = 'rtlsdr'
def stream_rtlamr_output(process: subprocess.Popen[bytes]) -> None: def stream_rtlamr_output(process: subprocess.Popen[bytes]) -> None:
@@ -62,16 +62,14 @@ def stream_rtlamr_output(process: subprocess.Popen[bytes]) -> None:
except Exception as e: except Exception as e:
app_module.rtlamr_queue.put({'type': 'error', 'text': str(e)}) app_module.rtlamr_queue.put({'type': 'error', 'text': str(e)})
finally: finally:
global rtl_tcp_process, rtlamr_active_device global rtl_tcp_process, rtlamr_active_device, rtlamr_active_sdr_type
# Ensure rtlamr process is terminated # Ensure rtlamr process is terminated
try: try:
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:
@@ -80,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'})
@@ -91,19 +87,23 @@ def stream_rtlamr_output(process: subprocess.Popen[bytes]) -> None:
app_module.rtlamr_process = None app_module.rtlamr_process = None
# Release SDR device # Release SDR device
if rtlamr_active_device is not None: if rtlamr_active_device is not None:
app_module.release_sdr_device(rtlamr_active_device) app_module.release_sdr_device(rtlamr_active_device, rtlamr_active_sdr_type)
rtlamr_active_device = None rtlamr_active_device = None
@rtlamr_bp.route('/start_rtlamr', methods=['POST']) @rtlamr_bp.route('/start_rtlamr', methods=['POST'])
def start_rtlamr() -> Response: def start_rtlamr() -> Response:
global rtl_tcp_process, rtlamr_active_device global rtl_tcp_process, rtlamr_active_device, rtlamr_active_sdr_type
with app_module.rtlamr_lock: with app_module.rtlamr_lock:
if app_module.rtlamr_process: if app_module.rtlamr_process:
return jsonify({'status': 'error', 'message': 'RTLAMR already running'}), 409 return api_error('RTLAMR already running', 409)
data = request.json or {} data = request.json or {}
sdr_type_str = data.get('sdr_type', 'rtlsdr')
if sdr_type_str != 'rtlsdr':
return api_error(f'{sdr_type_str.replace("_", " ").title()} is not yet supported for this mode. Please use an RTL-SDR device.', 400)
# Validate inputs # Validate inputs
try: try:
@@ -112,19 +112,16 @@ def start_rtlamr() -> Response:
ppm = validate_ppm(data.get('ppm', '0')) ppm = validate_ppm(data.get('ppm', '0'))
device = validate_device_index(data.get('device', '0')) device = validate_device_index(data.get('device', '0'))
except ValueError as e: except ValueError as e:
return jsonify({'status': 'error', 'message': str(e)}), 400 return api_error(str(e), 400)
# Check if device is available # Check if device is available
device_int = int(device) device_int = int(device)
error = app_module.claim_sdr_device(device_int, 'rtlamr') error = app_module.claim_sdr_device(device_int, 'rtlamr', sdr_type_str)
if error: if error:
return jsonify({ return api_error(error, 409, error_type='DEVICE_BUSY')
'status': 'error',
'error_type': 'DEVICE_BUSY',
'message': error
}), 409
rtlamr_active_device = device_int rtlamr_active_device = device_int
rtlamr_active_sdr_type = sdr_type_str
# Clear queue # Clear queue
while not app_module.rtlamr_queue.empty(): while not app_module.rtlamr_queue.empty():
@@ -170,9 +167,9 @@ def start_rtlamr() -> Response:
logger.error(f"Failed to start rtl_tcp: {e}") logger.error(f"Failed to start rtl_tcp: {e}")
# Release SDR device on rtl_tcp failure # Release SDR device on rtl_tcp failure
if rtlamr_active_device is not None: if rtlamr_active_device is not None:
app_module.release_sdr_device(rtlamr_active_device) app_module.release_sdr_device(rtlamr_active_device, rtlamr_active_sdr_type)
rtlamr_active_device = None rtlamr_active_device = None
return jsonify({'status': 'error', 'message': f'Failed to start rtl_tcp: {e}'}), 500 return api_error(f'Failed to start rtl_tcp: {e}', 500)
# Wait for rtl_tcp to start outside lock # Wait for rtl_tcp to start outside lock
if rtl_tcp_just_started: if rtl_tcp_just_started:
@@ -242,9 +239,9 @@ def start_rtlamr() -> Response:
rtl_tcp_process.wait(timeout=2) rtl_tcp_process.wait(timeout=2)
rtl_tcp_process = None rtl_tcp_process = None
if rtlamr_active_device is not None: if rtlamr_active_device is not None:
app_module.release_sdr_device(rtlamr_active_device) app_module.release_sdr_device(rtlamr_active_device, rtlamr_active_sdr_type)
rtlamr_active_device = None rtlamr_active_device = None
return jsonify({'status': 'error', 'message': 'rtlamr not found. Install from https://github.com/bemasher/rtlamr'}) return api_error('rtlamr not found. Install from https://github.com/bemasher/rtlamr')
except Exception as e: except Exception as e:
# If rtlamr fails, clean up rtl_tcp and release device # If rtlamr fails, clean up rtl_tcp and release device
with rtl_tcp_lock: with rtl_tcp_lock:
@@ -253,14 +250,14 @@ def start_rtlamr() -> Response:
rtl_tcp_process.wait(timeout=2) rtl_tcp_process.wait(timeout=2)
rtl_tcp_process = None rtl_tcp_process = None
if rtlamr_active_device is not None: if rtlamr_active_device is not None:
app_module.release_sdr_device(rtlamr_active_device) app_module.release_sdr_device(rtlamr_active_device, rtlamr_active_sdr_type)
rtlamr_active_device = None rtlamr_active_device = None
return jsonify({'status': 'error', 'message': str(e)}) return api_error(str(e))
@rtlamr_bp.route('/stop_rtlamr', methods=['POST']) @rtlamr_bp.route('/stop_rtlamr', methods=['POST'])
def stop_rtlamr() -> Response: def stop_rtlamr() -> Response:
global rtl_tcp_process, rtlamr_active_device global rtl_tcp_process, rtlamr_active_device, rtlamr_active_sdr_type
# Grab process refs inside locks, clear state, then terminate outside # Grab process refs inside locks, clear state, then terminate outside
rtlamr_proc = None rtlamr_proc = None
@@ -293,7 +290,7 @@ def stop_rtlamr() -> Response:
# Release device from registry # Release device from registry
if rtlamr_active_device is not None: if rtlamr_active_device is not None:
app_module.release_sdr_device(rtlamr_active_device) app_module.release_sdr_device(rtlamr_active_device, rtlamr_active_sdr_type)
rtlamr_active_device = None rtlamr_active_device = None
return jsonify({'status': 'stopped'}) return jsonify({'status': 'stopped'})
+481 -173
View File
@@ -2,32 +2,43 @@
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 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')
# Cache skyfield timescale to avoid re-downloading/re-parsing per request
_cached_timescale = None
def _get_timescale():
global _cached_timescale
if _cached_timescale is None:
from skyfield.api import load
# Use bundled timescale data so the first request does not block on network I/O.
_cached_timescale = load.timescale(builtin=True)
return _cached_timescale
# Maximum response size for external requests (1MB) # Maximum response size for external requests (1MB)
MAX_RESPONSE_SIZE = 1024 * 1024 MAX_RESPONSE_SIZE = 1024 * 1024
@@ -37,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."""
@@ -57,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:
@@ -69,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.
@@ -116,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,
@@ -167,174 +453,152 @@ 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 load, 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))
min_el = validate_elevation(data.get('minEl', 10)) min_el = validate_elevation(data.get('minEl', 10))
except ValueError as e: except ValueError as e:
return jsonify({'status': 'error', 'message': 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 = load.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 load, wgs84, EarthSatellite from skyfield.api import EarthSatellite, wgs84
except ImportError: except ImportError:
return jsonify({'status': 'error', 'message': 'skyfield not installed'}), 503 return api_error('skyfield not installed', 503)
data = request.json or {} data = request.json or {}
@@ -343,39 +607,34 @@ def get_satellite_position():
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)))
except ValueError as e: except ValueError as e:
return jsonify({'status': 'error', 'message': str(e)}), 400 return api_error(str(e), 400)
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 = load.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 = []
@@ -395,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)
@@ -414,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),
@@ -439,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:
@@ -451,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.
@@ -468,7 +775,8 @@ def refresh_tle_data() -> list:
'NOAA 20 (JPSS-1)': 'NOAA-20', 'NOAA 20 (JPSS-1)': 'NOAA-20',
'NOAA 21 (JPSS-2)': 'NOAA-21', 'NOAA 21 (JPSS-2)': 'NOAA-21',
'METEOR-M 2': 'METEOR-M2', 'METEOR-M 2': 'METEOR-M2',
'METEOR-M2 3': 'METEOR-M2-3' 'METEOR-M2 3': 'METEOR-M2-3',
'METEOR-M2 4': 'METEOR-M2-4'
} }
updated = [] updated = []
@@ -516,7 +824,7 @@ def update_tle():
}) })
except Exception as e: except Exception as e:
logger.error(f"Error updating TLE data: {e}") logger.error(f"Error updating TLE data: {e}")
return jsonify({'status': 'error', 'message': 'TLE update failed'}) return api_error('TLE update failed')
@satellite_bp.route('/celestrak/<category>') @satellite_bp.route('/celestrak/<category>')
@@ -530,7 +838,7 @@ def fetch_celestrak(category):
] ]
if category not in valid_categories: if category not in valid_categories:
return jsonify({'status': 'error', 'message': f'Invalid category. Valid: {valid_categories}'}) return api_error(f'Invalid category. Valid: {valid_categories}')
try: try:
url = f'https://celestrak.org/NORAD/elements/gp.php?GROUP={category}&FORMAT=tle' url = f'https://celestrak.org/NORAD/elements/gp.php?GROUP={category}&FORMAT=tle'
@@ -571,7 +879,7 @@ def fetch_celestrak(category):
except Exception as e: except Exception as e:
logger.error(f"Error fetching CelesTrak data: {e}") logger.error(f"Error fetching CelesTrak data: {e}")
return jsonify({'status': 'error', 'message': 'Failed to fetch satellite data'}) return api_error('Failed to fetch satellite data')
# ============================================================================= # =============================================================================
@@ -592,7 +900,7 @@ def add_tracked_satellites_endpoint():
global _tle_cache global _tle_cache
data = request.get_json(silent=True) data = request.get_json(silent=True)
if not data: if not data:
return jsonify({'status': 'error', 'message': 'No data provided'}), 400 return api_error('No data provided', 400)
# Accept a single satellite dict or a list # Accept a single satellite dict or a list
sat_list = data if isinstance(data, list) else [data] sat_list = data if isinstance(data, list) else [data]
@@ -655,12 +963,12 @@ def update_tracked_satellite_endpoint(norad_id):
data = request.json or {} data = request.json or {}
enabled = data.get('enabled') enabled = data.get('enabled')
if enabled is None: if enabled is None:
return jsonify({'status': 'error', 'message': 'Missing enabled field'}), 400 return api_error('Missing enabled field', 400)
ok = update_tracked_satellite(str(norad_id), bool(enabled)) ok = update_tracked_satellite(str(norad_id), bool(enabled))
if ok: if ok:
return jsonify({'status': 'success'}) return jsonify({'status': 'success'})
return jsonify({'status': 'error', 'message': 'Satellite not found'}), 404 return api_error('Satellite not found', 404)
@satellite_bp.route('/tracked/<norad_id>', methods=['DELETE']) @satellite_bp.route('/tracked/<norad_id>', methods=['DELETE'])
@@ -670,4 +978,4 @@ def delete_tracked_satellite_endpoint(norad_id):
if ok: if ok:
return jsonify({'status': 'success', 'message': msg}) return jsonify({'status': 'success', 'message': msg})
status_code = 403 if 'builtin' in msg.lower() else 404 status_code = 403 if 'builtin' in msg.lower() else 404
return jsonify({'status': 'error', 'message': msg}), status_code return api_error(msg, status_code)
+23 -23
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,20 +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
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__)
@@ -136,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:
@@ -165,7 +169,7 @@ def start_sensor() -> Response:
with app_module.sensor_lock: with app_module.sensor_lock:
if app_module.sensor_process: if app_module.sensor_process:
return jsonify({'status': 'error', 'message': 'Sensor already running'}), 409 return api_error('Sensor already running', 409)
data = request.json or {} data = request.json or {}
@@ -176,7 +180,7 @@ def start_sensor() -> Response:
ppm = validate_ppm(data.get('ppm', '0')) ppm = validate_ppm(data.get('ppm', '0'))
device = validate_device_index(data.get('device', '0')) device = validate_device_index(data.get('device', '0'))
except ValueError as e: except ValueError as e:
return jsonify({'status': 'error', 'message': str(e)}), 400 return api_error(str(e), 400)
# Check for rtl_tcp (remote SDR) connection # Check for rtl_tcp (remote SDR) connection
rtl_tcp_host = data.get('rtl_tcp_host') rtl_tcp_host = data.get('rtl_tcp_host')
@@ -190,11 +194,7 @@ def start_sensor() -> Response:
device_int = int(device) device_int = int(device)
error = app_module.claim_sdr_device(device_int, 'sensor', sdr_type_str) error = app_module.claim_sdr_device(device_int, 'sensor', sdr_type_str)
if error: if error:
return jsonify({ return api_error(error, 409, error_type='DEVICE_BUSY')
'status': 'error',
'error_type': 'DEVICE_BUSY',
'message': error
}), 409
sensor_active_device = device_int sensor_active_device = device_int
sensor_active_sdr_type = sdr_type_str sensor_active_sdr_type = sdr_type_str
@@ -217,7 +217,7 @@ def start_sensor() -> Response:
rtl_tcp_host = validate_rtl_tcp_host(rtl_tcp_host) rtl_tcp_host = validate_rtl_tcp_host(rtl_tcp_host)
rtl_tcp_port = validate_rtl_tcp_port(rtl_tcp_port) rtl_tcp_port = validate_rtl_tcp_port(rtl_tcp_port)
except ValueError as e: except ValueError as e:
return jsonify({'status': 'error', 'message': str(e)}), 400 return api_error(str(e), 400)
sdr_device = SDRFactory.create_network_device(rtl_tcp_host, rtl_tcp_port) sdr_device = SDRFactory.create_network_device(rtl_tcp_host, rtl_tcp_port)
logger.info(f"Using remote SDR: rtl_tcp://{rtl_tcp_host}:{rtl_tcp_port}") logger.info(f"Using remote SDR: rtl_tcp://{rtl_tcp_host}:{rtl_tcp_port}")
@@ -285,14 +285,14 @@ def start_sensor() -> Response:
app_module.release_sdr_device(sensor_active_device, sensor_active_sdr_type or 'rtlsdr') app_module.release_sdr_device(sensor_active_device, sensor_active_sdr_type or 'rtlsdr')
sensor_active_device = None sensor_active_device = None
sensor_active_sdr_type = None sensor_active_sdr_type = None
return jsonify({'status': 'error', 'message': 'rtl_433 not found. Install with: brew install rtl_433'}) return api_error('rtl_433 not found. Install with: brew install rtl_433')
except Exception as e: except Exception as e:
# Release device on failure # Release device on failure
if sensor_active_device is not None: if sensor_active_device is not None:
app_module.release_sdr_device(sensor_active_device, sensor_active_sdr_type or 'rtlsdr') app_module.release_sdr_device(sensor_active_device, sensor_active_sdr_type or 'rtlsdr')
sensor_active_device = None sensor_active_device = None
sensor_active_sdr_type = None sensor_active_sdr_type = None
return jsonify({'status': 'error', 'message': str(e)}) return api_error(str(e))
@sensor_bp.route('/stop_sensor', methods=['POST']) @sensor_bp.route('/stop_sensor', methods=['POST'])
@@ -346,4 +346,4 @@ def get_rssi_history() -> Response:
result = {} result = {}
for key, entries in sensor_rssi_history.items(): for key, entries in sensor_rssi_history.items():
result[key] = [{'t': round(t, 1), 'rssi': rssi} for t, rssi in entries] result[key] = [{'t': round(t, 1), 'rssi': rssi} for t, rssi in entries]
return jsonify({'status': 'success', 'devices': result}) return api_success(data={'devices': result})
+124 -71
View File
@@ -2,24 +2,100 @@
from __future__ import annotations from __future__ import annotations
import contextlib
import os import os
import re
import subprocess import subprocess
import sys import sys
import threading
from pathlib import Path
from flask import Blueprint, jsonify, request, Response from flask import Blueprint, Response, jsonify, request
from utils.database import ( from utils.database import (
get_setting,
set_setting,
delete_setting, delete_setting,
get_all_settings, get_all_settings,
get_correlations, get_correlations,
get_setting,
set_setting,
) )
from utils.logging import get_logger from utils.logging import get_logger
from utils.responses import api_error, api_success
from utils.validation import validate_latitude, validate_longitude
logger = get_logger('intercept.settings') logger = get_logger('intercept.settings')
settings_bp = Blueprint('settings', __name__, url_prefix='/settings') settings_bp = Blueprint('settings', __name__, url_prefix='/settings')
_env_lock = threading.Lock()
def _get_env_file_path() -> Path:
"""Return the project .env path."""
return Path(__file__).resolve().parent.parent / '.env'
def _write_env_value(key: str, value: str, env_path: Path | None = None) -> None:
"""Create or update a single key in the project .env file."""
path = env_path or _get_env_file_path()
path.parent.mkdir(parents=True, exist_ok=True)
with _env_lock:
lines = path.read_text().splitlines() if path.exists() else [
'# INTERCEPT environment configuration',
'',
]
pattern = re.compile(rf'^\s*{re.escape(key)}=')
updated = False
new_lines: list[str] = []
for line in lines:
if pattern.match(line):
if not updated:
new_lines.append(f'{key}={value}')
updated = True
continue
new_lines.append(line)
if not updated:
if new_lines and new_lines[-1] != '':
new_lines.append('')
new_lines.append(f'{key}={value}')
path.write_text('\n'.join(new_lines).rstrip('\n') + '\n')
sudo_uid = os.environ.get('INTERCEPT_SUDO_UID')
sudo_gid = os.environ.get('INTERCEPT_SUDO_GID')
if os.geteuid() == 0 and sudo_uid and sudo_gid:
with contextlib.suppress(OSError, ValueError):
os.chown(path, int(sudo_uid), int(sudo_gid))
def _apply_runtime_observer_defaults(lat: float, lon: float) -> None:
"""Update in-process defaults so refreshed pages use the saved location."""
lat_str = str(lat)
lon_str = str(lon)
os.environ['INTERCEPT_DEFAULT_LAT'] = lat_str
os.environ['INTERCEPT_DEFAULT_LON'] = lon_str
import config
config.DEFAULT_LATITUDE = lat
config.DEFAULT_LONGITUDE = lon
with contextlib.suppress(Exception):
import app as app_module
app_module.DEFAULT_LATITUDE = lat
app_module.DEFAULT_LONGITUDE = lon
with contextlib.suppress(Exception):
from routes import adsb as adsb_routes
adsb_routes.DEFAULT_LATITUDE = lat
adsb_routes.DEFAULT_LONGITUDE = lon
with contextlib.suppress(Exception):
from routes import ais as ais_routes
ais_routes.DEFAULT_LATITUDE = lat
ais_routes.DEFAULT_LONGITUDE = lon
@settings_bp.route('', methods=['GET']) @settings_bp.route('', methods=['GET'])
@@ -27,16 +103,10 @@ def get_settings() -> Response:
"""Get all settings.""" """Get all settings."""
try: try:
settings = get_all_settings() settings = get_all_settings()
return jsonify({ return api_success(data={'settings': settings})
'status': 'success',
'settings': settings
})
except Exception as e: except Exception as e:
logger.error(f"Error getting settings: {e}") logger.error(f"Error getting settings: {e}")
return jsonify({ return api_error(str(e), 500)
'status': 'error',
'message': str(e)
}), 500
@settings_bp.route('', methods=['POST']) @settings_bp.route('', methods=['POST'])
@@ -45,10 +115,7 @@ def save_settings() -> Response:
data = request.json or {} data = request.json or {}
if not data: if not data:
return jsonify({ return api_error('No settings provided', 400)
'status': 'error',
'message': 'No settings provided'
}), 400
try: try:
saved = [] saved = []
@@ -60,16 +127,10 @@ def save_settings() -> Response:
set_setting(key, value) set_setting(key, value)
saved.append(key) saved.append(key)
return jsonify({ return api_success(data={'saved': saved})
'status': 'success',
'saved': saved
})
except Exception as e: except Exception as e:
logger.error(f"Error saving settings: {e}") logger.error(f"Error saving settings: {e}")
return jsonify({ return api_error(str(e), 500)
'status': 'error',
'message': str(e)
}), 500
@settings_bp.route('/<key>', methods=['GET']) @settings_bp.route('/<key>', methods=['GET'])
@@ -83,17 +144,10 @@ def get_single_setting(key: str) -> Response:
'key': key 'key': key
}), 404 }), 404
return jsonify({ return api_success(data={'key': key, 'value': value})
'status': 'success',
'key': key,
'value': value
})
except Exception as e: except Exception as e:
logger.error(f"Error getting setting {key}: {e}") logger.error(f"Error getting setting {key}: {e}")
return jsonify({ return api_error(str(e), 500)
'status': 'error',
'message': str(e)
}), 500
@settings_bp.route('/<key>', methods=['PUT']) @settings_bp.route('/<key>', methods=['PUT'])
@@ -103,24 +157,14 @@ def update_single_setting(key: str) -> Response:
value = data.get('value') value = data.get('value')
if value is None and 'value' not in data: if value is None and 'value' not in data:
return jsonify({ return api_error('Value is required', 400)
'status': 'error',
'message': 'Value is required'
}), 400
try: try:
set_setting(key, value) set_setting(key, value)
return jsonify({ return api_success(data={'key': key, 'value': value})
'status': 'success',
'key': key,
'value': value
})
except Exception as e: except Exception as e:
logger.error(f"Error updating setting {key}: {e}") logger.error(f"Error updating setting {key}: {e}")
return jsonify({ return api_error(str(e), 500)
'status': 'error',
'message': str(e)
}), 500
@settings_bp.route('/<key>', methods=['DELETE']) @settings_bp.route('/<key>', methods=['DELETE'])
@@ -129,11 +173,7 @@ def delete_single_setting(key: str) -> Response:
try: try:
deleted = delete_setting(key) deleted = delete_setting(key)
if deleted: if deleted:
return jsonify({ return api_success(data={'key': key, 'deleted': True})
'status': 'success',
'key': key,
'deleted': True
})
else: else:
return jsonify({ return jsonify({
'status': 'not_found', 'status': 'not_found',
@@ -141,10 +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 jsonify({ return api_error(str(e), 500)
'status': 'error',
'message': 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)
# ============================================================================= # =============================================================================
@@ -158,16 +223,10 @@ def get_device_correlations() -> Response:
try: try:
correlations = get_correlations(min_confidence) correlations = get_correlations(min_confidence)
return jsonify({ return api_success(data={'correlations': correlations})
'status': 'success',
'correlations': correlations
})
except Exception as e: except Exception as e:
logger.error(f"Error getting correlations: {e}") logger.error(f"Error getting correlations: {e}")
return jsonify({ return api_error(str(e), 500)
'status': 'error',
'message': str(e)
}), 500
# ============================================================================= # =============================================================================
@@ -207,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
@@ -229,17 +288,11 @@ def check_dvb_driver_status() -> Response:
def blacklist_dvb_drivers() -> Response: def blacklist_dvb_drivers() -> Response:
"""Blacklist DVB kernel drivers to prevent them from claiming RTL-SDR devices.""" """Blacklist DVB kernel drivers to prevent them from claiming RTL-SDR devices."""
if sys.platform != 'linux': if sys.platform != 'linux':
return jsonify({ return api_error('This feature is only available on Linux', 400)
'status': 'error',
'message': 'This feature is only available on Linux'
}), 400
# Check if we have permission (need to be running as root or with sudo) # Check if we have permission (need to be running as root or with sudo)
if os.geteuid() != 0: if os.geteuid() != 0:
return jsonify({ return api_error('Root privileges required. Run the app with sudo or manually run: sudo modprobe -r dvb_usb_rtl28xxu rtl2832_sdr rtl2832 r820t', 403)
'status': 'error',
'message': 'Root privileges required. Run the app with sudo or manually run: sudo modprobe -r dvb_usb_rtl28xxu rtl2832_sdr rtl2832 r820t'
}), 403
errors = [] errors = []
successes = [] successes = []
+5 -4
View File
@@ -11,6 +11,7 @@ from typing import Any
from flask import Blueprint, Response, jsonify, request from flask import Blueprint, Response, jsonify, request
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')
@@ -294,15 +295,15 @@ def sigidwiki_lookup() -> Response:
freq_raw = payload.get('frequency_mhz') freq_raw = payload.get('frequency_mhz')
if freq_raw is None: if freq_raw is None:
return jsonify({'status': 'error', 'message': 'frequency_mhz is required'}), 400 return api_error('frequency_mhz is required', 400)
try: try:
frequency_mhz = float(freq_raw) frequency_mhz = float(freq_raw)
except (TypeError, ValueError): except (TypeError, ValueError):
return jsonify({'status': 'error', 'message': 'Invalid frequency_mhz'}), 400 return api_error('Invalid frequency_mhz', 400)
if frequency_mhz <= 0: if frequency_mhz <= 0:
return jsonify({'status': 'error', 'message': 'frequency_mhz must be positive'}), 400 return api_error('frequency_mhz must be positive', 400)
modulation = str(payload.get('modulation') or '').strip().upper() modulation = str(payload.get('modulation') or '').strip().upper()
if modulation and len(modulation) > 16: if modulation and len(modulation) > 16:
@@ -331,7 +332,7 @@ def sigidwiki_lookup() -> Response:
lookup = _lookup_sigidwiki_matches(frequency_mhz, modulation, limit) lookup = _lookup_sigidwiki_matches(frequency_mhz, modulation, limit)
except Exception as exc: except Exception as exc:
logger.error('SigID lookup failed: %s', exc) logger.error('SigID lookup failed: %s', exc)
return jsonify({'status': 'error', 'message': 'SigID lookup failed'}), 502 return api_error('SigID lookup failed', 502)
response_payload = { response_payload = {
'matches': lookup.get('matches', []), 'matches': lookup.get('matches', []),
+57 -17
View File
@@ -2,6 +2,7 @@
from __future__ import annotations from __future__ import annotations
import concurrent.futures
import json import json
import time import time
import urllib.error import urllib.error
@@ -12,6 +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_error
logger = get_logger('intercept.space_weather') logger = get_logger('intercept.space_weather')
@@ -259,22 +261,27 @@ IMAGE_WHITELIST: dict[str, dict[str, str]] = {
@space_weather_bp.route('/data') @space_weather_bp.route('/data')
def get_data(): def get_data():
"""Return aggregated space weather data from all sources.""" """Return aggregated space weather data from all sources."""
data = { fetchers = {
'kp_index': _fetch_kp_index(), 'kp_index': _fetch_kp_index,
'kp_forecast': _fetch_kp_forecast(), 'kp_forecast': _fetch_kp_forecast,
'scales': _fetch_scales(), 'scales': _fetch_scales,
'flux': _fetch_flux(), 'flux': _fetch_flux,
'alerts': _fetch_alerts(), 'alerts': _fetch_alerts,
'solar_wind_plasma': _fetch_solar_wind_plasma(), 'solar_wind_plasma': _fetch_solar_wind_plasma,
'solar_wind_mag': _fetch_solar_wind_mag(), 'solar_wind_mag': _fetch_solar_wind_mag,
'xrays': _fetch_xrays(), 'xrays': _fetch_xrays,
'xray_flares': _fetch_xray_flares(), 'xray_flares': _fetch_xray_flares,
'flare_probability': _fetch_flare_probability(), 'flare_probability': _fetch_flare_probability,
'solar_regions': _fetch_solar_regions(), 'solar_regions': _fetch_solar_regions,
'sunspot_report': _fetch_sunspot_report(), 'sunspot_report': _fetch_sunspot_report,
'band_conditions': _fetch_band_conditions(), 'band_conditions': _fetch_band_conditions,
'timestamp': time.time(),
} }
data = {}
with concurrent.futures.ThreadPoolExecutor(max_workers=13) as executor:
futures = {executor.submit(fn): key for key, fn in fetchers.items()}
for future in concurrent.futures.as_completed(futures):
data[futures[future]] = future.result()
data['timestamp'] = time.time()
return jsonify(data) return jsonify(data)
@@ -283,7 +290,7 @@ def get_image(key: str):
"""Proxy and cache whitelisted space weather images.""" """Proxy and cache whitelisted space weather images."""
entry = IMAGE_WHITELIST.get(key) entry = IMAGE_WHITELIST.get(key)
if not entry: if not entry:
return jsonify({'error': 'Unknown image key'}), 404 return api_error('Unknown image key', 404)
cache_key = f'img_{key}' cache_key = f'img_{key}'
cached = _cache_get(cache_key) cached = _cache_get(cache_key)
@@ -293,8 +300,41 @@ def get_image(key: str):
img_data = _fetch_bytes(entry['url']) img_data = _fetch_bytes(entry['url'])
if img_data is None: if img_data is None:
return jsonify({'error': 'Failed to fetch image'}), 502 return api_error('Failed to fetch image', 502)
_cache_set(cache_key, img_data, TTL_IMAGE) _cache_set(cache_key, img_data, TTL_IMAGE)
return Response(img_data, content_type=entry['content_type'], return Response(img_data, content_type=entry['content_type'],
headers={'Cache-Control': 'public, max-age=300'}) headers={'Cache-Control': 'public, max-age=300'})
@space_weather_bp.route('/prefetch-images')
def prefetch_images():
"""Warm the image cache by fetching all whitelisted images in parallel."""
# Only fetch images not already cached
to_fetch = {}
for key, entry in IMAGE_WHITELIST.items():
cache_key = f'img_{key}'
if _cache_get(cache_key) is None:
to_fetch[key] = entry
if not to_fetch:
return jsonify({'status': 'all cached', 'count': 0})
def _fetch_and_cache(key: str, entry: dict) -> bool:
img_data = _fetch_bytes(entry['url'])
if img_data:
_cache_set(f'img_{key}', img_data, TTL_IMAGE)
return True
return False
fetched = 0
with concurrent.futures.ThreadPoolExecutor(max_workers=6) as executor:
futures = {
executor.submit(_fetch_and_cache, k, e): k
for k, e in to_fetch.items()
}
for future in concurrent.futures.as_completed(futures):
if future.result():
fetched += 1
return jsonify({'status': 'ok', 'fetched': fetched, 'cached': len(IMAGE_WHITELIST) - len(to_fetch)})
+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',
+161 -83
View File
@@ -6,21 +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 time import time
from pathlib import Path from pathlib import Path
from typing import Generator from typing import Any
from flask import Blueprint, jsonify, request, Response, send_file from flask import Blueprint, Response, jsonify, request, send_file
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')
@@ -36,8 +39,27 @@ ISS_SSTV_FREQ_TOLERANCE_MHZ = 0.05
# Queue for SSE progress streaming # Queue for SSE progress streaming
_sstv_queue: queue.Queue = queue.Queue(maxsize=100) _sstv_queue: queue.Queue = queue.Queue(maxsize=100)
# ---------------------------------------------------------------------------
# Caching — ISS position (external API) and schedule (skyfield computation)
# ---------------------------------------------------------------------------
_iss_position_cache: dict | None = None
_iss_position_cache_time: float = 0
_iss_position_lock = threading.Lock()
ISS_POSITION_CACHE_TTL = 10 # seconds
_iss_schedule_cache: dict | None = None
_iss_schedule_cache_time: float = 0
_iss_schedule_cache_key: str | None = None
_iss_schedule_lock = threading.Lock()
ISS_SCHEDULE_CACHE_TTL = 900 # 15 minutes
# Reusable skyfield timescale (expensive to create)
_timescale = None
_timescale_lock = threading.Lock()
# Track which device is being used # Track which device is being used
sstv_active_device: int | None = None sstv_active_device: int | None = None
sstv_active_sdr_type: str = 'rtlsdr'
def _progress_callback(data: dict) -> None: def _progress_callback(data: dict) -> None:
@@ -135,6 +157,14 @@ def start_decoder():
# Get parameters # Get parameters
data = request.get_json(silent=True) or {} data = request.get_json(silent=True) or {}
sdr_type_str = data.get('sdr_type', 'rtlsdr')
if sdr_type_str != 'rtlsdr':
return jsonify({
'status': 'error',
'message': f'{sdr_type_str.replace("_", " ").title()} is not yet supported for this mode. Please use an RTL-SDR device.'
}), 400
frequency = data.get('frequency', ISS_SSTV_FREQ) frequency = data.get('frequency', ISS_SSTV_FREQ)
modulation = str(data.get('modulation', ISS_SSTV_MODULATION)).strip().lower() modulation = str(data.get('modulation', ISS_SSTV_MODULATION)).strip().lower()
device_index = data.get('device', 0) device_index = data.get('device', 0)
@@ -190,9 +220,9 @@ def start_decoder():
longitude = None longitude = None
# Claim SDR device # Claim SDR device
global sstv_active_device global sstv_active_device, sstv_active_sdr_type
device_int = int(device_index) device_int = int(device_index)
error = app_module.claim_sdr_device(device_int, 'sstv') error = app_module.claim_sdr_device(device_int, 'sstv', sdr_type_str)
if error: if error:
return jsonify({ return jsonify({
'status': 'error', 'status': 'error',
@@ -212,6 +242,7 @@ def start_decoder():
if success: if success:
sstv_active_device = device_int sstv_active_device = device_int
sstv_active_sdr_type = sdr_type_str
result = { result = {
'status': 'started', 'status': 'started',
@@ -228,7 +259,7 @@ def start_decoder():
return jsonify(result) return jsonify(result)
else: else:
# Release device on failure # Release device on failure
app_module.release_sdr_device(device_int) app_module.release_sdr_device(device_int, sdr_type_str)
return jsonify({ return jsonify({
'status': 'error', 'status': 'error',
'message': 'Failed to start decoder' 'message': 'Failed to start decoder'
@@ -243,13 +274,13 @@ def stop_decoder():
Returns: Returns:
JSON confirmation. JSON confirmation.
""" """
global sstv_active_device global sstv_active_device, sstv_active_sdr_type
decoder = get_sstv_decoder() decoder = get_sstv_decoder()
decoder.stop() decoder.stop()
# Release device from registry # Release device from registry
if sstv_active_device is not None: if sstv_active_device is not None:
app_module.release_sdr_device(sstv_active_device) app_module.release_sdr_device(sstv_active_device, sstv_active_sdr_type)
sstv_active_device = None sstv_active_device = None
return jsonify({'status': 'stopped'}) return jsonify({'status': 'stopped'})
@@ -328,16 +359,16 @@ def get_image(filename: str):
# Security: only allow alphanumeric filenames with .png extension # Security: only allow alphanumeric filenames with .png extension
if not filename.replace('_', '').replace('-', '').replace('.', '').isalnum(): if not filename.replace('_', '').replace('-', '').replace('.', '').isalnum():
return jsonify({'status': 'error', 'message': 'Invalid filename'}), 400 return api_error('Invalid filename', 400)
if not filename.endswith('.png'): if not filename.endswith('.png'):
return jsonify({'status': 'error', 'message': 'Only PNG files supported'}), 400 return api_error('Only PNG files supported', 400)
# Find image in decoder's output directory # Find image in decoder's output directory
image_path = decoder._output_dir / filename image_path = decoder._output_dir / filename
if not image_path.exists(): if not image_path.exists():
return jsonify({'status': 'error', 'message': 'Image not found'}), 404 return api_error('Image not found', 404)
return send_file(image_path, mimetype='image/png') return send_file(image_path, mimetype='image/png')
@@ -357,15 +388,15 @@ def download_image(filename: str):
# Security: only allow alphanumeric filenames with .png extension # Security: only allow alphanumeric filenames with .png extension
if not filename.replace('_', '').replace('-', '').replace('.', '').isalnum(): if not filename.replace('_', '').replace('-', '').replace('.', '').isalnum():
return jsonify({'status': 'error', 'message': 'Invalid filename'}), 400 return api_error('Invalid filename', 400)
if not filename.endswith('.png'): if not filename.endswith('.png'):
return jsonify({'status': 'error', 'message': 'Only PNG files supported'}), 400 return api_error('Only PNG files supported', 400)
image_path = decoder._output_dir / filename image_path = decoder._output_dir / filename
if not image_path.exists(): if not image_path.exists():
return jsonify({'status': 'error', 'message': 'Image not found'}), 404 return api_error('Image not found', 404)
return send_file(image_path, mimetype='image/png', as_attachment=True, download_name=filename) return send_file(image_path, mimetype='image/png', as_attachment=True, download_name=filename)
@@ -385,15 +416,15 @@ def delete_image(filename: str):
# Security: only allow alphanumeric filenames with .png extension # Security: only allow alphanumeric filenames with .png extension
if not filename.replace('_', '').replace('-', '').replace('.', '').isalnum(): if not filename.replace('_', '').replace('-', '').replace('.', '').isalnum():
return jsonify({'status': 'error', 'message': 'Invalid filename'}), 400 return api_error('Invalid filename', 400)
if not filename.endswith('.png'): if not filename.endswith('.png'):
return jsonify({'status': 'error', 'message': 'Only PNG files supported'}), 400 return api_error('Only PNG files supported', 400)
if decoder.delete_image(filename): if decoder.delete_image(filename):
return jsonify({'status': 'ok'}) return jsonify({'status': 'ok'})
else: else:
return jsonify({'status': 'error', 'message': 'Image not found'}), 404 return api_error('Image not found', 404)
@sstv_bp.route('/images', methods=['DELETE']) @sstv_bp.route('/images', methods=['DELETE'])
@@ -441,12 +472,23 @@ def stream_progress():
return response return response
def _get_timescale():
"""Return a cached skyfield timescale (expensive to create)."""
global _timescale
with _timescale_lock:
if _timescale is None:
from skyfield.api import load
_timescale = load.timescale(builtin=True)
return _timescale
@sstv_bp.route('/iss-schedule') @sstv_bp.route('/iss-schedule')
def iss_schedule(): def iss_schedule():
""" """
Get ISS pass schedule for SSTV reception. Get ISS pass schedule for SSTV reception.
Calculates ISS passes directly using skyfield. Calculates ISS passes directly using skyfield.
Results are cached for 15 minutes per rounded location.
Query parameters: Query parameters:
latitude: Observer latitude (required) latitude: Observer latitude (required)
@@ -456,6 +498,8 @@ def iss_schedule():
Returns: Returns:
JSON with ISS pass schedule. JSON with ISS pass schedule.
""" """
global _iss_schedule_cache, _iss_schedule_cache_time, _iss_schedule_cache_key
lat = request.args.get('latitude', type=float) lat = request.args.get('latitude', type=float)
lon = request.args.get('longitude', type=float) lon = request.args.get('longitude', type=float)
hours = request.args.get('hours', 48, type=int) hours = request.args.get('hours', 48, type=int)
@@ -466,10 +510,22 @@ def iss_schedule():
'message': 'latitude and longitude parameters required' 'message': 'latitude and longitude parameters required'
}), 400 }), 400
# Cache key: rounded lat/lon (1 decimal place) so nearby locations share cache
cache_key = f"{round(lat, 1)}:{round(lon, 1)}:{hours}"
with _iss_schedule_lock:
now = time.time()
if (_iss_schedule_cache is not None
and cache_key == _iss_schedule_cache_key
and (now - _iss_schedule_cache_time) < ISS_SCHEDULE_CACHE_TTL):
return jsonify(_iss_schedule_cache)
try: try:
from skyfield.api import load, 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
@@ -480,7 +536,7 @@ def iss_schedule():
'message': 'ISS TLE data not available' 'message': 'ISS TLE data not available'
}), 500 }), 500
ts = load.timescale() ts = _get_timescale()
satellite = EarthSatellite(iss_tle[1], iss_tle[2], iss_tle[0], ts) satellite = EarthSatellite(iss_tle[1], iss_tle[2], iss_tle[0], ts)
observer = wgs84.latlon(lat, lon) observer = wgs84.latlon(lat, lon)
@@ -543,13 +599,21 @@ def iss_schedule():
i += 1 i += 1
return jsonify({ result = {
'status': 'ok', 'status': 'ok',
'passes': passes, 'passes': passes,
'count': len(passes), 'count': len(passes),
'sstv_frequency': ISS_SSTV_FREQ, 'sstv_frequency': ISS_SSTV_FREQ,
'note': 'ISS SSTV events are not continuous. Check ARISS.org for scheduled events.' 'note': 'ISS SSTV events are not continuous. Check ARISS.org for scheduled events.'
}) }
# Update cache
with _iss_schedule_lock:
_iss_schedule_cache = result
_iss_schedule_cache_time = time.time()
_iss_schedule_cache_key = cache_key
return jsonify(result)
except ImportError: except ImportError:
return jsonify({ return jsonify({
@@ -565,13 +629,65 @@ def iss_schedule():
}), 500 }), 500
def _fetch_iss_position() -> dict | None:
"""Fetch raw ISS lat/lon/altitude from external APIs, with 10s cache."""
global _iss_position_cache, _iss_position_cache_time
with _iss_position_lock:
now = time.time()
if _iss_position_cache is not None and (now - _iss_position_cache_time) < ISS_POSITION_CACHE_TTL:
return _iss_position_cache
import requests
cached = None
# Try primary API: Where The ISS At
try:
response = requests.get('https://api.wheretheiss.at/v1/satellites/25544', timeout=3)
if response.status_code == 200:
data = response.json()
cached = {
'lat': float(data['latitude']),
'lon': float(data['longitude']),
'altitude': float(data.get('altitude', 420)),
'source': 'wheretheiss',
}
except Exception as e:
logger.warning(f"Where The ISS At API failed: {e}")
# Try fallback API: Open Notify
if cached is None:
try:
response = requests.get('http://api.open-notify.org/iss-now.json', timeout=3)
if response.status_code == 200:
data = response.json()
if data.get('message') == 'success':
cached = {
'lat': float(data['iss_position']['latitude']),
'lon': float(data['iss_position']['longitude']),
'altitude': 420,
'source': 'open-notify',
}
except Exception as e:
logger.warning(f"Open Notify API failed: {e}")
if cached is not None:
with _iss_position_lock:
_iss_position_cache = cached
_iss_position_cache_time = time.time()
return cached
@sstv_bp.route('/iss-position') @sstv_bp.route('/iss-position')
def iss_position(): def iss_position():
""" """
Get current ISS position from real-time API. Get current ISS position from real-time API.
Uses the Open Notify API for accurate real-time position, Uses the "Where The ISS At" API for accurate real-time position,
with fallback to "Where The ISS At" API. with fallback to Open Notify API. Raw position is cached for 10 seconds;
observer-relative data (elevation/azimuth) is computed per-request.
Query parameters: Query parameters:
latitude: Observer latitude (optional, for elevation calc) latitude: Observer latitude (optional, for elevation calc)
@@ -580,68 +696,32 @@ def iss_position():
Returns: Returns:
JSON with ISS current position. JSON with ISS current position.
""" """
import requests
from datetime import datetime from datetime import datetime
observer_lat = request.args.get('latitude', type=float) observer_lat = request.args.get('latitude', type=float)
observer_lon = request.args.get('longitude', type=float) observer_lon = request.args.get('longitude', type=float)
# Try primary API: Where The ISS At pos = _fetch_iss_position()
try: if pos is None:
response = requests.get('https://api.wheretheiss.at/v1/satellites/25544', timeout=5) return jsonify({
if response.status_code == 200: 'status': 'error',
data = response.json() 'message': 'Unable to fetch ISS position from real-time APIs'
iss_lat = float(data['latitude']) }), 503
iss_lon = float(data['longitude'])
result = { result = {
'status': 'ok', 'status': 'ok',
'lat': iss_lat, 'lat': pos['lat'],
'lon': iss_lon, 'lon': pos['lon'],
'altitude': float(data.get('altitude', 420)), 'altitude': pos['altitude'],
'timestamp': datetime.utcnow().isoformat(), 'timestamp': datetime.utcnow().isoformat(),
'source': 'wheretheiss' 'source': pos['source'],
} }
# Calculate observer-relative data if location provided # Calculate observer-relative data if location provided
if observer_lat is not None and observer_lon is not None: if observer_lat is not None and observer_lon is not None:
result.update(_calculate_observer_data(iss_lat, iss_lon, observer_lat, observer_lon)) result.update(_calculate_observer_data(pos['lat'], pos['lon'], observer_lat, observer_lon))
return jsonify(result) return jsonify(result)
except Exception as e:
logger.warning(f"Where The ISS At API failed: {e}")
# Try fallback API: Open Notify
try:
response = requests.get('http://api.open-notify.org/iss-now.json', timeout=5)
if response.status_code == 200:
data = response.json()
if data.get('message') == 'success':
iss_lat = float(data['iss_position']['latitude'])
iss_lon = float(data['iss_position']['longitude'])
result = {
'status': 'ok',
'lat': iss_lat,
'lon': iss_lon,
'altitude': 420, # Approximate ISS altitude in km
'timestamp': datetime.utcnow().isoformat(),
'source': 'open-notify'
}
# Calculate observer-relative data if location provided
if observer_lat is not None and observer_lon is not None:
result.update(_calculate_observer_data(iss_lat, iss_lon, observer_lat, observer_lon))
return jsonify(result)
except Exception as e:
logger.warning(f"Open Notify API failed: {e}")
# Both APIs failed
return jsonify({
'status': 'error',
'message': 'Unable to fetch ISS position from real-time APIs'
}), 503
def _calculate_observer_data(iss_lat: float, iss_lon: float, obs_lat: float, obs_lon: float) -> dict: def _calculate_observer_data(iss_lat: float, iss_lon: float, obs_lat: float, obs_lon: float) -> dict:
@@ -739,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
+36 -62
View File
@@ -6,17 +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
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,
) )
@@ -30,6 +30,7 @@ _sstv_general_queue: queue.Queue = queue.Queue(maxsize=100)
# Track which device is being used # Track which device is being used
_sstv_general_active_device: int | None = None _sstv_general_active_device: int | None = None
_sstv_general_active_sdr_type: str = 'rtlsdr'
# Predefined SSTV frequencies # Predefined SSTV frequencies
SSTV_FREQUENCIES = [ SSTV_FREQUENCIES = [
@@ -101,10 +102,7 @@ def start_decoder():
decoder = get_general_sstv_decoder() decoder = get_general_sstv_decoder()
if decoder.decoder_available is None: if decoder.decoder_available is None:
return jsonify({ return api_error('SSTV decoder not available. Install numpy and Pillow: pip install numpy Pillow', 400)
'status': 'error',
'message': 'SSTV decoder not available. Install numpy and Pillow: pip install numpy Pillow',
}), 400
if decoder.is_running: if decoder.is_running:
return jsonify({ return jsonify({
@@ -119,29 +117,25 @@ def start_decoder():
break break
data = request.get_json(silent=True) or {} data = request.get_json(silent=True) or {}
sdr_type_str = data.get('sdr_type', 'rtlsdr')
if sdr_type_str != 'rtlsdr':
return api_error(f'{sdr_type_str.replace("_", " ").title()} is not yet supported for this mode. Please use an RTL-SDR device.', 400)
frequency = data.get('frequency') frequency = data.get('frequency')
modulation = data.get('modulation') modulation = data.get('modulation')
device_index = data.get('device', 0) device_index = data.get('device', 0)
# Validate frequency # Validate frequency
if frequency is None: if frequency is None:
return jsonify({ return api_error('Frequency is required', 400)
'status': 'error',
'message': 'Frequency is required',
}), 400
try: try:
frequency = float(frequency) frequency = float(frequency)
if not (1 <= frequency <= 500): if not (1 <= frequency <= 500):
return jsonify({ return api_error('Frequency must be between 1-500 MHz (HF requires upconverter for RTL-SDR)', 400)
'status': 'error',
'message': 'Frequency must be between 1-500 MHz (HF requires upconverter for RTL-SDR)',
}), 400
except (TypeError, ValueError): except (TypeError, ValueError):
return jsonify({ return api_error('Invalid frequency', 400)
'status': 'error',
'message': 'Invalid frequency',
}), 400
# Auto-detect modulation from frequency table if not specified # Auto-detect modulation from frequency table if not specified
if not modulation: if not modulation:
@@ -149,21 +143,14 @@ def start_decoder():
# Validate modulation # Validate modulation
if modulation not in ('fm', 'usb', 'lsb'): if modulation not in ('fm', 'usb', 'lsb'):
return jsonify({ return api_error('Modulation must be fm, usb, or lsb', 400)
'status': 'error',
'message': 'Modulation must be fm, usb, or lsb',
}), 400
# Claim SDR device # Claim SDR device
global _sstv_general_active_device global _sstv_general_active_device, _sstv_general_active_sdr_type
device_int = int(device_index) device_int = int(device_index)
error = app_module.claim_sdr_device(device_int, 'sstv_general') error = app_module.claim_sdr_device(device_int, 'sstv_general', sdr_type_str)
if error: if error:
return jsonify({ return api_error(error, 409, error_type='DEVICE_BUSY')
'status': 'error',
'error_type': 'DEVICE_BUSY',
'message': error,
}), 409
# Set callback and start # Set callback and start
decoder.set_callback(_progress_callback) decoder.set_callback(_progress_callback)
@@ -175,6 +162,7 @@ def start_decoder():
if success: if success:
_sstv_general_active_device = device_int _sstv_general_active_device = device_int
_sstv_general_active_sdr_type = sdr_type_str
return jsonify({ return jsonify({
'status': 'started', 'status': 'started',
'frequency': frequency, 'frequency': frequency,
@@ -182,22 +170,19 @@ def start_decoder():
'device': device_index, 'device': device_index,
}) })
else: else:
app_module.release_sdr_device(device_int) app_module.release_sdr_device(device_int, sdr_type_str)
return jsonify({ return api_error('Failed to start decoder', 500)
'status': 'error',
'message': 'Failed to start decoder',
}), 500
@sstv_general_bp.route('/stop', methods=['POST']) @sstv_general_bp.route('/stop', methods=['POST'])
def stop_decoder(): def stop_decoder():
"""Stop general SSTV decoder.""" """Stop general SSTV decoder."""
global _sstv_general_active_device global _sstv_general_active_device, _sstv_general_active_sdr_type
decoder = get_general_sstv_decoder() decoder = get_general_sstv_decoder()
decoder.stop() decoder.stop()
if _sstv_general_active_device is not None: if _sstv_general_active_device is not None:
app_module.release_sdr_device(_sstv_general_active_device) app_module.release_sdr_device(_sstv_general_active_device, _sstv_general_active_sdr_type)
_sstv_general_active_device = None _sstv_general_active_device = None
return jsonify({'status': 'stopped'}) return jsonify({'status': 'stopped'})
@@ -227,15 +212,15 @@ def get_image(filename: str):
# Security: only allow alphanumeric filenames with .png extension # Security: only allow alphanumeric filenames with .png extension
if not filename.replace('_', '').replace('-', '').replace('.', '').isalnum(): if not filename.replace('_', '').replace('-', '').replace('.', '').isalnum():
return jsonify({'status': 'error', 'message': 'Invalid filename'}), 400 return api_error('Invalid filename', 400)
if not filename.endswith('.png'): if not filename.endswith('.png'):
return jsonify({'status': 'error', 'message': 'Only PNG files supported'}), 400 return api_error('Only PNG files supported', 400)
image_path = decoder._output_dir / filename image_path = decoder._output_dir / filename
if not image_path.exists(): if not image_path.exists():
return jsonify({'status': 'error', 'message': 'Image not found'}), 404 return api_error('Image not found', 404)
return send_file(image_path, mimetype='image/png') return send_file(image_path, mimetype='image/png')
@@ -247,15 +232,15 @@ def download_image(filename: str):
# Security: only allow alphanumeric filenames with .png extension # Security: only allow alphanumeric filenames with .png extension
if not filename.replace('_', '').replace('-', '').replace('.', '').isalnum(): if not filename.replace('_', '').replace('-', '').replace('.', '').isalnum():
return jsonify({'status': 'error', 'message': 'Invalid filename'}), 400 return api_error('Invalid filename', 400)
if not filename.endswith('.png'): if not filename.endswith('.png'):
return jsonify({'status': 'error', 'message': 'Only PNG files supported'}), 400 return api_error('Only PNG files supported', 400)
image_path = decoder._output_dir / filename image_path = decoder._output_dir / filename
if not image_path.exists(): if not image_path.exists():
return jsonify({'status': 'error', 'message': 'Image not found'}), 404 return api_error('Image not found', 404)
return send_file(image_path, mimetype='image/png', as_attachment=True, download_name=filename) return send_file(image_path, mimetype='image/png', as_attachment=True, download_name=filename)
@@ -267,15 +252,15 @@ def delete_image(filename: str):
# Security: only allow alphanumeric filenames with .png extension # Security: only allow alphanumeric filenames with .png extension
if not filename.replace('_', '').replace('-', '').replace('.', '').isalnum(): if not filename.replace('_', '').replace('-', '').replace('.', '').isalnum():
return jsonify({'status': 'error', 'message': 'Invalid filename'}), 400 return api_error('Invalid filename', 400)
if not filename.endswith('.png'): if not filename.endswith('.png'):
return jsonify({'status': 'error', 'message': 'Only PNG files supported'}), 400 return api_error('Only PNG files supported', 400)
if decoder.delete_image(filename): if decoder.delete_image(filename):
return jsonify({'status': 'ok'}) return jsonify({'status': 'ok'})
else: else:
return jsonify({'status': 'error', 'message': 'Image not found'}), 404 return api_error('Image not found', 404)
@sstv_general_bp.route('/images', methods=['DELETE']) @sstv_general_bp.route('/images', methods=['DELETE'])
@@ -312,18 +297,12 @@ def stream_progress():
def decode_file(): def decode_file():
"""Decode SSTV from an uploaded audio file.""" """Decode SSTV from an uploaded audio file."""
if 'audio' not in request.files: if 'audio' not in request.files:
return jsonify({ return api_error('No audio file provided', 400)
'status': 'error',
'message': 'No audio file provided',
}), 400
audio_file = request.files['audio'] audio_file = request.files['audio']
if not audio_file.filename: if not audio_file.filename:
return jsonify({ return api_error('No file selected', 400)
'status': 'error',
'message': 'No file selected',
}), 400
import tempfile import tempfile
with tempfile.NamedTemporaryFile(suffix='.wav', delete=False) as tmp: with tempfile.NamedTemporaryFile(suffix='.wav', delete=False) as tmp:
@@ -342,13 +321,8 @@ def decode_file():
except Exception as e: except Exception as e:
logger.error(f"Error decoding file: {e}") logger.error(f"Error decoding file: {e}")
return jsonify({ return api_error(str(e), 500)
'status': 'error',
'message': str(e),
}), 500
finally: finally:
try: with contextlib.suppress(Exception):
Path(tmp_path).unlink() Path(tmp_path).unlink()
except Exception:
pass
+37 -37
View File
@@ -6,24 +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.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')
@@ -35,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:
@@ -141,7 +141,7 @@ def start_receive():
freq_hz, err = _validate_frequency_hz(data) freq_hz, err = _validate_frequency_hz(data)
if err: if err:
return jsonify({'status': 'error', 'message': err}), 400 return api_error(err, 400)
sample_rate = _validate_int(data, 'sample_rate', 2000000, 2000000, 20000000) sample_rate = _validate_int(data, 'sample_rate', 2000000, 2000000, 20000000)
lna_gain = _validate_int(data, 'lna_gain', 32, 0, SUBGHZ_LNA_GAIN_MAX) lna_gain = _validate_int(data, 'lna_gain', 32, 0, SUBGHZ_LNA_GAIN_MAX)
@@ -186,7 +186,7 @@ def start_decode():
freq_hz, err = _validate_frequency_hz(data) freq_hz, err = _validate_frequency_hz(data)
if err: if err:
return jsonify({'status': 'error', 'message': err}), 400 return api_error(err, 400)
sample_rate = _validate_int(data, 'sample_rate', 2000000, 2000000, 20000000) sample_rate = _validate_int(data, 'sample_rate', 2000000, 2000000, 20000000)
lna_gain = _validate_int(data, 'lna_gain', 32, 0, SUBGHZ_LNA_GAIN_MAX) lna_gain = _validate_int(data, 'lna_gain', 32, 0, SUBGHZ_LNA_GAIN_MAX)
@@ -227,20 +227,20 @@ def start_transmit():
capture_id = data.get('capture_id') capture_id = data.get('capture_id')
if not capture_id or not isinstance(capture_id, str): if not capture_id or not isinstance(capture_id, str):
return jsonify({'status': 'error', 'message': 'capture_id is required'}), 400 return api_error('capture_id is required', 400)
# Sanitize capture_id # Sanitize capture_id
if not capture_id.isalnum(): if not capture_id.isalnum():
return jsonify({'status': 'error', 'message': 'Invalid capture_id'}), 400 return api_error('Invalid capture_id', 400)
tx_gain = _validate_int(data, 'tx_gain', 20, 0, SUBGHZ_TX_VGA_GAIN_MAX) tx_gain = _validate_int(data, 'tx_gain', 20, 0, SUBGHZ_TX_VGA_GAIN_MAX)
max_duration = _validate_int(data, 'max_duration', 10, 1, SUBGHZ_TX_MAX_DURATION) max_duration = _validate_int(data, 'max_duration', 10, 1, SUBGHZ_TX_MAX_DURATION)
start_seconds, start_err = _validate_optional_float(data, 'start_seconds') start_seconds, start_err = _validate_optional_float(data, 'start_seconds')
if start_err: if start_err:
return jsonify({'status': 'error', 'message': start_err}), 400 return api_error(start_err, 400)
duration_seconds, duration_err = _validate_optional_float(data, 'duration_seconds') duration_seconds, duration_err = _validate_optional_float(data, 'duration_seconds')
if duration_err: if duration_err:
return jsonify({'status': 'error', 'message': duration_err}), 400 return api_error(duration_err, 400)
device_serial = _validate_serial(data) device_serial = _validate_serial(data)
manager = get_subghz_manager() manager = get_subghz_manager()
@@ -278,11 +278,11 @@ def start_sweep():
freq_start = float(data.get('freq_start_mhz', 300)) freq_start = float(data.get('freq_start_mhz', 300))
freq_end = float(data.get('freq_end_mhz', 928)) freq_end = float(data.get('freq_end_mhz', 928))
if freq_start >= freq_end: if freq_start >= freq_end:
return jsonify({'status': 'error', 'message': 'freq_start must be less than freq_end'}), 400 return api_error('freq_start must be less than freq_end', 400)
if freq_start < SUBGHZ_FREQ_MIN_MHZ or freq_end > SUBGHZ_FREQ_MAX_MHZ: if freq_start < SUBGHZ_FREQ_MIN_MHZ or freq_end > SUBGHZ_FREQ_MAX_MHZ:
return jsonify({'status': 'error', 'message': f'Frequency range: {SUBGHZ_FREQ_MIN_MHZ}-{SUBGHZ_FREQ_MAX_MHZ} MHz'}), 400 return api_error(f'Frequency range: {SUBGHZ_FREQ_MIN_MHZ}-{SUBGHZ_FREQ_MAX_MHZ} MHz', 400)
except (ValueError, TypeError): except (ValueError, TypeError):
return jsonify({'status': 'error', 'message': 'Invalid frequency range'}), 400 return api_error('Invalid frequency range', 400)
bin_width = _validate_int(data, 'bin_width', 100000, 10000, 5000000) bin_width = _validate_int(data, 'bin_width', 100000, 10000, 5000000)
device_serial = _validate_serial(data) device_serial = _validate_serial(data)
@@ -326,12 +326,12 @@ def list_captures():
@subghz_bp.route('/captures/<capture_id>') @subghz_bp.route('/captures/<capture_id>')
def get_capture(capture_id: str): def get_capture(capture_id: str):
if not capture_id.isalnum(): if not capture_id.isalnum():
return jsonify({'status': 'error', 'message': 'Invalid capture_id'}), 400 return api_error('Invalid capture_id', 400)
manager = get_subghz_manager() manager = get_subghz_manager()
capture = manager.get_capture(capture_id) capture = manager.get_capture(capture_id)
if not capture: if not capture:
return jsonify({'status': 'error', 'message': 'Capture not found'}), 404 return api_error('Capture not found', 404)
return jsonify({'status': 'ok', 'capture': capture.to_dict()}) return jsonify({'status': 'ok', 'capture': capture.to_dict()})
@@ -339,12 +339,12 @@ def get_capture(capture_id: str):
@subghz_bp.route('/captures/<capture_id>/download') @subghz_bp.route('/captures/<capture_id>/download')
def download_capture(capture_id: str): def download_capture(capture_id: str):
if not capture_id.isalnum(): if not capture_id.isalnum():
return jsonify({'status': 'error', 'message': 'Invalid capture_id'}), 400 return api_error('Invalid capture_id', 400)
manager = get_subghz_manager() manager = get_subghz_manager()
path = manager.get_capture_path(capture_id) path = manager.get_capture_path(capture_id)
if not path: if not path:
return jsonify({'status': 'error', 'message': 'Capture not found'}), 404 return api_error('Capture not found', 404)
return send_file( return send_file(
path, path,
@@ -357,21 +357,21 @@ def download_capture(capture_id: str):
@subghz_bp.route('/captures/<capture_id>/trim', methods=['POST']) @subghz_bp.route('/captures/<capture_id>/trim', methods=['POST'])
def trim_capture(capture_id: str): def trim_capture(capture_id: str):
if not capture_id.isalnum(): if not capture_id.isalnum():
return jsonify({'status': 'error', 'message': 'Invalid capture_id'}), 400 return api_error('Invalid capture_id', 400)
data = request.get_json(silent=True) or {} data = request.get_json(silent=True) or {}
start_seconds, start_err = _validate_optional_float(data, 'start_seconds') start_seconds, start_err = _validate_optional_float(data, 'start_seconds')
if start_err: if start_err:
return jsonify({'status': 'error', 'message': start_err}), 400 return api_error(start_err, 400)
duration_seconds, duration_err = _validate_optional_float(data, 'duration_seconds') duration_seconds, duration_err = _validate_optional_float(data, 'duration_seconds')
if duration_err: if duration_err:
return jsonify({'status': 'error', 'message': duration_err}), 400 return api_error(duration_err, 400)
label = data.get('label', '') label = data.get('label', '')
if label is None: if label is None:
label = '' label = ''
if not isinstance(label, str) or len(label) > 100: if not isinstance(label, str) or len(label) > 100:
return jsonify({'status': 'error', 'message': 'Label must be a string (max 100 chars)'}), 400 return api_error('Label must be a string (max 100 chars)', 400)
manager = get_subghz_manager() manager = get_subghz_manager()
result = manager.trim_capture( result = manager.trim_capture(
@@ -391,29 +391,29 @@ def trim_capture(capture_id: str):
@subghz_bp.route('/captures/<capture_id>', methods=['DELETE']) @subghz_bp.route('/captures/<capture_id>', methods=['DELETE'])
def delete_capture(capture_id: str): def delete_capture(capture_id: str):
if not capture_id.isalnum(): if not capture_id.isalnum():
return jsonify({'status': 'error', 'message': 'Invalid capture_id'}), 400 return api_error('Invalid capture_id', 400)
manager = get_subghz_manager() manager = get_subghz_manager()
if manager.delete_capture(capture_id): if manager.delete_capture(capture_id):
return jsonify({'status': 'deleted', 'id': capture_id}) return jsonify({'status': 'deleted', 'id': capture_id})
return jsonify({'status': 'error', 'message': 'Capture not found'}), 404 return api_error('Capture not found', 404)
@subghz_bp.route('/captures/<capture_id>', methods=['PATCH']) @subghz_bp.route('/captures/<capture_id>', methods=['PATCH'])
def update_capture(capture_id: str): def update_capture(capture_id: str):
if not capture_id.isalnum(): if not capture_id.isalnum():
return jsonify({'status': 'error', 'message': 'Invalid capture_id'}), 400 return api_error('Invalid capture_id', 400)
data = request.get_json(silent=True) or {} data = request.get_json(silent=True) or {}
label = data.get('label', '') label = data.get('label', '')
if not isinstance(label, str) or len(label) > 100: if not isinstance(label, str) or len(label) > 100:
return jsonify({'status': 'error', 'message': 'Label must be a string (max 100 chars)'}), 400 return api_error('Label must be a string (max 100 chars)', 400)
manager = get_subghz_manager() manager = get_subghz_manager()
if manager.update_capture_label(capture_id, label): if manager.update_capture_label(capture_id, label):
return jsonify({'status': 'updated', 'id': capture_id, 'label': label}) return jsonify({'status': 'updated', 'id': capture_id, 'label': label})
return jsonify({'status': 'error', 'message': 'Capture not found'}), 404 return api_error('Capture not found', 404)
# ------------------------------------------------------------------ # ------------------------------------------------------------------
+4 -3
View File
@@ -22,6 +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_error
from utils.sse import sse_stream_fanout from utils.sse import sse_stream_fanout
try: try:
@@ -549,10 +550,10 @@ def get_weather() -> Response:
lat, lon = loc.get('lat'), loc.get('lon') lat, lon = loc.get('lat'), loc.get('lon')
if lat is None or lon is None: if lat is None or lon is None:
return jsonify({'error': 'No location available'}) return api_error('No location available')
if _requests is None: if _requests is None:
return jsonify({'error': 'requests library not available'}) return api_error('requests library not available')
try: try:
resp = _requests.get( resp = _requests.get(
@@ -580,4 +581,4 @@ def get_weather() -> Response:
return jsonify(weather) return jsonify(weather)
except Exception as exc: except Exception as exc:
logger.debug('Weather fetch failed: %s', exc) logger.debug('Weather fetch failed: %s', exc)
return jsonify({'error': str(exc)}) return api_error(str(exc))
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+270
View File
@@ -0,0 +1,270 @@
"""
TSCM Baseline Routes
Handles /baseline/*, /baselines endpoints.
"""
from __future__ import annotations
import json
import logging
from datetime import datetime
from flask import jsonify, request
from routes.tscm import (
_baseline_recorder,
tscm_bp,
)
from utils.database import (
delete_tscm_baseline,
get_active_tscm_baseline,
get_all_tscm_baselines,
get_tscm_baseline,
get_tscm_sweep,
set_active_tscm_baseline,
)
from utils.tscm.baseline import (
get_comparison_for_active_baseline,
)
logger = logging.getLogger('intercept.tscm')
@tscm_bp.route('/baseline/record', methods=['POST'])
def record_baseline():
"""Start recording a new baseline."""
data = request.get_json() or {}
name = data.get('name', f'Baseline {datetime.now().strftime("%Y-%m-%d %H:%M")}')
location = data.get('location')
description = data.get('description')
baseline_id = _baseline_recorder.start_recording(name, location, description)
return jsonify({
'status': 'success',
'message': 'Baseline recording started',
'baseline_id': baseline_id
})
@tscm_bp.route('/baseline/stop', methods=['POST'])
def stop_baseline():
"""Stop baseline recording."""
result = _baseline_recorder.stop_recording()
if 'error' in result:
return jsonify({'status': 'error', 'message': result['error']})
return jsonify({
'status': 'success',
'message': 'Baseline recording complete',
**result
})
@tscm_bp.route('/baseline/status')
def baseline_status():
"""Get baseline recording status."""
return jsonify(_baseline_recorder.get_recording_status())
@tscm_bp.route('/baselines')
def list_baselines():
"""List all baselines."""
baselines = get_all_tscm_baselines()
return jsonify({'status': 'success', 'baselines': baselines})
@tscm_bp.route('/baseline/<int:baseline_id>')
def get_baseline(baseline_id: int):
"""Get a specific baseline."""
baseline = get_tscm_baseline(baseline_id)
if not baseline:
return jsonify({'status': 'error', 'message': 'Baseline not found'}), 404
return jsonify({'status': 'success', 'baseline': baseline})
@tscm_bp.route('/baseline/<int:baseline_id>/activate', methods=['POST'])
def activate_baseline(baseline_id: int):
"""Set a baseline as active."""
success = set_active_tscm_baseline(baseline_id)
if not success:
return jsonify({'status': 'error', 'message': 'Baseline not found'}), 404
return jsonify({'status': 'success', 'message': 'Baseline activated'})
@tscm_bp.route('/baseline/<int:baseline_id>', methods=['DELETE'])
def remove_baseline(baseline_id: int):
"""Delete a baseline."""
success = delete_tscm_baseline(baseline_id)
if not success:
return jsonify({'status': 'error', 'message': 'Baseline not found'}), 404
return jsonify({'status': 'success', 'message': 'Baseline deleted'})
@tscm_bp.route('/baseline/active')
def get_active_baseline():
"""Get the currently active baseline."""
baseline = get_active_tscm_baseline()
if not baseline:
return jsonify({'status': 'success', 'baseline': None})
return jsonify({'status': 'success', 'baseline': baseline})
@tscm_bp.route('/baseline/compare', methods=['POST'])
def compare_against_baseline():
"""
Compare provided device data against the active baseline.
Expects JSON body with:
- wifi_devices: list of WiFi devices (optional)
- wifi_clients: list of WiFi clients (optional)
- bt_devices: list of Bluetooth devices (optional)
- rf_signals: list of RF signals (optional)
Returns comparison showing new, missing, and matching devices.
"""
data = request.get_json() or {}
wifi_devices = data.get('wifi_devices')
wifi_clients = data.get('wifi_clients')
bt_devices = data.get('bt_devices')
rf_signals = data.get('rf_signals')
# Use the convenience function that gets active baseline
comparison = get_comparison_for_active_baseline(
wifi_devices=wifi_devices,
wifi_clients=wifi_clients,
bt_devices=bt_devices,
rf_signals=rf_signals
)
if comparison is None:
return jsonify({
'status': 'error',
'message': 'No active baseline set'
}), 400
return jsonify({
'status': 'success',
'comparison': comparison
})
# =============================================================================
# Baseline Diff & Health Endpoints
# =============================================================================
@tscm_bp.route('/baseline/diff/<int:baseline_id>/<int:sweep_id>')
def get_baseline_diff(baseline_id: int, sweep_id: int):
"""
Get comprehensive diff between a baseline and a sweep.
Shows new devices, missing devices, changed characteristics,
and baseline health assessment.
"""
try:
from utils.tscm.advanced import calculate_baseline_diff
baseline = get_tscm_baseline(baseline_id)
if not baseline:
return jsonify({'status': 'error', 'message': 'Baseline not found'}), 404
sweep = get_tscm_sweep(sweep_id)
if not sweep:
return jsonify({'status': 'error', 'message': 'Sweep not found'}), 404
# Get current devices from sweep results
results = sweep.get('results', {})
if isinstance(results, str):
results = json.loads(results)
current_wifi = results.get('wifi_devices', [])
current_wifi_clients = results.get('wifi_clients', [])
current_bt = results.get('bt_devices', [])
current_rf = results.get('rf_signals', [])
diff = calculate_baseline_diff(
baseline=baseline,
current_wifi=current_wifi,
current_wifi_clients=current_wifi_clients,
current_bt=current_bt,
current_rf=current_rf,
sweep_id=sweep_id
)
return jsonify({
'status': 'success',
'diff': diff.to_dict()
})
except Exception as e:
logger.error(f"Get baseline diff error: {e}")
return jsonify({'status': 'error', 'message': str(e)}), 500
@tscm_bp.route('/baseline/<int:baseline_id>/health')
def get_baseline_health(baseline_id: int):
"""Get health assessment for a baseline."""
try:
baseline = get_tscm_baseline(baseline_id)
if not baseline:
return jsonify({'status': 'error', 'message': 'Baseline not found'}), 404
# Calculate age
created_at = baseline.get('created_at')
age_hours = 0
if created_at:
if isinstance(created_at, str):
created = datetime.fromisoformat(created_at.replace('Z', '+00:00'))
age_hours = (datetime.now() - created.replace(tzinfo=None)).total_seconds() / 3600
elif isinstance(created_at, datetime):
age_hours = (datetime.now() - created_at).total_seconds() / 3600
# Count devices
total_devices = (
len(baseline.get('wifi_networks', [])) +
len(baseline.get('bt_devices', [])) +
len(baseline.get('rf_frequencies', []))
)
# Determine health
health = 'healthy'
score = 1.0
reasons = []
if age_hours > 168:
health = 'stale'
score = 0.3
reasons.append(f'Baseline is {age_hours:.0f} hours old (over 1 week)')
elif age_hours > 72:
health = 'noisy'
score = 0.6
reasons.append(f'Baseline is {age_hours:.0f} hours old (over 3 days)')
if total_devices < 3:
score -= 0.2
reasons.append(f'Baseline has few devices ({total_devices})')
if health == 'healthy':
health = 'noisy'
return jsonify({
'status': 'success',
'health': {
'status': health,
'score': round(max(0, score), 2),
'age_hours': round(age_hours, 1),
'total_devices': total_devices,
'reasons': reasons,
}
})
except Exception as e:
logger.error(f"Get baseline health error: {e}")
return jsonify({'status': 'error', 'message': str(e)}), 500
+149
View File
@@ -0,0 +1,149 @@
"""
TSCM Case Management Routes
Handles /cases/* endpoints.
"""
from __future__ import annotations
import logging
from flask import jsonify, request
from routes.tscm import tscm_bp
logger = logging.getLogger('intercept.tscm')
@tscm_bp.route('/cases', methods=['GET'])
def list_cases():
"""List all TSCM cases."""
from utils.database import get_all_tscm_cases
status = request.args.get('status')
limit = request.args.get('limit', 50, type=int)
cases = get_all_tscm_cases(status=status, limit=limit)
return jsonify({
'status': 'success',
'count': len(cases),
'cases': cases
})
@tscm_bp.route('/cases', methods=['POST'])
def create_case():
"""Create a new TSCM case."""
from utils.database import create_tscm_case
data = request.get_json() or {}
name = data.get('name')
if not name:
return jsonify({'status': 'error', 'message': 'name is required'}), 400
case_id = create_tscm_case(
name=name,
description=data.get('description'),
location=data.get('location'),
priority=data.get('priority', 'normal'),
created_by=data.get('created_by'),
metadata=data.get('metadata')
)
return jsonify({
'status': 'success',
'message': 'Case created',
'case_id': case_id
})
@tscm_bp.route('/cases/<int:case_id>', methods=['GET'])
def get_case(case_id: int):
"""Get a TSCM case with all linked sweeps, threats, and notes."""
from utils.database import get_tscm_case
case = get_tscm_case(case_id)
if not case:
return jsonify({'status': 'error', 'message': 'Case not found'}), 404
return jsonify({
'status': 'success',
'case': case
})
@tscm_bp.route('/cases/<int:case_id>', methods=['PUT'])
def update_case(case_id: int):
"""Update a TSCM case."""
from utils.database import update_tscm_case
data = request.get_json() or {}
success = update_tscm_case(
case_id=case_id,
status=data.get('status'),
priority=data.get('priority'),
assigned_to=data.get('assigned_to'),
notes=data.get('notes')
)
if not success:
return jsonify({'status': 'error', 'message': 'Case not found'}), 404
return jsonify({
'status': 'success',
'message': 'Case updated'
})
@tscm_bp.route('/cases/<int:case_id>/sweeps/<int:sweep_id>', methods=['POST'])
def link_sweep_to_case(case_id: int, sweep_id: int):
"""Link a sweep to a case."""
from utils.database import add_sweep_to_case
success = add_sweep_to_case(case_id, sweep_id)
return jsonify({
'status': 'success' if success else 'error',
'message': 'Sweep linked to case' if success else 'Already linked or not found'
})
@tscm_bp.route('/cases/<int:case_id>/threats/<int:threat_id>', methods=['POST'])
def link_threat_to_case(case_id: int, threat_id: int):
"""Link a threat to a case."""
from utils.database import add_threat_to_case
success = add_threat_to_case(case_id, threat_id)
return jsonify({
'status': 'success' if success else 'error',
'message': 'Threat linked to case' if success else 'Already linked or not found'
})
@tscm_bp.route('/cases/<int:case_id>/notes', methods=['POST'])
def add_note_to_case(case_id: int):
"""Add a note to a case."""
from utils.database import add_case_note
data = request.get_json() or {}
content = data.get('content')
if not content:
return jsonify({'status': 'error', 'message': 'content is required'}), 400
note_id = add_case_note(
case_id=case_id,
content=content,
note_type=data.get('note_type', 'general'),
created_by=data.get('created_by')
)
return jsonify({
'status': 'success',
'message': 'Note added',
'note_id': note_id
})
+203
View File
@@ -0,0 +1,203 @@
"""
TSCM Meeting Window Routes
Handles /meeting/* endpoints for time correlation during sensitive periods.
"""
from __future__ import annotations
import logging
from datetime import datetime
from flask import jsonify, request
from routes.tscm import (
_current_sweep_id,
_emit_event,
tscm_bp,
)
from utils.tscm.correlation import get_correlation_engine
logger = logging.getLogger('intercept.tscm')
@tscm_bp.route('/meeting/start', methods=['POST'])
def start_meeting():
"""
Mark the start of a sensitive period (meeting, briefing, etc.).
Devices detected during this window will receive additional scoring
for meeting-correlated activity.
"""
correlation = get_correlation_engine()
correlation.start_meeting_window()
_emit_event('meeting_started', {
'timestamp': datetime.now().isoformat(),
'message': 'Sensitive period monitoring active'
})
return jsonify({
'status': 'success',
'message': 'Meeting window started - devices detected now will be flagged'
})
@tscm_bp.route('/meeting/end', methods=['POST'])
def end_meeting():
"""Mark the end of a sensitive period."""
correlation = get_correlation_engine()
correlation.end_meeting_window()
_emit_event('meeting_ended', {
'timestamp': datetime.now().isoformat()
})
return jsonify({
'status': 'success',
'message': 'Meeting window ended'
})
@tscm_bp.route('/meeting/status')
def meeting_status():
"""Check if currently in a meeting window."""
correlation = get_correlation_engine()
in_meeting = correlation.is_during_meeting()
return jsonify({
'status': 'success',
'in_meeting': in_meeting,
'windows': [
{
'start': start.isoformat(),
'end': end.isoformat() if end else None
}
for start, end in correlation.meeting_windows
]
})
# =============================================================================
# Meeting Window Enhanced Endpoints
# =============================================================================
@tscm_bp.route('/meeting/start-tracked', methods=['POST'])
def start_tracked_meeting():
"""
Start a tracked meeting window with database persistence.
Tracks devices first seen during meeting and behavior changes.
"""
from utils.database import start_meeting_window
from utils.tscm.advanced import get_timeline_manager
data = request.get_json() or {}
meeting_id = start_meeting_window(
sweep_id=_current_sweep_id,
name=data.get('name'),
location=data.get('location'),
notes=data.get('notes')
)
# Start meeting in correlation engine
correlation = get_correlation_engine()
correlation.start_meeting_window()
# Start in timeline manager
manager = get_timeline_manager()
manager.start_meeting_window()
_emit_event('meeting_started', {
'meeting_id': meeting_id,
'timestamp': datetime.now().isoformat(),
'name': data.get('name'),
})
return jsonify({
'status': 'success',
'message': 'Tracked meeting window started',
'meeting_id': meeting_id
})
@tscm_bp.route('/meeting/<int:meeting_id>/end', methods=['POST'])
def end_tracked_meeting(meeting_id: int):
"""End a tracked meeting window."""
from utils.database import end_meeting_window
from utils.tscm.advanced import get_timeline_manager
success = end_meeting_window(meeting_id)
if not success:
return jsonify({'status': 'error', 'message': 'Meeting not found or already ended'}), 404
# End in correlation engine
correlation = get_correlation_engine()
correlation.end_meeting_window()
# End in timeline manager
manager = get_timeline_manager()
manager.end_meeting_window()
_emit_event('meeting_ended', {
'meeting_id': meeting_id,
'timestamp': datetime.now().isoformat()
})
return jsonify({
'status': 'success',
'message': 'Meeting window ended'
})
@tscm_bp.route('/meeting/<int:meeting_id>/summary')
def get_meeting_summary_endpoint(meeting_id: int):
"""Get detailed summary of device activity during a meeting."""
try:
from routes.tscm import _current_sweep_id
from utils.database import get_meeting_windows
from utils.tscm.advanced import generate_meeting_summary, get_timeline_manager
# Get meeting window
windows = get_meeting_windows(_current_sweep_id or 0)
meeting = None
for w in windows:
if w.get('id') == meeting_id:
meeting = w
break
if not meeting:
return jsonify({'status': 'error', 'message': 'Meeting not found'}), 404
# Get timelines and profiles
manager = get_timeline_manager()
timelines = manager.get_all_timelines()
correlation = get_correlation_engine()
profiles = [p.to_dict() for p in correlation.device_profiles.values()]
summary = generate_meeting_summary(meeting, timelines, profiles)
return jsonify({
'status': 'success',
'summary': summary.to_dict()
})
except Exception as e:
logger.error(f"Get meeting summary error: {e}")
return jsonify({'status': 'error', 'message': str(e)}), 500
@tscm_bp.route('/meeting/active')
def get_active_meeting():
"""Get currently active meeting window."""
from utils.database import get_active_meeting_window
meeting = get_active_meeting_window(_current_sweep_id)
return jsonify({
'status': 'success',
'meeting': meeting,
'is_active': meeting is not None
})
+185
View File
@@ -0,0 +1,185 @@
"""
TSCM Schedule Routes
Handles /schedules/* endpoints for automated sweep scheduling.
"""
from __future__ import annotations
import logging
from datetime import datetime, timezone
from typing import Any
from flask import jsonify, request
from routes.tscm import (
_get_schedule_timezone,
_next_run_from_cron,
_start_sweep_internal,
tscm_bp,
)
from utils.database import (
create_tscm_schedule,
delete_tscm_schedule,
get_all_tscm_schedules,
get_tscm_schedule,
update_tscm_schedule,
)
logger = logging.getLogger('intercept.tscm')
@tscm_bp.route('/schedules', methods=['GET'])
def list_schedules():
"""List all TSCM sweep schedules."""
enabled_param = request.args.get('enabled')
enabled = None
if enabled_param is not None:
enabled = enabled_param.lower() in ('1', 'true', 'yes')
schedules = get_all_tscm_schedules(enabled=enabled, limit=200)
return jsonify({
'status': 'success',
'count': len(schedules),
'schedules': schedules,
})
@tscm_bp.route('/schedules', methods=['POST'])
def create_schedule():
"""Create a new sweep schedule."""
data = request.get_json() or {}
name = (data.get('name') or '').strip()
cron_expression = (data.get('cron_expression') or '').strip()
sweep_type = data.get('sweep_type', 'standard')
baseline_id = data.get('baseline_id')
zone_name = data.get('zone_name')
enabled = bool(data.get('enabled', True))
notify_on_threat = bool(data.get('notify_on_threat', True))
notify_email = data.get('notify_email')
if not name:
return jsonify({'status': 'error', 'message': 'Schedule name required'}), 400
if not cron_expression:
return jsonify({'status': 'error', 'message': 'cron_expression required'}), 400
next_run = None
if enabled:
try:
tz = _get_schedule_timezone(zone_name)
next_local = _next_run_from_cron(cron_expression, datetime.now(tz))
next_run = next_local.astimezone(timezone.utc).isoformat() if next_local else None
except Exception as e:
return jsonify({'status': 'error', 'message': f'Invalid cron: {e}'}), 400
schedule_id = create_tscm_schedule(
name=name,
cron_expression=cron_expression,
sweep_type=sweep_type,
baseline_id=baseline_id,
zone_name=zone_name,
enabled=enabled,
notify_on_threat=notify_on_threat,
notify_email=notify_email,
next_run=next_run,
)
schedule = get_tscm_schedule(schedule_id)
return jsonify({
'status': 'success',
'message': 'Schedule created',
'schedule': schedule
})
@tscm_bp.route('/schedules/<int:schedule_id>', methods=['PUT', 'PATCH'])
def update_schedule(schedule_id: int):
"""Update a sweep schedule."""
schedule = get_tscm_schedule(schedule_id)
if not schedule:
return jsonify({'status': 'error', 'message': 'Schedule not found'}), 404
data = request.get_json() or {}
updates: dict[str, Any] = {}
for key in ('name', 'cron_expression', 'sweep_type', 'baseline_id', 'zone_name', 'notify_email'):
if key in data:
updates[key] = data[key]
if 'baseline_id' in updates and updates['baseline_id'] in ('', None):
updates['baseline_id'] = None
if 'enabled' in data:
updates['enabled'] = 1 if data['enabled'] else 0
if 'notify_on_threat' in data:
updates['notify_on_threat'] = 1 if data['notify_on_threat'] else 0
# Recalculate next_run when cron/zone/enabled changes
if any(k in updates for k in ('cron_expression', 'zone_name', 'enabled')):
if updates.get('enabled', schedule.get('enabled', 1)):
cron_expr = updates.get('cron_expression', schedule.get('cron_expression', ''))
zone_name = updates.get('zone_name', schedule.get('zone_name'))
try:
tz = _get_schedule_timezone(zone_name)
next_local = _next_run_from_cron(cron_expr, datetime.now(tz))
updates['next_run'] = next_local.astimezone(timezone.utc).isoformat() if next_local else None
except Exception as e:
return jsonify({'status': 'error', 'message': f'Invalid cron: {e}'}), 400
else:
updates['next_run'] = None
if not updates:
return jsonify({'status': 'error', 'message': 'No updates provided'}), 400
update_tscm_schedule(schedule_id, **updates)
schedule = get_tscm_schedule(schedule_id)
return jsonify({'status': 'success', 'schedule': schedule})
@tscm_bp.route('/schedules/<int:schedule_id>', methods=['DELETE'])
def delete_schedule(schedule_id: int):
"""Delete a sweep schedule."""
success = delete_tscm_schedule(schedule_id)
if not success:
return jsonify({'status': 'error', 'message': 'Schedule not found'}), 404
return jsonify({'status': 'success', 'message': 'Schedule deleted'})
@tscm_bp.route('/schedules/<int:schedule_id>/run', methods=['POST'])
def run_schedule_now(schedule_id: int):
"""Trigger a scheduled sweep immediately."""
schedule = get_tscm_schedule(schedule_id)
if not schedule:
return jsonify({'status': 'error', 'message': 'Schedule not found'}), 404
result = _start_sweep_internal(
sweep_type=schedule.get('sweep_type') or 'standard',
baseline_id=schedule.get('baseline_id'),
wifi_enabled=True,
bt_enabled=True,
rf_enabled=True,
wifi_interface='',
bt_interface='',
sdr_device=None,
verbose_results=False,
)
if result.get('status') != 'success':
status_code = result.pop('http_status', 400)
return jsonify(result), status_code
# Update schedule run timestamps
cron_expr = schedule.get('cron_expression') or ''
tz = _get_schedule_timezone(schedule.get('zone_name'))
now_utc = datetime.now(timezone.utc)
try:
next_local = _next_run_from_cron(cron_expr, datetime.now(tz))
except Exception:
next_local = None
update_tscm_schedule(
schedule_id,
last_run=now_utc.isoformat(),
next_run=next_local.astimezone(timezone.utc).isoformat() if next_local else None,
)
return jsonify(result)
+430
View File
@@ -0,0 +1,430 @@
"""
TSCM Sweep Routes
Handles /sweep/*, /status, /devices, /presets/*, /feed/*,
/capabilities, and /sweep/<id>/capabilities endpoints.
"""
from __future__ import annotations
import logging
import os
import platform
import re
import subprocess
from typing import Any
from flask import Response, jsonify, request
from data.tscm_frequencies import get_all_sweep_presets, get_sweep_preset
from routes.tscm import (
_baseline_recorder,
_current_sweep_id,
_emit_event,
_start_sweep_internal,
_sweep_running,
tscm_bp,
tscm_queue,
)
from utils.database import get_tscm_sweep, update_tscm_sweep
from utils.event_pipeline import process_event
from utils.sse import sse_stream_fanout
logger = logging.getLogger('intercept.tscm')
@tscm_bp.route('/status')
def tscm_status():
"""Check if any TSCM operation is currently running."""
import routes.tscm as _tscm_pkg
return jsonify({'running': _tscm_pkg._sweep_running})
@tscm_bp.route('/sweep/start', methods=['POST'])
def start_sweep():
"""Start a TSCM sweep."""
data = request.get_json() or {}
sweep_type = data.get('sweep_type', 'standard')
baseline_id = data.get('baseline_id')
if baseline_id in ('', None):
baseline_id = None
wifi_enabled = data.get('wifi', True)
bt_enabled = data.get('bluetooth', True)
rf_enabled = data.get('rf', True)
verbose_results = bool(data.get('verbose_results', False))
# Get interface selections
wifi_interface = data.get('wifi_interface', '')
bt_interface = data.get('bt_interface', '')
sdr_device = data.get('sdr_device')
result = _start_sweep_internal(
sweep_type=sweep_type,
baseline_id=baseline_id,
wifi_enabled=wifi_enabled,
bt_enabled=bt_enabled,
rf_enabled=rf_enabled,
wifi_interface=wifi_interface,
bt_interface=bt_interface,
sdr_device=sdr_device,
verbose_results=verbose_results,
)
http_status = result.pop('http_status', 200)
return jsonify(result), http_status
@tscm_bp.route('/sweep/stop', methods=['POST'])
def stop_sweep():
"""Stop the current TSCM sweep."""
import routes.tscm as _tscm_pkg
if not _tscm_pkg._sweep_running:
return jsonify({'status': 'error', 'message': 'No sweep running'})
_tscm_pkg._sweep_running = False
if _tscm_pkg._current_sweep_id:
update_tscm_sweep(_tscm_pkg._current_sweep_id, status='aborted', completed=True)
_emit_event('sweep_stopped', {'reason': 'user_requested'})
logger.info("TSCM sweep stopped by user")
return jsonify({'status': 'success', 'message': 'Sweep stopped'})
@tscm_bp.route('/sweep/status')
def sweep_status():
"""Get current sweep status."""
import routes.tscm as _tscm_pkg
status = {
'running': _tscm_pkg._sweep_running,
'sweep_id': _tscm_pkg._current_sweep_id,
}
if _tscm_pkg._current_sweep_id:
sweep = get_tscm_sweep(_tscm_pkg._current_sweep_id)
if sweep:
status['sweep'] = sweep
return jsonify(status)
@tscm_bp.route('/sweep/stream')
def sweep_stream():
"""SSE stream for real-time sweep updates."""
import routes.tscm as _tscm_pkg
def _on_msg(msg: dict[str, Any]) -> None:
process_event('tscm', msg, msg.get('type'))
return Response(
sse_stream_fanout(
source_queue=_tscm_pkg.tscm_queue,
channel_key='tscm',
timeout=1.0,
keepalive_interval=30.0,
on_message=_on_msg,
),
mimetype='text/event-stream',
headers={
'Cache-Control': 'no-cache',
'Connection': 'keep-alive',
'X-Accel-Buffering': 'no'
}
)
@tscm_bp.route('/devices')
def get_tscm_devices():
"""Get available scanning devices for TSCM sweeps."""
devices = {
'wifi_interfaces': [],
'bt_adapters': [],
'sdr_devices': []
}
# Detect WiFi interfaces
if platform.system() == 'Darwin': # macOS
try:
result = subprocess.run(
['networksetup', '-listallhardwareports'],
capture_output=True, text=True, timeout=5
)
lines = result.stdout.split('\n')
for i, line in enumerate(lines):
if 'Wi-Fi' in line or 'AirPort' in line:
# Get the hardware port name (e.g., "Wi-Fi")
port_name = line.replace('Hardware Port:', '').strip()
for j in range(i + 1, min(i + 3, len(lines))):
if 'Device:' in lines[j]:
device = lines[j].split('Device:')[1].strip()
devices['wifi_interfaces'].append({
'name': device,
'display_name': f'{port_name} ({device})',
'type': 'internal',
'monitor_capable': False
})
break
except (FileNotFoundError, subprocess.TimeoutExpired, subprocess.SubprocessError):
pass
else: # Linux
try:
result = subprocess.run(
['iw', 'dev'],
capture_output=True, text=True, timeout=5
)
current_iface = None
for line in result.stdout.split('\n'):
line = line.strip()
if line.startswith('Interface'):
current_iface = line.split()[1]
elif current_iface and 'type' in line:
iface_type = line.split()[-1]
devices['wifi_interfaces'].append({
'name': current_iface,
'display_name': f'Wireless ({current_iface}) - {iface_type}',
'type': iface_type,
'monitor_capable': True
})
current_iface = None
except (FileNotFoundError, subprocess.TimeoutExpired, subprocess.SubprocessError):
# Fall back to iwconfig
try:
result = subprocess.run(
['iwconfig'],
capture_output=True, text=True, timeout=5
)
for line in result.stdout.split('\n'):
if 'IEEE 802.11' in line:
iface = line.split()[0]
devices['wifi_interfaces'].append({
'name': iface,
'display_name': f'Wireless ({iface})',
'type': 'managed',
'monitor_capable': True
})
except (FileNotFoundError, subprocess.TimeoutExpired, subprocess.SubprocessError):
pass
# Detect Bluetooth adapters
if platform.system() == 'Linux':
try:
result = subprocess.run(
['hciconfig'],
capture_output=True, text=True, timeout=5
)
blocks = re.split(r'(?=^hci\d+:)', result.stdout, flags=re.MULTILINE)
for _idx, block in enumerate(blocks):
if block.strip():
first_line = block.split('\n')[0]
match = re.match(r'(hci\d+):', first_line)
if match:
iface_name = match.group(1)
is_up = 'UP RUNNING' in block or '\tUP ' in block
devices['bt_adapters'].append({
'name': iface_name,
'display_name': f'Bluetooth Adapter ({iface_name})',
'type': 'hci',
'status': 'up' if is_up else 'down'
})
except (FileNotFoundError, subprocess.TimeoutExpired, subprocess.SubprocessError):
# Try bluetoothctl as fallback
try:
result = subprocess.run(
['bluetoothctl', 'list'],
capture_output=True, text=True, timeout=5
)
for line in result.stdout.split('\n'):
if 'Controller' in line:
# Format: Controller XX:XX:XX:XX:XX:XX Name
parts = line.split()
if len(parts) >= 3:
addr = parts[1]
name = ' '.join(parts[2:]) if len(parts) > 2 else 'Bluetooth'
devices['bt_adapters'].append({
'name': addr,
'display_name': f'{name} ({addr[-8:]})',
'type': 'controller',
'status': 'available'
})
except (FileNotFoundError, subprocess.TimeoutExpired, subprocess.SubprocessError):
pass
elif platform.system() == 'Darwin':
# macOS has built-in Bluetooth - get more info via system_profiler
try:
result = subprocess.run(
['system_profiler', 'SPBluetoothDataType'],
capture_output=True, text=True, timeout=10
)
# Extract controller info
bt_name = 'Built-in Bluetooth'
bt_addr = ''
for line in result.stdout.split('\n'):
if 'Address:' in line:
bt_addr = line.split('Address:')[1].strip()
break
devices['bt_adapters'].append({
'name': 'default',
'display_name': f'{bt_name}' + (f' ({bt_addr[-8:]})' if bt_addr else ''),
'type': 'macos',
'status': 'available'
})
except (FileNotFoundError, subprocess.TimeoutExpired, subprocess.SubprocessError):
devices['bt_adapters'].append({
'name': 'default',
'display_name': 'Built-in Bluetooth',
'type': 'macos',
'status': 'available'
})
# Detect SDR devices
try:
from utils.sdr import SDRFactory
sdr_list = SDRFactory.detect_devices()
for sdr in sdr_list:
# SDRDevice is a dataclass with attributes, not a dict
sdr_type_name = sdr.sdr_type.value if hasattr(sdr.sdr_type, 'value') else str(sdr.sdr_type)
# Create a friendly display name
display_name = sdr.name
if sdr.serial and sdr.serial not in ('N/A', 'Unknown'):
display_name = f'{sdr.name} (SN: {sdr.serial[-8:]})'
devices['sdr_devices'].append({
'index': sdr.index,
'name': sdr.name,
'display_name': display_name,
'type': sdr_type_name,
'serial': sdr.serial,
'driver': sdr.driver
})
except ImportError:
logger.debug("SDR module not available")
except Exception as e:
logger.warning(f"Error detecting SDR devices: {e}")
# Check if running as root
from flask import current_app
running_as_root = current_app.config.get('RUNNING_AS_ROOT', os.geteuid() == 0)
warnings = []
if not running_as_root:
warnings.append({
'type': 'privileges',
'message': 'Not running as root. WiFi monitor mode and some Bluetooth features require sudo.',
'action': 'Run with: sudo -E venv/bin/python intercept.py'
})
return jsonify({
'status': 'success',
'devices': devices,
'running_as_root': running_as_root,
'warnings': warnings
})
# =============================================================================
# Preset Endpoints
# =============================================================================
@tscm_bp.route('/presets')
def list_presets():
"""List available sweep presets."""
presets = get_all_sweep_presets()
return jsonify({'status': 'success', 'presets': presets})
@tscm_bp.route('/presets/<preset_name>')
def get_preset(preset_name: str):
"""Get details for a specific preset."""
preset = get_sweep_preset(preset_name)
if not preset:
return jsonify({'status': 'error', 'message': 'Preset not found'}), 404
return jsonify({'status': 'success', 'preset': preset})
# =============================================================================
# Data Feed Endpoints (for adding data during sweeps/baselines)
# =============================================================================
@tscm_bp.route('/feed/wifi', methods=['POST'])
def feed_wifi():
"""Feed WiFi device data for baseline recording."""
data = request.get_json()
if data:
if data.get('is_client'):
_baseline_recorder.add_wifi_client(data)
else:
_baseline_recorder.add_wifi_device(data)
return jsonify({'status': 'success'})
@tscm_bp.route('/feed/bluetooth', methods=['POST'])
def feed_bluetooth():
"""Feed Bluetooth device data for baseline recording."""
data = request.get_json()
if data:
_baseline_recorder.add_bt_device(data)
return jsonify({'status': 'success'})
@tscm_bp.route('/feed/rf', methods=['POST'])
def feed_rf():
"""Feed RF signal data for baseline recording."""
data = request.get_json()
if data:
_baseline_recorder.add_rf_signal(data)
return jsonify({'status': 'success'})
# =============================================================================
# Capabilities & Coverage Endpoints
# =============================================================================
@tscm_bp.route('/capabilities')
def get_capabilities():
"""
Get current system capabilities for TSCM sweeping.
Returns what the system CAN and CANNOT detect based on OS,
privileges, adapters, and SDR hardware.
"""
try:
from utils.tscm.advanced import detect_sweep_capabilities
wifi_interface = request.args.get('wifi_interface', '')
bt_adapter = request.args.get('bt_adapter', '')
caps = detect_sweep_capabilities(
wifi_interface=wifi_interface,
bt_adapter=bt_adapter
)
return jsonify({
'status': 'success',
'capabilities': caps.to_dict()
})
except Exception as e:
logger.error(f"Get capabilities error: {e}")
return jsonify({'status': 'error', 'message': str(e)}), 500
@tscm_bp.route('/sweep/<int:sweep_id>/capabilities')
def get_sweep_stored_capabilities(sweep_id: int):
"""Get stored capabilities for a specific sweep."""
from utils.database import get_sweep_capabilities
caps = get_sweep_capabilities(sweep_id)
if not caps:
return jsonify({'status': 'error', 'message': 'No capabilities stored for this sweep'}), 404
return jsonify({
'status': 'success',
'capabilities': caps
})
+6 -20
View File
@@ -5,6 +5,7 @@ from __future__ import annotations
from flask import Blueprint, Response, jsonify, request from flask import Blueprint, Response, jsonify, request
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,
@@ -39,10 +40,7 @@ def check_updates() -> Response:
return jsonify(result) return jsonify(result)
except Exception as e: except Exception as e:
logger.error(f"Error checking for updates: {e}") logger.error(f"Error checking for updates: {e}")
return jsonify({ return api_error(str(e), 500)
'success': False,
'error': str(e)
}), 500
@updater_bp.route('/status', methods=['GET']) @updater_bp.route('/status', methods=['GET'])
@@ -61,10 +59,7 @@ def update_status() -> Response:
return jsonify(result) return jsonify(result)
except Exception as e: except Exception as e:
logger.error(f"Error getting update status: {e}") logger.error(f"Error getting update status: {e}")
return jsonify({ return api_error(str(e), 500)
'success': False,
'error': str(e)
}), 500
@updater_bp.route('/update', methods=['POST']) @updater_bp.route('/update', methods=['POST'])
@@ -100,10 +95,7 @@ def do_update() -> Response:
except Exception as e: except Exception as e:
logger.error(f"Error performing update: {e}") logger.error(f"Error performing update: {e}")
return jsonify({ return api_error(str(e), 500)
'success': False,
'error': str(e)
}), 500
@updater_bp.route('/dismiss', methods=['POST']) @updater_bp.route('/dismiss', methods=['POST'])
@@ -124,20 +116,14 @@ def dismiss_notification() -> Response:
version = data.get('version') version = data.get('version')
if not version: if not version:
return jsonify({ return api_error('Version is required', 400)
'success': False,
'error': 'Version is required'
}), 400
try: try:
result = dismiss_update(version) result = dismiss_update(version)
return jsonify(result) return jsonify(result)
except Exception as e: except Exception as e:
logger.error(f"Error dismissing update: {e}") logger.error(f"Error dismissing update: {e}")
return jsonify({ return api_error(str(e), 500)
'success': False,
'error': str(e)
}), 500
@updater_bp.route('/restart', methods=['POST']) @updater_bp.route('/restart', methods=['POST'])
+60 -38
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,23 +13,26 @@ import subprocess
import threading import threading
import time import time
from datetime import datetime from datetime import datetime
from typing import Generator from typing import Any
from flask import Blueprint, jsonify, request, Response from flask import Blueprint, Response, jsonify, request
import app as app_module import app as app_module
from utils.logging import sensor_logger as logger from utils.acars_translator import translate_message
from utils.validation import validate_device_index, validate_gain, validate_ppm
from utils.sdr import SDRFactory, SDRType
from utils.sse import sse_stream_fanout
from utils.event_pipeline import process_event
from utils.constants import ( from utils.constants import (
PROCESS_START_WAIT,
PROCESS_TERMINATE_TIMEOUT, PROCESS_TERMINATE_TIMEOUT,
SSE_KEEPALIVE_INTERVAL, SSE_KEEPALIVE_INTERVAL,
SSE_QUEUE_TIMEOUT, SSE_QUEUE_TIMEOUT,
PROCESS_START_WAIT,
) )
from utils.event_pipeline import process_event
from utils.flight_correlator import get_flight_correlator
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.sse import sse_stream_fanout
from utils.validation import validate_device_index, validate_gain, validate_ppm
vdl2_bp = Blueprint('vdl2', __name__, url_prefix='/vdl2') vdl2_bp = Blueprint('vdl2', __name__, url_prefix='/vdl2')
@@ -80,6 +83,21 @@ def stream_vdl2_output(process: subprocess.Popen, is_text_mode: bool = False) ->
data['type'] = 'vdl2' data['type'] = 'vdl2'
data['timestamp'] = datetime.utcnow().isoformat() + 'Z' data['timestamp'] = datetime.utcnow().isoformat() + 'Z'
# Enrich with translated ACARS label at top level (consistent with ACARS route)
try:
vdl2_inner = data.get('vdl2', data)
acars_payload = (vdl2_inner.get('avlc') or {}).get('acars')
if acars_payload and acars_payload.get('label'):
translation = translate_message({
'label': acars_payload.get('label'),
'text': acars_payload.get('msg_text', ''),
})
data['label_description'] = translation['label_description']
data['message_type'] = translation['message_type']
data['parsed'] = translation['parsed']
except Exception:
pass
# Update stats # Update stats
vdl2_message_count += 1 vdl2_message_count += 1
vdl2_last_message_time = time.time() vdl2_last_message_time = time.time()
@@ -87,11 +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):
from utils.flight_correlator import get_flight_correlator
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:
@@ -117,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:
@@ -165,18 +178,12 @@ def start_vdl2() -> Response:
with app_module.vdl2_lock: with app_module.vdl2_lock:
if app_module.vdl2_process and app_module.vdl2_process.poll() is None: if app_module.vdl2_process and app_module.vdl2_process.poll() is None:
return jsonify({ return api_error('VDL2 decoder already running', 409)
'status': 'error',
'message': 'VDL2 decoder already running'
}), 409
# Check for dumpvdl2 # Check for dumpvdl2
dumpvdl2_path = find_dumpvdl2() dumpvdl2_path = find_dumpvdl2()
if not dumpvdl2_path: if not dumpvdl2_path:
return jsonify({ return api_error('dumpvdl2 not found. Install from: https://github.com/szpajder/dumpvdl2', 400)
'status': 'error',
'message': 'dumpvdl2 not found. Install from: https://github.com/szpajder/dumpvdl2'
}), 400
data = request.json or {} data = request.json or {}
@@ -186,7 +193,7 @@ def start_vdl2() -> Response:
gain = validate_gain(data.get('gain', '40')) gain = validate_gain(data.get('gain', '40'))
ppm = validate_ppm(data.get('ppm', '0')) ppm = validate_ppm(data.get('ppm', '0'))
except ValueError as e: except ValueError as e:
return jsonify({'status': 'error', 'message': str(e)}), 400 return api_error(str(e), 400)
# Resolve SDR type for device selection # Resolve SDR type for device selection
sdr_type_str = data.get('sdr_type', 'rtlsdr') sdr_type_str = data.get('sdr_type', 'rtlsdr')
@@ -199,11 +206,7 @@ def start_vdl2() -> Response:
device_int = int(device) device_int = int(device)
error = app_module.claim_sdr_device(device_int, 'vdl2', sdr_type_str) error = app_module.claim_sdr_device(device_int, 'vdl2', sdr_type_str)
if error: if error:
return jsonify({ return api_error(error, 409, error_type='DEVICE_BUSY')
'status': 'error',
'error_type': 'DEVICE_BUSY',
'message': error
}), 409
vdl2_active_device = device_int vdl2_active_device = device_int
vdl2_active_sdr_type = sdr_type_str vdl2_active_sdr_type = sdr_type_str
@@ -268,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(
@@ -290,11 +293,13 @@ def start_vdl2() -> Response:
stderr = '' stderr = ''
if process.stderr: if process.stderr:
stderr = process.stderr.read().decode('utf-8', errors='replace') stderr = process.stderr.read().decode('utf-8', errors='replace')
if stderr:
logger.error(f"dumpvdl2 stderr:\n{stderr}")
error_msg = 'dumpvdl2 failed to start' error_msg = 'dumpvdl2 failed to start'
if stderr: if stderr:
error_msg += f': {stderr[:200]}' error_msg += f': {stderr[:500]}'
logger.error(error_msg) logger.error(error_msg)
return jsonify({'status': 'error', 'message': error_msg}), 500 return api_error(error_msg, 500)
app_module.vdl2_process = process app_module.vdl2_process = process
register_process(process) register_process(process)
@@ -321,7 +326,7 @@ def start_vdl2() -> Response:
vdl2_active_device = None vdl2_active_device = None
vdl2_active_sdr_type = None vdl2_active_sdr_type = None
logger.error(f"Failed to start VDL2 decoder: {e}") logger.error(f"Failed to start VDL2 decoder: {e}")
return jsonify({'status': 'error', 'message': str(e)}), 500 return api_error(str(e), 500)
@vdl2_bp.route('/stop', methods=['POST']) @vdl2_bp.route('/stop', methods=['POST'])
@@ -331,10 +336,7 @@ def stop_vdl2() -> Response:
with app_module.vdl2_lock: with app_module.vdl2_lock:
if not app_module.vdl2_process: if not app_module.vdl2_process:
return jsonify({ return api_error('VDL2 decoder not running', 400)
'status': 'error',
'message': 'VDL2 decoder not running'
}), 400
try: try:
app_module.vdl2_process.terminate() app_module.vdl2_process.terminate()
@@ -376,6 +378,26 @@ def stream_vdl2() -> Response:
return response return response
@vdl2_bp.route('/messages')
def get_vdl2_messages() -> Response:
"""Get recent VDL2 messages from correlator (for history reload)."""
limit = request.args.get('limit', 50, type=int)
limit = max(1, min(limit, 200))
msgs = get_flight_correlator().get_recent_messages('vdl2', limit)
return jsonify(msgs)
@vdl2_bp.route('/clear', methods=['POST'])
def clear_vdl2_messages() -> Response:
"""Clear stored VDL2 messages and reset counter."""
global vdl2_message_count, vdl2_last_message_time
get_flight_correlator().clear_vdl2()
vdl2_message_count = 0
vdl2_last_message_time = None
return jsonify({'status': 'cleared'})
@vdl2_bp.route('/frequencies') @vdl2_bp.route('/frequencies')
def get_frequencies() -> Response: def get_frequencies() -> Response:
"""Get default VDL2 frequencies.""" """Get default VDL2 frequencies."""
-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,
+177 -43
View File
@@ -1,24 +1,36 @@
"""Weather Satellite decoder routes. """Weather Satellite decoder routes.
Provides endpoints for capturing and decoding weather satellite images Provides endpoints for capturing and decoding Meteor LRPT weather
from NOAA (APT) and Meteor (LRPT) satellites using SatDump. imagery, including shared results produced by the ground-station
observation pipeline.
""" """
from __future__ import annotations from __future__ import annotations
import json
import queue import queue
from pathlib import Path
from flask import Blueprint, jsonify, request, Response, send_file from flask import Blueprint, Response, jsonify, request, send_file
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 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')
@@ -28,6 +40,15 @@ weather_sat_bp = Blueprint('weather_sat', __name__, url_prefix='/weather-sat')
# Queue for SSE progress streaming # Queue for SSE progress streaming
_weather_sat_queue: queue.Queue = queue.Queue(maxsize=100) _weather_sat_queue: queue.Queue = queue.Queue(maxsize=100)
METEOR_NORAD_IDS = {
'METEOR-M2-3': 57166,
'METEOR-M2-4': 59051,
}
ALLOWED_TEST_DECODE_DIRS = (
Path(__file__).resolve().parent.parent / 'data',
Path(__file__).resolve().parent.parent / 'instance' / 'ground_station' / 'recordings',
)
def _progress_callback(progress: CaptureProgress) -> None: def _progress_callback(progress: CaptureProgress) -> None:
"""Callback to queue progress updates for SSE stream.""" """Callback to queue progress updates for SSE stream."""
@@ -111,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)
@@ -136,6 +157,13 @@ def start_capture():
}) })
data = request.get_json(silent=True) or {} data = request.get_json(silent=True) or {}
sdr_type_str = data.get('sdr_type', 'rtlsdr')
if sdr_type_str != 'rtlsdr':
return jsonify({
'status': 'error',
'message': f'{sdr_type_str.replace("_", " ").title()} is not yet supported for this mode. Please use an RTL-SDR device.'
}), 400
# Validate satellite # Validate satellite
satellite = data.get('satellite') satellite = data.get('satellite')
@@ -158,18 +186,26 @@ def start_capture():
bias_t = bool(data.get('bias_t', False)) bias_t = bool(data.get('bias_t', False))
# Claim SDR device # Check for rtl_tcp (remote SDR) connection
try: rtl_tcp_host = data.get('rtl_tcp_host')
import app as app_module rtl_tcp_port = data.get('rtl_tcp_port', 1234)
error = app_module.claim_sdr_device(device_index, 'weather_sat')
if error: if rtl_tcp_host:
return jsonify({ try:
'status': 'error', rtl_tcp_host = validate_rtl_tcp_host(rtl_tcp_host)
'error_type': 'DEVICE_BUSY', rtl_tcp_port = validate_rtl_tcp_port(rtl_tcp_port)
'message': error, except ValueError as e:
}), 409 return api_error(str(e), 400)
except ImportError:
pass # Claim SDR device (skip for remote rtl_tcp)
if not rtl_tcp_host:
try:
import app as app_module
error = app_module.claim_sdr_device(device_index, 'weather_sat', sdr_type_str)
if error:
return api_error(error, 409, error_type='DEVICE_BUSY')
except ImportError:
pass
# Clear queue # Clear queue
while not _weather_sat_queue.empty(): while not _weather_sat_queue.empty():
@@ -182,7 +218,8 @@ def start_capture():
decoder.set_callback(_progress_callback) decoder.set_callback(_progress_callback)
def _release_device(): def _release_device():
_release_weather_sat_device(device_index) if not rtl_tcp_host:
_release_weather_sat_device(device_index)
decoder.set_on_complete(_release_device) decoder.set_on_complete(_release_device)
@@ -192,6 +229,8 @@ def start_capture():
gain=gain, gain=gain,
sample_rate=DEFAULT_SAMPLE_RATE, sample_rate=DEFAULT_SAMPLE_RATE,
bias_t=bias_t, bias_t=bias_t,
rtl_tcp_host=rtl_tcp_host,
rtl_tcp_port=rtl_tcp_port,
) )
if success: if success:
@@ -221,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)
} }
@@ -265,14 +304,13 @@ def test_decode():
from pathlib import Path from pathlib import Path
input_path = Path(input_file) input_path = Path(input_file)
# Security: restrict to data directory (anchored to app root, not CWD) # Restrict test-decode to application-owned sample and recording paths.
allowed_base = Path(__file__).resolve().parent.parent / 'data'
try: try:
resolved = input_path.resolve() resolved = input_path.resolve()
if not resolved.is_relative_to(allowed_base): if not any(resolved.is_relative_to(base) for base in ALLOWED_TEST_DECODE_DIRS):
return jsonify({ return jsonify({
'status': 'error', 'status': 'error',
'message': 'input_file must be under the data/ directory' 'message': 'input_file must be under INTERCEPT data or ground-station recordings'
}), 403 }), 403
except (OSError, ValueError): except (OSError, ValueError):
return jsonify({ return jsonify({
@@ -362,21 +400,34 @@ def list_images():
JSON with list of decoded images. JSON with list of decoded images.
""" """
decoder = get_weather_sat_decoder() decoder = get_weather_sat_decoder()
images = decoder.get_images() images = [
{
**img.to_dict(),
'source': 'weather_sat',
'deletable': True,
}
for img in decoder.get_images()
]
images.extend(_get_ground_station_images())
# Filter by satellite if specified # Filter by satellite if specified
satellite_filter = request.args.get('satellite') satellite_filter = request.args.get('satellite')
if satellite_filter: if satellite_filter:
images = [img for img in images if img.satellite == satellite_filter] images = [
img for img in images
if str(img.get('satellite', '')).upper() == satellite_filter.upper()
]
images.sort(key=lambda img: img.get('timestamp') or '', reverse=True)
# Apply limit # Apply limit
limit = request.args.get('limit', type=int) limit = request.args.get('limit', type=int)
if limit and limit > 0: if limit and limit > 0:
images = images[-limit:] images = images[:limit]
return jsonify({ return jsonify({
'status': 'ok', 'status': 'ok',
'images': [img.to_dict() for img in images], 'images': images,
'count': len(images), 'count': len(images),
}) })
@@ -395,20 +446,50 @@ def get_image(filename: str):
# Security: only allow safe filenames # Security: only allow safe filenames
if not filename.replace('_', '').replace('-', '').replace('.', '').isalnum(): if not filename.replace('_', '').replace('-', '').replace('.', '').isalnum():
return jsonify({'status': 'error', 'message': 'Invalid filename'}), 400 return api_error('Invalid filename', 400)
if not (filename.endswith('.png') or filename.endswith('.jpg') or filename.endswith('.jpeg')): if not (filename.endswith('.png') or filename.endswith('.jpg') or filename.endswith('.jpeg')):
return jsonify({'status': 'error', 'message': 'Only PNG/JPG files supported'}), 400 return api_error('Only PNG/JPG files supported', 400)
image_path = decoder._output_dir / filename image_path = decoder._output_dir / filename
if not image_path.exists(): if not image_path.exists():
return jsonify({'status': 'error', 'message': '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'])
def delete_image(filename: str): def delete_image(filename: str):
"""Delete a decoded image. """Delete a decoded image.
@@ -422,12 +503,12 @@ def delete_image(filename: str):
decoder = get_weather_sat_decoder() decoder = get_weather_sat_decoder()
if not filename.replace('_', '').replace('-', '').replace('.', '').isalnum(): if not filename.replace('_', '').replace('-', '').replace('.', '').isalnum():
return jsonify({'status': 'error', 'message': 'Invalid filename'}), 400 return api_error('Invalid filename', 400)
if decoder.delete_image(filename): if decoder.delete_image(filename):
return jsonify({'status': 'deleted', 'filename': filename}) return jsonify({'status': 'deleted', 'filename': filename})
else: else:
return jsonify({'status': 'error', 'message': 'Image not found'}), 404 return api_error('Image not found', 404)
@weather_sat_bp.route('/images', methods=['DELETE']) @weather_sat_bp.route('/images', methods=['DELETE'])
@@ -442,6 +523,62 @@ def delete_all_images():
return jsonify({'status': 'ok', 'deleted': count}) return jsonify({'status': 'ok', 'deleted': count})
def _get_ground_station_images() -> list[dict]:
try:
from utils.database import get_db
with get_db() as conn:
rows = conn.execute(
'''
SELECT id, norad_id, file_path, metadata_json, created_at
FROM ground_station_outputs
WHERE output_type='image' AND backend='meteor_lrpt'
ORDER BY created_at DESC
LIMIT 200
'''
).fetchall()
except Exception as e:
logger.debug("Failed to fetch ground-station weather outputs: %s", e)
return []
images: list[dict] = []
for row in rows:
file_path = Path(row['file_path'])
if not file_path.exists():
continue
metadata = {}
raw_metadata = row['metadata_json']
if raw_metadata:
try:
metadata = json.loads(raw_metadata)
except json.JSONDecodeError:
metadata = {}
satellite = metadata.get('satellite') or _satellite_from_norad(row['norad_id'])
images.append({
'filename': file_path.name,
'satellite': satellite,
'mode': metadata.get('mode', 'LRPT'),
'timestamp': metadata.get('timestamp') or row['created_at'],
'frequency': metadata.get('frequency', 137.9),
'size_bytes': metadata.get('size_bytes') or file_path.stat().st_size,
'product': metadata.get('product', ''),
'url': f"/weather-sat/images/shared/{row['id']}",
'source': 'ground_station',
'deletable': False,
'output_id': row['id'],
})
return images
def _satellite_from_norad(norad_id: int | None) -> str:
for satellite, known_norad in METEOR_NORAD_IDS.items():
if known_norad == norad_id:
return satellite
return 'METEOR'
@weather_sat_bp.route('/stream') @weather_sat_bp.route('/stream')
def stream_progress(): def stream_progress():
"""SSE stream of capture/decode progress. """SSE stream of capture/decode progress.
@@ -478,17 +615,14 @@ def get_passes():
raw_lon = request.args.get('longitude') raw_lon = request.args.get('longitude')
if raw_lat is None or raw_lon is None: if raw_lat is None or raw_lon is None:
return jsonify({ return api_error('latitude and longitude parameters required', 400)
'status': 'error',
'message': 'latitude and longitude parameters required'
}), 400
try: try:
lat = validate_latitude(raw_lat) lat = validate_latitude(raw_lat)
lon = validate_longitude(raw_lon) lon = validate_longitude(raw_lon)
except ValueError as e: except ValueError as e:
logger.warning('Invalid coordinates in get_passes: %s', e) logger.warning('Invalid coordinates in get_passes: %s', e)
return jsonify({'status': 'error', 'message': 'Invalid coordinates'}), 400 return api_error('Invalid coordinates', 400)
hours = max(1, min(request.args.get('hours', 24, type=int), 72)) hours = max(1, min(request.args.get('hours', 24, type=int), 72))
min_elevation = max(0, min(request.args.get('min_elevation', 15, type=float), 90)) min_elevation = max(0, min(request.args.get('min_elevation', 15, type=float), 90))
@@ -597,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',
@@ -646,10 +780,10 @@ def skip_pass(pass_id: str):
from utils.weather_sat_scheduler import get_weather_sat_scheduler from utils.weather_sat_scheduler import get_weather_sat_scheduler
if not pass_id.replace('_', '').replace('-', '').isalnum(): if not pass_id.replace('_', '').replace('-', '').isalnum():
return jsonify({'status': 'error', 'message': 'Invalid pass ID'}), 400 return api_error('Invalid pass ID', 400)
scheduler = get_weather_sat_scheduler() scheduler = get_weather_sat_scheduler()
if scheduler.skip_pass(pass_id): if scheduler.skip_pass(pass_id):
return jsonify({'status': 'skipped', 'pass_id': pass_id}) return jsonify({'status': 'skipped', 'pass_id': pass_id})
else: else:
return jsonify({'status': 'error', 'message': 'Pass not found or already processed'}), 404 return api_error('Pass not found or already processed', 404)
+20 -30
View File
@@ -9,9 +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_error, api_success
try: try:
from flask_sock import Sock from flask_sock import Sock
@@ -19,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')
@@ -36,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
@@ -68,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
@@ -226,8 +229,7 @@ def list_receivers() -> Response:
if r.get('freq_lo', 0) <= freq_khz <= r.get('freq_hi', 30000) if r.get('freq_lo', 0) <= freq_khz <= r.get('freq_hi', 30000)
] ]
return jsonify({ return api_success(data={
'status': 'success',
'receivers': filtered[:100], 'receivers': filtered[:100],
'total': len(filtered), 'total': len(filtered),
'cached_total': len(receivers), 'cached_total': len(receivers),
@@ -242,7 +244,7 @@ def nearest_receivers() -> Response:
freq_khz = request.args.get('freq_khz', type=float) freq_khz = request.args.get('freq_khz', type=float)
if lat is None or lon is None: if lat is None or lon is None:
return jsonify({'status': 'error', 'message': 'lat and lon are required'}), 400 return api_error('lat and lon are required', 400)
receivers = get_receivers() receivers = get_receivers()
@@ -264,10 +266,7 @@ def nearest_receivers() -> Response:
with_distance.sort(key=lambda x: x['distance_km']) with_distance.sort(key=lambda x: x['distance_km'])
return jsonify({ return api_success(data={'receivers': with_distance[:10]})
'status': 'success',
'receivers': with_distance[:10],
})
@websdr_bp.route('/spy-station/<station_id>/receivers') @websdr_bp.route('/spy-station/<station_id>/receivers')
@@ -276,7 +275,7 @@ def spy_station_receivers(station_id: str) -> Response:
try: try:
from routes.spy_stations import STATIONS from routes.spy_stations import STATIONS
except ImportError: except ImportError:
return jsonify({'status': 'error', 'message': 'Spy stations module not available'}), 503 return api_error('Spy stations module not available', 503)
# Find the station # Find the station
station = None station = None
@@ -286,7 +285,7 @@ def spy_station_receivers(station_id: str) -> Response:
break break
if not station: if not station:
return jsonify({'status': 'error', 'message': 'Station not found'}), 404 return api_error('Station not found', 404)
# Get primary frequency # Get primary frequency
freq_khz = None freq_khz = None
@@ -298,7 +297,7 @@ def spy_station_receivers(station_id: str) -> Response:
freq_khz = station['frequencies'][0].get('freq_khz') freq_khz = station['frequencies'][0].get('freq_khz')
if freq_khz is None: if freq_khz is None:
return jsonify({'status': 'error', 'message': 'No frequency found for station'}), 404 return api_error('No frequency found for station', 404)
receivers = get_receivers() receivers = get_receivers()
@@ -308,8 +307,7 @@ def spy_station_receivers(station_id: str) -> Response:
if r.get('freq_lo', 0) <= freq_khz <= r.get('freq_hi', 30000) and r.get('available', True) if r.get('freq_lo', 0) <= freq_khz <= r.get('freq_hi', 30000) and r.get('available', True)
] ]
return jsonify({ return api_success(data={
'status': 'success',
'station': { 'station': {
'id': station['id'], 'id': station['id'],
'name': station.get('name', ''), 'name': station.get('name', ''),
@@ -338,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)
@@ -390,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(
+25 -71
View File
@@ -6,12 +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
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
@@ -109,10 +111,7 @@ def start_decoder():
# Validate frequency (required) # Validate frequency (required)
frequency_khz = data.get('frequency_khz') frequency_khz = data.get('frequency_khz')
if frequency_khz is None: if frequency_khz is None:
return jsonify({ return api_error('frequency_khz is required', 400)
'status': 'error',
'message': 'frequency_khz is required',
}), 400
try: try:
frequency_khz = float(frequency_khz) frequency_khz = float(frequency_khz)
@@ -120,10 +119,7 @@ def start_decoder():
freq_mhz = frequency_khz / 1000.0 freq_mhz = frequency_khz / 1000.0
validate_frequency(freq_mhz, min_mhz=2.0, max_mhz=30.0) validate_frequency(freq_mhz, min_mhz=2.0, max_mhz=30.0)
except (TypeError, ValueError) as e: except (TypeError, ValueError) as e:
return jsonify({ return api_error(f'Invalid frequency: {e}', 400)
'status': 'error',
'message': f'Invalid frequency: {e}',
}), 400
station = str(data.get('station', '')).strip() station = str(data.get('station', '')).strip()
device_index = data.get('device', 0) device_index = data.get('device', 0)
@@ -134,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'
@@ -152,34 +146,21 @@ def start_decoder():
tuned_mhz = tuned_frequency_khz / 1000.0 tuned_mhz = tuned_frequency_khz / 1000.0
validate_frequency(tuned_mhz, min_mhz=2.0, max_mhz=30.0) validate_frequency(tuned_mhz, min_mhz=2.0, max_mhz=30.0)
except ValueError as e: except ValueError as e:
return jsonify({ return api_error(f'Invalid frequency settings: {e}', 400)
'status': 'error',
'message': f'Invalid frequency settings: {e}',
}), 400
# Validate IOC and LPM # Validate IOC and LPM
if ioc not in (288, 576): if ioc not in (288, 576):
return jsonify({ return api_error('IOC must be 288 or 576', 400)
'status': 'error',
'message': 'IOC must be 288 or 576',
}), 400
if lpm not in (60, 120): if lpm not in (60, 120):
return jsonify({ return api_error('LPM must be 60 or 120', 400)
'status': 'error',
'message': 'LPM must be 60 or 120',
}), 400
# Claim SDR device # Claim SDR device
global wefax_active_device, wefax_active_sdr_type global wefax_active_device, wefax_active_sdr_type
device_int = int(device_index) device_int = int(device_index)
error = app_module.claim_sdr_device(device_int, 'wefax', sdr_type_str) error = app_module.claim_sdr_device(device_int, 'wefax', sdr_type_str)
if error: if error:
return jsonify({ return api_error(error, 409, error_type='DEVICE_BUSY')
'status': 'error',
'error_type': 'DEVICE_BUSY',
'message': error,
}), 409
# Set callback and start # Set callback and start
decoder.set_callback(_progress_callback) decoder.set_callback(_progress_callback)
@@ -213,10 +194,7 @@ def start_decoder():
}) })
else: else:
app_module.release_sdr_device(device_int, sdr_type_str) app_module.release_sdr_device(device_int, sdr_type_str)
return jsonify({ return api_error('Failed to start decoder', 500)
'status': 'error',
'message': 'Failed to start decoder',
}), 500
@wefax_bp.route('/stop', methods=['POST']) @wefax_bp.route('/stop', methods=['POST'])
@@ -275,14 +253,14 @@ def get_image(filename: str):
decoder = get_wefax_decoder() decoder = get_wefax_decoder()
if not filename.replace('_', '').replace('-', '').replace('.', '').isalnum(): if not filename.replace('_', '').replace('-', '').replace('.', '').isalnum():
return jsonify({'status': 'error', 'message': 'Invalid filename'}), 400 return api_error('Invalid filename', 400)
if not filename.endswith('.png'): if not filename.endswith('.png'):
return jsonify({'status': 'error', 'message': 'Only PNG files supported'}), 400 return api_error('Only PNG files supported', 400)
image_path = decoder._output_dir / filename image_path = decoder._output_dir / filename
if not image_path.exists(): if not image_path.exists():
return jsonify({'status': 'error', 'message': 'Image not found'}), 404 return api_error('Image not found', 404)
return send_file(image_path, mimetype='image/png') return send_file(image_path, mimetype='image/png')
@@ -293,15 +271,15 @@ def delete_image(filename: str):
decoder = get_wefax_decoder() decoder = get_wefax_decoder()
if not filename.replace('_', '').replace('-', '').replace('.', '').isalnum(): if not filename.replace('_', '').replace('-', '').replace('.', '').isalnum():
return jsonify({'status': 'error', 'message': 'Invalid filename'}), 400 return api_error('Invalid filename', 400)
if not filename.endswith('.png'): if not filename.endswith('.png'):
return jsonify({'status': 'error', 'message': 'Only PNG files supported'}), 400 return api_error('Only PNG files supported', 400)
if decoder.delete_image(filename): if decoder.delete_image(filename):
return jsonify({'status': 'ok'}) return jsonify({'status': 'ok'})
else: else:
return jsonify({'status': 'error', 'message': 'Image not found'}), 404 return api_error('Image not found', 404)
@wefax_bp.route('/images', methods=['DELETE']) @wefax_bp.route('/images', methods=['DELETE'])
@@ -354,27 +332,18 @@ def enable_schedule():
station = str(data.get('station', '')).strip() station = str(data.get('station', '')).strip()
if not station: if not station:
return jsonify({ return api_error('station is required', 400)
'status': 'error',
'message': 'station is required',
}), 400
frequency_khz = data.get('frequency_khz') frequency_khz = data.get('frequency_khz')
if frequency_khz is None: if frequency_khz is None:
return jsonify({ return api_error('frequency_khz is required', 400)
'status': 'error',
'message': 'frequency_khz is required',
}), 400
try: try:
frequency_khz = float(frequency_khz) frequency_khz = float(frequency_khz)
freq_mhz = frequency_khz / 1000.0 freq_mhz = frequency_khz / 1000.0
validate_frequency(freq_mhz, min_mhz=2.0, max_mhz=30.0) validate_frequency(freq_mhz, min_mhz=2.0, max_mhz=30.0)
except (TypeError, ValueError) as e: except (TypeError, ValueError) as e:
return jsonify({ return api_error(f'Invalid frequency: {e}', 400)
'status': 'error',
'message': f'Invalid frequency: {e}',
}), 400
device = int(data.get('device', 0)) device = int(data.get('device', 0))
gain = float(data.get('gain', 40.0)) gain = float(data.get('gain', 40.0))
@@ -396,10 +365,7 @@ def enable_schedule():
tuned_mhz = tuned_frequency_khz / 1000.0 tuned_mhz = tuned_frequency_khz / 1000.0
validate_frequency(tuned_mhz, min_mhz=2.0, max_mhz=30.0) validate_frequency(tuned_mhz, min_mhz=2.0, max_mhz=30.0)
except ValueError as e: except ValueError as e:
return jsonify({ return api_error(f'Invalid frequency settings: {e}', 400)
'status': 'error',
'message': f'Invalid frequency settings: {e}',
}), 400
scheduler = get_wefax_scheduler() scheduler = get_wefax_scheduler()
scheduler.set_callbacks(_progress_callback, _scheduler_event_callback) scheduler.set_callbacks(_progress_callback, _scheduler_event_callback)
@@ -416,10 +382,7 @@ def enable_schedule():
) )
except Exception: except Exception:
logger.exception("Failed to enable WeFax scheduler") logger.exception("Failed to enable WeFax scheduler")
return jsonify({ return api_error('Failed to enable scheduler', 500)
'status': 'error',
'message': 'Failed to enable scheduler',
}), 500
return jsonify({ return jsonify({
'status': 'ok', 'status': 'ok',
@@ -473,19 +436,13 @@ def skip_broadcast(broadcast_id: str):
from utils.wefax_scheduler import get_wefax_scheduler from utils.wefax_scheduler import get_wefax_scheduler
if not broadcast_id.replace('_', '').replace('-', '').isalnum(): if not broadcast_id.replace('_', '').replace('-', '').isalnum():
return jsonify({ return api_error('Invalid broadcast ID', 400)
'status': 'error',
'message': 'Invalid broadcast ID',
}), 400
scheduler = get_wefax_scheduler() scheduler = get_wefax_scheduler()
if scheduler.skip_broadcast(broadcast_id): if scheduler.skip_broadcast(broadcast_id):
return jsonify({'status': 'skipped', 'broadcast_id': broadcast_id}) return jsonify({'status': 'skipped', 'broadcast_id': broadcast_id})
else: else:
return jsonify({ return api_error('Broadcast not found or already processed', 404)
'status': 'error',
'message': 'Broadcast not found or already processed',
}), 404
@wefax_bp.route('/stations') @wefax_bp.route('/stations')
@@ -504,10 +461,7 @@ def station_detail(callsign: str):
"""Get station detail including current schedule info.""" """Get station detail including current schedule info."""
station = get_station(callsign) station = get_station(callsign)
if not station: if not station:
return jsonify({ return api_error(f'Station {callsign} not found', 404)
'status': 'error',
'message': f'Station {callsign} not found',
}), 404
current = get_current_broadcasts(callsign) current = get_current_broadcasts(callsign)
+128 -128
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,41 +12,46 @@ 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
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')
# --- v1 deprecation ---
# These endpoints are deprecated in favor of /wifi/v2/*.
# Frontend still uses v1, so they remain active.
# Migration: switch frontend to v2 endpoints, then remove this file.
_v1_deprecation_logged = set()
@wifi_bp.after_request
def _add_deprecation_header(response):
"""Add X-Deprecated header to all v1 WiFi responses."""
response.headers['X-Deprecated'] = 'Use /wifi/v2/* endpoints instead'
endpoint = request.endpoint or ''
if endpoint not in _v1_deprecation_logged:
_v1_deprecation_logged.add(endpoint)
logger.warning(f"Deprecated v1 WiFi endpoint called: {request.path} — migrate to /wifi/v2/*")
return response
# PMKID process state # PMKID process state
pmkid_process = None pmkid_process = None
pmkid_lock = threading.Lock() pmkid_lock = threading.Lock()
@@ -182,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
@@ -193,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)
@@ -211,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
@@ -227,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
@@ -238,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
@@ -261,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
@@ -275,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')
@@ -455,7 +460,7 @@ def toggle_monitor_mode():
try: try:
interface = validate_network_interface(data.get('interface')) interface = validate_network_interface(data.get('interface'))
except ValueError as e: except ValueError as e:
return jsonify({'status': 'error', 'message': str(e)}), 400 return api_error(str(e), 400)
if action == 'start': if action == 'start':
if check_tool('airmon-ng'): if check_tool('airmon-ng'):
@@ -575,20 +580,16 @@ def toggle_monitor_mode():
all_wireless = [f for f in os.listdir('/sys/class/net') all_wireless = [f for f in os.listdir('/sys/class/net')
if os.path.exists(f'/sys/class/net/{f}/wireless') or 'mon' in f or f.startswith('wl')] if os.path.exists(f'/sys/class/net/{f}/wireless') or 'mon' in f or f.startswith('wl')]
logger.error(f"Monitor interface not found. Tried: {monitor_iface}. Available: {all_wireless}") logger.error(f"Monitor interface not found. Tried: {monitor_iface}. Available: {all_wireless}")
return jsonify({ return api_error(f'Monitor interface not created. airmon-ng output: {output[:500]}. Available interfaces: {all_wireless}')
'status': 'error',
'message': f'Monitor interface not created. airmon-ng output: {output[:500]}. Available interfaces: {all_wireless}'
})
app_module.wifi_monitor_interface = monitor_iface app_module.wifi_monitor_interface = monitor_iface
app_module.wifi_queue.put({'type': 'info', 'text': f'Monitor mode enabled on {app_module.wifi_monitor_interface}'}) app_module.wifi_queue.put({'type': 'info', 'text': f'Monitor mode enabled on {app_module.wifi_monitor_interface}'})
logger.info(f"Monitor mode enabled on {monitor_iface}") logger.info(f"Monitor mode enabled on {monitor_iface}")
return jsonify({'status': 'success', '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 jsonify({'status': 'error', 'message': str(e)}) return api_error(str(e))
elif check_tool('iw'): elif check_tool('iw'):
try: try:
@@ -596,11 +597,11 @@ def toggle_monitor_mode():
subprocess.run(['iw', interface, 'set', 'monitor', 'control'], capture_output=True) subprocess.run(['iw', interface, 'set', 'monitor', 'control'], capture_output=True)
subprocess.run(['ip', 'link', 'set', interface, 'up'], capture_output=True) subprocess.run(['ip', 'link', 'set', interface, 'up'], capture_output=True)
app_module.wifi_monitor_interface = interface app_module.wifi_monitor_interface = interface
return jsonify({'status': 'success', 'monitor_interface': interface}) return api_success(data={'monitor_interface': interface})
except Exception as e: except Exception as e:
return jsonify({'status': 'error', 'message': str(e)}) return api_error(str(e))
else: else:
return jsonify({'status': 'error', 'message': 'No monitor mode tools available.'}) return api_error('No monitor mode tools available.')
else: # stop else: # stop
if check_tool('airmon-ng'): if check_tool('airmon-ng'):
@@ -609,20 +610,20 @@ def toggle_monitor_mode():
subprocess.run([airmon_path, 'stop', app_module.wifi_monitor_interface or interface], subprocess.run([airmon_path, 'stop', app_module.wifi_monitor_interface or interface],
capture_output=True, text=True, timeout=15) capture_output=True, text=True, timeout=15)
app_module.wifi_monitor_interface = None app_module.wifi_monitor_interface = None
return jsonify({'status': 'success', 'message': 'Monitor mode disabled'}) return api_success(message='Monitor mode disabled')
except Exception as e: except Exception as e:
return jsonify({'status': 'error', 'message': str(e)}) return api_error(str(e))
elif check_tool('iw'): elif check_tool('iw'):
try: try:
subprocess.run(['ip', 'link', 'set', interface, 'down'], capture_output=True) subprocess.run(['ip', 'link', 'set', interface, 'down'], capture_output=True)
subprocess.run(['iw', interface, 'set', 'type', 'managed'], capture_output=True) subprocess.run(['iw', interface, 'set', 'type', 'managed'], capture_output=True)
subprocess.run(['ip', 'link', 'set', interface, 'up'], capture_output=True) subprocess.run(['ip', 'link', 'set', interface, 'up'], capture_output=True)
app_module.wifi_monitor_interface = None app_module.wifi_monitor_interface = None
return jsonify({'status': 'success', 'message': 'Monitor mode disabled'}) return api_success(message='Monitor mode disabled')
except Exception as e: except Exception as e:
return jsonify({'status': 'error', 'message': str(e)}) return api_error(str(e))
return jsonify({'status': 'error', 'message': 'Unknown action'}) return api_error('Unknown action')
@wifi_bp.route('/scan/start', methods=['POST']) @wifi_bp.route('/scan/start', methods=['POST'])
@@ -630,7 +631,7 @@ def start_wifi_scan():
"""Start WiFi scanning with airodump-ng.""" """Start WiFi scanning with airodump-ng."""
with app_module.wifi_lock: with app_module.wifi_lock:
if app_module.wifi_process: if app_module.wifi_process:
return jsonify({'status': 'error', 'message': 'Scan already running'}) return api_error('Scan already running')
data = request.json data = request.json
channel = data.get('channel') channel = data.get('channel')
@@ -643,21 +644,18 @@ def start_wifi_scan():
try: try:
interface = validate_network_interface(interface) interface = validate_network_interface(interface)
except ValueError as e: except ValueError as e:
return jsonify({'status': 'error', 'message': str(e)}), 400 return api_error(str(e), 400)
else: else:
interface = app_module.wifi_monitor_interface interface = app_module.wifi_monitor_interface
if not interface: if not interface:
return jsonify({'status': 'error', 'message': 'No monitor interface available.'}) return api_error('No monitor interface available.')
# Verify interface exists # Verify interface exists
if not os.path.exists(f'/sys/class/net/{interface}'): if not os.path.exists(f'/sys/class/net/{interface}'):
all_wireless = [f for f in os.listdir('/sys/class/net') all_wireless = [f for f in os.listdir('/sys/class/net')
if os.path.exists(f'/sys/class/net/{f}/wireless') or 'mon' in f or f.startswith('wl')] if os.path.exists(f'/sys/class/net/{f}/wireless') or 'mon' in f or f.startswith('wl')]
return jsonify({ return api_error(f'Interface "{interface}" does not exist. Available: {all_wireless}')
'status': 'error',
'message': f'Interface "{interface}" does not exist. Available: {all_wireless}'
})
app_module.wifi_networks = {} app_module.wifi_networks = {}
app_module.wifi_clients = {} app_module.wifi_clients = {}
@@ -670,32 +668,35 @@ 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:
try: try:
channel_list = _parse_channel_list(channels) channel_list = _parse_channel_list(channels)
except ValueError as e: except ValueError as e:
return jsonify({'status': 'error', 'message': 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)}")
@@ -723,7 +724,7 @@ def start_wifi_scan():
error_msg = 'Permission denied. Try running with sudo.' error_msg = 'Permission denied. Try running with sudo.'
logger.error(f"airodump-ng failed for interface '{interface}': {error_msg}") logger.error(f"airodump-ng failed for interface '{interface}': {error_msg}")
return jsonify({'status': 'error', 'message': error_msg, 'interface': interface}) return api_error(error_msg)
thread = threading.Thread(target=stream_airodump_output, args=(app_module.wifi_process, csv_path)) thread = threading.Thread(target=stream_airodump_output, args=(app_module.wifi_process, csv_path))
thread.daemon = True thread.daemon = True
@@ -734,9 +735,9 @@ def start_wifi_scan():
return jsonify({'status': 'started', 'interface': interface}) return jsonify({'status': 'started', 'interface': interface})
except FileNotFoundError: except FileNotFoundError:
return jsonify({'status': 'error', 'message': 'airodump-ng not found.'}) return api_error('airodump-ng not found.')
except Exception as e: except Exception as e:
return jsonify({'status': 'error', 'message': str(e)}) return api_error(str(e))
@wifi_bp.route('/scan/stop', methods=['POST']) @wifi_bp.route('/scan/stop', methods=['POST'])
@@ -768,18 +769,18 @@ def send_deauth():
try: try:
interface = validate_network_interface(interface) interface = validate_network_interface(interface)
except ValueError as e: except ValueError as e:
return jsonify({'status': 'error', 'message': str(e)}), 400 return api_error(str(e), 400)
else: else:
interface = app_module.wifi_monitor_interface interface = app_module.wifi_monitor_interface
if not target_bssid: if not target_bssid:
return jsonify({'status': 'error', 'message': 'Target BSSID required'}) return api_error('Target BSSID required')
if not is_valid_mac(target_bssid): if not is_valid_mac(target_bssid):
return jsonify({'status': 'error', 'message': 'Invalid BSSID format'}) return api_error('Invalid BSSID format')
if not is_valid_mac(target_client): if not is_valid_mac(target_client):
return jsonify({'status': 'error', 'message': 'Invalid client MAC format'}) return api_error('Invalid client MAC format')
try: try:
count = int(count) count = int(count)
@@ -789,10 +790,10 @@ def send_deauth():
count = 5 count = 5
if not interface: if not interface:
return jsonify({'status': 'error', 'message': 'No monitor interface'}) return api_error('No monitor interface')
if not check_tool('aireplay-ng'): if not check_tool('aireplay-ng'):
return jsonify({'status': 'error', 'message': 'aireplay-ng not found'}) return api_error('aireplay-ng not found')
try: try:
aireplay_path = get_tool_path('aireplay-ng') aireplay_path = get_tool_path('aireplay-ng')
@@ -809,14 +810,14 @@ def send_deauth():
result = subprocess.run(cmd, capture_output=True, text=True, timeout=30) result = subprocess.run(cmd, capture_output=True, text=True, timeout=30)
if result.returncode == 0: if result.returncode == 0:
return jsonify({'status': 'success', 'message': f'Sent {count} deauth packets'}) return api_success(message=f'Sent {count} deauth packets')
else: else:
return jsonify({'status': 'error', 'message': result.stderr}) return api_error(result.stderr)
except subprocess.TimeoutExpired: except subprocess.TimeoutExpired:
return jsonify({'status': 'success', 'message': 'Deauth sent (timed out)'}) return api_success(message='Deauth sent (timed out)')
except Exception as e: except Exception as e:
return jsonify({'status': 'error', 'message': str(e)}) return api_error(str(e))
@wifi_bp.route('/handshake/capture', methods=['POST']) @wifi_bp.route('/handshake/capture', methods=['POST'])
@@ -832,22 +833,22 @@ def capture_handshake():
try: try:
interface = validate_network_interface(interface) interface = validate_network_interface(interface)
except ValueError as e: except ValueError as e:
return jsonify({'status': 'error', 'message': str(e)}), 400 return api_error(str(e), 400)
else: else:
interface = app_module.wifi_monitor_interface interface = app_module.wifi_monitor_interface
if not target_bssid or not channel: if not target_bssid or not channel:
return jsonify({'status': 'error', 'message': 'BSSID and channel required'}) return api_error('BSSID and channel required')
if not is_valid_mac(target_bssid): if not is_valid_mac(target_bssid):
return jsonify({'status': 'error', 'message': 'Invalid BSSID format'}) return api_error('Invalid BSSID format')
if not is_valid_channel(channel): if not is_valid_channel(channel):
return jsonify({'status': 'error', 'message': 'Invalid channel'}) return api_error('Invalid channel')
with app_module.wifi_lock: with app_module.wifi_lock:
if app_module.wifi_process: if app_module.wifi_process:
return jsonify({'status': 'error', 'message': 'Scan already running.'}) return api_error('Scan already running.')
capture_path = f'/tmp/intercept_handshake_{target_bssid.replace(":", "")}' capture_path = f'/tmp/intercept_handshake_{target_bssid.replace(":", "")}'
@@ -866,7 +867,7 @@ def capture_handshake():
app_module.wifi_queue.put({'type': 'info', 'text': f'Capturing handshakes for {target_bssid}'}) app_module.wifi_queue.put({'type': 'info', 'text': f'Capturing handshakes for {target_bssid}'})
return jsonify({'status': 'started', 'capture_file': capture_path + '-01.cap'}) return jsonify({'status': 'started', 'capture_file': capture_path + '-01.cap'})
except Exception as e: except Exception as e:
return jsonify({'status': 'error', 'message': str(e)}) return api_error(str(e))
@wifi_bp.route('/handshake/status', methods=['POST']) @wifi_bp.route('/handshake/status', methods=['POST'])
@@ -877,7 +878,7 @@ def check_handshake_status():
target_bssid = data.get('bssid', '') target_bssid = data.get('bssid', '')
if not capture_file.startswith('/tmp/intercept_handshake_') or '..' in capture_file: if not capture_file.startswith('/tmp/intercept_handshake_') or '..' in capture_file:
return jsonify({'status': 'error', 'message': 'Invalid capture file path'}) return api_error('Invalid capture file path')
if not os.path.exists(capture_file): if not os.path.exists(capture_file):
with app_module.wifi_lock: with app_module.wifi_lock:
@@ -951,19 +952,19 @@ def capture_pmkid():
try: try:
interface = validate_network_interface(interface) interface = validate_network_interface(interface)
except ValueError as e: except ValueError as e:
return jsonify({'status': 'error', 'message': str(e)}), 400 return api_error(str(e), 400)
else: else:
interface = app_module.wifi_monitor_interface interface = app_module.wifi_monitor_interface
if not target_bssid: if not target_bssid:
return jsonify({'status': 'error', 'message': 'BSSID required'}) return api_error('BSSID required')
if not is_valid_mac(target_bssid): if not is_valid_mac(target_bssid):
return jsonify({'status': 'error', 'message': 'Invalid BSSID format'}) return api_error('Invalid BSSID format')
with pmkid_lock: with pmkid_lock:
if pmkid_process and pmkid_process.poll() is None: if pmkid_process and pmkid_process.poll() is None:
return jsonify({'status': 'error', 'message': 'PMKID capture already running'}) return api_error('PMKID capture already running')
capture_path = f'/tmp/intercept_pmkid_{target_bssid.replace(":", "")}.pcapng' capture_path = f'/tmp/intercept_pmkid_{target_bssid.replace(":", "")}.pcapng'
filter_file = f'/tmp/pmkid_filter_{target_bssid.replace(":", "")}' filter_file = f'/tmp/pmkid_filter_{target_bssid.replace(":", "")}'
@@ -986,9 +987,9 @@ def capture_pmkid():
pmkid_process = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE) pmkid_process = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
return jsonify({'status': 'started', 'file': capture_path}) return jsonify({'status': 'started', 'file': capture_path})
except FileNotFoundError: except FileNotFoundError:
return jsonify({'status': 'error', 'message': 'hcxdumptool not found.'}) return api_error('hcxdumptool not found.')
except Exception as e: except Exception as e:
return jsonify({'status': 'error', 'message': str(e)}) return api_error(str(e))
@wifi_bp.route('/pmkid/status', methods=['POST']) @wifi_bp.route('/pmkid/status', methods=['POST'])
@@ -998,7 +999,7 @@ def check_pmkid_status():
capture_file = data.get('file', '') capture_file = data.get('file', '')
if not capture_file.startswith('/tmp/intercept_pmkid_') or '..' in capture_file: if not capture_file.startswith('/tmp/intercept_pmkid_') or '..' in capture_file:
return jsonify({'status': 'error', 'message': 'Invalid capture file path'}) return api_error('Invalid capture file path')
if not os.path.exists(capture_file): if not os.path.exists(capture_file):
return jsonify({'pmkid_found': False, 'file_exists': False}) return jsonify({'pmkid_found': False, 'file_exists': False})
@@ -1008,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
) )
@@ -1054,23 +1055,23 @@ def crack_handshake():
# Validate paths to prevent path traversal # Validate paths to prevent path traversal
if not capture_file.startswith('/tmp/intercept_handshake_') or '..' in capture_file: if not capture_file.startswith('/tmp/intercept_handshake_') or '..' in capture_file:
return jsonify({'status': 'error', 'message': 'Invalid capture file path'}), 400 return api_error('Invalid capture file path', 400)
if '..' in wordlist: if '..' in wordlist:
return jsonify({'status': 'error', 'message': 'Invalid wordlist path'}), 400 return api_error('Invalid wordlist path', 400)
if not os.path.exists(capture_file): if not os.path.exists(capture_file):
return jsonify({'status': 'error', 'message': 'Capture file not found'}), 404 return api_error('Capture file not found', 404)
if not os.path.exists(wordlist): if not os.path.exists(wordlist):
return jsonify({'status': 'error', 'message': 'Wordlist file not found'}), 404 return api_error('Wordlist file not found', 404)
if target_bssid and not is_valid_mac(target_bssid): if target_bssid and not is_valid_mac(target_bssid):
return jsonify({'status': 'error', 'message': 'Invalid BSSID format'}), 400 return api_error('Invalid BSSID format', 400)
aircrack_path = get_tool_path('aircrack-ng') aircrack_path = get_tool_path('aircrack-ng')
if not aircrack_path: if not aircrack_path:
return jsonify({'status': 'error', 'message': 'aircrack-ng not found'}), 500 return api_error('aircrack-ng not found', 500)
try: try:
cmd = [aircrack_path, '-a', '2', '-w', wordlist] cmd = [aircrack_path, '-a', '2', '-w', wordlist]
@@ -1099,8 +1100,7 @@ def crack_handshake():
if match: if match:
password = match.group(1) password = match.group(1)
logger.info(f"Password cracked for {target_bssid}: {password}") logger.info(f"Password cracked for {target_bssid}: {password}")
return jsonify({ return api_success(data={
'status': 'success',
'password': password, 'password': password,
'bssid': target_bssid 'bssid': target_bssid
}) })
@@ -1118,7 +1118,7 @@ def crack_handshake():
}) })
except Exception as e: except Exception as e:
logger.error(f"Crack error: {e}") logger.error(f"Crack error: {e}")
return jsonify({'status': 'error', 'message': str(e)}), 500 return api_error(str(e), 500)
@wifi_bp.route('/networks') @wifi_bp.route('/networks')
@@ -1158,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')
@@ -1189,7 +1189,7 @@ def get_v2_capabilities():
}) })
except Exception as e: except Exception as e:
logger.exception("Error checking capabilities") logger.exception("Error checking capabilities")
return jsonify({'error': str(e)}), 500 return api_error(str(e), 500)
@wifi_bp.route('/v2/scan/quick', methods=['POST']) @wifi_bp.route('/v2/scan/quick', methods=['POST'])
@@ -1220,7 +1220,7 @@ def v2_quick_scan():
}) })
except Exception as e: except Exception as e:
logger.exception("Error in quick scan") logger.exception("Error in quick scan")
return jsonify({'error': str(e)}), 500 return api_error(str(e), 500)
@wifi_bp.route('/v2/scan/start', methods=['POST']) @wifi_bp.route('/v2/scan/start', methods=['POST'])
@@ -1239,10 +1239,10 @@ def v2_start_scan():
return jsonify({'status': 'started'}) return jsonify({'status': 'started'})
else: else:
status = scanner.get_status() status = scanner.get_status()
return jsonify({'error': status.error or 'Failed to start scan'}), 400 return api_error(status.error or 'Failed to start scan', 400)
except Exception as e: except Exception as e:
logger.exception("Error starting deep scan") logger.exception("Error starting deep scan")
return jsonify({'error': str(e)}), 500 return api_error(str(e), 500)
@wifi_bp.route('/v2/scan/stop', methods=['POST']) @wifi_bp.route('/v2/scan/stop', methods=['POST'])
@@ -1254,7 +1254,7 @@ def v2_stop_scan():
return jsonify({'status': 'stopped'}) return jsonify({'status': 'stopped'})
except Exception as e: except Exception as e:
logger.exception("Error stopping scan") logger.exception("Error stopping scan")
return jsonify({'error': str(e)}), 500 return api_error(str(e), 500)
@wifi_bp.route('/v2/scan/status') @wifi_bp.route('/v2/scan/status')
@@ -1274,7 +1274,7 @@ def v2_scan_status():
}) })
except Exception as e: except Exception as e:
logger.exception("Error getting scan status") logger.exception("Error getting scan status")
return jsonify({'error': str(e)}), 500 return api_error(str(e), 500)
@wifi_bp.route('/v2/networks') @wifi_bp.route('/v2/networks')
@@ -1289,7 +1289,7 @@ def v2_get_networks():
}) })
except Exception as e: except Exception as e:
logger.exception("Error getting networks") logger.exception("Error getting networks")
return jsonify({'error': str(e)}), 500 return api_error(str(e), 500)
@wifi_bp.route('/v2/clients') @wifi_bp.route('/v2/clients')
@@ -1326,7 +1326,7 @@ def v2_get_clients():
}) })
except Exception as e: except Exception as e:
logger.exception("Error getting clients") logger.exception("Error getting clients")
return jsonify({'error': str(e)}), 500 return api_error(str(e), 500)
@wifi_bp.route('/v2/probes') @wifi_bp.route('/v2/probes')
@@ -1341,7 +1341,7 @@ def v2_get_probes():
}) })
except Exception as e: except Exception as e:
logger.exception("Error getting probes") logger.exception("Error getting probes")
return jsonify({'error': str(e)}), 500 return api_error(str(e), 500)
@wifi_bp.route('/v2/channels') @wifi_bp.route('/v2/channels')
@@ -1357,7 +1357,7 @@ def v2_get_channels():
}) })
except Exception as e: except Exception as e:
logger.exception("Error getting channel stats") logger.exception("Error getting channel stats")
return jsonify({'error': str(e)}), 500 return api_error(str(e), 500)
@wifi_bp.route('/v2/stream') @wifi_bp.route('/v2/stream')
@@ -1448,11 +1448,11 @@ def v2_export():
return response return response
else: else:
return jsonify({'error': f'Unknown format: {format_type}'}), 400 return api_error(f'Unknown format: {format_type}', 400)
except Exception as e: except Exception as e:
logger.exception("Error exporting data") logger.exception("Error exporting data")
return jsonify({'error': str(e)}), 500 return api_error(str(e), 500)
@wifi_bp.route('/v2/baseline/set', methods=['POST']) @wifi_bp.route('/v2/baseline/set', methods=['POST'])
@@ -1464,7 +1464,7 @@ def v2_set_baseline():
return jsonify({'status': 'baseline_set', 'count': len(scanner._baseline_networks)}) return jsonify({'status': 'baseline_set', 'count': len(scanner._baseline_networks)})
except Exception as e: except Exception as e:
logger.exception("Error setting baseline") logger.exception("Error setting baseline")
return jsonify({'error': str(e)}), 500 return api_error(str(e), 500)
@wifi_bp.route('/v2/baseline/clear', methods=['POST']) @wifi_bp.route('/v2/baseline/clear', methods=['POST'])
@@ -1476,7 +1476,7 @@ def v2_clear_baseline():
return jsonify({'status': 'baseline_cleared'}) return jsonify({'status': 'baseline_cleared'})
except Exception as e: except Exception as e:
logger.exception("Error clearing baseline") logger.exception("Error clearing baseline")
return jsonify({'error': str(e)}), 500 return api_error(str(e), 500)
@wifi_bp.route('/v2/clear', methods=['POST']) @wifi_bp.route('/v2/clear', methods=['POST'])
@@ -1488,7 +1488,7 @@ def v2_clear_data():
return jsonify({'status': 'cleared'}) return jsonify({'status': 'cleared'})
except Exception as e: except Exception as e:
logger.exception("Error clearing data") logger.exception("Error clearing data")
return jsonify({'error': str(e)}), 500 return api_error(str(e), 500)
# ============================================================================= # =============================================================================
@@ -1535,7 +1535,7 @@ def v2_deauth_status():
}) })
except Exception as e: except Exception as e:
logger.exception("Error getting deauth status") logger.exception("Error getting deauth status")
return jsonify({'error': str(e)}), 500 return api_error(str(e), 500)
@wifi_bp.route('/v2/deauth/stream') @wifi_bp.route('/v2/deauth/stream')
@@ -1600,7 +1600,7 @@ def v2_deauth_alerts():
}) })
except Exception as e: except Exception as e:
logger.exception("Error getting deauth alerts") logger.exception("Error getting deauth alerts")
return jsonify({'error': str(e)}), 500 return api_error(str(e), 500)
@wifi_bp.route('/v2/deauth/clear', methods=['POST']) @wifi_bp.route('/v2/deauth/clear', methods=['POST'])
@@ -1620,4 +1620,4 @@ def v2_deauth_clear():
return jsonify({'status': 'cleared'}) return jsonify({'status': 'cleared'})
except Exception as e: except Exception as e:
logger.exception("Error clearing deauth alerts") logger.exception("Error clearing deauth alerts")
return jsonify({'error': str(e)}), 500 return api_error(str(e), 500)
+17 -21
View File
@@ -7,25 +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.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__)
@@ -110,13 +111,13 @@ def start_deep_scan():
try: try:
channel_list = [validate_wifi_channel(c) for c in channel_list] channel_list = [validate_wifi_channel(c) for c in channel_list]
except (TypeError, ValueError): except (TypeError, ValueError):
return jsonify({'error': 'Invalid channels'}), 400 return api_error('Invalid channels', 400)
if channel: if channel:
try: try:
channel = validate_wifi_channel(channel) channel = validate_wifi_channel(channel)
except ValueError: except ValueError:
return jsonify({'error': 'Invalid channel'}), 400 return api_error('Invalid channel', 400)
scanner = get_wifi_scanner() scanner = get_wifi_scanner()
success = scanner.start_deep_scan( success = scanner.start_deep_scan(
@@ -133,10 +134,7 @@ def start_deep_scan():
'interface': interface or scanner._capabilities.monitor_interface, 'interface': interface or scanner._capabilities.monitor_interface,
}) })
else: else:
return jsonify({ return api_error(scanner._status.error or 'Scan failed', 400)
'status': 'error',
'error': scanner._status.error,
}), 400
@wifi_v2_bp.route('/scan/stop', methods=['POST']) @wifi_v2_bp.route('/scan/stop', methods=['POST'])
@@ -235,7 +233,7 @@ def get_network(bssid):
if network: if network:
return jsonify(network.to_dict()) return jsonify(network.to_dict())
else: else:
return jsonify({'error': 'Network not found'}), 404 return api_error('Network not found', 404)
@wifi_v2_bp.route('/clients', methods=['GET']) @wifi_v2_bp.route('/clients', methods=['GET'])
@@ -282,7 +280,7 @@ def get_client(mac):
if client: if client:
return jsonify(client.to_dict()) return jsonify(client.to_dict())
else: else:
return jsonify({'error': 'Client not found'}), 404 return api_error('Client not found', 404)
@wifi_v2_bp.route('/probes', methods=['GET']) @wifi_v2_bp.route('/probes', methods=['GET'])
@@ -409,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
+1779 -515
View File
File diff suppressed because it is too large Load Diff
Executable
+217
View File
@@ -0,0 +1,217 @@
#!/usr/bin/env bash
# INTERCEPT - Production Startup Script
#
# Starts INTERCEPT with gunicorn + gevent for production use.
# Falls back to Flask dev server if gunicorn is not installed.
#
# Requires sudo for SDR, WiFi monitor mode, and Bluetooth access.
#
# Usage:
# sudo ./start.sh # Default: 0.0.0.0:5050
# sudo ./start.sh -p 8080 # Custom port
# sudo ./start.sh --https # HTTPS with self-signed cert
# sudo ./start.sh --debug # Debug mode (Flask dev server)
# sudo ./start.sh --check-deps # Check dependencies and exit
set -euo pipefail
# ── Resolve Python from venv or system ───────────────────────────────────────
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
# ── Load .env if present ──────────────────────────────────────────────────────
if [[ -f "$SCRIPT_DIR/.env" ]]; then
set -a
source "$SCRIPT_DIR/.env"
set +a
fi
if [[ -x "$SCRIPT_DIR/venv/bin/python" ]]; then
PYTHON="$SCRIPT_DIR/venv/bin/python"
elif [[ -n "${VIRTUAL_ENV:-}" && -x "$VIRTUAL_ENV/bin/python" ]]; then
PYTHON="$VIRTUAL_ENV/bin/python"
else
PYTHON="$(command -v python3 || command -v python)"
fi
# ── Defaults (can be overridden by env vars or CLI flags) ────────────────────
HOST="${INTERCEPT_HOST:-0.0.0.0}"
PORT="${INTERCEPT_PORT:-5050}"
DEBUG=0
HTTPS=0
CHECK_DEPS=0
# ── Parse CLI arguments ─────────────────────────────────────────────────────
while [[ $# -gt 0 ]]; do
case "$1" in
-p|--port)
PORT="$2"
shift 2
;;
-H|--host)
HOST="$2"
shift 2
;;
-d|--debug)
DEBUG=1
shift
;;
--https)
HTTPS=1
shift
;;
--check-deps)
CHECK_DEPS=1
shift
;;
-h|--help)
echo "Usage: start.sh [OPTIONS]"
echo ""
echo "Options:"
echo " -p, --port PORT Port to listen on (default: 5050)"
echo " -H, --host HOST Host to bind to (default: 0.0.0.0)"
echo " -d, --debug Run in debug mode (Flask dev server)"
echo " --https Enable HTTPS with self-signed certificate"
echo " --check-deps Check dependencies and exit"
echo " -h, --help Show this help message"
exit 0
;;
*)
echo "Unknown option: $1" >&2
exit 1
;;
esac
done
# ── Export for config.py ─────────────────────────────────────────────────────
export INTERCEPT_HOST="$HOST"
export INTERCEPT_PORT="$PORT"
# ── macOS: allow fork() after ObjC initialisation (gunicorn + gevent) ────
if [[ "$(uname)" == "Darwin" ]]; then
export OBJC_DISABLE_INITIALIZE_FORK_SAFETY=YES
fi
# ── Fix ownership of user data dirs when run via sudo ────────────────────────
# When invoked via sudo the server process runs as root, so every file it
# creates (configs, logs, database) ends up owned by root. On the *next*
# startup we fix that retroactively, and we also pre-create known runtime
# directories so they get correct ownership from the start.
if [[ "$(id -u)" -eq 0 && -n "${SUDO_USER:-}" ]]; then
# Pre-create directories that routes may need at runtime
mkdir -p "$SCRIPT_DIR/instance" \
"$SCRIPT_DIR/data/radiosonde/logs" \
"$SCRIPT_DIR/data/weather_sat"
for dir in instance data certs; do
if [[ -d "$SCRIPT_DIR/$dir" ]]; then
chown -R "$SUDO_USER" "$SCRIPT_DIR/$dir"
fi
done
# Export real user identity so Python can chown runtime-created files
export INTERCEPT_SUDO_UID="$(id -u "$SUDO_USER")"
export INTERCEPT_SUDO_GID="$(id -g "$SUDO_USER")"
fi
# ── Dependency check (delegate to intercept.py) ─────────────────────────────
if [[ "$CHECK_DEPS" -eq 1 ]]; then
exec "$PYTHON" intercept.py --check-deps
fi
# ── Debug mode always uses Flask dev server ──────────────────────────────────
if [[ "$DEBUG" -eq 1 ]]; then
echo "[INTERCEPT] Starting in debug mode (Flask dev server)..."
export INTERCEPT_DEBUG=1
exec "$PYTHON" intercept.py --host "$HOST" --port "$PORT" --debug
fi
# ── HTTPS certificate generation ────────────────────────────────────────────
CERT_DIR="certs"
CERT_FILE="$CERT_DIR/intercept.crt"
KEY_FILE="$CERT_DIR/intercept.key"
if [[ "$HTTPS" -eq 1 ]]; then
if [[ ! -f "$CERT_FILE" || ! -f "$KEY_FILE" ]]; then
echo "[INTERCEPT] Generating self-signed SSL certificate..."
mkdir -p "$CERT_DIR"
openssl req -x509 -newkey rsa:2048 \
-keyout "$KEY_FILE" -out "$CERT_FILE" \
-days 365 -nodes \
-subj '/CN=intercept/O=INTERCEPT/C=US' 2>/dev/null
echo "[INTERCEPT] SSL certificate generated: $CERT_FILE"
else
echo "[INTERCEPT] Using existing SSL certificate: $CERT_FILE"
fi
fi
# ── Detect gunicorn + gevent ─────────────────────────────────────────────────
HAS_GUNICORN=0
HAS_GEVENT=0
if "$PYTHON" -c "import gunicorn" 2>/dev/null; then
HAS_GUNICORN=1
fi
if "$PYTHON" -c "import gevent" 2>/dev/null; then
HAS_GEVENT=1
fi
# ── Resolve LAN address for display ──────────────────────────────────────────
if [[ "$HOST" == "0.0.0.0" ]]; then
LAN_IP=$(hostname -I 2>/dev/null | awk '{print $1}' || true)
# hostname -I on macOS fails or returns empty — try macOS methods
if [[ -z "$LAN_IP" ]]; then
LAN_IP=$(ipconfig getifaddr en0 2>/dev/null || true)
fi
if [[ -z "$LAN_IP" ]]; then
LAN_IP=$(ipconfig getifaddr en1 2>/dev/null || true)
fi
if [[ -z "$LAN_IP" ]]; then
LAN_IP=$(ifconfig 2>/dev/null | grep "inet " | grep -v 127.0.0.1 | head -1 | awk '{print $2}' || true)
fi
LAN_IP="${LAN_IP:-localhost}"
else
LAN_IP="$HOST"
fi
PROTO="http"
[[ "$HTTPS" -eq 1 ]] && PROTO="https"
# ── Start the server ─────────────────────────────────────────────────────────
if [[ "$HAS_GUNICORN" -eq 1 && "$HAS_GEVENT" -eq 1 ]]; then
echo "[INTERCEPT] Starting production server (gunicorn + gevent)..."
echo "[INTERCEPT] Listening on ${PROTO}://${LAN_IP}:${PORT}"
GUNICORN_ARGS=(
-c "$SCRIPT_DIR/gunicorn.conf.py"
-k gevent
-w 1
--timeout 300
--graceful-timeout 5
--worker-connections 1000
--bind "${HOST}:${PORT}"
--access-logfile -
--error-logfile -
)
if [[ "$HTTPS" -eq 1 ]]; then
GUNICORN_ARGS+=(--certfile "$CERT_FILE" --keyfile "$KEY_FILE")
echo "[INTERCEPT] HTTPS enabled"
fi
exec "$PYTHON" -m gunicorn "${GUNICORN_ARGS[@]}" app:app
else
if [[ "$HAS_GUNICORN" -eq 0 ]]; then
echo "[INTERCEPT] gunicorn not found — falling back to Flask dev server"
fi
if [[ "$HAS_GEVENT" -eq 0 ]]; then
echo "[INTERCEPT] gevent not found — falling back to Flask dev server"
fi
echo "[INTERCEPT] Install with: pip install gunicorn gevent"
echo ""
FLASK_ARGS=(--host "$HOST" --port "$PORT")
if [[ "$HTTPS" -eq 1 ]]; then
FLASK_ARGS+=(--https)
fi
exec "$PYTHON" intercept.py "${FLASK_ARGS[@]}"
fi
+9 -9
View File
@@ -414,7 +414,7 @@ body {
.acars-sidebar .acars-btn { .acars-sidebar .acars-btn {
background: var(--accent-green); background: var(--accent-green);
border: none; border: none;
color: #fff; color: var(--text-inverse);
padding: 6px 10px; padding: 6px 10px;
font-size: 10px; font-size: 10px;
font-weight: 600; font-weight: 600;
@@ -575,7 +575,7 @@ body {
.vdl2-sidebar .vdl2-btn { .vdl2-sidebar .vdl2-btn {
background: var(--accent-green); background: var(--accent-green);
border: none; border: none;
color: #fff; color: var(--text-inverse);
padding: 6px 10px; padding: 6px 10px;
font-size: 10px; font-size: 10px;
font-weight: 600; font-weight: 600;
@@ -1347,7 +1347,7 @@ body {
padding: 6px 16px; padding: 6px 16px;
border: none; border: none;
background: var(--accent-green); background: var(--accent-green);
color: #fff; color: var(--text-inverse);
font-family: var(--font-mono); font-family: var(--font-mono);
font-size: 10px; font-size: 10px;
font-weight: 600; font-weight: 600;
@@ -1365,7 +1365,7 @@ body {
.start-btn.active { .start-btn.active {
background: var(--accent-red); background: var(--accent-red);
color: #fff; color: var(--text-inverse);
} }
.start-btn.active:hover { .start-btn.active:hover {
@@ -1497,7 +1497,7 @@ body {
padding: 6px 12px; padding: 6px 12px;
background: var(--accent-green); background: var(--accent-green);
border: none; border: none;
color: #fff; color: var(--text-inverse);
border-radius: 4px; border-radius: 4px;
cursor: pointer; cursor: pointer;
font-size: 11px; font-size: 11px;
@@ -1518,7 +1518,7 @@ body {
.airband-btn.active { .airband-btn.active {
background: var(--accent-red); background: var(--accent-red);
color: #fff; color: var(--text-inverse);
} }
.airband-btn.active:hover { .airband-btn.active:hover {
@@ -1912,7 +1912,7 @@ body {
.strip-report-btn { .strip-report-btn {
background: linear-gradient(135deg, var(--accent-cyan) 0%, #2b6fb8 100%); background: linear-gradient(135deg, var(--accent-cyan) 0%, #2b6fb8 100%);
border: none; border: none;
color: white; color: var(--text-inverse);
padding: 8px 12px; padding: 8px 12px;
border-radius: 4px; border-radius: 4px;
font-size: 10px; font-size: 10px;
@@ -2224,7 +2224,7 @@ body {
.strip-btn.primary { .strip-btn.primary {
background: linear-gradient(135deg, var(--accent-cyan) 0%, #2b6fb8 100%); background: linear-gradient(135deg, var(--accent-cyan) 0%, #2b6fb8 100%);
border: none; border: none;
color: white; color: var(--text-inverse);
} }
.strip-btn.primary:hover { .strip-btn.primary:hover {
@@ -2449,7 +2449,7 @@ body {
font-size: 10px; font-size: 10px;
} }
@media (max-width: 600px) { @media (max-width: 480px) {
.squawk-item { .squawk-item {
grid-template-columns: 45px 80px 1fr; grid-template-columns: 45px 80px 1fr;
gap: 8px; gap: 8px;
+91 -1
View File
@@ -269,6 +269,21 @@ body {
min-width: 160px; min-width: 160px;
} }
.data-control-group {
min-width: 320px;
}
.data-actions {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
}
.data-actions input[type="date"] {
min-width: 150px;
}
.primary-btn { .primary-btn {
background: var(--accent-cyan); background: var(--accent-cyan);
border: none; border: none;
@@ -285,6 +300,31 @@ body {
box-shadow: 0 6px 14px rgba(74, 158, 255, 0.3); box-shadow: 0 6px 14px rgba(74, 158, 255, 0.3);
} }
.primary-btn:disabled {
opacity: 0.55;
cursor: not-allowed;
transform: none;
box-shadow: none;
}
.warn-btn {
background: var(--accent-amber);
color: #0a0c10;
}
.warn-btn:hover {
box-shadow: 0 6px 14px rgba(214, 168, 94, 0.3);
}
.danger-btn {
background: #d84f63;
color: #f8fafc;
}
.danger-btn:hover {
box-shadow: 0 6px 14px rgba(216, 79, 99, 0.35);
}
.status-pill { .status-pill {
font-family: var(--font-mono); font-family: var(--font-mono);
font-size: 11px; font-size: 11px;
@@ -296,6 +336,16 @@ body {
letter-spacing: 1px; letter-spacing: 1px;
} }
.status-pill.ok {
border-color: var(--accent-green);
color: var(--accent-green);
}
.status-pill.error {
border-color: #d84f63;
color: #d84f63;
}
.content-grid { .content-grid {
display: grid; display: grid;
grid-template-columns: minmax(300px, 1fr) minmax(320px, 1fr); grid-template-columns: minmax(300px, 1fr) minmax(320px, 1fr);
@@ -364,6 +414,37 @@ body {
background: rgba(74, 158, 255, 0.1); background: rgba(74, 158, 255, 0.1);
} }
.aircraft-row.military {
background: rgba(85, 107, 47, 0.12);
}
.aircraft-row.military:hover {
background: rgba(85, 107, 47, 0.22);
}
.mil-badge,
.civ-badge {
display: inline-block;
font-size: 9px;
font-weight: 700;
letter-spacing: 0.8px;
padding: 2px 6px;
border-radius: 3px;
text-transform: uppercase;
}
.mil-badge {
background: rgba(85, 107, 47, 0.35);
color: #a3b86c;
border: 1px solid rgba(85, 107, 47, 0.6);
}
.civ-badge {
background: rgba(74, 158, 255, 0.15);
color: var(--text-dim);
border: 1px solid rgba(74, 158, 255, 0.25);
}
.mono { .mono {
font-family: var(--font-mono); font-family: var(--font-mono);
} }
@@ -603,7 +684,7 @@ body {
} }
} }
@media (max-width: 720px) { @media (max-width: 768px) {
.controls { .controls {
flex-direction: column; flex-direction: column;
align-items: stretch; align-items: stretch;
@@ -614,6 +695,15 @@ body {
min-width: 100%; min-width: 100%;
} }
.data-actions {
width: 100%;
}
.data-actions input[type="date"],
.data-actions .primary-btn {
width: 100%;
}
.panel { .panel {
min-height: 320px; min-height: 320px;
} }
+5 -5
View File
@@ -394,7 +394,7 @@ body {
.strip-btn.primary { .strip-btn.primary {
background: linear-gradient(135deg, var(--accent-cyan) 0%, #2b6fb8 100%); background: linear-gradient(135deg, var(--accent-cyan) 0%, #2b6fb8 100%);
border: none; border: none;
color: white; color: var(--text-inverse);
} }
/* Main dashboard grid - Mobile first */ /* Main dashboard grid - Mobile first */
@@ -812,7 +812,7 @@ body {
padding: 6px 16px; padding: 6px 16px;
border: none; border: none;
background: var(--accent-green); background: var(--accent-green);
color: #fff; color: var(--text-inverse);
font-family: var(--font-mono); font-family: var(--font-mono);
font-size: 10px; font-size: 10px;
font-weight: 600; font-weight: 600;
@@ -830,7 +830,7 @@ body {
.start-btn.active { .start-btn.active {
background: var(--accent-red); background: var(--accent-red);
color: #fff; color: var(--text-inverse);
} }
.start-btn.active:hover { .start-btn.active:hover {
@@ -1282,7 +1282,7 @@ body {
.dsc-distress-alert button { .dsc-distress-alert button {
background: var(--accent-red); background: var(--accent-red);
border: none; border: none;
color: white; color: var(--text-inverse);
padding: 10px 24px; padding: 10px 24px;
font-family: var(--font-mono); font-family: var(--font-mono);
font-size: 12px; font-size: 12px;
@@ -1313,7 +1313,7 @@ body {
align-items: center; align-items: center;
justify-content: center; justify-content: center;
font-size: 14px; font-size: 14px;
color: white; color: var(--text-inverse);
border: 2px solid white; border: 2px solid white;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.5); box-shadow: 0 2px 8px rgba(0, 0, 0, 0.5);
} }
+1 -1
View File
@@ -136,7 +136,7 @@
.activity-timeline-btn.active { .activity-timeline-btn.active {
background: var(--timeline-accent); background: var(--timeline-accent);
color: #000; color: var(--text-inverse);
border-color: var(--timeline-accent); border-color: var(--timeline-accent);
} }
+2 -2
View File
@@ -522,7 +522,7 @@
/* ============================================ /* ============================================
RESPONSIVE ADJUSTMENTS RESPONSIVE ADJUSTMENTS
============================================ */ ============================================ */
@media (max-width: 600px) { @media (max-width: 480px) {
.device-signal-row { .device-signal-row {
flex-direction: column; flex-direction: column;
align-items: stretch; align-items: stretch;
@@ -841,7 +841,7 @@
/* ============================================ /* ============================================
RESPONSIVE MODAL RESPONSIVE MODAL
============================================ */ ============================================ */
@media (max-width: 600px) { @media (max-width: 480px) {
.modal-signal-stats { .modal-signal-stats {
grid-template-columns: repeat(2, 1fr); grid-template-columns: repeat(2, 1fr);
} }
+10 -10
View File
@@ -185,7 +185,7 @@
.function-strip .strip-btn.primary { .function-strip .strip-btn.primary {
background: linear-gradient(135deg, var(--accent-green) 0%, #10b981 100%); background: linear-gradient(135deg, var(--accent-green) 0%, #10b981 100%);
border: none; border: none;
color: #000; color: var(--text-inverse);
} }
.function-strip .strip-btn.primary:hover:not(:disabled) { .function-strip .strip-btn.primary:hover:not(:disabled) {
@@ -195,7 +195,7 @@
.function-strip .strip-btn.stop { .function-strip .strip-btn.stop {
background: linear-gradient(135deg, var(--accent-red) 0%, #dc2626 100%); background: linear-gradient(135deg, var(--accent-red) 0%, #dc2626 100%);
border: none; border: none;
color: #fff; color: var(--text-inverse);
} }
.function-strip .strip-btn.stop:hover:not(:disabled) { .function-strip .strip-btn.stop:hover:not(:disabled) {
@@ -304,7 +304,7 @@
border-color: rgba(0, 122, 255, 0.3); border-color: rgba(0, 122, 255, 0.3);
} }
.function-strip.bt-strip .strip-value { .function-strip.bt-strip .strip-value {
color: #0a84ff; color: var(--accent-blue, #0a84ff);
} }
.function-strip.wifi-strip .strip-stat { .function-strip.wifi-strip .strip-stat {
@@ -332,24 +332,24 @@
border-color: rgba(255, 59, 48, 0.6); border-color: rgba(255, 59, 48, 0.6);
} }
.function-strip.tscm-strip .strip-value { .function-strip.tscm-strip .strip-value {
color: #ef4444; /* Explicit red color */ color: var(--accent-red);
} }
.function-strip.tscm-strip .strip-label { .function-strip.tscm-strip .strip-label {
color: #9ca3af; /* Explicit light gray */ color: var(--text-secondary);
} }
.function-strip.tscm-strip .strip-select { .function-strip.tscm-strip .strip-select {
color: #e8eaed; /* Explicit white for selects */ color: var(--text-primary);
background: rgba(0, 0, 0, 0.4); background: rgba(0, 0, 0, 0.4);
} }
.function-strip.tscm-strip .strip-btn { .function-strip.tscm-strip .strip-btn {
color: #e8eaed; /* Explicit white for buttons */ color: var(--text-primary);
} }
.function-strip.tscm-strip .strip-tool { .function-strip.tscm-strip .strip-tool {
color: #e8eaed; /* Explicit white for tool indicators */ color: var(--text-primary);
} }
.function-strip.tscm-strip .strip-time, .function-strip.tscm-strip .strip-time,
.function-strip.tscm-strip .strip-status span { .function-strip.tscm-strip .strip-status span {
color: #9ca3af; /* Explicit gray for status/time */ color: var(--text-secondary);
} }
.function-strip.rtlamr-strip .strip-stat { .function-strip.rtlamr-strip .strip-stat {
@@ -361,7 +361,7 @@
border-color: rgba(175, 82, 222, 0.3); border-color: rgba(175, 82, 222, 0.3);
} }
.function-strip.rtlamr-strip .strip-value { .function-strip.rtlamr-strip .strip-value {
color: #af52de; color: var(--accent-purple, #af52de);
} }
.function-strip.listening-strip .strip-stat { .function-strip.listening-strip .strip-stat {
+11 -11
View File
@@ -52,19 +52,19 @@
.bt-radar-filter-btn:hover { .bt-radar-filter-btn:hover {
background: var(--bg-hover, #333) !important; background: var(--bg-hover, #333) !important;
color: #fff !important; color: var(--text-primary) !important;
} }
.bt-radar-filter-btn.active { .bt-radar-filter-btn.active {
background: #00d4ff !important; background: var(--accent-cyan) !important;
color: #000 !important; color: var(--text-inverse) !important;
border-color: #00d4ff !important; border-color: var(--accent-cyan) !important;
} }
#btRadarPauseBtn.active { #btRadarPauseBtn.active {
background: #f97316 !important; background: var(--accent-orange) !important;
color: #000 !important; color: var(--text-inverse) !important;
border-color: #f97316 !important; border-color: var(--accent-orange) !important;
} }
/* ============================================ /* ============================================
@@ -120,9 +120,9 @@
} }
.heatmap-btn.active { .heatmap-btn.active {
background: #f97316; background: var(--accent-orange);
color: #000; color: var(--text-inverse);
border-color: #f97316; border-color: var(--accent-orange);
} }
.timeline-heatmap-content { .timeline-heatmap-content {
@@ -141,7 +141,7 @@
} }
.heatmap-error { .heatmap-error {
color: #ef4444; color: var(--accent-red);
} }
.heatmap-grid { .heatmap-grid {
+16 -16
View File
@@ -279,19 +279,19 @@
.signal-proto-badge.aprs { .signal-proto-badge.aprs {
background: rgba(6, 182, 212, 0.15); background: rgba(6, 182, 212, 0.15);
color: #06b6d4; color: var(--proto-aprs, #06b6d4);
border-color: rgba(6, 182, 212, 0.25); border-color: rgba(6, 182, 212, 0.25);
} }
.signal-proto-badge.ais { .signal-proto-badge.ais {
background: rgba(139, 92, 246, 0.15); background: rgba(139, 92, 246, 0.15);
color: #8b5cf6; color: var(--proto-ais, #8b5cf6);
border-color: rgba(139, 92, 246, 0.25); border-color: rgba(139, 92, 246, 0.25);
} }
.signal-proto-badge.acars { .signal-proto-badge.acars {
background: rgba(236, 72, 153, 0.15); background: rgba(236, 72, 153, 0.15);
color: #ec4899; color: var(--proto-acars, #ec4899);
border-color: rgba(236, 72, 153, 0.25); border-color: rgba(236, 72, 153, 0.25);
} }
@@ -976,25 +976,25 @@
/* Meter protocol badges */ /* Meter protocol badges */
.signal-proto-badge.meter { .signal-proto-badge.meter {
background: rgba(234, 179, 8, 0.15); background: rgba(234, 179, 8, 0.15);
color: #eab308; color: var(--accent-yellow, #eab308);
border-color: rgba(234, 179, 8, 0.25); border-color: rgba(234, 179, 8, 0.25);
} }
.signal-proto-badge.meter.electric { .signal-proto-badge.meter.electric {
background: rgba(234, 179, 8, 0.15); background: rgba(234, 179, 8, 0.15);
color: #eab308; color: var(--accent-yellow, #eab308);
border-color: rgba(234, 179, 8, 0.25); border-color: rgba(234, 179, 8, 0.25);
} }
.signal-proto-badge.meter.gas { .signal-proto-badge.meter.gas {
background: rgba(249, 115, 22, 0.15); background: rgba(249, 115, 22, 0.15);
color: #f97316; color: var(--accent-orange, #f97316);
border-color: rgba(249, 115, 22, 0.25); border-color: rgba(249, 115, 22, 0.25);
} }
.signal-proto-badge.meter.water { .signal-proto-badge.meter.water {
background: rgba(59, 130, 246, 0.15); background: rgba(59, 130, 246, 0.15);
color: #3b82f6; color: var(--signal-new, #3b82f6);
border-color: rgba(59, 130, 246, 0.25); border-color: rgba(59, 130, 246, 0.25);
} }
@@ -1060,12 +1060,12 @@
.meter-delta.positive { .meter-delta.positive {
background: rgba(34, 197, 94, 0.15); background: rgba(34, 197, 94, 0.15);
color: #22c55e; color: var(--accent-green);
} }
.meter-delta.negative { .meter-delta.negative {
background: rgba(239, 68, 68, 0.15); background: rgba(239, 68, 68, 0.15);
color: #ef4444; color: var(--accent-red);
} }
/* Sparkline container */ /* Sparkline container */
@@ -1128,7 +1128,7 @@
} }
/* Responsive adjustments for aggregated meters */ /* Responsive adjustments for aggregated meters */
@media (max-width: 500px) { @media (max-width: 480px) {
.meter-aggregated-grid { .meter-aggregated-grid {
grid-template-columns: 1fr 1fr; grid-template-columns: 1fr 1fr;
grid-template-rows: auto auto; grid-template-rows: auto auto;
@@ -1431,7 +1431,7 @@
.signal-station-clickable:hover { .signal-station-clickable:hover {
background: var(--accent-purple); background: var(--accent-purple);
color: #000; color: var(--text-inverse);
transform: scale(1.05); transform: scale(1.05);
box-shadow: 0 0 8px rgba(138, 43, 226, 0.4); box-shadow: 0 0 8px rgba(138, 43, 226, 0.4);
} }
@@ -1587,14 +1587,14 @@
background: var(--accent-purple, #8a2be2); background: var(--accent-purple, #8a2be2);
border: none; border: none;
border-radius: 4px; border-radius: 4px;
color: #fff; color: var(--text-inverse);
cursor: pointer; cursor: pointer;
transition: all 0.15s ease; transition: all 0.15s ease;
} }
.station-raw-copy-btn:hover { .station-raw-copy-btn:hover {
background: var(--accent-cyan, #00d4ff); background: var(--accent-cyan, #00d4ff);
color: #000; color: var(--text-inverse);
} }
/* ============================================ /* ============================================
@@ -1794,14 +1794,14 @@
background: var(--accent-purple, #8a2be2); background: var(--accent-purple, #8a2be2);
border: none; border: none;
border-radius: 4px; border-radius: 4px;
color: #fff; color: var(--text-inverse);
cursor: pointer; cursor: pointer;
transition: all 0.15s ease; transition: all 0.15s ease;
} }
.signal-details-copy-btn:hover { .signal-details-copy-btn:hover {
background: var(--accent-cyan, #00d4ff); background: var(--accent-cyan, #00d4ff);
color: #000; color: var(--text-inverse);
} }
/* Signal Details Content Sections */ /* Signal Details Content Sections */
@@ -1922,7 +1922,7 @@
} }
/* Responsive adjustments */ /* Responsive adjustments */
@media (max-width: 500px) { @media (max-width: 480px) {
.signal-details-modal-content { .signal-details-modal-content {
width: 95%; width: 95%;
max-height: 90vh; max-height: 90vh;
+1 -1
View File
@@ -103,7 +103,7 @@
.signal-timeline-btn.active { .signal-timeline-btn.active {
background: var(--accent-cyan, #4a9eff); background: var(--accent-cyan, #4a9eff);
color: #000; color: var(--text-inverse);
border-color: var(--accent-cyan, #4a9eff); border-color: var(--accent-cyan, #4a9eff);
} }
+39
View File
@@ -0,0 +1,39 @@
/**
* Signal Waveform Component
* Animated SVG bar waveform for indicating live signal activity
*/
.signal-waveform {
display: inline-flex;
align-items: center;
vertical-align: middle;
}
.signal-waveform-svg {
display: block;
}
.signal-waveform-bar {
will-change: height, y;
transition: fill 0.3s ease;
}
/* Idle breathing animation */
.signal-waveform.idle .signal-waveform-bar {
animation: signal-waveform-breathe 2.5s ease-in-out infinite;
}
.signal-waveform.idle .signal-waveform-bar:nth-child(even) {
animation-delay: -1.25s;
}
@keyframes signal-waveform-breathe {
0%, 100% { opacity: 0.4; }
50% { opacity: 0.7; }
}
/* Active state - disable CSS breathing, JS drives heights */
.signal-waveform.active .signal-waveform-bar {
animation: none;
opacity: 1;
}

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