Compare commits

...

163 Commits

Author SHA1 Message Date
Smittix 243a0f0e7f chore: Bump version to v2.16.0
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-12 23:32:15 +00:00
Smittix 7c3ec9e920 chore: commit all changes and remove large IQ captures from tracking
Add .gitignore entry for data/subghz/captures/ to prevent large
IQ recording files from being committed.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-12 23:30:37 +00:00
Smittix 4639146f05 fix: Remove incomplete MLAT feature causing ImportError on startup
The partially-added MLAT support was out of sync between config and
routes, causing an ImportError when importing adsb_bp. Remove all MLAT
additions from config, template UI/JS, and docs.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-10 20:55:21 +00:00
Smittix a354fee792 fix: Resolve listening post audio stuttering introduced in v2.15.0
Throttle audio waterfall rendering (50ms→200ms), eliminate per-frame
Array.from() allocation, drain stale pipe buffer before streaming,
increase chunk size to 8192, and remove debug logging from animation
hot paths.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-10 20:24:51 +00:00
Smittix a1cb6b2692 feat: Add SatDump to setup.sh for local (non-Docker) installs
Weather satellite decoding (NOAA APT & Meteor LRPT) was added in the
Dockerfile but setup.sh had no SatDump support, leaving local installs
with a broken weather satellite mode. Adds build-from-source functions
for both Debian and macOS, a check_optional entry, and prompted install
steps in both platform installers.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-10 09:15:53 +00:00
Smittix 8376415074 feat: Add weather satellite decoder (NOAA APT & Meteor LRPT)
feat: Add weather satellite decoder (NOAA APT & Meteor LRPT)   -  Alpha
2026-02-10 08:36:34 +00:00
Mitch Ross b25615317b Merge upstream/main: sync fork with latest DMR fixes
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-09 19:40:25 -05:00
Mitch Ross 311d268b10 Explicitly remove libgtk-3-dev in Dockerfile cleanup step
Adds libgtk-3-dev to the apt-get remove list so it doesn't remain
in the final image. Runtime GTK libs stay for slowrx.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-09 19:09:50 -05:00
Mitch Ross 6581620cb0 Merge pull request #2 from mitchross/copilot/add-test-coverage-weather-satellite
[WIP] Add test coverage for weather satellite decoder modules
2026-02-09 16:58:09 -05:00
copilot-swe-agent[bot] aa963519e9 Fix str(e) in error responses, remove location modal, document GTK dependency
Co-authored-by: mitchross <6330506+mitchross@users.noreply.github.com>
2026-02-09 21:55:30 +00:00
copilot-swe-agent[bot] 4a6dddbb48 Add comprehensive test coverage for weather satellite modules
- Created test_weather_sat_routes.py with 42 tests for all endpoints
- Created test_weather_sat_decoder.py with 47 tests for WeatherSatDecoder class
- Created test_weather_sat_predict.py with 14 tests for pass prediction
- Created test_weather_sat_scheduler.py with 31 tests for auto-scheduler
- Total: 134 test functions across 14 test classes
- All tests follow existing patterns (mocking, fixtures, docstrings)
- Tests cover happy paths, error handling, and edge cases
- Mock all external subprocess calls and HTTP requests

Co-authored-by: mitchross <6330506+mitchross@users.noreply.github.com>
2026-02-09 21:50:22 +00:00
copilot-swe-agent[bot] f217230ef4 Initial plan 2026-02-09 21:41:46 +00:00
Mitch Ross e27b4d78cb Merge pull request #1 from mitchross/copilot/fix-security-issues
Address code review feedback for weather satellite decoder
2026-02-09 16:09:39 -05:00
copilot-swe-agent[bot] d41ba61aee Fix security issues, breaking changes, and code cleanup for weather satellite PR
Co-authored-by: mitchross <6330506+mitchross@users.noreply.github.com>
2026-02-09 20:58:26 +00:00
copilot-swe-agent[bot] 35cf01c11e Initial plan 2026-02-09 20:52:52 +00:00
Smittix 00c9a6fdd9 Fix DMR audio/text deadlock: start ffmpeg per-client, not at launch
Starting ffmpeg at decoder launch caused a pipe-buffer deadlock: ffmpeg
stdout filled up (~64KB on Linux) before the browser connected to the
audio stream, back-pressuring the entire pipeline and freezing dsd-fme
stderr output (no text data, no syncs, no calls).

New architecture: a mux thread always drains dsd-fme stdout to keep the
pipeline flowing. ffmpeg starts lazily per-client when /dmr/audio/stream
is requested (matching the listening post pattern). The mux forwards
decoded audio to the active ffmpeg with silence fill during voice gaps,
and discards audio when no client is connected.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-09 19:25:07 +00:00
Smittix fce66a6a60 Fix DMR audio stream failing with "no supported source found"
Digital voice is intermittent — dsd-fme only outputs PCM during active
voice transmissions. Without input, ffmpeg never wrote the WAV header
and the browser got an empty response. Add an audio bridge thread that
feeds 100ms silence chunks during voice gaps so ffmpeg always has input
and the browser receives a continuous WAV stream. Add auto-reconnect
on the frontend if the audio stream drops while the decoder is running.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-09 18:14:33 +00:00
Smittix b023e4cdc7 Add DMR audio output, frequency persistence, and bookmarks
Stream decoded digital voice audio to the browser via ffmpeg pipeline
(dsd-fme 8kHz PCM → ffmpeg → 44.1kHz WAV → chunked HTTP). Persist
frequency/protocol/gain/ppm settings in localStorage so they survive
page navigation. Add bookmark system for saving and recalling frequencies.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-09 18:05:27 +00:00
Smittix a8f2912b90 Fix waterfall-to-listen SDR busy race condition
Wait for server-side WebSocket stop confirmation before closing the
connection, ensuring the IQ process is fully terminated and the USB
device released. Add retry logic with back-off in the audio start
endpoint as defense-in-depth for any remaining timing gaps.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-09 13:58:42 +00:00
Smittix a2a7ac8fec Fix banner filter eating dsd-fme data lines and add event log capture
The box-drawing character filter was dropping ANY line containing │ or ─,
including dsd-fme data lines that use these as column separators (e.g.
"DMR BS │ Slot 1 │ TG: 12345 │ SRC: 67890"). Now only filters lines
that are purely decorative (no alphanumeric content).

Also adds -J /dev/stderr so dsd-fme writes its event log to stderr
where we capture it, and debug logging of raw stderr lines.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-09 11:41:32 +00:00
Smittix 4e168ff502 Fix dsd-fme DMR flag (-fd is D-STAR, not DMR) and audio output
-fd means D-STAR in dsd-fme, not DMR — causing sync detection
(shared C4FM modulation) but no decoded data. DMR Simplex is -fs.
Also fix -o - (invalid in dsd-fme) to -o null for headless servers,
add D-STAR flag mapping, and handle TGT/SRC output format in parser.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-09 10:31:44 +00:00
Smittix 51aba87852 Bump version to 2.15.0
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-09 09:22:41 +00:00
Smittix 4c13e98091 Fix dsd-fme protocol flags, device label, and add tuning controls
dsd-fme remapped several flags from classic DSD: -fp is ProVoice (not
P25), -fi is NXDN48 (not D-Star), -fv doesn't exist. This caused P25
to trigger ProVoice decoding and D-Star to trigger NXDN48. Corrected
flag table and added C4FM modulation hints for better sync reliability.

Also fixes: device panel showing "DMR" regardless of protocol, signal
activity status flip-flopping between LISTENING and IDLE, and rtl_fm
squelch chopping the bitstream mid-frame. Adds PPM correction and
relax CRC controls for fine-tuning on marginal signals.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-09 08:44:23 +00:00
Mitch Ross 54c849ab60 Fix weather satellite decoder security, architecture, and race conditions
Security: replace path traversal-vulnerable str().startswith() with
is_relative_to(), anchor path checks to app root, strip filesystem
paths from error responses, add decoder-level path validation.

Architecture: use safe_terminate/register_process for subprocess
lifecycle, replace custom SSE generator with sse_stream(), use
centralized validate_* functions, remove unused app.py declarations.

Bugs: add thread-safe singleton locks, protect _images list across
threads, move blocking process.wait() to async daemon thread, fix
timezone handling for tz-aware datetimes, use full path for image
deduplication, guard TLE auto-refresh during tests, validate
scheduler parameters to avoid 500 errors.

Docker: pin SatDump to v1.2.2 and slowrx to ca6d7012, document
INTERCEPT_IMAGE fallback pattern.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-08 21:29:45 -05:00
Mitch Ross 94ee22fdd4 Merge upstream/main: sync fork with conflict resolution
Resolve conflicts keeping local GSM tools in kill_all() process list
and weather satellite config settings while merging upstream changes
including GSM spy removal, DMR fixes, USB device probe, APRS crash
fix, and cross-module frequency routing.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-08 20:06:41 -05:00
Smittix b96eb8ccba Fix DMR frontend/backend state desync causing 409 on start
When the backend has an active DMR session but the frontend lost track
(page refresh, broken flags causing silent running), clicking Start
returned 409 with no recovery path. Now the frontend resyncs on
"Already running" responses and checks backend status on tab activation.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-08 23:20:02 +00:00
Smittix b8a80460bf Fix digital voice decoder producing no output due to wrong dsd-fme flags
The _DSD_FME_PROTOCOL_FLAGS dictionary had every protocol flag wrong,
causing dsd-fme (the preferred binary) to receive invalid or mismatched
-f flags. Also fix orphaned process leak on startup failure and add
centralized input validation for frequency/gain/device.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-08 23:12:54 +00:00
Smittix 7130c2d4c4 Add cross-module frequency routing from Listening Post to decoders
Enable sending discovered frequencies from the Listening Post scanner,
signal identification panel, and waterfall display directly to Pager,
433 Sensor, or RTLAMR decoder modes with one click.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-08 22:45:47 +00:00
Smittix 62c34c1e95 Fix settings modal overflowing viewport on smaller screens
Constrain modal height to viewport and make tab content scrollable
so the modal no longer falls off the bottom of the screen.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-08 22:25:58 +00:00
Smittix e413f54651 Add USB-level device probe to prevent cryptic rtl_fm crashes
When an external process (or stale handle from a crash) holds an SDR
device, claim_sdr_device() registry check passes but rtl_fm fails with
usb_claim_interface error -6. This adds a quick rtl_test probe inside
claim_sdr_device() so all modes get a clear error message before the
decoder pipeline is launched.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-08 22:24:02 +00:00
Smittix 1a4af214bf Fix APRS crash on large station count and station list overflow
- Fix infinite loop in updateAprsStationList: querySelectorAll returns a
  static NodeList so the while(cards.length > 50) loop never terminated,
  crashing the page. Use live childElementCount instead.
- Fix station list pushing map off-screen by adding overflow:hidden and
  min-height:0 to flex containers so only the station list scrolls.
- Cap backend aprs_stations dict at 500 entries with oldest-eviction to
  prevent unbounded memory growth.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-08 22:13:28 +00:00
Smittix c2891938ab Remove GSM spy functionality for legal compliance
Remove all GSM cellular intelligence features including tower scanning,
signal monitoring, rogue detection, crowd density analysis, and
OpenCellID integration across routes, templates, utils, tests, and
build configuration.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-08 22:04:12 +00:00
Smittix 2bed35dd64 Fix signal handler deadlock and add satellite TLE data
Remove logging and cleanup_all_processes() from signal handler to
prevent deadlocks when another thread holds the logging or process lock.
Process cleanup is handled by the atexit handler instead.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-08 21:50:13 +00:00
Smittix 0c656cff2b Fix heatmap for towers with CID=0 or no geocoded coordinates
The monitored tower may have CID=0 (partially decoded cell) which
OpenCellID can't geocode, leaving it without coordinates. The heatmap
now falls back through: monitored tower by ARFCN, any geocoded tower,
then observer location. Also tracks the monitored ARFCN so the fallback
can find the right tower even when CID matching fails.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-08 21:27:36 +00:00
Smittix e03ba3f5ed Fix heatmap: add type coercion, LAC matching, debug logging, and user feedback
The heatmap silently failed when: CID types mismatched (string vs number),
LAC wasn't checked (wrong tower matched), or no data existed yet (button
showed ON with no layer). Now coerces CID/LAC to Number for comparison,
validates coordinates with parseFloat, logs match diagnostics to console,
and only shows ON when the layer is actually rendered.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-08 21:22:23 +00:00
Smittix c6ff8abf11 Add Leaflet.heat crowd density heatmap to GSM Spy dashboard
Adds a toggleable heatmap layer that visualizes crowd density data from
the existing /gsm_spy/crowd_density endpoint as a gradient overlay on the
map, with auto-refresh every 30s during active monitoring.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-08 20:55:06 +00:00
Smittix eff6ca3e87 Add 2G generation label to GSM band selector options
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-08 20:41:02 +00:00
Smittix 1a5b076a8d Fix grgsm_scanner crash on unsupported band names (GSM800, EGSM900_EXT)
Add explicit band name mapping from internal names to grgsm_scanner's
accepted -b values (GSM900, GSM850, DCS1800, PCS1900). Bands without
a valid grgsm_scanner equivalent (GSM800, EGSM900_EXT) are skipped
with a log message instead of crashing the scanner. Remove GSM800
from the dashboard band selector since it can't be scanned.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-08 20:39:12 +00:00
Smittix 90e88fc469 Fix tshark hex parsing and add API key settings UI
Parse tshark GSM field values with int(value, 0) instead of int(value)
to auto-detect hex 0x-prefixed output (e.g. 0x039e for TMSI/LAC/CID).
Without this, every tshark line with hex values fails to parse, causing
0 devices to be captured during monitoring.

Also add API Keys tab to Settings modal for configuring OpenCellID key
via the UI (in addition to env var), with status display and usage bar.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-08 20:35:31 +00:00
Smittix 98f6d18bea Fix GSM dashboard counters, improve lists, add device detail modal
Wire SIGNALS/DEVICES/CROWD counters to monitor_heartbeat SSE data so
they update in real-time during monitoring. Redesign device list items
as richer cards with type badges, TA/distance, and observation counts.
Add clickable device detail modal with full device info and copy
support. Improve tower list with signal strength bars. Widen right
sidebar and bump list font sizes for readability.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-08 20:24:51 +00:00
Smittix 7d69cac7e7 Fix geocoding: validate API responses, clean poisoned cache, improve logging
- Cache lookup now requires non-NULL lat/lon — previously a row with
  NULL coordinates counted as a cache hit, returning {lat: None, lon: None}
  which the frontend silently ignored (tower in list but no map pin)
- API response handler validates lat/lon exist before caching, preventing
  error responses (status 200 with error body) from poisoning the cache
- On geocoding worker start, delete any existing poisoned cache rows
- Geocoding worker now logs "API key not configured" vs "rate limit
  reached" so the actual problem is visible in logs
- API error responses now log the response body for easier debugging
2026-02-08 19:55:00 +00:00
Smittix c6a8a4a492 Fix EGSM900 downlink frequency: 935 MHz not 925 MHz
The EGSM900 band table had start=925e6 but ARFCNs 0-124 use downlink
frequencies starting at 935 MHz (DL = 935 + 0.2*ARFCN). The 925 MHz
value is the E-GSM extension band (ARFCNs 975-1023).

This caused grgsm_livemon to tune 10 MHz too low — ARFCN 22 tuned to
929.4 MHz instead of 939.4 MHz, receiving no GSM frames and producing
zero GSMTAP packets for tshark to capture.

Also adds EGSM900_EXT band (ARFCNs 975-1023, DL 925.2-934.8 MHz)
and diagnostic logging in the monitor thread to track raw tshark
line counts vs parsed packets.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-08 19:50:04 +00:00
Mitch Ross ca15e227cd add test harness 2026-02-08 14:45:12 -05:00
Smittix 391aff52ce Fix OpenCellID integration: CID=0 handling, API key check, tab parsing
- /lookup_cell and /detect_rogue rejected CID=0 towers because
  `all([..., cid])` is falsy when cid=0; use `is not None` checks
- can_use_api() now returns False when GSM_OPENCELLID_API_KEY is empty,
  preventing the geocoding worker from wasting daily quota on doomed calls
- /lookup_cell returns 503 with clear message when API key not configured
- parse_tshark_output uses rstrip('\n\r') instead of strip() to preserve
  leading empty tab-separated fields (strip() ate leading tabs, shifting
  all columns when the first field was empty)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-08 19:37:06 +00:00
Smittix 3dc16b392b Remove tshark -Y display filter that blocked all GSM packets
The display filter `gsm_a.tmsi || e212.imsi` was too restrictive —
paging requests use different field paths for TMSI so nothing matched.
The capture filter (-f 'udp port 4729') already limits to GSMTAP, and
the parser discards rows without TMSI/IMSI identifiers.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-08 19:24:10 +00:00
Smittix 4d7be047da Fix tshark crash by skipping invalid fields instead of using fallbacks
When tshark field discovery finds no valid candidate for a logical field
(e.g. timing_advance, cellid), the old code fell back to the first
candidate name even though it was known to be invalid. This caused tshark
to exit immediately with "Some fields aren't valid".

Now fields resolve to None when no valid candidate exists, and the tshark
command is built using only validated fields. The parser dynamically maps
columns via field_order instead of assuming a fixed 5-column layout.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-08 19:14:09 +00:00
Smittix 182e1f3239 Fix tshark field discovery to validate with actual extraction test
tshark -G fields lists fields that exist in the protocol tree but
aren't all valid for -T fields -e extraction. Changed discovery to
actually test candidates by running tshark -T fields -e <field> -r
/dev/null and parsing stderr for invalid field names. This correctly
identifies which fields work for extraction on the installed version.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-08 18:56:50 +00:00
Smittix 87782319f2 Auto-discover tshark field names for GSM protocol compatibility
tshark field names differ between Wireshark versions (3.x vs 4.x):
- 3.x: gsm_a.rr.timing_advance, gsm_a.tmsi, gsm_a.cellid
- 4.x: gsm_a_rr.timing_adv, gsm_a_dtap.tmsi, e212.ci

Added _discover_tshark_fields() that queries `tshark -G fields` to
find which field names are available on the installed version, then
uses the correct ones for the capture filter and field extraction.
Results are cached after first discovery.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-08 18:49:20 +00:00
Smittix 6b7f817aa6 Add live monitoring status overlay with heartbeat updates
Backend: monitor_thread sends periodic monitor_heartbeat events (every
5s) with elapsed time, packet count, and device count so the frontend
knows monitoring is active.

Frontend: new monitoring overlay replaces scan progress bar when
auto-monitor starts. Shows pulsing green indicator, ARFCN being
monitored, live elapsed timer, packet/device counts, and
"Listening..."/"Capturing" activity state.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-08 18:41:30 +00:00
Smittix 82f442ffb8 Fix tshark capture: add GSMTAP filter, line buffering, stderr capture
- Add capture filter (-f 'udp port 4729') to only capture GSMTAP packets
- Add -l flag for line-buffered output on live capture
- Add early exit detection for tshark with stderr capture
- Add stderr reader thread in monitor_thread for ongoing tshark diagnostics
- Clean up grgsm_livemon if tshark fails to start

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-08 18:37:48 +00:00
Mitch Ross 1924203c19 Merge upstream/main: add gsm_spy blueprint 2026-02-08 13:15:20 -05:00
Smittix f18ed26005 Fix grgsm_livemon Qt crash in headless Docker container
Set QT_QPA_PLATFORM=offscreen for both grgsm_livemon and
grgsm_scanner to prevent SIGABRT when no X11 display is available.
grgsm_livemon uses GNU Radio which loads Qt plugins — without a
display, Qt aborts with "could not connect to display".

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-08 18:13:15 +00:00
Smittix 897cea5b54 Move scan progress bar above map as prominent overlay
- Repositioned progress indicator from right sidebar to a full-width
  overlay at the top of the map panel
- Added animated spinning icon, glowing progress bar, blurred backdrop
- Centered layout with max-width constraint for readability
- Progress bar and status text more visible during active scans

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-08 18:04:25 +00:00
Smittix cd2d51ee40 Fix grgsm_livemon crash diagnostics and SSE race condition
- Add pre-flight checks (shutil.which) for grgsm_livemon and tshark
- Capture stderr when grgsm_livemon exits immediately (exit code 1)
- Start background stderr reader thread for ongoing livemon diagnostics
- Add idle_count grace period in SSE stream to handle scanner→monitor
  transition without premature disconnect
- Forward monitor failure errors to SSE for frontend display

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-08 18:03:03 +00:00
Smittix 39ed4bffba Auto-switch to monitor mode after scan for device tracking
The scanner and monitor are mutually exclusive (both need the SDR).
Previously auto-monitor tried to start mid-scan (causing device
conflicts) and required 3 towers (rarely achieved with weak signals).

Now after the first scan completes:
- If any towers were found, automatically stop scanner and start
  grgsm_livemon + tshark on the strongest tower's ARFCN
- SDR handoff is clean (scanner process has already exited)
- If monitor fails to start, scanner loop resumes
- Scanner thread's finally block preserves SDR allocation when
  monitor has taken over
- Frontend shows "Monitoring ARFCN X for devices..." status

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-08 17:42:10 +00:00
Smittix 6010c7d589 Add scan progress to frontend, fix Europe band defaults
- Forward scanner progress (%) and status to SSE stream
- Show progress bar and scan status in TRACKED TOWERS panel
- Send scan_complete event with tower count and duration
- Fix Europe BAND_CONFIG: only EGSM900 is recommended (GSM850/GSM800
  are rarely used in Europe and waste scan time)
- DCS1800 available but not recommended (RTL-SDR sensitivity is lower)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-08 17:28:54 +00:00
Smittix 01978730ba Relax CID=0 filter: allow partially decoded cells with valid MCC/MNC
CID=0 with valid MCC/MNC means the scanner found the cell but didn't
decode System Information 3/4 (which carries the Cell ID). These are
still valid towers worth displaying. Only filter when MCC=0 AND MNC=0
(truly unidentified signals).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-08 17:14:17 +00:00
Smittix 451eff83a8 Fix GSM Spy dashboard: stats, signal display, CID=0 filter, tower details
Backend:
- Filter out CID=0 and MCC=0 entries (ARFCNs with no decoded cell identity)

Frontend:
- Move stats update before coordinate check so towers always counted
- Fix signal_strength display using null check instead of || (0 is falsy)
- Show operator name, frequency, and status in tower detail panel
- Show "Located" indicator in tower list for geocoded towers
- Fix selectTower crash when tower has no coordinates
- Update placeholder text to "Select a tower from the list"
- Add try/catch to selectTower for error resilience

Tests:
- Add tests for CID=0 and MCC=0 filtering

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-08 17:04:04 +00:00
Smittix 7cb2efca30 Fix GSM Spy frontend: SSE state replay, field name mismatch, crash fix
- Send all existing towers on SSE connect (fixes data loss on reconnect)
- Fix tower.signal -> tower.signal_strength field name in frontend
- Fix TypeError crash in selectTower when tower has no coordinates
- Add Connection: keep-alive header to SSE response
- Add comprehensive console.log debugging for SSE data flow
- Handle error/disconnected SSE event types in frontend

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-08 16:57:39 +00:00
Smittix 33953fcf2b Add SSE stream logging to diagnose frontend data delivery
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-08 16:47:41 +00:00
Smittix 1eec4a2342 Fix stdout buffering: use PYTHONUNBUFFERED for grgsm_scanner
grgsm_scanner is a Python/GNU Radio script, so stdbuf has no effect.
Setting PYTHONUNBUFFERED=1 in the subprocess env forces Python to
flush stdout on every write, enabling real-time scan output.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-08 16:36:13 +00:00
Smittix 2dc4940ca2 Fix grgsm_scanner stdout buffering and increase scan timeout
grgsm_scanner fully buffers stdout when piped, so scan results never
reach Python until the buffer fills or process exits. Wrapping with
stdbuf -oL forces line-buffered output for real-time data streaming.

Also increased scan timeout from 120s to 300s since scanning 4 bands
legitimately takes 2-3 minutes.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-08 16:30:51 +00:00
Smittix cd5f1464b6 Switch gr-gsm source from ptrkrysik to bkerler fork
The ptrkrysik/gr-gsm repo uses SWIG which is incompatible with
GNU Radio 3.10+. The bkerler fork supports modern GNU Radio and
builds successfully on current Ubuntu/Debian systems.

Updated all references in Dockerfile, setup.sh, dependencies.py,
and error messages.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-08 16:26:09 +00:00
Smittix 4aeb51a973 Set OSMO_FSM_DUP_CHECK_DISABLED for grgsm_scanner and grgsm_livemon
apt-packaged gr-gsm aborts with SIGABRT (-6) due to duplicate FSM
registration in libosmocore. Setting this env var suppresses the
fatal assertion, allowing grgsm_scanner to run normally.

Applied to both scanner and livemon subprocess spawns.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-08 16:02:01 +00:00
Smittix 15efe56762 Detect grgsm_scanner crash-on-startup and report to UI
grgsm_scanner exits in <300ms with osmo_fsm assertion error due to
libosmocore incompatibility. Added crash detection: if process exits
in <5s with non-zero code, counts as crash. After 3 crashes, stops
retrying and sends error to SSE stream so the UI can display it.

Also drains remaining queue items after process exits and logs exit
code and scan duration for diagnostics.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-08 16:00:31 +00:00
Smittix 995bc17418 Set GSM Spy logger to DEBUG level to override WARNING default
Global LOG_LEVEL defaults to WARNING, silencing all INFO/DEBUG logs.
GSM Spy needs verbose logging for scanner diagnostics. Override the
module logger level to DEBUG so scanner output is always visible.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-08 15:57:51 +00:00
Smittix c3dcf1401a Fix GSM Spy logger never configured - all log output was silenced
gsm_spy.py used logging.getLogger() directly which returns a bare
logger with no handler. The parent 'intercept' logger has
propagate=False, so all GSM Spy logs were silently dropped.

Now uses utils.logging.get_logger() which adds a stderr handler
and sets the log level, matching all other route modules.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-08 15:53:57 +00:00
Smittix 6f9873d47f Parse grgsm_scanner stderr output (GNU Radio outputs data to stderr)
grgsm_scanner (like many GNU Radio tools) writes scan results to
stderr, not stdout. The stderr reader was only logging at debug
level and discarding lines. Now feeds stderr into the parse queue.

Also added info-level logging for all scanner output lines to aid
debugging.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-08 15:51:16 +00:00
Smittix 28185727e3 Fix grgsm_scanner output parser to match real output format
Parser expected pipe-delimited table rows but grgsm_scanner outputs
comma-separated key-value pairs like:
  ARFCN: 975, Freq: 925.2M, CID: 13522, LAC: 38722, MCC: 262, MNC: 1, Pwr: -58

This was the root cause of no data appearing in GSM Spy.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-08 15:48:57 +00:00
Smittix 48795f6ec3 Fix SDR device not released on GSM Spy stop
stop_scanner() cleared gsm_spy_active_device without calling
release_sdr_device(), so the device stayed claimed in the registry.
The scanner thread's finally block then saw None and skipped release.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-08 15:43:06 +00:00
Smittix f5021a0fdf Fix GSM band name mismatch between UI and backend
UI was sending GSM900 but backend REGIONAL_BANDS expects EGSM900
for Europe and Asia regions, causing validation rejection.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-08 15:39:28 +00:00
Smittix 7312f330ed Add gr-gsm and tshark as auto-installed dependencies
GSM Spy was failing with FileNotFoundError because grgsm_scanner
wasn't installed. These tools are now installed automatically by
setup.sh (both Debian and macOS) and included in the Dockerfile,
matching how other tools like multimon-ng and ffmpeg are handled.

- setup.sh: Remove ask_yes_no prompts for gr-gsm and tshark, install
  unconditionally; add check_recommended tier for final summary
- Dockerfile: Add tshark to apt layer, add gr-gsm RUN layer with
  apt-then-source-build fallback, preseed debconf for tshark
- gsm_spy.py: Add shutil.which pre-check in start_scanner route,
  catch FileNotFoundError in scanner_thread to stop retry loop

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-08 15:35:15 +00:00
Smittix 2115bc551d Merge branch 'pr-124'
# Conflicts:
#	app.py
#	routes/__init__.py
#	utils/database.py
2026-02-08 15:04:17 +00:00
Smittix f6c19af33a Fix PR #124 remaining issues: XSS, state management, DB regression
- kill_all() now resets gsm_spy_scanner_running and related state so
  the scanner thread stops after killall
- scanner_thread sets flag to False instead of None on exit
- Restore alert_rules, alert_events, recording_sessions tables and
  wifi_clients column removed by PR in database.py
- Escape all server-sourced values in analysis modals with escapeHtml()
- Reset gsm_towers_found/gsm_devices_tracked on stop to prevent
  counter drift across sessions
- Replace raw terminate/kill with safe_terminate() in scanner_thread

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-08 15:02:14 +00:00
Smittix ebd9eb81f2 Add WATERFALL title label to function bar
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-08 14:30:39 +00:00
Smittix c87c01cdfe Load function-strip.css so waterfall bar renders horizontally
The function-strip CSS was never linked in index.html, causing all
strip items to render as unstyled stacked elements instead of a
horizontal flex bar.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-08 14:29:09 +00:00
Smittix 19a94d4a84 Move waterfall controls to function bar and fix SDR claim race on tune
Move waterfall controls from the sidebar into a function-strip bar inside
#listeningPostVisuals so they sit directly above the waterfall canvas.
Also fix the "SDR device in use" error when clicking a waterfall frequency
to listen — the WebSocket waterfall's device claim wasn't being released
before the audio start request because the backend cleanup hadn't finished.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-08 14:25:36 +00:00
Smittix cca04918a9 Fix waterfall crash on zoom by reusing WebSocket and adding USB release retry
Zooming caused "I/Q capture process exited immediately" because the client
closed the WebSocket and opened a new one, racing with the old rtl_sdr
process releasing the USB device. Now zoom/retune sends a start command on
the existing WebSocket, and the server adds a USB release delay plus retry
loop when restarting capture within the same connection.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-08 14:00:40 +00:00
Smittix 777b83f6e0 Fix waterfall showing solid yellow by auto-scaling FFT quantization
The FFT pipeline produces power values in the ~0-60 dB range for
normalized IQ data, but quantize_to_uint8 used a hardcoded range
of -90 to -20 dB. Every bin saturated to 255, producing a uniform
yellow waterfall with no signal differentiation.

Now auto-scales to the actual min/max of each frame so the full
colour palette is always used.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-08 13:45:07 +00:00
Smittix 455bc05c69 Shut down WebSocket socket to prevent Werkzeug HTTP response leak
After a WebSocket handler exits, flask-sock returns a Response to
Werkzeug which writes "HTTP/1.1 200 OK..." on the still-open socket.
Browsers see these HTTP bytes as a malformed WebSocket frame, causing
"Invalid frame header".

Now the handler explicitly closes the raw TCP socket after the
WebSocket close handshake, so Werkzeug's write harmlessly fails.
Applied to both waterfall and audio WebSocket handlers.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-08 13:40:17 +00:00
Smittix 37842dc1ef Fix WebSocket handler exiting immediately on receive timeout
simple-websocket 1.1.0's receive(timeout=N) returns None on timeout
instead of raising TimeoutError. The handler treated None as
"connection closed" and broke out of the loop, causing Werkzeug to
write its HTTP 200 response on the still-open WebSocket socket.
The browser saw those HTTP bytes as an invalid WebSocket frame.

Now checks ws.connected to distinguish timeout (None + connected)
from actual close (None + not connected).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-08 13:21:25 +00:00
Smittix 01f3cc845b Add missing /sensor/status and /tscm/status endpoints
agents.js syncLocalModeStates() expects these endpoints to check
whether each mode is running locally. Both were missing, causing
404 errors on mode switch.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-08 13:14:27 +00:00
Marc bdba56bef1 PR #124 fixed major and minor issues 2026-02-08 07:04:10 -06:00
Smittix a5ea632cc2 Fix WebSocket waterfall blocked by login redirect
The before_request require_login hook was returning a 302 redirect
for WebSocket upgrade requests, which browsers report as "Invalid
frame header". WebSocket requests don't always carry session cookies
reliably. Allow /ws/ paths through the login check since the page
that initiates these connections already requires authentication.

Also keeps the prior fix: serialize WebSocket sends through a queue
to avoid concurrent read/write on the non-thread-safe simple-websocket.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-08 13:03:34 +00:00
Smittix a3b81bead8 Fix WebSocket waterfall "Invalid frame header" by serializing sends
The fft_reader thread was calling ws.send() concurrently with
ws.receive() in the main loop. simple-websocket is not thread-safe
for simultaneous read/write, corrupting frame headers. Now the reader
thread enqueues frames and only the main loop touches the WebSocket.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-08 12:52:42 +00:00
Smittix 026337a350 Add real-time WebSocket waterfall with I/Q capture and server-side FFT
Replace the batch rtl_power SSE pipeline with continuous I/Q streaming
via WebSocket for smooth ~25fps waterfall display. The server captures
raw I/Q samples (rtl_sdr/rx_sdr), computes Hann-windowed FFT, and
sends compact binary frames (1035 bytes vs ~15KB JSON, 93% reduction).
Client falls back to existing SSE path if WebSocket is unavailable.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-08 12:37:50 +00:00
Marc 44b1a74838 Fixes regarding for PR #124, also added vector images for towers and phones 2026-02-08 03:23:23 -06:00
Smittix 7aae2944d4 Add waterfall modulation auto-select and fix kill-all message
Waterfall clicks now auto-select the correct modulation for the frequency
band (e.g., WFM for FM broadcast, AM for airband) instead of using whatever
modulation was last selected. Adds a hover tooltip showing frequency and
suggested modulation. Fixes the kill-all notification to show a clean
"All processes stopped" message instead of listing "bluetooth_scanner".

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-08 00:41:18 +00:00
Smittix 766a51753d Add real-time signal scope to both SSTV modes
Adds a phosphor-persistence waveform scope showing audio RMS/peak
levels during ISS SSTV and General SSTV decoding, matching the
existing pager scope pattern with a purple color scheme.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-08 00:28:33 +00:00
Smittix 92e5e7c6da Add real-time signal scope to 433MHz sensor mode
Enable -M level on rtl_433 to include RSSI/SNR in decoded JSON, extract
signal levels and push scope events to the SSE stream. Renders a green-
themed canvas oscilloscope showing signal strength pulses on packet decode
with amber SNR indicator and decay between packets.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-08 00:00:01 +00:00
Smittix 154dc898ff Add real-time signal scope to pager mode
Tap the rtl_fm → multimon-ng audio pipeline via a relay thread to extract
RMS/peak amplitude levels and render a 60fps canvas oscilloscope during
pager decoding, giving visual feedback of RF activity before messages are
fully decoded.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-07 23:50:41 +00:00
Smittix beb38b6b98 Remove waterfall from all modes except listening post
Reverts IQ pipeline and removes syncWaterfallToFrequency calls from
pager, sensor, rtlamr, DMR, SSTV, and SSTV general modes. Waterfall
is now exclusive to listening post mode.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-07 23:29:56 +00:00
Smittix f04ba7f143 Add live waterfall during pager and sensor decoding via IQ pipeline
Replace rtl_fm/rtl_433 with rtl_sdr for raw IQ capture when available,
enabling a Python IQ processor to compute FFT for the waterfall while
simultaneously feeding decoded data to multimon-ng (pager) or rtl_433
(sensor). Falls back to the legacy pipeline when rtl_sdr is unavailable.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-07 23:18:43 +00:00
Mitch Ross fd0953bfb5 up 2026-02-07 17:56:45 -05:00
Smittix b312eb20aa Resume waterfall after listen and sync to mode frequency 2026-02-07 22:40:00 +00:00
Smittix 8eb8a2fe97 Fix waterfall resume and add zoom controls 2026-02-07 22:13:50 +00:00
Mitch Ross 13be4302c3 Update index.html 2026-02-07 16:07:12 -05:00
Mitch Ross 5fd45d3e94 Merge remote-tracking branch 'upstream/main' 2026-02-07 16:03:32 -05:00
Smittix e88b815dc9 Add shared waterfall UI across SDR modes 2026-02-07 16:01:01 -05:00
Mitch Ross 556a4ffcc2 tweaks
1. utils/weather_sat.py — Added delete_all_images() method that globs for *.png, *.jpg, *.jpeg in the output dir, unlinks each, clears _images list, and returns the
  count.
  2. routes/weather_sat.py — Added DELETE /weather-sat/images route that calls decoder.delete_all_images() and returns {'status': 'ok', 'deleted': count}.
  3. static/js/modes/weather-satellite.js:
    - Added currentModalFilename state variable
    - renderGallery() now sorts images by timestamp descending, groups by date using toLocaleDateString(), renders date headers spanning the grid, and adds a delete
  overlay button on each card
    - showImage() accepts a filename param, stores it in currentModalFilename, and creates a modal toolbar with a delete button
    - Added deleteImage(filename) — confirm dialog → DELETE /weather-sat/images/{filename} → filter from array → re-render + close modal
    - Added deleteAllImages() — confirm dialog → DELETE /weather-sat/images → clear array → re-render
    - Exposed deleteImage, deleteAllImages, and _getModalFilename in public API
  4. static/css/modes/weather-satellite.css:
    - Added position: relative to .wxsat-image-card
    - .wxsat-image-actions — absolute top-right overlay, hidden by default, appears on card hover
    - .wxsat-image-actions button — dark background, turns red on hover
    - .wxsat-date-header — full-grid-width date separator with dimmed uppercase text
    - .wxsat-modal-toolbar — absolute top-left in modal for the delete button
    - .wxsat-modal-btn.delete — turns red on hover
    - .wxsat-gallery-clear-btn — subtle icon button, pushed right via margin-left: auto, turns red on hover
    - Updated .wxsat-gallery-header from justify-content: space-between to gap: 8px for proper 3-child layout
  5. templates/index.html — Added clear-all trash button with SVG icon in the gallery header, wired to WeatherSat.deleteAllImages().
2026-02-07 15:52:52 -05:00
Mitch Ross 03c5d33eb7 Fix race condition: set _running before starting reader thread
The reader thread loop checks self._running but it was being set to
True after _start_satdump() returned, which is after the thread
already started. The thread would see _running=False and exit
immediately without reading any SatDump output.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-07 15:28:07 -05:00
Mitch Ross f9786aa75a Use PTY for SatDump output capture instead of pipe
SatDump writes to stderr via fwrite() with its custom logger. When
stderr is redirected to a pipe, C runtime fully buffers it. Neither
stdbuf nor bufsize settings help since SatDump doesn't use stdio for
output.

PTY (pseudo-terminal) makes SatDump think it's writing to a real
terminal, which disables buffering. Also strips ANSI escape codes
from the output and properly handles \r progress lines.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-07 15:17:03 -05:00
Mitch Ross b87623cf66 Update weather_sat.py 2026-02-07 15:06:58 -05:00
Mitch Ross 4d24e648ab Update weather_sat.py 2026-02-07 15:04:53 -05:00
Mitch Ross 99f42f66b2 Merge upstream main: add DMR, WebSDR, HF SSTV, alerts, recordings, waterfall
Merges upstream changes into fork while preserving weather satellite
(NOAA APT/Meteor LRPT via SatDump), rtlamr, multi-arch build, and
decoder console features from our branch.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-07 14:29:09 -05:00
Smittix 3240b0788b Add shared waterfall UI across SDR modes 2026-02-07 19:18:57 +00:00
Smittix 3ab1501a90 Clamp waterfall interval to server minimum 2026-02-07 19:08:28 +00:00
Smittix 7e42e00449 Fix waterfall stop before direct listen 2026-02-07 19:06:06 +00:00
Smittix 51ea558e19 Allow listening with waterfall and speed up updates 2026-02-07 18:49:48 +00:00
Smittix 75bd3228e5 Improve waterfall rendering and add click-to-tune 2026-02-07 18:36:14 +00:00
Smittix 86e4ba7e29 Add alerts/recording, WiFi/TSCM updates, optimize waterfall 2026-02-07 18:29:58 +00:00
Smittix 4bbc00b765 Improve TSCM detection and include WiFi clients 2026-02-07 17:31:17 +00:00
Smittix 32b373bf2c Fix stalled audio pipeline cleanup and scanner stop race condition
- Kill audio pipeline when startup produces no data instead of leaving
  zombie processes running
- Skip unnecessary 1s USB release delay when no processes were active
- Remove racy fresh=1 pipeline restart from stream endpoint
- Await stopScanner() before starting direct listen to prevent race

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-07 15:39:51 +00:00
Smittix cdfc10c854 Make Postgres data path configurable for ADS-B history
Allow users to override the pgdata volume mount via PGDATA_PATH env var,
enabling external storage (e.g. USB) for ADS-B history. Defaults to
./pgdata for backwards compatibility.

Based on PR #88 by JamesIOmete, rebased cleanly onto main.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-07 15:35:32 +00:00
Smittix adb472956e Merge pull request #126 from suidroot/add_airspy_docker
Add Soapy Airspy package and airspy pages to Dockerfile
2026-02-07 15:31:08 +00:00
Smittix 60d3cff5e7 Fix SDR device lock-up from unreleased device registry on process crash
Stream threads for sensor, pager, acars, rtlamr, dmr, and dsc modes
never called release_sdr_device() when their SDR process crashed,
leaving devices permanently locked in the registry. Also fixes orphaned
companion processes (rtl_fm, rtl_tcp) not being killed on crash, start
path failures leaking processes, DMR stop handler missing lock, and
listening post/audio websocket pkill nuking all system-wide rtl_fm
processes. Wires up register_process()/unregister_process() so the
atexit/signal cleanup safety net actually works, and adds rtl_tcp,
rtl_power, rtlamr, ffmpeg to the killall endpoint.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-07 15:18:15 +00:00
Smittix b208576068 Fix TSCM sweep KeyError on RiskLevel.NEEDS_REVIEW
The RiskLevel.NEEDS_REVIEW enum value was 'review' but the
devices_by_risk dict and all summary keys used 'needs_review',
causing a KeyError during sweep correlation.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-07 15:02:57 +00:00
Smittix 1ee64efc81 Fix PD120 SSTV decode hang and false leader tone detection
Fix infinite CPU spin in PD120 decoding caused by a 1-sample rounding
mismatch between line_samples (24407) and the sum of sub-component
samples (24408). The feed() while loop would re-enter _decode_line()
endlessly when the buffer was too short by 1 sample. Added a stall
guard that breaks the loop when no progress is made.

Fix false "leader tone detected" in the signal monitor by requiring
the detected tone to dominate the other tone by 2x, matching the
approach already used by the VIS detector.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-07 14:55:22 +00:00
Smittix bb4ccc6355 Auto-bring WiFi interface up before TSCM scan
When the WiFi interface is down (e.g. USB adapter not activated),
scanning fails with "Network is down" errors. Now the scanner
proactively checks interface state via /sys/class/net and brings
it up using ip link (or ifconfig fallback) before attempting scans,
with a retry loop if the initial scan still fails.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-07 14:45:24 +00:00
Smittix 70e9611f02 Remove orphaned receiver count section from WebSDR sidebar
The Receiver Count section had no <h3> so it didn't get collapsible
panel styling, rendering as a small out-of-place rectangle. The count
is already shown in the main receiver list panel so this was redundant.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-07 14:38:07 +00:00
Smittix d05144bdb3 Fix WebSDR map black space with dynamic minZoom and background color
Static minZoom: 2 wasn't enough for tall containers. Now calculate
minZoom from actual container height so tiles always cover the visible
area. Also set map background to match CartoDB dark tile ocean color
so any remaining edge at extreme latitudes blends seamlessly.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-07 14:32:16 +00:00
Smittix bfd92e3883 Constrain WebSDR map to prevent vertical black space
Add maxBounds to limit vertical panning to ±85° latitude and set
minZoom to 2 so tiles always cover the visible area. Prevents the
large black bands above and below the map tiles.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-07 14:30:23 +00:00
Smittix 3b191dccd6 Remove raw output display and filter DSD startup banner lines
The dmrRawOutput div was rendering garbled box-drawing characters from
the dsd-fme ASCII art banner below the signal activity canvas. Remove
the div and filter banner lines (box-drawing chars, version info) in
the parser so they never become events.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-07 12:52:59 +00:00
Smittix c0eda84644 Fix signal activity panel dying after DSD startup banner
The stream thread used a blocking readline() with no timeout, so once
DSD finished outputting its startup banner there were no more events
until actual signal activity. The frontend decayed to zero and appeared
dead. If DSD crashed, the synthesizer state never transitioned to
'stopped' so there was no visual or textual indication of failure.

- Use select() with 1s timeout on DSD stderr to avoid indefinite block
- Send heartbeat events every 3s while decoder is alive but idle
- Detect DSD crashes: capture exit code and remaining stderr, send as
  'crashed' status with details and show notification to user
- Frontend properly transitions synthesizer to 'stopped' on process
  death (was only happening on user-initiated stop)
- Increase idle breathing amplitude so LISTENING state is clearly
  visible (0.12 +/- 0.06 vs old 0.05 +/- 0.035)
- Release device reservation on crash, not just user stop

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-07 12:49:35 +00:00
Smittix 19f382a31a Add device reservation to DMR mode and improve USB busy error message
DMR was missing checkDeviceAvailability/reserveDevice/releaseDevice
calls that other modes (SSTV, listening post) use, so the device
dropdown showed device 0 as available even when another process held
it. Also detect USB claim errors from rtl_fm and surface a clear
message telling the user to pick a different device.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-07 12:44:47 +00:00
Smittix 3b205db329 Fix DMR decoder signal activity and parsing for dsd-fme compatibility
The DSD stderr parser had regex ordering bugs that swallowed voice and
call events as bare slot events, and only matched classic dsd output
format (not dsd-fme). Unmatched lines were silently dropped, leaving
the signal activity panel with nothing to display.

- Reorder regex checks: TG/Src before voice before slot
- Support dsd-fme comma-separated format (TG: x, Src: y)
- Make bare slot regex strict (only standalone "Slot N" lines)
- Forward unmatched DSD lines as raw events for diagnostics
- Add LISTENING state to signal activity panel for raw output
- Show raw decoder output text below synthesizer canvas
- Fix test mocks for find_dsd() tuple return value

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-07 12:41:15 +00:00
Smittix d8c5491200 Fix APRS start crash from calling nonexistent reserve_sdr_device
The APRS route called app_module.reserve_sdr_device() which does not
exist, causing an AttributeError that Flask returned as an HTML error
page. The frontend then failed to parse it as JSON, showing
"Unexpected token '<'" to the user. Fixed to use claim_sdr_device()
which is the correct function used by all other modes.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-07 12:31:48 +00:00
Smittix b9c8b1c730 Register SSTV mode with SDR device registry for device state panel
SSTV was not claiming/releasing SDR devices through the centralized
registry, so the device state panel always showed the device as idle
during SSTV use. Added claim_sdr_device/release_sdr_device on the
backend and reserveDevice/releaseDevice on the frontend, matching the
pattern used by all other modes.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-07 12:28:37 +00:00
Smittix 684f17f507 Add delete and download functionality to SSTV image gallery
Users can now manage decoded SSTV images with download and delete actions
accessible from hover overlays on gallery cards, the full-size image modal
toolbar, and a "Clear All" button in the gallery header. Both ISS and
General SSTV modes are supported.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-07 12:12:26 +00:00
Smittix a0f64f6fa6 Stream partial decoded images during SSTV decode progress
The decode canvas was always black because nothing drew on it. Now the
backend encodes partial JPEG snapshots every 5% progress and the frontend
uses an <img> tag with in-place DOM updates instead of recreating innerHTML
on every SSE event.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-07 11:58:00 +00:00
Smittix 06c218c736 Add VIS detector state to signal monitor for decode diagnostics
Shows the current VIS detection state machine position (Idle, Leader,
Break, Start bit, Data bits, etc.) in the signal monitor. This helps
diagnose why decoding may not be starting - e.g. if the VIS detector
is stuck in Idle despite a leader tone being present, the signal may
not contain a valid VIS header.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-07 11:44:35 +00:00
Smittix 1e249a0eec Fix Doppler detecting events clobbering decode progress UI
The Doppler tracking thread emits detecting events every 5s from a
separate thread, unaware of decode state. The previous to_dict() change
included signal_level for ALL detecting events, causing the frontend to
replace the decode progress canvas with the signal monitor mid-decode.

Fix: use None as default for signal_level so only signal-metrics events
(which explicitly set the value) include the field. Also add a frontend
guard to ignore detecting events while the UI is in decoding state.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-07 11:37:14 +00:00
Smittix 249fccadd3 Fix signal monitor not appearing by always emitting signal_level for detecting status
The to_dict() method was skipping signal_level when it was 0, so the
frontend never received the field and never rendered the monitor.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-07 11:28:34 +00:00
Smittix 82957ab162 Add real-time signal level monitor to SSTV decoder UI
Shows RMS audio level bar and SSTV tone classification (leader/sync/noise)
via SSE during detecting mode, replacing the static "Listening..." state
with actionable signal feedback.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-07 11:25:01 +00:00
Smittix e8727358eb Log rtl_fm stderr when pipeline fails and surface error to UI
When rtl_fm exits unexpectedly, read its stderr output to diagnose
the failure (no device, permission denied, etc.) and include the
error message in both the server log and the SSE progress event
sent to the browser.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-07 11:12:38 +00:00
Smittix 28891f4709 Fix SSTV decoder thread lifecycle and VIS detection reliability
Three bugs preventing the live SSTV pipeline from working:

1. Race condition: self._running was set AFTER starting the decode
   thread, so the thread checked the flag, found it False, and exited
   immediately without ever processing audio.

2. Ghost running state: when the decode thread exited (e.g. rtl_fm
   died), self._running stayed True. The decoder reported as running
   but was dead, and subsequent start() calls returned without doing
   anything - permanently stuck until app restart.

3. VIS detection fragility: unclassifiable windows at tone transition
   boundaries (mixed energy from two tones) caused the state machine
   to reset from LEADER/BREAK states back to IDLE, dropping valid
   VIS headers on real signals.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-07 10:33:08 +00:00
Marc 297f971bd5 adding vector images for the towers and phones 2026-02-07 01:22:50 -06:00
Mitch Ross 4bf35cf786 up 2026-02-07 00:30:41 -05:00
Ben Mason 28e19b8898 Add Soapy Airspy package and airspy pages to Dockerfile 2026-02-06 17:00:52 -05:00
Mitch Ross 4ed7969e90 fixes 2026-02-06 15:05:04 -05:00
Smittix ef7d8cca9f Replace broken slowrx dependency with pure Python SSTV decoder
slowrx is a GTK GUI app that doesn't support CLI usage, so the SSTV
decoder was silently failing. This replaces it with a pure Python
implementation using numpy and Pillow that supports Robot36/72,
Martin1/2, Scottie1/2, and PD120/180 modes via VIS header auto-detection.

Key implementation details:
- Generalized Goertzel (DTFT) for exact-frequency tone detection
- Vectorized batch Goertzel for real-time pixel decoding performance
- Overlapping analysis windows for short-window frequency estimation
- VIS header detection state machine with parity validation
- Per-line sync re-synchronization for drift tolerance

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-06 19:47:02 +00:00
Mitch Ross 1683d98b90 up 2026-02-06 13:29:45 -05:00
Marc 18aa7fe669 Merge branch 'main' of https://github.com/xdep/intercept 2026-02-06 09:12:23 -06:00
Marc 8409a4469d removing test script from root project folder 2026-02-06 09:09:03 -06:00
Device b75492ec18 Merge branch 'smittix:main' into main 2026-02-06 16:05:05 +01:00
Marc fef8db6c00 Adding more available bands for europe as testing fase 2026-02-06 08:39:26 -06:00
Marc a70502fb77 endpoints return empty results gracefully instead of 400 errors 2026-02-06 08:33:42 -06:00
Marc e8a9afa221 fixing bands and how the gsm scanner loops with tshark 2026-02-06 08:27:25 -06:00
Marc 8e9588c4ff Added ARFCN to Frequency Conversion 2026-02-06 07:45:32 -06:00
Marc 7bc1d5b643 Fixing the process routes and child processes part 2 2026-02-06 07:39:04 -06:00
Marc ef14f5f1a1 Fixing the process routes and child processes 2026-02-06 07:32:47 -06:00
Marc 7caa7247ef Adding device detection for SDR 2026-02-06 07:28:47 -06:00
Marc 04d9d2fd56 First GSM SPY addition 2026-02-06 07:15:33 -06:00
Mitch Ross ff36687f53 Merge branch 'smittix:main' into claude/docker-dual-sdr-config-6yro9 2026-02-05 19:33:49 -05:00
Mitch Ross b860a4309b Add weather satellite auto-scheduler, polar plot, ground track map, and rtlamr Docker support
- Fix SDR device stuck claimed on capture failure via on_complete callback
- Improve SatDump output parsing to emit all lines (throttled 2s) for real-time feedback
- Extract shared pass prediction into utils/weather_sat_predict.py with trajectory/ground track support
- Add auto-scheduler (utils/weather_sat_scheduler.py) using threading.Timer for unattended captures
- Add scheduler API endpoints (enable/disable/status/passes/skip) with SSE event notifications
- Add countdown timer (D/H/M/S) with imminent/active glow states
- Add 24h timeline bar with colored pass markers and current-time cursor
- Add canvas polar plot showing az/el trajectory arc with cardinal directions
- Add Leaflet ground track map with satellite path and observer marker
- Restructure to 3-column layout (passes | polar+map | gallery) with responsive stacking
- Add auto-schedule toggle in strip bar and sidebar
- Add rtlamr (Go utility meter decoder) to Dockerfile

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-05 19:32:12 -05:00
Mitch Ross f409222f8a Update Dockerfile 2026-02-05 17:16:49 -05:00
Mitch Ross 1c051933b7 Update Dockerfile 2026-02-05 17:12:33 -05:00
Claude c83a2ef56f Add antenna quick reference guides to all mode sidebar panels
Each SDR mode now includes frequency-specific antenna guidance:
- Pager: VHF/UHF dipole info for 153/929 MHz bands
- 433 MHz Sensors: quarter-wave ground plane for ISM band
- Utility Meters: 912 MHz stock antenna tips and upgrades
- APRS: 2m band dipole and commercial options for 144.39 MHz
- SSTV: V-dipole for ISS reception at 145.800 MHz
- AIS: marine VHF antenna for 162 MHz vessel tracking
- Listening Post: wideband discone recommendation with band table
- Meshtastic: LoRa 915/868 MHz antenna upgrades and placement
- ADS-B: 1090 MHz collinear, commercial options, LNA/placement

Each guide includes antenna type, element lengths, placement tips,
and a quick reference table with key specs for the mode.

https://claude.ai/code/session_01FjLTkyELaqh27U1wEXngFQ
2026-02-05 22:10:20 +00:00
Mitch Ross 6d1f8f022e Update CLAUDE.md 2026-02-05 17:07:39 -05:00
Claude 500ddf59fe Add multi-arch build support and detailed antenna guide
Multi-arch Docker builds:
- build-multiarch.sh: Cross-compile amd64+arm64 on x64 and push to
  registry, so RPi5 can docker pull instead of building natively
- docker-compose.yml: Add INTERCEPT_IMAGE env var to support pulling
  pre-built images from a registry instead of local build
- README.md: Docker build section rewritten with multi-arch workflow,
  registry pull instructions, and build script options

Weather satellite antenna guide (sidebar panel):
- V-Dipole: ASCII diagram, 53.4cm element length, 120 degree angle,
  materials, orientation, connection instructions
- Turnstile/Crossed Dipole: phasing coax length (37cm RG-58),
  reflector distance (52cm below), RHCP explanation
- QFH Quadrifilar Helix: design overview, materials, height (46cm),
  hemispherical gain pattern
- Placement & LNA: outdoor requirements, coax loss figures,
  LNA mounting position, Nooelec SAWbird+ recommendation, Bias-T
- Quick reference table: wavelength, quarter-wave, elevation,
  duration, polarization, APT/LRPT bandwidth

Also added Weather Satellites and ISS SSTV to README features list,
SatDump to acknowledgments.

https://claude.ai/code/session_01FjLTkyELaqh27U1wEXngFQ
2026-02-05 21:59:55 +00:00
Claude 5e4be0c279 Enable persistent data volume mount for Docker services
Uncomment and enable the ./data:/app/data volume mount on both the
basic and history service profiles. This persists decoded weather
satellite images, the SQLite database, and other data across
container rebuilds. Critical for Docker-only deployments.

https://claude.ai/code/session_01FjLTkyELaqh27U1wEXngFQ
2026-02-05 21:55:44 +00:00
Claude 7b68c19dc5 Add weather satellite decoder for NOAA APT and Meteor LRPT
New module for receiving and decoding weather satellite images using
SatDump CLI. Supports NOAA-15/18/19 (APT) and Meteor-M2-3 (LRPT)
with live SDR capture, pass prediction, and image gallery.

Backend:
- utils/weather_sat.py: SatDump process manager with image watcher
- routes/weather_sat.py: API endpoints (start/stop/images/passes/stream)
- SSE streaming for real-time capture progress
- Pass prediction using existing skyfield + TLE data
- SDR device registry integration (prevents conflicts)

Frontend:
- Sidebar panel with satellite selector and antenna build guide
  (V-dipole and QFH instructions for 137 MHz reception)
- Stats strip with status, frequency, mode, location inputs
- Split-panel layout: upcoming passes list + decoded image gallery
- Full-size image modal viewer
- SSE-driven progress updates during capture

Infrastructure:
- Dockerfile: Add SatDump build from source (headless CLI mode)
  with runtime deps (libpng, libtiff, libjemalloc, libvolk2, libnng)
- Config: WEATHER_SAT_GAIN, SAMPLE_RATE, MIN_ELEVATION, PREDICTION_HOURS
- Nav: Weather Sat entry in Space group (desktop + mobile)

https://claude.ai/code/session_01FjLTkyELaqh27U1wEXngFQ
2026-02-05 21:45:33 +00:00
Claude 780ba9c58b Update Docker config for dual-SDR setup and arm64 compatibility
- Add slowrx SSTV decoder build with required deps (libsndfile1,
  libgtk-3-dev, libasound2-dev, libfftw3-dev) for arm64/RPi5 support
- Enable USB device passthrough (/dev/bus/usb) on both service profiles
- Add 'basic' profile to main intercept service for explicit selection
- Fix intercept-history container_name conflict (was duplicating 'intercept')

https://claude.ai/code/session_01FjLTkyELaqh27U1wEXngFQ
2026-02-05 19:46:54 +00:00
139 changed files with 32180 additions and 2692 deletions
+1
View File
@@ -35,6 +35,7 @@ htmlcov/
# Local Postgres data
pgdata/
pgdata.bak/
# Captured files (don't include in image)
*.cap
+2
View File
@@ -0,0 +1,2 @@
# Uncomment and set to use external storage for ADS-B history
# PGDATA_PATH=/mnt/external/intercept/pgdata
+11
View File
@@ -54,3 +54,14 @@ intercept_agent_*.cfg
# Temporary files
/tmp/
*.tmp
# Weather satellite runtime data (decoded images, samples, SatDump output)
data/weather_sat/
# SDR capture files (large IQ recordings)
data/subghz/captures/
# Env files
.env
.env.*
!.env.example
+33
View File
@@ -2,6 +2,39 @@
All notable changes to iNTERCEPT will be documented in this file.
## [2.15.0] - 2026-02-09
### Added
- **Real-time WebSocket Waterfall** - I/Q capture with server-side FFT
- Click-to-tune, zoom controls, and auto-scaling quantization
- Shared waterfall UI across SDR modes with function bar controls
- WebSocket frame serialization and connection reuse
- **Cross-Module Frequency Routing** - Tune from Listening Post directly to decoders
- **Pure Python SSTV Decoder** - Replaces broken slowrx C dependency
- Real-time decode progress with partial image streaming
- VIS detector state in signal monitor diagnostics
- Image gallery with delete and download functionality
- **Real-time Signal Scope** - Live signal visualization for pager, sensor, and SSTV modes
- **SSTV Image Gallery** - Delete and download decoded images
- **USB Device Probe** - Detect broken SDR devices before rtl_fm crashes
### Fixed
- DMR dsd-fme protocol flags, device label, and tuning controls
- DMR frontend/backend state desync causing 409 on start
- Digital voice decoder producing no output due to wrong dsd-fme flags
- SDR device lock-up from unreleased device registry on process crash
- APRS crash on large station count and station list overflow
- Settings modal overflowing viewport on smaller screens
- Waterfall crash on zoom by reusing WebSocket and adding USB release retry
- PD120 SSTV decode hang and false leader tone detection
- WebSocket waterfall blocked by login redirect
- TSCM sweep KeyError on RiskLevel.NEEDS_REVIEW
### Removed
- GSM Spy functionality removed for legal compliance
---
## [2.14.0] - 2026-02-06
### Added
+47 -3
View File
@@ -4,11 +4,26 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
## Project Overview
INTERCEPT is a web-based Signal Intelligence (SIGINT) platform providing a unified Flask interface for software-defined radio (SDR) tools. It supports pager decoding, 433MHz sensors, ADS-B aircraft tracking, ACARS messaging, WiFi/Bluetooth scanning, and satellite tracking.
INTERCEPT is a web-based Signal Intelligence (SIGINT) platform providing a unified Flask interface for software-defined radio (SDR) tools. It supports pager decoding, 433MHz sensors, ADS-B aircraft tracking, ACARS messaging, WiFi/Bluetooth scanning, satellite tracking, ISS SSTV decoding, AIS vessel tracking, weather satellite imagery (NOAA APT & Meteor LRPT), and Meshtastic mesh networking.
## Common Commands
### Setup and Running
### Docker (Primary)
```bash
# Build and run (basic profile)
docker compose --profile basic up -d
# Build and run with ADS-B history (Postgres)
docker compose --profile history up -d
# Rebuild after code changes
docker compose --profile basic up -d --build
# Multi-arch build (amd64 + arm64 for RPi)
./build-multiarch.sh
```
### Local Setup (Alternative)
```bash
# Initial setup (installs dependencies and configures SDR tools)
./setup.sh
@@ -66,8 +81,12 @@ Each signal type has its own Flask blueprint:
- `wifi.py`, `wifi_v2.py` - WiFi scanning (legacy and unified APIs)
- `bluetooth.py`, `bluetooth_v2.py` - Bluetooth scanning (legacy and unified APIs)
- `satellite.py` - Pass prediction using TLE data
- `sstv.py` - ISS SSTV image decoding via slowrx
- `weather_sat.py` - NOAA APT & Meteor LRPT via SatDump
- `ais.py` - AIS vessel tracking and VHF DSC distress monitoring
- `aprs.py` - Amateur packet radio via direwolf
- `rtlamr.py` - Utility meter reading
- `meshtastic_routes.py` - Meshtastic LoRa mesh networking
### Core Utilities (utils/)
@@ -91,6 +110,15 @@ Each signal type has its own Flask blueprint:
- Platform-agnostic scanner with parsers for airodump-ng, nmcli, iw, iwlist, airport (macOS)
- `channel_analyzer.py` - Frequency band analysis
**Weather Satellite** (`utils/weather_sat.py`):
- Singleton `WeatherSatDecoder` using SatDump CLI for NOAA APT and Meteor LRPT
- Subprocess management with stdout parsing, image watcher via rglob
- Pass prediction using skyfield TLE data
**SSTV Decoder** (`utils/sstv.py`):
- ISS SSTV reception via slowrx with Doppler tracking
- Singleton pattern, image gallery with timestamped filenames
### 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.
@@ -112,9 +140,25 @@ Each signal type has its own Flask blueprint:
| acarsdec | ACARS messages | Output parsing |
| airmon-ng/airodump-ng | WiFi scanning | Monitor mode, CSV parsing |
| bluetoothctl/hcitool | Bluetooth | Fallback when DBus unavailable |
| slowrx | SSTV decoding | Subprocess with audio pipe |
| SatDump | Weather satellites | CLI live mode, NOAA APT + Meteor LRPT |
| AIS-catcher | AIS vessel tracking | JSON output parsing |
| direwolf | APRS | TNC modem for packet radio |
### Frontend Structure
- **Templates**: `templates/index.html` (main SPA), `templates/partials/modes/*.html` (sidebar panels), `templates/partials/nav.html` (global nav)
- **JS Modules**: `static/js/modes/*.js` - IIFE pattern per mode (e.g., `WeatherSat`, `SSTV`, `Meshtastic`)
- **CSS**: `static/css/modes/*.css` - scoped styles per mode, CSS variables for theming (`--bg-card`, `--accent-cyan`, `--font-mono`)
- **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
- `Dockerfile` - Single-stage build with all SDR tools compiled from source (dump1090, AIS-catcher, slowrx, SatDump, etc.)
- `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)
- Data persisted via `./data:/app/data` volume mount
### Configuration
- `config.py` - Environment variable support with `INTERCEPT_` prefix
- `config.py` - Environment variable support with `INTERCEPT_` prefix (e.g., `INTERCEPT_PORT`, `INTERCEPT_WEATHER_SAT_GAIN`)
- Database: SQLite in `instance/` directory for settings, baselines, history
## Testing Notes
+74 -1
View File
@@ -9,6 +9,9 @@ LABEL description="Signal Intelligence Platform for SDR monitoring"
# Set working directory
WORKDIR /app
# Pre-accept tshark non-root capture prompt for non-interactive install
RUN echo 'wireshark-common wireshark-common/install-setuid boolean true' | debconf-set-selections
# Install system dependencies for SDR tools
RUN apt-get update && apt-get install -y --no-install-recommends \
# RTL-SDR tools
@@ -21,6 +24,15 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
multimon-ng \
# Audio tools for Listening Post
ffmpeg \
# SSTV decoder runtime libs
libsndfile1 \
# SatDump runtime libs (weather satellite decoding)
libpng16-16 \
libtiff6 \
libjemalloc2 \
libvolk-bin \
libnng1 \
libzstd1 \
# WiFi tools (aircrack-ng suite)
aircrack-ng \
iw \
@@ -41,6 +53,8 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
soapysdr-module-rtlsdr \
soapysdr-module-hackrf \
soapysdr-module-lms7 \
soapysdr-module-airspy \
airspy \
limesuite \
hackrf \
# Utilities
@@ -56,9 +70,22 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
cmake \
libncurses-dev \
libsndfile1-dev \
# GTK is required for slowrx (SSTV decoder GUI dependency).
# Note: slowrx is kept for backwards compatibility, but the pure Python
# SSTV decoder in utils/sstv/ is now the primary implementation.
# 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 \
@@ -113,6 +140,43 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
&& make \
&& cp acarsdec /usr/bin/acarsdec \
&& rm -rf /tmp/acarsdec \
# 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 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 \
# Build mbelib (required by DSD)
&& cd /tmp \
&& git clone https://github.com/lwvmobile/mbelib.git \
@@ -135,6 +199,7 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
&& ldconfig \
&& rm -rf /tmp/dsd-fme \
# 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 \
@@ -142,6 +207,14 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
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 \
@@ -164,7 +237,7 @@ RUN pip install --no-cache-dir -r requirements.txt
COPY . .
# Create data directory for persistence
RUN mkdir -p /app/data
RUN mkdir -p /app/data /app/data/weather_sat
# Expose web interface port
EXPOSE 5050
+45 -4
View File
@@ -33,8 +33,9 @@ Support the developer of this open-source project
- **ACARS Messaging** - Aircraft datalink messages via acarsdec
- **DMR Digital Voice** - DMR/P25/NXDN/D-STAR decoding via dsd-fme with visual synthesizer
- **Listening Post** - Frequency scanner with audio monitoring
- **Weather Satellites** - NOAA APT and Meteor LRPT image decoding via SatDump
- **WebSDR** - Remote HF/shortwave listening via WebSDR servers
- **ISS SSTV** - Receive slow-scan TV from the International Space Station
- **ISS SSTV** - Slow-scan TV image reception from the International Space Station
- **HF SSTV** - Terrestrial SSTV on shortwave frequencies
- **Satellite Tracking** - Pass prediction using TLE data
- **ADS-B History** - Persistent aircraft history with reporting dashboard (Postgres optional)
@@ -60,15 +61,54 @@ cd intercept
sudo -E venv/bin/python intercept.py
```
### Docker (Alternative)
### Docker
```bash
git clone https://github.com/smittix/intercept.git
cd intercept
docker compose up -d
docker compose --profile basic up -d --build
```
> **Note:** Docker requires privileged mode for USB SDR access. See `docker-compose.yml` for configuration options.
> **Note:** Docker requires privileged mode for USB SDR access. SDR devices are passed through via `/dev/bus/usb`.
#### Multi-Architecture Builds (amd64 + arm64)
Cross-compile on an x64 machine and push to a registry. This is much faster than building natively on an RPi.
```bash
# One-time setup on your x64 build machine
docker run --privileged --rm tonistiigi/binfmt --install all
docker buildx create --name intercept-builder --use --bootstrap
# Build and push for both architectures
REGISTRY=ghcr.io/youruser ./build-multiarch.sh --push
# On the RPi5, just pull and run
INTERCEPT_IMAGE=ghcr.io/youruser/intercept:latest docker compose --profile basic up -d
```
Build script options:
| Flag | Description |
|------|-------------|
| `--push` | Push to container registry |
| `--load` | Load into local Docker (single platform only) |
| `--arm64-only` | Build arm64 only (for RPi deployment) |
| `--amd64-only` | Build amd64 only |
Environment variables: `REGISTRY`, `IMAGE_NAME`, `IMAGE_TAG`
#### Using a Pre-built Image
If you've pushed to a registry, you can skip building entirely on the target machine:
```bash
# Set in .env or export
INTERCEPT_IMAGE=ghcr.io/youruser/intercept:latest
# Then just run
docker compose --profile basic up -d
```
### ADS-B History (Optional)
@@ -200,6 +240,7 @@ Created by **smittix** - [GitHub](https://github.com/smittix)
[acarsdec](https://github.com/TLeconte/acarsdec) |
[aircrack-ng](https://www.aircrack-ng.org/) |
[Leaflet.js](https://leafletjs.com/) |
[SatDump](https://github.com/SatDump/SatDump) |
[Celestrak](https://celestrak.org/) |
[Priyom.org](https://priyom.org/)
+92 -10
View File
@@ -27,7 +27,7 @@ from typing import Any
from flask import Flask, render_template, jsonify, send_file, Response, request,redirect, url_for, flash, session
from werkzeug.security import check_password_hash
from config import VERSION, CHANGELOG, SHARED_OBSERVER_LOCATION_ENABLED
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 utils.process import cleanup_stale_processes
from utils.sdr import SDRFactory
@@ -182,6 +182,10 @@ dmr_lock = threading.Lock()
tscm_queue = queue.Queue(maxsize=QUEUE_MAX_SIZE)
tscm_lock = threading.Lock()
# SubGHz Transceiver (HackRF)
subghz_queue = queue.Queue(maxsize=QUEUE_MAX_SIZE)
subghz_lock = threading.Lock()
# Deauth Attack Detection
deauth_detector = None
deauth_detector_queue = queue.Queue(maxsize=QUEUE_MAX_SIZE)
@@ -244,6 +248,10 @@ sdr_device_registry_lock = threading.Lock()
def claim_sdr_device(device_index: int, mode_name: str) -> str | None:
"""Claim an SDR device for a mode.
Checks the in-app registry first, then probes the USB device to
catch stale handles held by external processes (e.g. a leftover
rtl_fm from a previous crash).
Args:
device_index: The SDR device index to claim
mode_name: Name of the mode claiming the device (e.g., 'sensor', 'rtlamr')
@@ -255,6 +263,16 @@ def claim_sdr_device(device_index: int, mode_name: str) -> str | None:
if device_index in sdr_device_registry:
in_use_by = sdr_device_registry[device_index]
return f'SDR device {device_index} is in use by {in_use_by}. Stop {in_use_by} first or use a different device.'
# Probe the USB device to catch external processes holding the handle
try:
from utils.sdr.detection import probe_rtlsdr_device
usb_error = probe_rtlsdr_device(device_index)
if usb_error:
return usb_error
except Exception:
pass # If probe fails, let the caller proceed normally
sdr_device_registry[device_index] = mode_name
return None
@@ -292,6 +310,10 @@ def require_login():
if request.path.startswith('/listening/audio/'):
return None
# Allow WebSocket upgrade requests (page load already required auth)
if request.path.startswith('/ws/'):
return None
# Controller API endpoints use API key auth, not session auth
# Allow agent push/pull endpoints without session login
if request.path.startswith('/controller/'):
@@ -352,6 +374,8 @@ def index() -> str:
version=VERSION,
changelog=CHANGELOG,
shared_observer_location=SHARED_OBSERVER_LOCATION_ENABLED,
default_latitude=DEFAULT_LATITUDE,
default_longitude=DEFAULT_LONGITUDE,
)
@@ -623,8 +647,27 @@ def export_bluetooth() -> Response:
})
@app.route('/health')
def health_check() -> Response:
def _get_subghz_active() -> bool:
"""Check if SubGHz manager has an active process."""
try:
from utils.subghz import get_subghz_manager
return get_subghz_manager().active_mode != 'idle'
except Exception:
return False
def _get_dmr_active() -> bool:
"""Check if Digital Voice decoder has an active process."""
try:
from routes import dmr as dmr_module
proc = dmr_module.dmr_dsd_process
return bool(dmr_module.dmr_running and proc and proc.poll() is None)
except Exception:
return False
@app.route('/health')
def health_check() -> Response:
"""Health check endpoint for monitoring."""
import time
return jsonify({
@@ -638,11 +681,12 @@ def health_check() -> Response:
'ais': ais_process is not None and (ais_process.poll() is None if ais_process else False),
'acars': acars_process is not None and (acars_process.poll() is None if acars_process else False),
'aprs': aprs_process is not None and (aprs_process.poll() is None if aprs_process else False),
'wifi': wifi_process is not None and (wifi_process.poll() is None if wifi_process else False),
'bluetooth': bt_process is not None and (bt_process.poll() is None if bt_process else False),
'dsc': dsc_process is not None and (dsc_process.poll() is None if dsc_process else False),
'dmr': dmr_process is not None and (dmr_process.poll() is None if dmr_process else False),
},
'wifi': wifi_process is not None and (wifi_process.poll() is None if wifi_process else False),
'bluetooth': bt_process is not None and (bt_process.poll() is None if bt_process else False),
'dsc': dsc_process is not None and (dsc_process.poll() is None if dsc_process else False),
'dmr': _get_dmr_active(),
'subghz': _get_subghz_active(),
},
'data': {
'aircraft_count': len(adsb_aircraft),
'vessel_count': len(ais_vessels),
@@ -671,7 +715,9 @@ def kill_all() -> Response:
'rtl_fm', 'multimon-ng', 'rtl_433',
'airodump-ng', 'aireplay-ng', 'airmon-ng',
'dump1090', 'acarsdec', 'direwolf', 'AIS-catcher',
'hcitool', 'bluetoothctl', 'dsd'
'hcitool', 'bluetoothctl', 'satdump', 'dsd',
'rtl_tcp', 'rtl_power', 'rtlamr', 'ffmpeg',
'hackrf_transfer', 'hackrf_sweep'
]
for proc in processes_to_kill:
@@ -736,7 +782,14 @@ def kill_all() -> Response:
# Reset Bluetooth v2 scanner
try:
reset_bluetooth_scanner()
killed.append('bluetooth_scanner')
killed.append('bluetooth')
except Exception:
pass
# Reset SubGHz state
try:
from utils.subghz import get_subghz_manager
get_subghz_manager().stop_all()
except Exception:
pass
@@ -829,6 +882,18 @@ def main() -> None:
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()
@@ -836,6 +901,15 @@ def main() -> None:
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:
@@ -868,6 +942,14 @@ def main() -> None:
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}")
print(f"Open http://localhost:{args.port} in your browser")
print()
print("Press Ctrl+C to stop")
+139
View File
@@ -0,0 +1,139 @@
#!/bin/bash
# INTERCEPT - Multi-architecture Docker image builder
#
# Builds for both linux/amd64 and linux/arm64 using Docker buildx.
# Run this on your x64 machine to cross-compile the arm64 image
# instead of building natively on the RPi5.
#
# Prerequisites (one-time setup):
# docker run --privileged --rm tonistiigi/binfmt --install all
# docker buildx create --name intercept-builder --use --bootstrap
#
# Usage:
# ./build-multiarch.sh # Build both platforms, load locally
# ./build-multiarch.sh --push # Build and push to registry
# ./build-multiarch.sh --arm64-only # Build arm64 only (for RPi)
# REGISTRY=ghcr.io/user ./build-multiarch.sh --push
#
# Environment variables:
# REGISTRY - Container registry (default: docker.io/library)
# IMAGE_NAME - Image name (default: intercept)
# IMAGE_TAG - Image tag (default: latest)
set -euo pipefail
# Configuration
REGISTRY="${REGISTRY:-}"
IMAGE_NAME="${IMAGE_NAME:-intercept}"
IMAGE_TAG="${IMAGE_TAG:-latest}"
BUILDER_NAME="intercept-builder"
PLATFORMS="linux/amd64,linux/arm64"
# Parse arguments
PUSH=false
LOAD=false
ARM64_ONLY=false
for arg in "$@"; do
case $arg in
--push) PUSH=true ;;
--load) LOAD=true ;;
--arm64-only)
ARM64_ONLY=true
PLATFORMS="linux/arm64"
;;
--amd64-only)
PLATFORMS="linux/amd64"
;;
--help|-h)
echo "Usage: $0 [--push] [--load] [--arm64-only] [--amd64-only]"
echo ""
echo "Options:"
echo " --push Push to container registry"
echo " --load Load into local Docker (single platform only)"
echo " --arm64-only Build arm64 only (for RPi5 deployment)"
echo " --amd64-only Build amd64 only"
echo ""
echo "Environment variables:"
echo " REGISTRY Container registry (e.g. ghcr.io/username)"
echo " IMAGE_NAME Image name (default: intercept)"
echo " IMAGE_TAG Image tag (default: latest)"
echo ""
echo "Examples:"
echo " $0 --push # Build both, push"
echo " REGISTRY=ghcr.io/myuser $0 --push # Push to GHCR"
echo " $0 --arm64-only --load # Build arm64, load locally"
echo " $0 --arm64-only --push && ssh rpi docker pull # Build + deploy to RPi"
exit 0
;;
*)
echo "Unknown option: $arg"
exit 1
;;
esac
done
# Build full image reference
if [ -n "$REGISTRY" ]; then
FULL_IMAGE="${REGISTRY}/${IMAGE_NAME}:${IMAGE_TAG}"
else
FULL_IMAGE="${IMAGE_NAME}:${IMAGE_TAG}"
fi
echo "============================================"
echo " INTERCEPT Multi-Architecture Builder"
echo "============================================"
echo " Image: ${FULL_IMAGE}"
echo " Platforms: ${PLATFORMS}"
echo " Push: ${PUSH}"
echo "============================================"
echo ""
# Check if buildx builder exists, create if not
if ! docker buildx inspect "$BUILDER_NAME" >/dev/null 2>&1; then
echo "Creating buildx builder: ${BUILDER_NAME}"
docker buildx create --name "$BUILDER_NAME" --use --bootstrap
# Check for QEMU support
if ! docker run --rm --privileged tonistiigi/binfmt --install all >/dev/null 2>&1; then
echo "WARNING: QEMU binfmt setup may have failed."
echo "Run: docker run --privileged --rm tonistiigi/binfmt --install all"
fi
else
docker buildx use "$BUILDER_NAME"
fi
# Build command
BUILD_CMD="docker buildx build --platform ${PLATFORMS} --tag ${FULL_IMAGE}"
if [ "$PUSH" = true ]; then
BUILD_CMD="${BUILD_CMD} --push"
echo "Will push to: ${FULL_IMAGE}"
elif [ "$LOAD" = true ]; then
# --load only works with single platform
if echo "$PLATFORMS" | grep -q ","; then
echo "ERROR: --load only works with a single platform."
echo "Use --arm64-only or --amd64-only with --load."
exit 1
fi
BUILD_CMD="${BUILD_CMD} --load"
echo "Will load into local Docker"
fi
echo ""
echo "Building..."
echo "Command: ${BUILD_CMD} ."
echo ""
$BUILD_CMD .
echo ""
echo "============================================"
echo " Build complete!"
if [ "$PUSH" = true ]; then
echo " Image pushed to: ${FULL_IMAGE}"
echo ""
echo " Pull on RPi5:"
echo " docker pull ${FULL_IMAGE}"
fi
echo "============================================"
+53 -1
View File
@@ -7,10 +7,36 @@ import os
import sys
# Application version
VERSION = "2.14.0"
VERSION = "2.16.0"
# Changelog - latest release notes (shown on welcome screen)
CHANGELOG = [
{
"version": "2.16.0",
"date": "February 2026",
"highlights": [
"Sub-GHz analyzer with real-time RF capture and protocol decoding",
"Weather satellite auto-scheduler with polar plot and ground track map",
"SatDump support for local (non-Docker) installs via setup.sh",
"DMR audio output, frequency persistence, and bookmarks",
"Shared waterfall UI across SDR modes",
"Listening post audio stuttering fix and SDR race condition fixes",
"Multi-arch Docker build support (amd64 + arm64)",
]
},
{
"version": "2.15.0",
"date": "February 2026",
"highlights": [
"Real-time WebSocket waterfall with I/Q capture and server-side FFT",
"Cross-module frequency routing from Listening Post to decoders",
"Pure Python SSTV decoder replacing broken slowrx dependency",
"Real-time signal scope for pager, sensor, and SSTV modes",
"USB-level device probe to prevent cryptic rtl_fm crashes",
"DMR dsd-fme protocol fixes, tuning controls, and state sync",
"SDR device lock-up fix from unreleased device registry on crash",
]
},
{
"version": "2.14.0",
"date": "February 2026",
@@ -198,21 +224,47 @@ ADSB_HISTORY_QUEUE_SIZE = _get_env_int('ADSB_HISTORY_QUEUE_SIZE', 50000)
# Observer location settings
SHARED_OBSERVER_LOCATION_ENABLED = _get_env_bool('SHARED_OBSERVER_LOCATION', True)
DEFAULT_LATITUDE = _get_env_float('DEFAULT_LAT', 0.0)
DEFAULT_LONGITUDE = _get_env_float('DEFAULT_LON', 0.0)
# Satellite settings
SATELLITE_UPDATE_INTERVAL = _get_env_int('SATELLITE_UPDATE_INTERVAL', 30)
SATELLITE_TRAJECTORY_POINTS = _get_env_int('SATELLITE_TRAJECTORY_POINTS', 30)
SATELLITE_ORBIT_MINUTES = _get_env_int('SATELLITE_ORBIT_MINUTES', 45)
# Weather satellite settings
WEATHER_SAT_DEFAULT_GAIN = _get_env_float('WEATHER_SAT_GAIN', 40.0)
WEATHER_SAT_SAMPLE_RATE = _get_env_int('WEATHER_SAT_SAMPLE_RATE', 1000000)
WEATHER_SAT_MIN_ELEVATION = _get_env_float('WEATHER_SAT_MIN_ELEVATION', 15.0)
WEATHER_SAT_PREDICTION_HOURS = _get_env_int('WEATHER_SAT_PREDICTION_HOURS', 24)
WEATHER_SAT_SCHEDULE_REFRESH_MINUTES = _get_env_int('WEATHER_SAT_SCHEDULE_REFRESH_MINUTES', 30)
WEATHER_SAT_CAPTURE_BUFFER_SECONDS = _get_env_int('WEATHER_SAT_CAPTURE_BUFFER_SECONDS', 30)
# SubGHz transceiver settings (HackRF)
SUBGHZ_DEFAULT_FREQUENCY = _get_env_float('SUBGHZ_FREQUENCY', 433.92)
SUBGHZ_DEFAULT_SAMPLE_RATE = _get_env_int('SUBGHZ_SAMPLE_RATE', 2000000)
SUBGHZ_DEFAULT_LNA_GAIN = _get_env_int('SUBGHZ_LNA_GAIN', 32)
SUBGHZ_DEFAULT_VGA_GAIN = _get_env_int('SUBGHZ_VGA_GAIN', 20)
SUBGHZ_DEFAULT_TX_GAIN = _get_env_int('SUBGHZ_TX_GAIN', 20)
SUBGHZ_MAX_TX_DURATION = _get_env_int('SUBGHZ_MAX_TX_DURATION', 10)
SUBGHZ_SWEEP_START_MHZ = _get_env_float('SUBGHZ_SWEEP_START', 300.0)
SUBGHZ_SWEEP_END_MHZ = _get_env_float('SUBGHZ_SWEEP_END', 928.0)
# Update checking
GITHUB_REPO = _get_env('GITHUB_REPO', 'smittix/intercept')
UPDATE_CHECK_ENABLED = _get_env_bool('UPDATE_CHECK_ENABLED', True)
UPDATE_CHECK_INTERVAL_HOURS = _get_env_int('UPDATE_CHECK_INTERVAL_HOURS', 6)
# Alerting
ALERT_WEBHOOK_URL = _get_env('ALERT_WEBHOOK_URL', '')
ALERT_WEBHOOK_SECRET = _get_env('ALERT_WEBHOOK_SECRET', '')
ALERT_WEBHOOK_TIMEOUT = _get_env_int('ALERT_WEBHOOK_TIMEOUT', 5)
# Admin credentials
ADMIN_USERNAME = _get_env('ADMIN_USERNAME', 'admin')
ADMIN_PASSWORD = _get_env('ADMIN_PASSWORD', 'admin')
def configure_logging() -> None:
"""Configure application logging."""
logging.basicConfig(
+30 -11
View File
@@ -1,27 +1,31 @@
# INTERCEPT - Signal Intelligence Platform
# Docker Compose configuration for easy deployment
#
# Basic usage:
# docker compose up -d
# Basic usage (build locally):
# docker compose --profile basic up -d --build
#
# Basic usage (pre-built image from registry):
# INTERCEPT_IMAGE=ghcr.io/user/intercept:latest docker compose --profile basic up -d
#
# With ADS-B history (Postgres):
# docker compose --profile history up -d
services:
intercept:
# When INTERCEPT_IMAGE is set, use that pre-built image; otherwise build locally
image: ${INTERCEPT_IMAGE:-intercept:latest}
build: .
container_name: intercept
ports:
- "5050:5050"
# Privileged mode required for USB SDR device access
# Alternatively, use device mapping (see below)
privileged: true
# USB device mapping (alternative to privileged mode)
# devices:
# - /dev/bus/usb:/dev/bus/usb
# volumes:
# Persist data directory
# - ./data:/app/data
# USB device mapping for all USB devices
devices:
- /dev/bus/usb:/dev/bus/usb
volumes:
# Persist decoded images and database across container rebuilds
- ./data:/app/data
# Optional: mount logs directory
# - ./logs:/app/logs
environment:
@@ -40,6 +44,9 @@ services:
- INTERCEPT_ADSB_AUTO_START=${INTERCEPT_ADSB_AUTO_START:-false}
# Shared observer location across modules
- INTERCEPT_SHARED_OBSERVER_LOCATION=${INTERCEPT_SHARED_OBSERVER_LOCATION:-true}
# Default observer coordinates (set to your location to skip the GPS prompt)
# - INTERCEPT_DEFAULT_LAT=${INTERCEPT_DEFAULT_LAT:-0}
# - INTERCEPT_DEFAULT_LON=${INTERCEPT_DEFAULT_LON:-0}
# Network mode for WiFi scanning (requires host network)
# network_mode: host
restart: unless-stopped
@@ -53,15 +60,23 @@ services:
# ADS-B history with Postgres persistence
# Enable with: docker compose --profile history up -d
intercept-history:
# Same image/build fallback pattern as above
image: ${INTERCEPT_IMAGE:-intercept:latest}
build: .
container_name: intercept
container_name: intercept-history
profiles:
- history
depends_on:
- adsb_db
ports:
- "5050:5050"
# Privileged mode required for USB SDR device access
privileged: true
# USB device mapping for all USB devices
devices:
- /dev/bus/usb:/dev/bus/usb
volumes:
- ./data:/app/data
environment:
- INTERCEPT_HOST=0.0.0.0
- INTERCEPT_PORT=5050
@@ -76,6 +91,9 @@ services:
- INTERCEPT_ADSB_AUTO_START=${INTERCEPT_ADSB_AUTO_START:-false}
# Shared observer location across modules
- INTERCEPT_SHARED_OBSERVER_LOCATION=${INTERCEPT_SHARED_OBSERVER_LOCATION:-true}
# Default observer coordinates (set to your location to skip the GPS prompt)
# - INTERCEPT_DEFAULT_LAT=${INTERCEPT_DEFAULT_LAT:-0}
# - INTERCEPT_DEFAULT_LON=${INTERCEPT_DEFAULT_LON:-0}
restart: unless-stopped
healthcheck:
test: ["CMD", "curl", "-sf", "http://localhost:5050/health"]
@@ -94,7 +112,8 @@ services:
- POSTGRES_USER=intercept
- POSTGRES_PASSWORD=intercept
volumes:
- ./pgdata:/var/lib/postgresql/data
# Default local path (override with PGDATA_PATH for external storage)
- ${PGDATA_PATH:-./pgdata}:/var/lib/postgresql/data
restart: unless-stopped
healthcheck:
test: ["CMD-SHELL", "pg_isready -U intercept -d intercept_adsb"]
+78 -76
View File
@@ -61,88 +61,90 @@ INTERCEPT automatically detects known trackers:
1. **Select Hardware** - Choose your SDR type (RTL-SDR uses dump1090, others use readsb)
2. **Check Tools** - Ensure dump1090 or readsb is installed
3. **Set Location** - Choose location source:
- **Manual Entry** - Type coordinates directly
- **Browser GPS** - Use browser's built-in geolocation (requires HTTPS)
- **USB GPS Dongle** - Connect a USB GPS receiver for continuous updates
- **Shared Location** - By default, the observer location is shared across modules
(disable with `INTERCEPT_SHARED_OBSERVER_LOCATION=false`)
4. **Start Tracking** - Click "Start Tracking" to begin ADS-B reception
5. **View Map** - Aircraft appear on the interactive Leaflet map
3. **Set Location** - Choose location source:
- **Manual Entry** - Type coordinates directly
- **Browser GPS** - Use browser's built-in geolocation (requires HTTPS)
- **USB GPS Dongle** - Connect a USB GPS receiver for continuous updates
- **Shared Location** - By default, the observer location is shared across modules
(disable with `INTERCEPT_SHARED_OBSERVER_LOCATION=false`)
4. **Start Tracking** - Click "Start Tracking" to begin ADS-B reception
5. **View Map** - Aircraft appear on the interactive Leaflet map
6. **Click Aircraft** - Click markers for detailed information
7. **Display Options** - Toggle callsigns, altitude, trails, range rings, clustering
8. **Filter Aircraft** - Use dropdown to show all, military, civil, or emergency only
9. **Full Dashboard** - Click "Full Screen Dashboard" for dedicated radar view
> Note: ADS-B auto-start is disabled by default. To enable auto-start on dashboard load,
> set `INTERCEPT_ADSB_AUTO_START=true`.
9. **Full Dashboard** - Click "Full Screen Dashboard" for dedicated radar view
> Note: ADS-B auto-start is disabled by default. To enable auto-start on dashboard load,
> set `INTERCEPT_ADSB_AUTO_START=true`.
### Emergency Squawks
The system highlights aircraft transmitting emergency squawks:
- **7500** - Hijack
- **7600** - Radio failure
- **7700** - General emergency
## ADS-B History (Optional)
The history dashboard persists aircraft messages and per-aircraft snapshots to Postgres for long-running tracking and reporting.
### Enable History
Set the following environment variables (Docker recommended):
| Variable | Default | Description |
|----------|---------|-------------|
| `INTERCEPT_ADSB_HISTORY_ENABLED` | `false` | Enables history storage and reporting |
| `INTERCEPT_ADSB_DB_HOST` | `localhost` | Postgres host (use `adsb_db` in Docker) |
| `INTERCEPT_ADSB_DB_PORT` | `5432` | Postgres port |
| `INTERCEPT_ADSB_DB_NAME` | `intercept_adsb` | Database name |
| `INTERCEPT_ADSB_DB_USER` | `intercept` | Database user |
| `INTERCEPT_ADSB_DB_PASSWORD` | `intercept` | Database password |
### Other ADS-B Settings
| 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 \
python app.py
```
**Docker example (.env)**
```bash
INTERCEPT_ADSB_AUTO_START=true
INTERCEPT_SHARED_OBSERVER_LOCATION=false
```
### Docker Setup
`docker-compose.yml` includes an `adsb_db` service and a persistent volume for history storage:
```bash
docker compose --profile history up -d
```
To store Postgres data on external storage, set `PGDATA_PATH` (defaults to `./pgdata`):
```bash
PGDATA_PATH=/mnt/usbpi1/intercept/pgdata
```
### Using the History Dashboard
1. Open **/adsb/history**
2. Use **Start Tracking** to run ADS-B in headless mode
3. View aircraft history and timelines
4. Stop tracking when desired (session history is recorded)
The system highlights aircraft transmitting emergency squawks:
- **7500** - Hijack
- **7600** - Radio failure
- **7700** - General emergency
## ADS-B History (Optional)
The history dashboard persists aircraft messages and per-aircraft snapshots to Postgres for long-running tracking and reporting.
### Enable History
Set the following environment variables (Docker recommended):
| Variable | Default | Description |
|----------|---------|-------------|
| `INTERCEPT_ADSB_HISTORY_ENABLED` | `false` | Enables history storage and reporting |
| `INTERCEPT_ADSB_DB_HOST` | `localhost` | Postgres host (use `adsb_db` in Docker) |
| `INTERCEPT_ADSB_DB_PORT` | `5432` | Postgres port |
| `INTERCEPT_ADSB_DB_NAME` | `intercept_adsb` | Database name |
| `INTERCEPT_ADSB_DB_USER` | `intercept` | Database user |
| `INTERCEPT_ADSB_DB_PASSWORD` | `intercept` | Database password |
### Other ADS-B Settings
| 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 \
python app.py
```
**Docker example (.env)**
```bash
INTERCEPT_ADSB_AUTO_START=true
INTERCEPT_SHARED_OBSERVER_LOCATION=false
```
### Docker Setup
`docker-compose.yml` includes an `adsb_db` service and a persistent volume for history storage:
```bash
docker compose --profile history up -d
```
To store Postgres data on external storage, set `PGDATA_PATH` (defaults to `./pgdata`):
```bash
PGDATA_PATH=/mnt/usbpi1/intercept/pgdata
```
### Using the History Dashboard
1. Open **/adsb/history**
2. Use **Start Tracking** to run ADS-B in headless mode
3. View aircraft history and timelines
4. Stop tracking when desired (session history is recorded)
If the History dashboard shows **HISTORY DISABLED**, enable `INTERCEPT_ADSB_HISTORY_ENABLED=true` and ensure Postgres is running.
## Satellite Mode
+30
View File
@@ -0,0 +1,30 @@
#!/usr/bin/env bash
# Download sample NOAA APT recordings for testing the weather satellite
# test-decode feature. These are FM-demodulated audio WAV files.
#
# Usage:
# ./download-weather-sat-samples.sh
# docker exec intercept /app/download-weather-sat-samples.sh
set -euo pipefail
SAMPLE_DIR="$(dirname "$0")/data/weather_sat/samples"
mkdir -p "$SAMPLE_DIR"
echo "Downloading NOAA APT sample files to $SAMPLE_DIR ..."
# Full satellite pass recorded over Argentina (NOAA, 11025 Hz mono WAV)
# Source: https://github.com/martinber/noaa-apt
if [ ! -f "$SAMPLE_DIR/noaa_apt_argentina.wav" ]; then
echo " -> noaa_apt_argentina.wav (18 MB) ..."
curl -fSL -o "$SAMPLE_DIR/noaa_apt_argentina.wav" \
"https://noaa-apt.mbernardi.com.ar/examples/argentina.wav"
else
echo " -> noaa_apt_argentina.wav (already exists)"
fi
echo ""
echo "Done. Test decode with:"
echo " Satellite: NOAA-18"
echo " File path: data/weather_sat/samples/noaa_apt_argentina.wav"
echo " Sample rate: 11025 Hz"
+210
View File
@@ -0,0 +1,210 @@
DMSP 5D-3 F16 (USA 172)
1 28054U 03048A 26037.66410905 .00000171 00000+0 11311-3 0 9991
2 28054 99.0018 60.5544 0007736 150.6435 318.8272 14.14449870151032
METEOSAT-9 (MSG-2)
1 28912U 05049B 26037.20698824 .00000122 00000+0 00000+0 0 9990
2 28912 9.0646 55.4438 0001292 220.3216 340.7358 1.00280364 5681
DMSP 5D-3 F17 (USA 191)
1 29522U 06050A 26037.63495522 .00000221 00000+0 13641-3 0 9997
2 29522 98.7406 46.8646 0011088 71.3269 288.9107 14.14949568993957
FENGYUN 3A
1 32958U 08026A 26037.29889977 .00000162 00000+0 97205-4 0 9995
2 32958 98.6761 340.6748 0009336 139.4536 220.7337 14.19536323916838
GOES 14
1 35491U 09033A 26037.59737599 .00000128 00000+0 00000+0 0 9998
2 35491 1.3510 84.7861 0001663 279.3774 203.6871 1.00112472 5283
DMSP 5D-3 F18 (USA 210)
1 35951U 09057A 26037.59574243 .00000344 00000+0 20119-3 0 9997
2 35951 98.8912 18.7405 0010014 262.2671 97.7365 14.14814612841124
EWS-G2 (GOES 15)
1 36411U 10008A 26037.42417604 .00000037 00000+0 00000+0 0 9998
2 36411 0.9477 85.6904 0004764 200.6178 64.5237 1.00275731 58322
COMS 1
1 36744U 10032A 26037.66884865 -.00000343 00000+0 00000+0 0 9998
2 36744 4.4730 77.2684 0001088 239.9858 188.4845 1.00274368 49786
FENGYUN 3B
1 37214U 10059A 26037.62488625 .00000510 00000+0 28715-3 0 9992
2 37214 98.9821 82.9728 0021838 194.4193 280.6049 14.14810700788968
SUOMI NPP
1 37849U 11061A 26037.58885771 .00000151 00000+0 92735-4 0 9993
2 37849 98.7835 339.4455 0001677 23.1332 336.9919 14.19534335739918
METEOSAT-10 (MSG-3)
1 38552U 12035B 26037.34062893 -.00000007 00000+0 00000+0 0 9993
2 38552 4.3618 61.5789 0002324 286.1065 271.3938 1.00272839 49549
METOP-B
1 38771U 12049A 26037.61376690 .00000161 00000+0 93652-4 0 9994
2 38771 98.6708 91.6029 0002456 28.4142 331.7169 14.21434029694718
INSAT-3D
1 39216U 13038B 26037.58021591 -.00000338 00000+0 00000+0 0 9998
2 39216 1.5890 84.3012 0001719 220.0673 170.6954 1.00273812 45771
FENGYUN 3C
1 39260U 13052A 26037.57879946 .00000181 00000+0 11337-3 0 9991
2 39260 98.4839 17.5531 0015475 42.6626 317.5748 14.15718213640089
METEOR-M 2
1 40069U 14037A 26037.57010537 .00000364 00000+0 18579-3 0 9995
2 40069 98.4979 18.0359 0006835 60.5067 299.6792 14.21415164600761
HIMAWARI-8
1 40267U 14060A 26037.58238259 -.00000273 00000+0 00000+0 0 9991
2 40267 0.0457 252.0286 0000958 31.3580 203.5957 1.00278490 41450
FENGYUN 2G
1 40367U 14090A 26037.64556289 -.00000299 00000+0 00000+0 0 9996
2 40367 5.3089 74.4184 0001565 198.1345 195.9683 1.00263067 40698
METEOSAT-11 (MSG-4)
1 40732U 15034A 26037.62779616 .00000065 00000+0 00000+0 0 9990
2 40732 2.8728 71.8294 0001180 241.7344 58.8290 1.00268087 5909
ELEKTRO-L 2
1 41105U 15074A 26037.40900929 -.00000118 00000+0 00000+0 0 9998
2 41105 6.3653 72.1489 0003612 229.0998 328.0297 1.00272232 37198
INSAT-3DR
1 41752U 16054A 26037.65505200 -.00000075 00000+0 00000+0 0 9997
2 41752 0.0554 93.8053 0013744 184.8269 167.9427 1.00271627 34504
HIMAWARI-9
1 41836U 16064A 26037.58238259 -.00000273 00000+0 00000+0 0 9990
2 41836 0.0124 137.0088 0001068 210.1850 139.9064 1.00271322 33905
GOES 16
1 41866U 16071A 26037.60517604 -.00000089 00000+0 00000+0 0 9993
2 41866 0.1490 94.1417 0002832 199.6896 316.0413 1.00271854 33798
FENGYUN 4A
1 41882U 16077A 26037.65041625 -.00000356 00000+0 00000+0 0 9994
2 41882 1.9907 81.7886 0006284 132.9819 279.8453 1.00276098 33627
CYGFM05
1 41884U 16078A 26037.42561482 .00027408 00000+0 46309-3 0 9992
2 41884 34.9596 42.6579 0007295 332.2973 27.7361 15.50585086508404
CYGFM04
1 41885U 16078B 26037.34428483 .00032519 00000+0 49575-3 0 9994
2 41885 34.9348 16.2836 0005718 359.2189 0.8525 15.53424088508589
CYGFM02
1 41886U 16078C 26037.35007768 .00035591 00000+0 50564-3 0 9998
2 41886 34.9436 13.7490 0006836 2.8379 357.2383 15.55324468508720
CYGFM01
1 41887U 16078D 26037.39685921 .00028560 00000+0 47572-3 0 9999
2 41887 34.9425 44.8029 0007415 323.1915 36.8298 15.50976884508344
CYGFM08
1 41888U 16078E 26037.34185185 .00031327 00000+0 49606-3 0 9997
2 41888 34.9457 27.4597 0008083 350.5361 9.5208 15.52364941508578
CYGFM07
1 41890U 16078G 26037.32199955 .00032204 00000+0 49829-3 0 9990
2 41890 34.9475 16.2411 0005914 7.0804 353.0002 15.53017084508593
CYGFM03
1 41891U 16078H 26037.35550653 .00031487 00000+0 48940-3 0 9995
2 41891 34.9430 17.9804 0005939 349.1458 10.9136 15.52895386508574
FENGYUN 3D
1 43010U 17072A 26037.62659924 .00000092 00000+0 65298-4 0 9990
2 43010 98.9980 9.7978 0002479 69.6779 290.4663 14.19704535426460
NOAA 20 (JPSS-1)
1 43013U 17073A 26037.60336371 .00000124 00000+0 79520-4 0 9999
2 43013 98.7658 338.3064 0000377 14.6433 345.4754 14.19527655425942
GOES 17
1 43226U 18022A 26037.60794939 -.00000180 00000+0 00000+0 0 9993
2 43226 0.6016 88.1527 0002754 213.0089 324.8756 1.00269924 29115
FENGYUN 2H
1 43491U 18050A 26037.66161282 -.00000125 00000+0 00000+0 0 9992
2 43491 2.6948 80.6967 0002145 171.8276 201.3055 1.00274855 28134
METOP-C
1 43689U 18087A 26037.63948662 .00000167 00000+0 96262-4 0 9998
2 43689 98.6834 99.5280 0001629 143.8933 216.2355 14.21510040376280
GEO-KOMPSAT-2A
1 43823U 18100A 26037.57995591 .00000000 00000+0 00000+0 0 9996
2 43823 0.0152 95.1913 0001141 313.4173 65.1318 1.00271011 26327
METEOR-M2 2
1 44387U 19038A 26037.58492015 .00000244 00000+0 12531-3 0 9993
2 44387 98.9044 23.0180 0002141 55.2566 304.8814 14.24320728342700
ARKTIKA-M 1
1 47719U 21016A 26035.90384421 -.00000136 00000+0 00000+0 0 9994
2 47719 63.1930 76.4940 7230705 269.3476 15.2984 2.00623094 36131
FENGYUN 3E
1 49008U 21062A 26037.62586080 .00000245 00000+0 13631-3 0 9992
2 49008 98.7499 42.4910 0002627 96.2819 263.8657 14.19890127238058
GOES 18
1 51850U 22021A 26037.59876267 .00000098 00000+0 00000+0 0 9999
2 51850 0.0198 91.3546 0000843 290.2366 193.6737 1.00273310 5288
NOAA 21 (JPSS-2)
1 54234U 22150A 26037.56792604 .00000152 00000+0 92800-4 0 9995
2 54234 98.7521 338.1972 0001388 169.8161 190.3044 14.19543641168012
METEOSAT-12 (MTG-I1)
1 54743U 22170C 26037.62580281 -.00000006 00000+0 00000+0 0 9990
2 54743 0.7119 25.1556 0002027 273.4388 63.0828 1.00270670 11667
TIANMU-1 03
1 55973U 23039A 26037.63298084 .00025307 00000+0 57478-3 0 9994
2 55973 97.5143 206.9374 0002852 198.5193 161.5950 15.43014921160671
TIANMU-1 04
1 55974U 23039B 26037.59957323 .00027172 00000+0 60888-3 0 9999
2 55974 97.5075 206.0729 0003605 196.0743 164.0390 15.43399931160675
TIANMU-1 05
1 55975U 23039C 26037.60840428 .00024975 00000+0 56836-3 0 9995
2 55975 97.5122 206.5750 0002421 224.3240 135.7814 15.42959696160653
TIANMU-1 06
1 55976U 23039D 26037.60004198 .00024821 00000+0 55598-3 0 9996
2 55976 97.5133 207.0788 0002810 218.0193 142.0857 15.43432906160673
FENGYUN 3G
1 56232U 23055A 26037.30935013 .00046475 00000+0 74423-3 0 9993
2 56232 49.9940 300.8928 0009962 237.3703 122.6303 15.52544991159665
METEOR-M2 3
1 57166U 23091A 26037.62090481 .00000022 00000+0 28455-4 0 9999
2 57166 98.6282 95.1607 0004003 174.5474 185.5750 14.24034408135931
TIANMU-1 07
1 57399U 23101A 26037.63242936 .00011510 00000+0 41012-3 0 9991
2 57399 97.2786 91.2606 0002747 218.4597 141.6448 15.29074661141694
TIANMU-1 08
1 57400U 23101B 26037.66743594 .00011474 00000+0 41016-3 0 9996
2 57400 97.2774 91.0783 0004440 227.8102 132.2762 15.28966110141699
TIANMU-1 09
1 57401U 23101C 26037.65072558 .00011360 00000+0 40433-3 0 9997
2 57401 97.2732 90.5514 0003773 229.5297 130.5615 15.29113177141698
TIANMU-1 10
1 57402U 23101D 26037.61974057 .00011836 00000+0 42113-3 0 9994
2 57402 97.2810 91.4302 0005461 233.7620 126.3116 15.29106286141685
FENGYUN 3F
1 57490U 23111A 26037.61228373 .00000135 00000+0 84019-4 0 9997
2 57490 98.6988 109.9815 0001494 99.6638 260.4707 14.19912110130332
ARKTIKA-M 2
1 58584U 23198A 26037.15964049 .00000160 00000+0 00000+0 0 9994
2 58584 63.2225 168.8508 6872222 267.8808 18.8364 2.00612776 15698
TIANMU-1 11
1 58645U 23205A 26037.58628093 .00009545 00000+0 37951-3 0 9999
2 58645 97.3574 61.2485 0010997 103.8713 256.3749 15.25445149117601
TIANMU-1 12
1 58646U 23205B 26037.61705312 .00010066 00000+0 40129-3 0 9995
2 58646 97.3561 61.0663 0009308 89.8253 270.4052 15.25355570117590
TIANMU-1 13
1 58647U 23205C 26037.64894829 .00010029 00000+0 39925-3 0 9992
2 58647 97.3589 61.3229 0009456 74.8265 285.4018 15.25403883117592
TIANMU-1 14
1 58648U 23205D 26037.63305929 .00009719 00000+0 38718-3 0 9993
2 58648 97.3523 60.6045 0010314 77.9995 282.2399 15.25381326117592
TIANMU-1 19
1 58660U 23208A 26037.58812600 .00016491 00000+0 58449-3 0 9991
2 58660 97.4377 153.5627 0006125 66.0574 294.1307 15.29155961117352
TIANMU-1 20
1 58661U 23208B 26037.59661536 .00016638 00000+0 56823-3 0 9990
2 58661 97.4315 154.0738 0008420 72.4906 287.7255 15.30347593117439
TIANMU-1 21
1 58662U 23208C 26037.56944589 .00017161 00000+0 55253-3 0 9998
2 58662 97.4367 156.2063 0008160 67.8039 292.4068 15.32247056117540
TIANMU-1 22
1 58663U 23208D 26037.59847459 .00015396 00000+0 55169-3 0 9994
2 58663 97.4371 153.6033 0005010 87.2275 272.9538 15.28818503117364
TIANMU-1 15
1 58700U 24004A 26037.63062994 .00009739 00000+0 38850-3 0 9991
2 58700 97.4651 223.9243 0008449 88.7599 271.4607 15.25356935115862
TIANMU-1 16
1 58701U 24004B 26037.61474986 .00010691 00000+0 42590-3 0 9993
2 58701 97.4590 223.2544 0006831 91.0928 269.1093 15.25387104115863
TIANMU-1 17
1 58702U 24004C 26037.59783649 .00011079 00000+0 44078-3 0 9994
2 58702 97.4624 223.6760 0006020 92.0871 268.1056 15.25425175115852
TIANMU-1 18
1 58703U 24004D 26037.64767373 .00010786 00000+0 42976-3 0 9996
2 58703 97.4642 223.9320 0005432 91.0134 269.1726 15.25387870115860
INSAT-3DS
1 58990U 24033A 26037.64159978 -.00000153 00000+0 00000+0 0 9998
2 58990 0.0277 242.2492 0001855 99.2205 108.3003 1.00271452 45758
METEOR-M2 4
1 59051U 24039A 26037.62796654 .00000070 00000+0 51194-4 0 9991
2 59051 98.6849 358.6843 0006923 178.9165 181.2029 14.22412185100701
GOES 19
1 60133U 24119A 26037.61098274 -.00000246 00000+0 00000+0 0 9996
2 60133 0.0027 288.6290 0001204 74.2636 278.5881 1.00270967 5651
FENGYUN 3H
1 65815U 25219A 26037.60879211 .00000151 00000+0 91464-4 0 9990
2 65815 98.6649 341.0050 0001596 86.5100 273.6260 14.19924132 18857
+132 -50
View File
@@ -838,14 +838,15 @@ class ModeManager:
data['data'] = list(getattr(self, 'ais_vessels', {}).values())
elif mode == 'aprs':
data['data'] = list(getattr(self, 'aprs_stations', {}).values())
elif mode == 'tscm':
data['data'] = {
'anomalies': getattr(self, 'tscm_anomalies', []),
'baseline': getattr(self, 'tscm_baseline', {}),
'wifi_devices': list(self.wifi_networks.values()),
'bt_devices': list(self.bluetooth_devices.values()),
'rf_signals': getattr(self, 'tscm_rf_signals', []),
}
elif mode == 'tscm':
data['data'] = {
'anomalies': getattr(self, 'tscm_anomalies', []),
'baseline': getattr(self, 'tscm_baseline', {}),
'wifi_devices': list(self.wifi_networks.values()),
'wifi_clients': list(getattr(self, 'tscm_wifi_clients', {}).values()),
'bt_devices': list(self.bluetooth_devices.values()),
'rf_signals': getattr(self, 'tscm_rf_signals', []),
}
elif mode == 'listening_post':
data['data'] = {
'activity': getattr(self, 'listening_post_activity', []),
@@ -1104,23 +1105,24 @@ class ModeManager:
self.wifi_clients.clear()
elif mode == 'bluetooth':
self.bluetooth_devices.clear()
elif mode == 'tscm':
# Clean up TSCM sub-threads
for sub_thread_name in ['tscm_wifi', 'tscm_bt', 'tscm_rf']:
if sub_thread_name in self.output_threads:
thread = self.output_threads[sub_thread_name]
if thread and thread.is_alive():
thread.join(timeout=2)
del self.output_threads[sub_thread_name]
# Clear TSCM data
self.tscm_anomalies = []
self.tscm_baseline = {}
self.tscm_rf_signals = []
# Clear reported threat tracking sets
if hasattr(self, '_tscm_reported_wifi'):
self._tscm_reported_wifi.clear()
if hasattr(self, '_tscm_reported_bt'):
self._tscm_reported_bt.clear()
elif mode == 'tscm':
# Clean up TSCM sub-threads
for sub_thread_name in ['tscm_wifi', 'tscm_bt', 'tscm_rf']:
if sub_thread_name in self.output_threads:
thread = self.output_threads[sub_thread_name]
if thread and thread.is_alive():
thread.join(timeout=2)
del self.output_threads[sub_thread_name]
# Clear TSCM data
self.tscm_anomalies = []
self.tscm_baseline = {}
self.tscm_rf_signals = []
self.tscm_wifi_clients = {}
# Clear reported threat tracking sets
if hasattr(self, '_tscm_reported_wifi'):
self._tscm_reported_wifi.clear()
if hasattr(self, '_tscm_reported_bt'):
self._tscm_reported_bt.clear()
elif mode == 'dsc':
# Clear DSC data
if hasattr(self, 'dsc_messages'):
@@ -1540,9 +1542,10 @@ class ModeManager:
def _start_wifi(self, params: dict) -> dict:
"""Start WiFi scanning using Intercept's UnifiedWiFiScanner."""
interface = params.get('interface')
channel = params.get('channel')
band = params.get('band', 'abg')
scan_type = params.get('scan_type', 'deep')
channel = params.get('channel')
channels = params.get('channels')
band = params.get('band', 'abg')
scan_type = params.get('scan_type', 'deep')
# Handle quick scan - returns results synchronously
if scan_type == 'quick':
@@ -1571,8 +1574,21 @@ class ModeManager:
else:
scan_band = 'all'
# Start deep scan
if scanner.start_deep_scan(interface=interface, band=scan_band, channel=channel):
channel_list = None
if channels:
if isinstance(channels, str):
channel_list = [c.strip() for c in channels.split(',') if c.strip()]
elif isinstance(channels, (list, tuple, set)):
channel_list = list(channels)
else:
channel_list = [channels]
try:
channel_list = [int(c) for c in channel_list]
except (TypeError, ValueError):
return {'status': 'error', 'message': 'Invalid channels'}
# Start deep scan
if scanner.start_deep_scan(interface=interface, band=scan_band, channel=channel, channels=channel_list):
# Start thread to sync data to agent's dictionaries
thread = threading.Thread(
target=self._wifi_data_sync,
@@ -1591,12 +1607,12 @@ class ModeManager:
else:
return {'status': 'error', 'message': scanner.get_status().error or 'Failed to start deep scan'}
except ImportError:
# Fallback to direct airodump-ng
return self._start_wifi_fallback(interface, channel, band)
except Exception as e:
logger.error(f"WiFi scanner error: {e}")
return {'status': 'error', 'message': str(e)}
except ImportError:
# Fallback to direct airodump-ng
return self._start_wifi_fallback(interface, channel, band, channels)
except Exception as e:
logger.error(f"WiFi scanner error: {e}")
return {'status': 'error', 'message': str(e)}
def _wifi_data_sync(self, scanner):
"""Sync WiFi scanner data to agent's data structures."""
@@ -1630,8 +1646,14 @@ class ModeManager:
if hasattr(self, '_wifi_scanner_instance') and self._wifi_scanner_instance:
self._wifi_scanner_instance.stop_deep_scan()
def _start_wifi_fallback(self, interface: str | None, channel: int | None, band: str) -> dict:
"""Fallback WiFi deep scan using airodump-ng directly."""
def _start_wifi_fallback(
self,
interface: str | None,
channel: int | None,
band: str,
channels: list[int] | str | None = None,
) -> dict:
"""Fallback WiFi deep scan using airodump-ng directly."""
if not interface:
return {'status': 'error', 'message': 'WiFi interface required'}
@@ -1658,8 +1680,23 @@ class ModeManager:
cmd = [airodump_path, '-w', csv_path, '--output-format', output_formats, '--band', band]
if gps_manager.is_running:
cmd.append('--gpsd')
if channel:
cmd.extend(['-c', str(channel)])
channel_list = None
if channels:
if isinstance(channels, str):
channel_list = [c.strip() for c in channels.split(',') if c.strip()]
elif isinstance(channels, (list, tuple, set)):
channel_list = list(channels)
else:
channel_list = [channels]
try:
channel_list = [int(c) for c in channel_list]
except (TypeError, ValueError):
return {'status': 'error', 'message': 'Invalid channels'}
if channel_list:
cmd.extend(['-c', ','.join(str(c) for c in channel_list)])
elif channel:
cmd.extend(['-c', str(channel)])
cmd.append(interface)
try:
@@ -3111,9 +3148,12 @@ class ModeManager:
self.tscm_baseline = {}
if not hasattr(self, 'tscm_anomalies'):
self.tscm_anomalies = []
if not hasattr(self, 'tscm_rf_signals'):
self.tscm_rf_signals = []
self.tscm_anomalies.clear()
if not hasattr(self, 'tscm_rf_signals'):
self.tscm_rf_signals = []
if not hasattr(self, 'tscm_wifi_clients'):
self.tscm_wifi_clients = {}
self.tscm_anomalies.clear()
self.tscm_wifi_clients.clear()
# Get params for what to scan
scan_wifi = params.get('wifi', True)
@@ -3168,7 +3208,7 @@ class ModeManager:
stop_event = self.stop_events.get(mode)
# Import existing Intercept TSCM functions
from routes.tscm import _scan_wifi_networks, _scan_bluetooth_devices, _scan_rf_signals
from routes.tscm import _scan_wifi_networks, _scan_wifi_clients, _scan_bluetooth_devices, _scan_rf_signals
logger.info("TSCM imports successful")
sweep_ranges = None
@@ -3202,8 +3242,9 @@ class ModeManager:
self._tscm_correlation = None
# Track devices seen during this sweep (like local mode's all_wifi/all_bt dicts)
seen_wifi = {}
seen_bt = {}
seen_wifi = {}
seen_wifi_clients = {}
seen_bt = {}
last_rf_scan = 0
rf_scan_interval = 30
@@ -3261,10 +3302,51 @@ class ModeManager:
for i in profile.indicators
]
enriched['recommended_action'] = profile.recommended_action
self.wifi_networks[bssid] = enriched
except Exception as e:
logger.debug(f"WiFi scan error: {e}")
self.wifi_networks[bssid] = enriched
# WiFi clients (monitor mode only)
try:
wifi_clients = _scan_wifi_clients(wifi_interface or '')
for client in wifi_clients:
mac = (client.get('mac') or '').upper()
if not mac or mac in seen_wifi_clients:
continue
seen_wifi_clients[mac] = client
rssi_val = client.get('rssi_current')
if rssi_val is None:
rssi_val = client.get('rssi_median') or client.get('rssi_ema')
client_device = {
'mac': mac,
'vendor': client.get('vendor'),
'name': client.get('vendor') or 'WiFi Client',
'rssi': rssi_val,
'associated_bssid': client.get('associated_bssid'),
'probed_ssids': client.get('probed_ssids', []),
'probe_count': client.get('probe_count', len(client.get('probed_ssids', []))),
'is_client': True,
}
if self._tscm_correlation:
profile = self._tscm_correlation.analyze_wifi_device(client_device)
client_device['classification'] = profile.risk_level.value
client_device['score'] = profile.total_score
client_device['score_modifier'] = profile.score_modifier
client_device['known_device'] = profile.known_device
client_device['known_device_name'] = profile.known_device_name
client_device['indicators'] = [
{'type': i.type.value, 'desc': i.description}
for i in profile.indicators
]
client_device['recommended_action'] = profile.recommended_action
self.tscm_wifi_clients[mac] = client_device
except Exception as e:
logger.debug(f"WiFi client scan error: {e}")
except Exception as e:
logger.debug(f"WiFi scan error: {e}")
# Bluetooth scan using Intercept's function (same as local mode)
if scan_bt:
+2 -1
View File
@@ -1,6 +1,6 @@
[project]
name = "intercept"
version = "2.14.0"
version = "2.16.0"
description = "Signal Intelligence Platform - Pager/433MHz/ADS-B/Satellite/WiFi/Bluetooth"
readme = "README.md"
requires-python = ">=3.9"
@@ -57,6 +57,7 @@ optionals = [
"scipy>=1.10.0",
"qrcode[pil]>=7.4",
"numpy>=1.24.0",
"Pillow>=9.0.0",
"meshtastic>=2.0.0",
"psycopg2-binary>=2.9.9",
"scapy>=2.4.5",
+4 -1
View File
@@ -13,10 +13,13 @@ bleak>=0.21.0
# Satellite tracking (optional - only needed for satellite features)
skyfield>=1.45
# DSC decoding (optional - only needed for VHF DSC maritime distress)
# DSC decoding and SSTV decoding (DSP pipeline)
scipy>=1.10.0
numpy>=1.24.0
# SSTV image output (optional - needed for SSTV image decoding)
Pillow>=9.0.0
# GPS dongle support (optional - only needed for USB GPS receivers)
pyserial>=3.5
+8
View File
@@ -26,9 +26,13 @@ def register_blueprints(app):
from .offline import offline_bp
from .updater import updater_bp
from .sstv import sstv_bp
from .weather_sat import weather_sat_bp
from .sstv_general import sstv_general_bp
from .dmr import dmr_bp
from .websdr import websdr_bp
from .alerts import alerts_bp
from .recordings import recordings_bp
from .subghz import subghz_bp
app.register_blueprint(pager_bp)
app.register_blueprint(sensor_bp)
@@ -54,9 +58,13 @@ def register_blueprints(app):
app.register_blueprint(offline_bp) # Offline mode settings
app.register_blueprint(updater_bp) # GitHub update checking
app.register_blueprint(sstv_bp) # ISS SSTV decoder
app.register_blueprint(weather_sat_bp) # NOAA/Meteor weather satellite decoder
app.register_blueprint(sstv_general_bp) # General terrestrial SSTV
app.register_blueprint(dmr_bp) # DMR / P25 / Digital Voice
app.register_blueprint(websdr_bp) # HF/Shortwave WebSDR
app.register_blueprint(alerts_bp) # Cross-mode alerts
app.register_blueprint(recordings_bp) # Session recordings
app.register_blueprint(subghz_bp) # SubGHz transceiver (HackRF)
# Initialize TSCM state with queue and lock from app
import app as app_module
+26 -4
View File
@@ -20,13 +20,15 @@ from flask import Blueprint, jsonify, request, Response
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 format_sse
from utils.sse import format_sse
from utils.event_pipeline import process_event
from utils.constants import (
PROCESS_TERMINATE_TIMEOUT,
SSE_KEEPALIVE_INTERVAL,
SSE_QUEUE_TIMEOUT,
PROCESS_START_WAIT,
)
from utils.process import register_process, unregister_process
acars_bp = Blueprint('acars', __name__, url_prefix='/acars')
@@ -144,9 +146,24 @@ def stream_acars_output(process: subprocess.Popen, is_text_mode: bool = False) -
logger.error(f"ACARS stream error: {e}")
app_module.acars_queue.put({'type': 'error', 'message': str(e)})
finally:
global acars_active_device
# Ensure process is terminated
try:
process.terminate()
process.wait(timeout=2)
except Exception:
try:
process.kill()
except Exception:
pass
unregister_process(process)
app_module.acars_queue.put({'type': 'status', 'status': 'stopped'})
with app_module.acars_lock:
app_module.acars_process = None
# Release SDR device
if acars_active_device is not None:
app_module.release_sdr_device(acars_active_device)
acars_active_device = None
@acars_bp.route('/tools')
@@ -311,6 +328,7 @@ def start_acars() -> Response:
return jsonify({'status': 'error', 'message': error_msg}), 500
app_module.acars_process = process
register_process(process)
# Start output streaming thread
thread = threading.Thread(
@@ -374,9 +392,13 @@ def stream_acars() -> Response:
while True:
try:
msg = app_module.acars_queue.get(timeout=SSE_QUEUE_TIMEOUT)
last_keepalive = time.time()
yield format_sse(msg)
msg = app_module.acars_queue.get(timeout=SSE_QUEUE_TIMEOUT)
last_keepalive = time.time()
try:
process_event('acars', msg, msg.get('type'))
except Exception:
pass
yield format_sse(msg)
except queue.Empty:
now = time.time()
if now - last_keepalive >= SSE_KEEPALIVE_INTERVAL:
+5
View File
@@ -43,6 +43,7 @@ from utils.validation import (
validate_rtl_tcp_host, validate_rtl_tcp_port
)
from utils.sse import format_sse
from utils.event_pipeline import process_event
from utils.sdr import SDRFactory, SDRType
from utils.constants import (
ADSB_SBS_PORT,
@@ -843,6 +844,10 @@ def stream_adsb():
try:
msg = app_module.adsb_queue.get(timeout=SSE_QUEUE_TIMEOUT)
last_keepalive = time.time()
try:
process_event('adsb', msg, msg.get('type'))
except Exception:
pass
yield format_sse(msg)
except queue.Empty:
now = time.time()
+5
View File
@@ -19,6 +19,7 @@ from config import SHARED_OBSERVER_LOCATION_ENABLED
from utils.logging import get_logger
from utils.validation import validate_device_index, validate_gain
from utils.sse import format_sse
from utils.event_pipeline import process_event
from utils.sdr import SDRFactory, SDRType
from utils.constants import (
AIS_TCP_PORT,
@@ -484,6 +485,10 @@ def stream_ais():
try:
msg = app_module.ais_queue.get(timeout=SSE_QUEUE_TIMEOUT)
last_keepalive = time.time()
try:
process_event('ais', msg, msg.get('type'))
except Exception:
pass
yield format_sse(msg)
except queue.Empty:
now = time.time()
+76
View File
@@ -0,0 +1,76 @@
"""Alerting API endpoints."""
from __future__ import annotations
import queue
import time
from typing import Generator
from flask import Blueprint, Response, jsonify, request
from utils.alerts import get_alert_manager
from utils.sse import format_sse
alerts_bp = Blueprint('alerts', __name__, url_prefix='/alerts')
@alerts_bp.route('/rules', methods=['GET'])
def list_rules():
manager = get_alert_manager()
include_disabled = request.args.get('all') in ('1', 'true', 'yes')
return jsonify({'status': 'success', 'rules': manager.list_rules(include_disabled=include_disabled)})
@alerts_bp.route('/rules', methods=['POST'])
def create_rule():
data = request.get_json() or {}
if not isinstance(data.get('match', {}), dict):
return jsonify({'status': 'error', 'message': 'match must be a JSON object'}), 400
manager = get_alert_manager()
rule_id = manager.add_rule(data)
return jsonify({'status': 'success', 'rule_id': rule_id})
@alerts_bp.route('/rules/<int:rule_id>', methods=['PUT', 'PATCH'])
def update_rule(rule_id: int):
data = request.get_json() or {}
manager = get_alert_manager()
ok = manager.update_rule(rule_id, data)
if not ok:
return jsonify({'status': 'error', 'message': 'Rule not found or no changes'}), 404
return jsonify({'status': 'success'})
@alerts_bp.route('/rules/<int:rule_id>', methods=['DELETE'])
def delete_rule(rule_id: int):
manager = get_alert_manager()
ok = manager.delete_rule(rule_id)
if not ok:
return jsonify({'status': 'error', 'message': 'Rule not found'}), 404
return jsonify({'status': 'success'})
@alerts_bp.route('/events', methods=['GET'])
def list_events():
manager = get_alert_manager()
limit = request.args.get('limit', default=100, type=int)
mode = request.args.get('mode')
severity = request.args.get('severity')
events = manager.list_events(limit=limit, mode=mode, severity=severity)
return jsonify({'status': 'success', 'events': events})
@alerts_bp.route('/stream', methods=['GET'])
def stream_alerts() -> Response:
manager = get_alert_manager()
def generate() -> Generator[str, None, None]:
for event in manager.stream_events(timeout=1.0):
yield format_sse(event)
response = Response(generate(), mimetype='text/event-stream')
response.headers['Cache-Control'] = 'no-cache'
response.headers['X-Accel-Buffering'] = 'no'
response.headers['Connection'] = 'keep-alive'
return response
+114 -79
View File
@@ -19,14 +19,16 @@ from typing import Generator, Optional
from flask import Blueprint, jsonify, request, Response
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 format_sse
from utils.constants import (
PROCESS_TERMINATE_TIMEOUT,
SSE_KEEPALIVE_INTERVAL,
SSE_QUEUE_TIMEOUT,
PROCESS_START_WAIT,
from utils.logging import sensor_logger as logger
from utils.validation import validate_device_index, validate_gain, validate_ppm
from utils.sse import format_sse
from utils.event_pipeline import process_event
from utils.sdr import SDRFactory, SDRType
from utils.constants import (
PROCESS_TERMINATE_TIMEOUT,
SSE_KEEPALIVE_INTERVAL,
SSE_QUEUE_TIMEOUT,
PROCESS_START_WAIT,
)
aprs_bp = Blueprint('aprs', __name__, url_prefix='/aprs')
@@ -52,6 +54,7 @@ aprs_packet_count = 0
aprs_station_count = 0
aprs_last_packet_time = None
aprs_stations = {} # callsign -> station data
APRS_MAX_STATIONS = 500 # Limit tracked stations to prevent memory growth
# Meter rate limiting
_last_meter_time = 0.0
@@ -70,14 +73,19 @@ def find_multimon_ng() -> Optional[str]:
return shutil.which('multimon-ng')
def find_rtl_fm() -> Optional[str]:
"""Find rtl_fm binary."""
return shutil.which('rtl_fm')
def find_rtl_power() -> Optional[str]:
"""Find rtl_power binary for spectrum scanning."""
return shutil.which('rtl_power')
def find_rtl_fm() -> Optional[str]:
"""Find rtl_fm binary."""
return shutil.which('rtl_fm')
def find_rx_fm() -> Optional[str]:
"""Find SoapySDR rx_fm binary."""
return shutil.which('rx_fm')
def find_rtl_power() -> Optional[str]:
"""Find rtl_power binary for spectrum scanning."""
return shutil.which('rtl_power')
# Path to direwolf config file
@@ -1370,6 +1378,13 @@ def stream_aprs_output(rtl_process: subprocess.Popen, decoder_process: subproces
'last_seen': packet.get('timestamp'),
'packet_type': packet.get('packet_type'),
}
# Evict oldest stations when limit is exceeded
if len(aprs_stations) > APRS_MAX_STATIONS:
oldest = min(
aprs_stations,
key=lambda k: aprs_stations[k].get('last_seen', ''),
)
del aprs_stations[oldest]
app_module.aprs_queue.put(packet)
@@ -1405,19 +1420,22 @@ def stream_aprs_output(rtl_process: subprocess.Popen, decoder_process: subproces
@aprs_bp.route('/tools')
def check_aprs_tools() -> Response:
"""Check for APRS decoding tools."""
has_rtl_fm = find_rtl_fm() is not None
has_direwolf = find_direwolf() is not None
has_multimon = find_multimon_ng() is not None
return jsonify({
'rtl_fm': has_rtl_fm,
'direwolf': has_direwolf,
'multimon_ng': has_multimon,
'ready': has_rtl_fm and (has_direwolf or has_multimon),
'decoder': 'direwolf' if has_direwolf else ('multimon-ng' if has_multimon else None)
})
def check_aprs_tools() -> Response:
"""Check for APRS decoding tools."""
has_rtl_fm = find_rtl_fm() is not None
has_rx_fm = find_rx_fm() is not None
has_direwolf = find_direwolf() is not None
has_multimon = find_multimon_ng() is not None
has_fm_demod = has_rtl_fm or has_rx_fm
return jsonify({
'rtl_fm': has_rtl_fm,
'rx_fm': has_rx_fm,
'direwolf': has_direwolf,
'multimon_ng': has_multimon,
'ready': has_fm_demod and (has_direwolf or has_multimon),
'decoder': 'direwolf' if has_direwolf else ('multimon-ng' if has_multimon else None)
})
@aprs_bp.route('/status')
@@ -1458,20 +1476,12 @@ def start_aprs() -> Response:
'message': 'APRS decoder already running'
}), 409
# Check for required tools
rtl_fm_path = find_rtl_fm()
if not rtl_fm_path:
return jsonify({
'status': 'error',
'message': 'rtl_fm not found. Install with: sudo apt install rtl-sdr'
}), 400
# Check for decoder (prefer direwolf, fallback to multimon-ng)
direwolf_path = find_direwolf()
multimon_path = find_multimon_ng()
if not direwolf_path and not multimon_path:
return jsonify({
# Check for decoder (prefer direwolf, fallback to multimon-ng)
direwolf_path = find_direwolf()
multimon_path = find_multimon_ng()
if not direwolf_path and not multimon_path:
return jsonify({
'status': 'error',
'message': 'No APRS decoder found. Install direwolf or multimon-ng'
}), 400
@@ -1479,15 +1489,34 @@ def start_aprs() -> Response:
data = request.json or {}
# Validate inputs
try:
device = validate_device_index(data.get('device', '0'))
gain = validate_gain(data.get('gain', '40'))
ppm = validate_ppm(data.get('ppm', '0'))
except ValueError as e:
return jsonify({'status': 'error', 'message': str(e)}), 400
try:
device = validate_device_index(data.get('device', '0'))
gain = validate_gain(data.get('gain', '40'))
ppm = validate_ppm(data.get('ppm', '0'))
except ValueError as e:
return jsonify({'status': 'error', 'message': str(e)}), 400
sdr_type_str = str(data.get('sdr_type', 'rtlsdr')).lower()
try:
sdr_type = SDRType(sdr_type_str)
except ValueError:
sdr_type = SDRType.RTL_SDR
if sdr_type == SDRType.RTL_SDR:
if find_rtl_fm() is None:
return jsonify({
'status': 'error',
'message': 'rtl_fm not found. Install with: sudo apt install rtl-sdr'
}), 400
else:
if find_rx_fm() is None:
return jsonify({
'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
error = app_module.reserve_sdr_device(device, 'APRS')
error = app_module.claim_sdr_device(device, 'aprs')
if error:
return jsonify({
'status': 'error',
@@ -1516,28 +1545,29 @@ def start_aprs() -> Response:
aprs_last_packet_time = None
aprs_stations = {}
# Build rtl_fm command for APRS (narrowband FM at 22050 Hz for AFSK1200)
freq_hz = f"{float(frequency)}M"
rtl_cmd = [
rtl_fm_path,
'-f', freq_hz,
'-M', 'nfm', # Narrowband FM for APRS
'-s', '22050', # Sample rate matching direwolf -r 22050
'-E', 'dc', # Enable DC blocking filter for cleaner audio
'-A', 'fast', # Fast AGC for packet bursts
'-d', str(device),
]
# Gain: 0 means auto, otherwise set specific gain
if gain and str(gain) != '0':
rtl_cmd.extend(['-g', str(gain)])
# PPM frequency correction
if ppm and str(ppm) != '0':
rtl_cmd.extend(['-p', str(ppm)])
# Output raw audio to stdout
rtl_cmd.append('-')
# Build FM demod command for APRS (AFSK1200 @ 22050 Hz) via SDR abstraction.
try:
sdr_device = SDRFactory.create_default_device(sdr_type, index=device)
builder = SDRFactory.get_builder(sdr_type)
rtl_cmd = builder.build_fm_demod_command(
device=sdr_device,
frequency_mhz=float(frequency),
sample_rate=22050,
gain=float(gain) if gain and str(gain) != '0' else None,
ppm=int(ppm) if ppm and str(ppm) != '0' else None,
modulation='nfm' if sdr_type == SDRType.RTL_SDR else 'fm',
squelch=None,
bias_t=bool(data.get('bias_t', False)),
)
if sdr_type == SDRType.RTL_SDR and rtl_cmd and rtl_cmd[-1] == '-':
# APRS benefits from DC blocking + fast AGC on rtl_fm.
rtl_cmd = rtl_cmd[:-1] + ['-E', 'dc', '-A', 'fast', '-']
except Exception as e:
if aprs_active_device is not None:
app_module.release_sdr_device(aprs_active_device)
aprs_active_device = None
return jsonify({'status': 'error', 'message': f'Failed to build SDR command: {e}'}), 500
# Build decoder command
if direwolf_path:
@@ -1660,13 +1690,14 @@ def start_aprs() -> Response:
)
thread.start()
return jsonify({
'status': 'started',
'frequency': frequency,
'region': region,
'device': device,
'decoder': decoder_name
})
return jsonify({
'status': 'started',
'frequency': frequency,
'region': region,
'device': device,
'sdr_type': sdr_type.value,
'decoder': decoder_name
})
except Exception as e:
logger.error(f"Failed to start APRS decoder: {e}")
@@ -1727,6 +1758,10 @@ def stream_aprs() -> Response:
try:
msg = app_module.aprs_queue.get(timeout=SSE_QUEUE_TIMEOUT)
last_keepalive = time.time()
try:
process_event('aprs', msg, msg.get('type'))
except Exception:
pass
yield format_sse(msg)
except queue.Empty:
now = time.time()
+44 -28
View File
@@ -1,10 +1,11 @@
"""WebSocket-based audio streaming for SDR."""
import json
import shutil
import socket
import subprocess
import threading
import time
import shutil
import json
from flask import Flask
# Try to import flask-sock
@@ -36,11 +37,17 @@ def find_rtl_fm():
return shutil.which('rtl_fm')
def find_ffmpeg():
return shutil.which('ffmpeg')
def kill_audio_processes():
def find_ffmpeg():
return shutil.which('ffmpeg')
def _rtl_fm_demod_mode(modulation):
"""Map UI modulation names to rtl_fm demod tokens."""
mod = str(modulation or '').lower().strip()
return 'wbfm' if mod == 'wfm' else mod
def kill_audio_processes():
"""Kill any running audio processes."""
global audio_process, rtl_process
@@ -66,12 +73,6 @@ def kill_audio_processes():
pass
rtl_process = None
# Kill any orphaned processes
try:
subprocess.run(['pkill', '-9', '-f', 'rtl_fm'], capture_output=True, timeout=1)
except:
pass
time.sleep(0.3)
@@ -109,14 +110,14 @@ def start_audio_stream(config):
freq_hz = int(freq * 1e6)
rtl_cmd = [
rtl_fm,
'-M', mod,
'-f', str(freq_hz),
'-s', str(sample_rate),
'-r', str(resample_rate),
'-g', str(gain),
'-d', str(device),
rtl_cmd = [
rtl_fm,
'-M', _rtl_fm_demod_mode(mod),
'-f', str(freq_hz),
'-s', str(sample_rate),
'-r', str(resample_rate),
'-g', str(gain),
'-d', str(device),
'-l', str(squelch),
]
@@ -228,13 +229,13 @@ def init_audio_websocket(app: Flask):
except TimeoutError:
pass
except Exception as e:
msg = str(e).lower()
if "connection closed" in msg:
logger.info("WebSocket closed by client")
break
if "timed out" not in msg:
logger.error(f"WebSocket receive error: {e}")
except Exception as e:
msg = str(e).lower()
if "connection closed" in msg:
logger.info("WebSocket closed by client")
break
if "timed out" not in msg:
logger.error(f"WebSocket receive error: {e}")
# Stream audio data if active
if streaming and proc and proc.poll() is None:
@@ -257,4 +258,19 @@ def init_audio_websocket(app: Flask):
finally:
with process_lock:
kill_audio_processes()
# Complete WebSocket close handshake, then shut down the
# raw socket so Werkzeug cannot write its HTTP 200 response
# on top of the WebSocket stream.
try:
ws.close()
except Exception:
pass
try:
ws.sock.shutdown(socket.SHUT_RDWR)
except Exception:
pass
try:
ws.sock.close()
except Exception:
pass
logger.info("WebSocket audio client disconnected")
+12 -7
View File
@@ -18,10 +18,11 @@ from typing import Any, Generator
from flask import Blueprint, jsonify, request, Response
import app as app_module
from utils.dependencies import check_tool
from utils.logging import bluetooth_logger as logger
from utils.sse import format_sse
from utils.validation import validate_bluetooth_interface
from utils.dependencies import check_tool
from utils.logging import bluetooth_logger as logger
from utils.sse import format_sse
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 (
@@ -561,9 +562,13 @@ def stream_bt():
while True:
try:
msg = app_module.bt_queue.get(timeout=1)
last_keepalive = time.time()
yield format_sse(msg)
msg = app_module.bt_queue.get(timeout=1)
last_keepalive = time.time()
try:
process_event('bluetooth', msg, msg.get('type'))
except Exception:
pass
yield format_sse(msg)
except queue.Empty:
now = time.time()
if now - last_keepalive >= keepalive_interval:
+177 -75
View File
@@ -7,32 +7,40 @@ aggregation, and heuristics.
from __future__ import annotations
import csv
import io
import json
import logging
import csv
import io
import json
import logging
import threading
import time
from datetime import datetime
from typing import Generator
from flask import Blueprint, Response, jsonify, request, session
from utils.bluetooth import (
BluetoothScanner,
BTDeviceAggregate,
get_bluetooth_scanner,
check_capabilities,
RANGE_UNKNOWN,
from utils.bluetooth import (
BluetoothScanner,
BTDeviceAggregate,
get_bluetooth_scanner,
check_capabilities,
RANGE_UNKNOWN,
TrackerType,
TrackerConfidence,
get_tracker_engine,
)
from utils.database import get_db
from utils.sse import format_sse
)
from utils.database import get_db
from utils.sse import format_sse
from utils.event_pipeline import process_event
logger = logging.getLogger('intercept.bluetooth_v2')
# Blueprint
bluetooth_v2_bp = Blueprint('bluetooth_v2', __name__, url_prefix='/api/bluetooth')
bluetooth_v2_bp = Blueprint('bluetooth_v2', __name__, url_prefix='/api/bluetooth')
# Seen-before tracking
_bt_seen_cache: set[str] = set()
_bt_session_seen: set[str] = set()
_bt_seen_lock = threading.Lock()
# =============================================================================
# DATABASE FUNCTIONS
@@ -164,13 +172,20 @@ def get_all_baselines() -> list[dict]:
return [dict(row) for row in cursor]
def save_observation_history(device: BTDeviceAggregate) -> None:
"""Save device observation to history."""
with get_db() as conn:
conn.execute('''
INSERT INTO bt_observation_history (device_id, rssi, seen_count)
VALUES (?, ?, ?)
''', (device.device_id, device.rssi_current, device.seen_count))
def save_observation_history(device: BTDeviceAggregate) -> None:
"""Save device observation to history."""
with get_db() as conn:
conn.execute('''
INSERT INTO bt_observation_history (device_id, rssi, seen_count)
VALUES (?, ?, ?)
''', (device.device_id, device.rssi_current, device.seen_count))
def load_seen_device_ids() -> set[str]:
"""Load distinct device IDs from history for seen-before tracking."""
with get_db() as conn:
cursor = conn.execute('SELECT DISTINCT device_id FROM bt_observation_history')
return {row['device_id'] for row in cursor}
# =============================================================================
@@ -191,7 +206,7 @@ def get_capabilities():
@bluetooth_v2_bp.route('/scan/start', methods=['POST'])
def start_scan():
def start_scan():
"""
Start Bluetooth scanning.
@@ -221,17 +236,42 @@ def start_scan():
# Get scanner instance
scanner = get_bluetooth_scanner(adapter_id)
# Check if already scanning
if scanner.is_scanning:
return jsonify({
'status': 'already_running',
'scan_status': scanner.get_status().to_dict()
})
# Initialize database tables if needed
init_bt_tables()
# Load active baseline if exists
# Initialize database tables if needed
init_bt_tables()
def _handle_seen_before(device: BTDeviceAggregate) -> None:
try:
with _bt_seen_lock:
device.seen_before = device.device_id in _bt_seen_cache
if device.device_id not in _bt_session_seen:
save_observation_history(device)
_bt_session_seen.add(device.device_id)
except Exception as e:
logger.debug(f"BT seen-before update failed: {e}")
# Setup seen-before callback
if scanner._on_device_updated is None:
scanner._on_device_updated = _handle_seen_before
# Ensure cache is initialized
with _bt_seen_lock:
if not _bt_seen_cache:
_bt_seen_cache.update(load_seen_device_ids())
# Check if already scanning
if scanner.is_scanning:
return jsonify({
'status': 'already_running',
'scan_status': scanner.get_status().to_dict()
})
# Refresh seen-before cache and reset session set for a new scan
with _bt_seen_lock:
_bt_seen_cache.clear()
_bt_seen_cache.update(load_seen_device_ids())
_bt_session_seen.clear()
# Load active baseline if exists
baseline_id = get_active_baseline_id()
if baseline_id:
device_ids = get_baseline_device_ids(baseline_id)
@@ -856,11 +896,15 @@ def stream_events():
else:
return event_type, event
def event_generator() -> Generator[str, None, None]:
"""Generate SSE events from scanner."""
for event in scanner.stream_events(timeout=1.0):
event_name, event_data = map_event_type(event)
yield format_sse(event_data, event=event_name)
def event_generator() -> Generator[str, None, None]:
"""Generate SSE events from scanner."""
for event in scanner.stream_events(timeout=1.0):
event_name, event_data = map_event_type(event)
try:
process_event('bluetooth', event_data, event_name)
except Exception:
pass
yield format_sse(event_data, event=event_name)
return Response(
event_generator(),
@@ -944,23 +988,34 @@ def get_tscm_bluetooth_snapshot(duration: int = 8) -> list[dict]:
devices = scanner.get_devices()
logger.info(f"TSCM snapshot: get_devices() returned {len(devices)} devices")
# Convert to TSCM format with tracker detection data
tscm_devices = []
for device in devices:
device_data = {
'mac': device.address,
'address_type': device.address_type,
'device_key': device.device_key,
'name': device.name or 'Unknown',
'rssi': device.rssi_current or -100,
'rssi_median': device.rssi_median,
'rssi_ema': round(device.rssi_ema, 1) if device.rssi_ema else None,
'type': _classify_device_type(device),
'manufacturer': device.manufacturer_name,
'manufacturer_id': device.manufacturer_id,
'manufacturer_data': device.manufacturer_bytes.hex() if device.manufacturer_bytes else None,
'protocol': device.protocol,
'first_seen': device.first_seen.isoformat(),
# Convert to TSCM format with tracker detection data
tscm_devices = []
for device in devices:
manufacturer_name = device.manufacturer_name
if (not manufacturer_name) or str(manufacturer_name).lower().startswith('unknown'):
if device.address and not device.is_randomized_mac:
try:
from data.oui import get_manufacturer
oui_vendor = get_manufacturer(device.address)
if oui_vendor and oui_vendor != 'Unknown':
manufacturer_name = oui_vendor
except Exception:
pass
device_data = {
'mac': device.address,
'address_type': device.address_type,
'device_key': device.device_key,
'name': device.name or 'Unknown',
'rssi': device.rssi_current or -100,
'rssi_median': device.rssi_median,
'rssi_ema': round(device.rssi_ema, 1) if device.rssi_ema else None,
'type': _classify_device_type(device),
'manufacturer': manufacturer_name,
'manufacturer_id': device.manufacturer_id,
'manufacturer_data': device.manufacturer_bytes.hex() if device.manufacturer_bytes else None,
'protocol': device.protocol,
'first_seen': device.first_seen.isoformat(),
'last_seen': device.last_seen.isoformat(),
'seen_count': device.seen_count,
'range_band': device.range_band,
@@ -1174,14 +1229,38 @@ def get_device_timeseries(device_key: str):
return jsonify(result)
def _classify_device_type(device: BTDeviceAggregate) -> str:
"""Classify device type from available data."""
name_lower = (device.name or '').lower()
manufacturer_lower = (device.manufacturer_name or '').lower()
# Check by name patterns
if any(x in name_lower for x in ['airpods', 'headphone', 'earbuds', 'buds', 'beats']):
return 'audio'
def _classify_device_type(device: BTDeviceAggregate) -> str:
"""Classify device type from available data."""
name_lower = (device.name or '').lower()
manufacturer_lower = (device.manufacturer_name or '').lower()
service_uuids = device.service_uuids or []
if (not manufacturer_lower) or manufacturer_lower.startswith('unknown'):
if device.address and not device.is_randomized_mac:
try:
from data.oui import get_manufacturer
oui_vendor = get_manufacturer(device.address)
if oui_vendor and oui_vendor != 'Unknown':
manufacturer_lower = oui_vendor.lower()
except Exception:
pass
def normalize_uuid(uuid: str) -> str:
if not uuid:
return ''
value = str(uuid).lower().strip()
if value.startswith('0x'):
value = value[2:]
# Bluetooth Base UUID normalization (16-bit UUIDs)
if value.endswith('-0000-1000-8000-00805f9b34fb') and len(value) >= 8:
return value[4:8]
if len(value) == 4:
return value
return value
# Check by name patterns
if any(x in name_lower for x in ['airpods', 'headphone', 'earbuds', 'buds', 'beats']):
return 'audio'
if any(x in name_lower for x in ['watch', 'band', 'fitbit', 'garmin']):
return 'wearable'
if any(x in name_lower for x in ['iphone', 'pixel', 'galaxy', 'phone']):
@@ -1190,18 +1269,41 @@ def _classify_device_type(device: BTDeviceAggregate) -> str:
return 'computer'
if any(x in name_lower for x in ['mouse', 'keyboard', 'trackpad']):
return 'peripheral'
if any(x in name_lower for x in ['tile', 'airtag', 'smarttag', 'chipolo']):
return 'tracker'
if any(x in name_lower for x in ['speaker', 'sonos', 'echo', 'home']):
return 'speaker'
if any(x in name_lower for x in ['tv', 'chromecast', 'roku', 'firestick']):
return 'media'
# Check by manufacturer
if 'apple' in manufacturer_lower:
return 'apple_device'
if 'samsung' in manufacturer_lower:
return 'samsung_device'
if any(x in name_lower for x in ['tile', 'airtag', 'smarttag', 'chipolo']):
return 'tracker'
if any(x in name_lower for x in ['speaker', 'sonos', 'echo', 'home']):
return 'speaker'
if any(x in name_lower for x in ['tv', 'chromecast', 'roku', 'firestick']):
return 'media'
# Tracker signals (metadata or Find My service)
if getattr(device, 'is_tracker', False) or getattr(device, 'tracker_type', None):
return 'tracker'
normalized_uuids = {normalize_uuid(u) for u in service_uuids if u}
if 'fd6f' in normalized_uuids:
return 'tracker'
# Service UUIDs (GATT / classic)
audio_uuids = {'110b', '110a', '111e', '111f', '1108', '1203'}
wearable_uuids = {'180d', '1814', '1816'}
hid_uuids = {'1812'}
beacon_uuids = {'feaa', 'feab', 'feb1', 'febe'}
if normalized_uuids & audio_uuids:
return 'audio'
if normalized_uuids & hid_uuids:
return 'peripheral'
if normalized_uuids & wearable_uuids:
return 'wearable'
if normalized_uuids & beacon_uuids:
return 'beacon'
# Check by manufacturer
if 'apple' in manufacturer_lower:
return 'apple_device'
if 'samsung' in manufacturer_lower:
return 'samsung_device'
# Check by class of device
if device.major_class:
+481 -126
View File
@@ -5,6 +5,7 @@ from __future__ import annotations
import os
import queue
import re
import select
import shutil
import subprocess
import threading
@@ -17,6 +18,10 @@ from flask import Blueprint, jsonify, request, Response
import app as app_module
from utils.logging import get_logger
from utils.sse import format_sse
from utils.event_pipeline import process_event
from utils.process import register_process, unregister_process
from utils.validation import validate_frequency, validate_gain, validate_device_index, validate_ppm
from utils.sdr import SDRFactory, SDRType
from utils.constants import (
SSE_QUEUE_TIMEOUT,
SSE_KEEPALIVE_INTERVAL,
@@ -35,10 +40,18 @@ dmr_rtl_process: Optional[subprocess.Popen] = None
dmr_dsd_process: Optional[subprocess.Popen] = None
dmr_thread: Optional[threading.Thread] = None
dmr_running = False
dmr_has_audio = False # True when ffmpeg available and dsd outputs audio
dmr_lock = threading.Lock()
dmr_queue: queue.Queue = queue.Queue(maxsize=QUEUE_MAX_SIZE)
dmr_active_device: Optional[int] = None
# Audio mux: the sole reader of dsd-fme stdout. Fans out bytes to all
# active ffmpeg stdin sinks when streaming clients are connected.
# This prevents dsd-fme from blocking on stdout (which would also
# freeze stderr / text data output).
_ffmpeg_sinks: set[object] = set()
_ffmpeg_sinks_lock = threading.Lock()
VALID_PROTOCOLS = ['auto', 'dmr', 'p25', 'nxdn', 'dstar', 'provoice']
# Classic dsd flags
@@ -51,14 +64,25 @@ _DSD_PROTOCOL_FLAGS = {
'provoice': ['-fv'],
}
# dsd-fme uses different flag names
# dsd-fme remapped several flags from classic DSD:
# -fs = DMR Simplex (NOT -fd which is D-STAR!),
# -fd = D-STAR (NOT DMR!), -fp = ProVoice (NOT P25),
# -fi = NXDN48 (NOT D-Star), -f1 = P25 Phase 1,
# -ft = XDMA multi-protocol decoder
_DSD_FME_PROTOCOL_FLAGS = {
'auto': ['-ft'],
'dmr': ['-fs'],
'p25': ['-f1'],
'nxdn': ['-fi'],
'dstar': [],
'provoice': ['-fp'],
'auto': ['-fa'], # Broad auto: P25 (P1/P2), DMR, D-STAR, YSF, X2-TDMA
'dmr': ['-fs'], # DMR Simplex (-fd is D-STAR in dsd-fme!)
'p25': ['-ft'], # P25 P1/P2 coverage (also includes DMR in dsd-fme)
'nxdn': ['-fn'], # NXDN96
'dstar': ['-fd'], # D-STAR (-fd in dsd-fme, NOT DMR!)
'provoice': ['-fp'], # ProVoice (-fp in dsd-fme, not -fv)
}
# Modulation hints: force C4FM for protocols that use it, improving
# sync reliability vs letting dsd-fme auto-detect modulation type.
_DSD_FME_MODULATION = {
'dmr': ['-mc'], # C4FM
'nxdn': ['-mc'], # C4FM
}
# ============================================
@@ -86,103 +110,298 @@ def find_rtl_fm() -> str | None:
return shutil.which('rtl_fm')
def find_rx_fm() -> str | None:
"""Find SoapySDR rx_fm binary."""
return shutil.which('rx_fm')
def find_ffmpeg() -> str | None:
"""Find ffmpeg for audio encoding."""
return shutil.which('ffmpeg')
def parse_dsd_output(line: str) -> dict | None:
"""Parse a line of DSD stderr output into a structured event."""
"""Parse a line of DSD stderr output into a structured event.
Handles output from both classic ``dsd`` and ``dsd-fme`` which use
different formatting for talkgroup / source / voice frame lines.
"""
line = line.strip()
if not line:
return None
# Skip DSD/dsd-fme startup banner lines (ASCII art, version info, etc.)
# Only filter lines that are purely decorative — dsd-fme uses box-drawing
# characters (│, ─) as column separators in DATA lines, so we must not
# discard lines that also contain alphanumeric content.
stripped_of_box = re.sub(r'[╔╗╚╝║═██▀▄╗╝╩╦╠╣╬│┤├┘└┐┌─┼█▓▒░\s]', '', line)
if not stripped_of_box:
return None
if re.match(r'^\s*(Build Version|MBElib|CODEC2|Audio (Out|In)|Decoding )', line):
return None
ts = datetime.now().strftime('%H:%M:%S')
# Sync detection: "Sync: +DMR (data)" or "Sync: +P25 Phase 1"
sync_match = re.match(r'Sync:\s*\+?(\S+.*)', line)
if sync_match:
return {
'type': 'sync',
'protocol': sync_match.group(1).strip(),
'timestamp': datetime.now().strftime('%H:%M:%S'),
'timestamp': ts,
}
# Talkgroup and Source: "TG: 12345 Src: 67890"
tg_match = re.match(r'.*TG:\s*(\d+)\s+Src:\s*(\d+)', line)
# Talkgroup and Source — check BEFORE slot so "Slot 1 Voice LC, TG: …"
# is captured as a call event rather than a bare slot event.
# Classic dsd: "TG: 12345 Src: 67890"
# dsd-fme: "TG: 12345, Src: 67890" or "Talkgroup: 12345, Source: 67890"
# "TGT: 12345 | SRC: 67890" (pipe-delimited variant)
tg_match = re.search(
r'(?:TGT?|Talkgroup)[:\s]+(\d+)[,|│\s]+(?:Src|Source|SRC)[:\s]+(\d+)', line, re.IGNORECASE
)
if tg_match:
return {
result = {
'type': 'call',
'talkgroup': int(tg_match.group(1)),
'source_id': int(tg_match.group(2)),
'timestamp': datetime.now().strftime('%H:%M:%S'),
'timestamp': ts,
}
# Extract slot if present on the same line
slot_inline = re.search(r'Slot\s*(\d+)', line)
if slot_inline:
result['slot'] = int(slot_inline.group(1))
return result
# Slot info: "Slot 1" or "Slot 2"
slot_match = re.match(r'.*Slot\s*(\d+)', line)
if slot_match:
return {
'type': 'slot',
'slot': int(slot_match.group(1)),
'timestamp': datetime.now().strftime('%H:%M:%S'),
}
# DMR voice frame
if 'Voice' in line or 'voice' in line:
return {
'type': 'voice',
'detail': line,
'timestamp': datetime.now().strftime('%H:%M:%S'),
}
# P25 NAC (Network Access Code)
nac_match = re.match(r'.*NAC:\s*(\w+)', line)
# P25 NAC (Network Access Code) — check before voice/slot
nac_match = re.search(r'NAC[:\s]+([0-9A-Fa-f]+)', line)
if nac_match:
return {
'type': 'nac',
'nac': nac_match.group(1),
'timestamp': datetime.now().strftime('%H:%M:%S'),
'timestamp': ts,
}
return None
# Voice frame detection — check BEFORE bare slot match
# Classic dsd: "Voice" keyword in frame lines
# dsd-fme: "voice" or "Voice LC" or "VOICE" in output
if re.search(r'\bvoice\b', line, re.IGNORECASE):
result = {
'type': 'voice',
'detail': line,
'timestamp': ts,
}
slot_inline = re.search(r'Slot\s*(\d+)', line)
if slot_inline:
result['slot'] = int(slot_inline.group(1))
return result
# Bare slot info (only when line is *just* slot info, not voice/call)
slot_match = re.match(r'\s*Slot\s*(\d+)\s*$', line)
if slot_match:
return {
'type': 'slot',
'slot': int(slot_match.group(1)),
'timestamp': ts,
}
# dsd-fme status lines we can surface: "TDMA", "CACH", "PI", "BS", etc.
# Also catches "Closing", "Input", and other lifecycle lines.
# Forward as raw so the frontend can show decoder is alive.
return {
'type': 'raw',
'text': line[:200],
'timestamp': ts,
}
_HEARTBEAT_INTERVAL = 3.0 # seconds between heartbeats when decoder is idle
# 100ms of silence at 8kHz 16-bit mono = 1600 bytes
_SILENCE_CHUNK = b'\x00' * 1600
def _register_audio_sink(sink: object) -> None:
"""Register an ffmpeg stdin sink for mux fanout."""
with _ffmpeg_sinks_lock:
_ffmpeg_sinks.add(sink)
def _unregister_audio_sink(sink: object) -> None:
"""Remove an ffmpeg stdin sink from mux fanout."""
with _ffmpeg_sinks_lock:
_ffmpeg_sinks.discard(sink)
def _get_audio_sinks() -> tuple[object, ...]:
"""Snapshot current audio sinks for lock-free iteration."""
with _ffmpeg_sinks_lock:
return tuple(_ffmpeg_sinks)
def _stop_process(proc: Optional[subprocess.Popen]) -> None:
"""Terminate and unregister a subprocess if present."""
if not proc:
return
if proc.poll() is None:
try:
proc.terminate()
proc.wait(timeout=2)
except Exception:
try:
proc.kill()
except Exception:
pass
unregister_process(proc)
def _reset_runtime_state(*, release_device: bool) -> None:
"""Reset process + runtime state and optionally release SDR ownership."""
global dmr_rtl_process, dmr_dsd_process
global dmr_running, dmr_has_audio, dmr_active_device
_stop_process(dmr_dsd_process)
_stop_process(dmr_rtl_process)
dmr_rtl_process = None
dmr_dsd_process = None
dmr_running = False
dmr_has_audio = False
with _ffmpeg_sinks_lock:
_ffmpeg_sinks.clear()
if release_device and dmr_active_device is not None:
app_module.release_sdr_device(dmr_active_device)
dmr_active_device = None
def _dsd_audio_mux(dsd_stdout):
"""Mux thread: sole reader of dsd-fme stdout.
Always drains dsd-fme's audio output to prevent the process from
blocking on stdout writes (which would also freeze stderr / text
data). When streaming clients are connected, forwards data to all
active ffmpeg stdin sinks with silence fill during voice gaps.
"""
try:
while dmr_running:
ready, _, _ = select.select([dsd_stdout], [], [], 0.1)
if ready:
data = os.read(dsd_stdout.fileno(), 4096)
if not data:
break
sinks = _get_audio_sinks()
for sink in sinks:
try:
sink.write(data)
sink.flush()
except (BrokenPipeError, OSError, ValueError):
_unregister_audio_sink(sink)
else:
# No audio from decoder — feed silence if client connected
sinks = _get_audio_sinks()
for sink in sinks:
try:
sink.write(_SILENCE_CHUNK)
sink.flush()
except (BrokenPipeError, OSError, ValueError):
_unregister_audio_sink(sink)
except (OSError, ValueError):
pass
def _queue_put(event: dict):
"""Put an event on the DMR queue, dropping oldest if full."""
try:
dmr_queue.put_nowait(event)
except queue.Full:
try:
dmr_queue.get_nowait()
except queue.Empty:
pass
try:
dmr_queue.put_nowait(event)
except queue.Full:
pass
def stream_dsd_output(rtl_process: subprocess.Popen, dsd_process: subprocess.Popen):
"""Read DSD stderr output and push parsed events to the queue."""
"""Read DSD stderr output and push parsed events to the queue.
Uses select() with a timeout so we can send periodic heartbeat
events while readline() would otherwise block indefinitely during
silence (no signal being decoded).
"""
global dmr_running
try:
dmr_queue.put_nowait({'type': 'status', 'text': 'started'})
_queue_put({'type': 'status', 'text': 'started'})
last_heartbeat = time.time()
while dmr_running:
if dsd_process.poll() is not None:
break
line = dsd_process.stderr.readline()
if not line:
if dsd_process.poll() is not None:
break
continue
# Wait up to 1s for data on stderr instead of blocking forever
ready, _, _ = select.select([dsd_process.stderr], [], [], 1.0)
text = line.decode('utf-8', errors='replace').strip()
if not text:
continue
if ready:
line = dsd_process.stderr.readline()
if not line:
if dsd_process.poll() is not None:
break
continue
parsed = parse_dsd_output(text)
if parsed:
try:
dmr_queue.put_nowait(parsed)
except queue.Full:
try:
dmr_queue.get_nowait()
except queue.Empty:
pass
try:
dmr_queue.put_nowait(parsed)
except queue.Full:
pass
text = line.decode('utf-8', errors='replace').strip()
if not text:
continue
logger.debug("DSD raw: %s", text)
parsed = parse_dsd_output(text)
if parsed:
_queue_put(parsed)
last_heartbeat = time.time()
else:
# No stderr output — send heartbeat so frontend knows
# decoder is still alive and listening
now = time.time()
if now - last_heartbeat >= _HEARTBEAT_INTERVAL:
_queue_put({
'type': 'heartbeat',
'timestamp': datetime.now().strftime('%H:%M:%S'),
})
last_heartbeat = now
except Exception as e:
logger.error(f"DSD stream error: {e}")
finally:
global dmr_active_device, dmr_rtl_process, dmr_dsd_process
global dmr_has_audio
dmr_running = False
try:
dmr_queue.put_nowait({'type': 'status', 'text': 'stopped'})
except queue.Full:
pass
dmr_has_audio = False
with _ffmpeg_sinks_lock:
_ffmpeg_sinks.clear()
# Capture exit info for diagnostics
rc = dsd_process.poll()
reason = 'stopped'
detail = ''
if rc is not None and rc != 0:
reason = 'crashed'
try:
remaining = dsd_process.stderr.read(1024)
if remaining:
detail = remaining.decode('utf-8', errors='replace').strip()[:200]
except Exception:
pass
logger.warning(f"DSD process exited with code {rc}: {detail}")
# Cleanup decoder + demod processes
_stop_process(dsd_process)
_stop_process(rtl_process)
dmr_rtl_process = None
dmr_dsd_process = None
_queue_put({'type': 'status', 'text': reason, 'exit_code': rc, 'detail': detail})
# Release SDR device
if dmr_active_device is not None:
app_module.release_sdr_device(dmr_active_device)
dmr_active_device = None
logger.info("DSD stream thread stopped")
@@ -195,10 +414,14 @@ def check_tools() -> Response:
"""Check for required tools."""
dsd_path, _ = find_dsd()
rtl_fm = find_rtl_fm()
rx_fm = find_rx_fm()
ffmpeg = find_ffmpeg()
return jsonify({
'dsd': dsd_path is not None,
'rtl_fm': rtl_fm is not None,
'available': dsd_path is not None and rtl_fm is not None,
'rx_fm': rx_fm is not None,
'ffmpeg': ffmpeg is not None,
'available': dsd_path is not None and (rtl_fm is not None or rx_fm is not None),
'protocols': VALID_PROTOCOLS,
})
@@ -206,36 +429,43 @@ def check_tools() -> Response:
@dmr_bp.route('/start', methods=['POST'])
def start_dmr() -> Response:
"""Start digital voice decoding."""
global dmr_rtl_process, dmr_dsd_process, dmr_thread, dmr_running, dmr_active_device
with dmr_lock:
if dmr_running:
return jsonify({'status': 'error', 'message': 'Already running'}), 409
global dmr_rtl_process, dmr_dsd_process, dmr_thread
global dmr_running, dmr_has_audio, dmr_active_device
dsd_path, is_fme = find_dsd()
if not dsd_path:
return jsonify({'status': 'error', 'message': 'dsd not found. Install dsd-fme or dsd.'}), 503
rtl_fm_path = find_rtl_fm()
if not rtl_fm_path:
return jsonify({'status': 'error', 'message': 'rtl_fm not found. Install rtl-sdr tools.'}), 503
data = request.json or {}
try:
frequency = float(data.get('frequency', 462.5625))
gain = int(data.get('gain', 40))
device = int(data.get('device', 0))
frequency = validate_frequency(data.get('frequency', 462.5625))
gain = int(validate_gain(data.get('gain', 40)))
device = validate_device_index(data.get('device', 0))
protocol = str(data.get('protocol', 'auto')).lower()
ppm = validate_ppm(data.get('ppm', 0))
except (ValueError, TypeError) as e:
return jsonify({'status': 'error', 'message': f'Invalid parameter: {e}'}), 400
if frequency <= 0:
return jsonify({'status': 'error', 'message': 'Frequency must be positive'}), 400
sdr_type_str = str(data.get('sdr_type', 'rtlsdr')).lower()
try:
sdr_type = SDRType(sdr_type_str)
except ValueError:
sdr_type = SDRType.RTL_SDR
if protocol not in VALID_PROTOCOLS:
return jsonify({'status': 'error', 'message': f'Invalid protocol. Use: {", ".join(VALID_PROTOCOLS)}'}), 400
if sdr_type == SDRType.RTL_SDR:
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 tools for {sdr_type.value}.'
}), 503
# Clear stale queue
try:
while True:
@@ -243,32 +473,67 @@ def start_dmr() -> Response:
except queue.Empty:
pass
# Claim SDR device
error = app_module.claim_sdr_device(device, 'dmr')
# Reserve running state before we start claiming resources/processes
# so concurrent /start requests cannot race each other.
with dmr_lock:
if dmr_running:
return jsonify({'status': 'error', 'message': 'Already running'}), 409
dmr_running = True
dmr_has_audio = False
# Claim SDR device — use protocol name so the device panel shows
# "D-STAR", "P25", etc. instead of always "DMR"
mode_label = protocol.upper() if protocol != 'auto' else 'DMR'
error = app_module.claim_sdr_device(device, mode_label)
if error:
with dmr_lock:
dmr_running = False
return jsonify({'status': 'error', 'error_type': 'DEVICE_BUSY', 'message': error}), 409
dmr_active_device = device
freq_hz = int(frequency * 1e6)
# Build rtl_fm command (48kHz sample rate for DSD)
rtl_cmd = [
rtl_fm_path,
'-M', 'fm',
'-f', str(freq_hz),
'-s', '48000',
'-g', str(gain),
'-d', str(device),
'-l', '1', # squelch level
]
# Build FM demodulation command via SDR abstraction.
try:
sdr_device = SDRFactory.create_default_device(sdr_type, index=device)
builder = SDRFactory.get_builder(sdr_type)
rtl_cmd = builder.build_fm_demod_command(
device=sdr_device,
frequency_mhz=frequency,
sample_rate=48000,
gain=float(gain) if gain > 0 else None,
ppm=int(ppm) if ppm != 0 else None,
modulation='fm',
squelch=None,
bias_t=bool(data.get('bias_t', False)),
)
if sdr_type == SDRType.RTL_SDR:
# Keep squelch fully open for digital bitstreams.
rtl_cmd.extend(['-l', '0'])
except Exception as e:
_reset_runtime_state(release_device=True)
return jsonify({'status': 'error', 'message': f'Failed to build SDR command: {e}'}), 500
# Build DSD command
# Use -o - to send decoded audio to stdout (piped to DEVNULL)
# instead of PulseAudio which may not be available under sudo
dsd_cmd = [dsd_path, '-i', '-', '-o', '-']
# Audio output: pipe decoded audio (8kHz s16le PCM) to stdout for
# ffmpeg transcoding. Both dsd-fme and classic dsd support '-o -'.
# If ffmpeg is unavailable, fall back to discarding audio.
ffmpeg_path = find_ffmpeg()
if ffmpeg_path:
audio_out = '-'
else:
audio_out = 'null' if is_fme else '-'
logger.warning("ffmpeg not found — audio streaming disabled, data-only mode")
dsd_cmd = [dsd_path, '-i', '-', '-o', audio_out]
if is_fme:
dsd_cmd.extend(_DSD_FME_PROTOCOL_FLAGS.get(protocol, []))
dsd_cmd.extend(_DSD_FME_MODULATION.get(protocol, []))
# Event log to stderr so we capture TG/Source/Voice data that
# dsd-fme may not output on stderr by default.
dsd_cmd.extend(['-J', '/dev/stderr'])
# Relax CRC checks for marginal signals — lets more frames
# through at the cost of occasional decode errors.
if data.get('relaxCrc', False):
dsd_cmd.append('-F')
else:
dsd_cmd.extend(_DSD_PROTOCOL_FLAGS.get(protocol, []))
@@ -278,17 +543,33 @@ def start_dmr() -> Response:
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
)
register_process(dmr_rtl_process)
# DSD stdout → PIPE when ffmpeg available (audio pipeline),
# otherwise DEVNULL (data-only mode)
dsd_stdout = subprocess.PIPE if ffmpeg_path else subprocess.DEVNULL
dmr_dsd_process = subprocess.Popen(
dsd_cmd,
stdin=dmr_rtl_process.stdout,
stdout=subprocess.DEVNULL,
stdout=dsd_stdout,
stderr=subprocess.PIPE,
)
register_process(dmr_dsd_process)
# Allow rtl_fm to send directly to dsd
dmr_rtl_process.stdout.close()
# Start mux thread: always drains dsd-fme stdout to prevent the
# process from blocking (which would freeze stderr / text data).
# ffmpeg is started lazily per-client in /dmr/audio/stream.
if ffmpeg_path and dmr_dsd_process.stdout:
dmr_has_audio = True
threading.Thread(
target=_dsd_audio_mux,
args=(dmr_dsd_process.stdout,),
daemon=True,
).start()
time.sleep(0.3)
rtl_rc = dmr_rtl_process.poll()
@@ -302,14 +583,16 @@ def start_dmr() -> Response:
if dmr_dsd_process.stderr:
dsd_err = dmr_dsd_process.stderr.read().decode('utf-8', errors='replace')[:500]
logger.error(f"DSD pipeline died: rtl_fm rc={rtl_rc} err={rtl_err!r}, dsd rc={dsd_rc} err={dsd_err!r}")
if dmr_active_device is not None:
app_module.release_sdr_device(dmr_active_device)
dmr_active_device = None
# Surface the most relevant error to the user
# Terminate surviving processes and release resources.
_reset_runtime_state(release_device=True)
# Surface a clear error to the user
detail = rtl_err.strip() or dsd_err.strip()
msg = 'Failed to start DSD pipeline'
if detail:
msg += f': {detail}'
if 'usb_claim_interface' in rtl_err or 'Failed to open' in rtl_err:
msg = f'SDR device {device} is busy — it may be in use by another mode or process. Try a different device.'
elif detail:
msg = f'Failed to start DSD pipeline: {detail}'
else:
msg = 'Failed to start DSD pipeline'
return jsonify({'status': 'error', 'message': msg}), 500
# Drain rtl_fm stderr in background to prevent pipe blocking
@@ -322,7 +605,6 @@ def start_dmr() -> Response:
threading.Thread(target=_drain_rtl_stderr, args=(dmr_rtl_process,), daemon=True).start()
dmr_running = True
dmr_thread = threading.Thread(
target=stream_dsd_output,
args=(dmr_rtl_process, dmr_dsd_process),
@@ -334,40 +616,21 @@ def start_dmr() -> Response:
'status': 'started',
'frequency': frequency,
'protocol': protocol,
'sdr_type': sdr_type.value,
'has_audio': dmr_has_audio,
})
except Exception as e:
logger.error(f"Failed to start DMR: {e}")
if dmr_active_device is not None:
app_module.release_sdr_device(dmr_active_device)
dmr_active_device = None
_reset_runtime_state(release_device=True)
return jsonify({'status': 'error', 'message': str(e)}), 500
@dmr_bp.route('/stop', methods=['POST'])
def stop_dmr() -> Response:
"""Stop digital voice decoding."""
global dmr_rtl_process, dmr_dsd_process, dmr_running, dmr_active_device
dmr_running = False
for proc in [dmr_dsd_process, dmr_rtl_process]:
if proc and proc.poll() is None:
try:
proc.terminate()
proc.wait(timeout=2)
except Exception:
try:
proc.kill()
except Exception:
pass
dmr_rtl_process = None
dmr_dsd_process = None
if dmr_active_device is not None:
app_module.release_sdr_device(dmr_active_device)
dmr_active_device = None
with dmr_lock:
_reset_runtime_state(release_device=True)
return jsonify({'status': 'stopped'})
@@ -378,9 +641,97 @@ def dmr_status() -> Response:
return jsonify({
'running': dmr_running,
'device': dmr_active_device,
'has_audio': dmr_has_audio,
})
@dmr_bp.route('/audio/stream')
def stream_dmr_audio() -> Response:
"""Stream decoded digital voice audio as WAV.
Starts a per-client ffmpeg encoder. The global mux thread
(_dsd_audio_mux) forwards DSD audio to this ffmpeg's stdin while
the client is connected, and discards audio otherwise. This avoids
the pipe-buffer deadlock that occurs when ffmpeg is started at
decoder launch (its stdout fills up before any HTTP client reads
it, back-pressuring the entire pipeline and freezing stderr/text
data output).
"""
if not dmr_running or not dmr_has_audio:
return Response(b'', mimetype='audio/wav', status=204)
ffmpeg_path = find_ffmpeg()
if not ffmpeg_path:
return Response(b'', mimetype='audio/wav', status=503)
encoder_cmd = [
ffmpeg_path, '-hide_banner', '-loglevel', 'error',
'-fflags', 'nobuffer', '-flags', 'low_delay',
'-probesize', '32', '-analyzeduration', '0',
'-f', 's16le', '-ar', '8000', '-ac', '1', '-i', 'pipe:0',
'-acodec', 'pcm_s16le', '-ar', '44100', '-f', 'wav', 'pipe:1',
]
audio_proc = subprocess.Popen(
encoder_cmd,
stdin=subprocess.PIPE,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
)
# Drain ffmpeg stderr to prevent blocking
threading.Thread(
target=lambda p: [None for _ in p.stderr],
args=(audio_proc,), daemon=True,
).start()
if audio_proc.stdin:
_register_audio_sink(audio_proc.stdin)
def generate():
try:
while dmr_running and audio_proc.poll() is None:
ready, _, _ = select.select([audio_proc.stdout], [], [], 2.0)
if ready:
chunk = audio_proc.stdout.read(4096)
if chunk:
yield chunk
else:
break
else:
if audio_proc.poll() is not None:
break
except GeneratorExit:
pass
except Exception as e:
logger.error(f"DMR audio stream error: {e}")
finally:
# Disconnect mux → ffmpeg, then clean up
if audio_proc.stdin:
_unregister_audio_sink(audio_proc.stdin)
try:
audio_proc.stdin.close()
except Exception:
pass
try:
audio_proc.terminate()
audio_proc.wait(timeout=2)
except Exception:
try:
audio_proc.kill()
except Exception:
pass
return Response(
generate(),
mimetype='audio/wav',
headers={
'Content-Type': 'audio/wav',
'Cache-Control': 'no-cache, no-store',
'X-Accel-Buffering': 'no',
'Transfer-Encoding': 'chunked',
},
)
@dmr_bp.route('/stream')
def stream_dmr() -> Response:
"""SSE stream for DMR decoder events."""
@@ -390,6 +741,10 @@ def stream_dmr() -> Response:
try:
msg = dmr_queue.get(timeout=SSE_QUEUE_TIMEOUT)
last_keepalive = time.time()
try:
process_event('dmr', msg, msg.get('type'))
except Exception:
pass
yield format_sse(msg)
except queue.Empty:
now = time.time()
+45 -2
View File
@@ -36,9 +36,11 @@ from utils.database import (
)
from utils.dsc.parser import parse_dsc_message
from utils.sse import format_sse
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.process import register_process, unregister_process
logger = logging.getLogger('intercept.dsc')
@@ -169,17 +171,34 @@ def stream_dsc_decoder(master_fd: int, decoder_process: subprocess.Popen) -> Non
'error': str(e)
})
finally:
global dsc_active_device
try:
os.close(master_fd)
except OSError:
pass
decoder_process.wait()
dsc_running = False
# Cleanup both processes
with app_module.dsc_lock:
rtl_proc = app_module.dsc_rtl_process
for proc in [rtl_proc, decoder_process]:
if proc:
try:
proc.terminate()
proc.wait(timeout=2)
except Exception:
try:
proc.kill()
except Exception:
pass
unregister_process(proc)
app_module.dsc_queue.put({'type': 'status', 'status': 'stopped'})
with app_module.dsc_lock:
app_module.dsc_process = None
app_module.dsc_rtl_process = None
# Release SDR device
if dsc_active_device is not None:
app_module.release_sdr_device(dsc_active_device)
dsc_active_device = None
def _store_critical_alert(msg: dict) -> None:
@@ -362,6 +381,7 @@ def start_decoding() -> Response:
stdout=subprocess.PIPE,
stderr=subprocess.PIPE
)
register_process(rtl_process)
# Start stderr monitor thread
stderr_thread = threading.Thread(
@@ -382,6 +402,7 @@ def start_decoding() -> Response:
stderr=slave_fd,
close_fds=True
)
register_process(decoder_process)
os.close(slave_fd)
rtl_process.stdout.close()
@@ -408,6 +429,15 @@ def start_decoding() -> Response:
})
except FileNotFoundError as e:
# Kill orphaned rtl_fm process
try:
rtl_process.terminate()
rtl_process.wait(timeout=2)
except Exception:
try:
rtl_process.kill()
except Exception:
pass
# Release device on failure
if dsc_active_device is not None:
app_module.release_sdr_device(dsc_active_device)
@@ -417,6 +447,15 @@ def start_decoding() -> Response:
'message': f'Tool not found: {e.filename}'
}), 400
except Exception as e:
# Kill orphaned rtl_fm process if it was started
try:
rtl_process.terminate()
rtl_process.wait(timeout=2)
except Exception:
try:
rtl_process.kill()
except Exception:
pass
# Release device on failure
if dsc_active_device is not None:
app_module.release_sdr_device(dsc_active_device)
@@ -487,6 +526,10 @@ def stream() -> Response:
try:
msg = app_module.dsc_queue.get(timeout=1)
last_keepalive = time.time()
try:
process_event('dsc', msg, msg.get('type'))
except Exception:
pass
yield format_sse(msg)
except queue.Empty:
now = time.time()
+267 -131
View File
@@ -20,6 +20,7 @@ from flask import Blueprint, jsonify, request, Response
import app as app_module
from utils.logging import get_logger
from utils.sse import format_sse
from utils.event_pipeline import process_event
from utils.constants import (
SSE_QUEUE_TIMEOUT,
SSE_KEEPALIVE_INTERVAL,
@@ -101,15 +102,21 @@ def find_ffmpeg() -> str | None:
return shutil.which('ffmpeg')
VALID_MODULATIONS = ['fm', 'wfm', 'am', 'usb', 'lsb']
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
VALID_MODULATIONS = ['fm', 'wfm', 'am', 'usb', 'lsb']
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
@@ -206,14 +213,14 @@ def scanner_loop():
resample_rate = 24000
# Don't use squelch in rtl_fm - we want to analyze raw audio
rtl_cmd = [
rtl_fm_path,
'-M', mod,
'-f', str(freq_hz),
'-s', str(sample_rate),
'-r', str(resample_rate),
'-g', str(gain),
'-d', str(device),
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):
@@ -678,14 +685,14 @@ def _start_audio_stream(frequency: float, modulation: str):
return
freq_hz = int(frequency * 1e6)
sdr_cmd = [
rtl_fm_path,
'-M', modulation,
'-f', str(freq_hz),
'-s', str(sample_rate),
'-r', str(resample_rate),
'-g', str(scanner_config['gain']),
'-d', str(scanner_config['device']),
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(scanner_config['gain']),
'-d', str(scanner_config['device']),
'-l', str(scanner_config['squelch']),
]
if scanner_config.get('bias_t', False):
@@ -839,9 +846,13 @@ def _start_audio_stream(frequency: float, modulation: str):
try:
ready, _, _ = select.select([audio_process.stdout], [], [], 4.0)
if not ready:
logger.warning("Audio pipeline produced no data in startup window")
logger.warning("Audio pipeline produced no data in startup window — killing stalled pipeline")
_stop_audio_stream_internal()
return
except Exception as e:
logger.warning(f"Audio startup check failed: {e}")
_stop_audio_stream_internal()
return
audio_running = True
audio_frequency = frequency
@@ -866,6 +877,8 @@ def _stop_audio_stream_internal():
audio_running = False
audio_frequency = 0.0
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:
@@ -891,19 +904,9 @@ def _stop_audio_stream_internal():
audio_process = None
audio_rtl_process = None
# Kill any orphaned rtl_fm, rtl_power, and ffmpeg processes
for proc_pattern in ['rtl_fm', 'rtl_power']:
try:
subprocess.run(['pkill', '-9', proc_pattern], capture_output=True, timeout=0.5)
except Exception:
pass
try:
subprocess.run(['pkill', '-9', '-f', 'ffmpeg.*pipe:0'], capture_output=True, timeout=0.5)
except Exception:
pass
# Pause for SDR device USB interface to be released by kernel
time.sleep(1.0)
if had_processes:
time.sleep(1.0)
# ============================================
@@ -1186,6 +1189,10 @@ def stream_scanner_events() -> Response:
try:
msg = scanner_queue.get(timeout=SSE_QUEUE_TIMEOUT)
last_keepalive = time.time()
try:
process_event('listening_scanner', msg, msg.get('type'))
except Exception:
pass
yield format_sse(msg)
except queue.Empty:
now = time.time()
@@ -1304,11 +1311,40 @@ def start_audio() -> Response:
scanner_config['device'] = device
scanner_config['sdr_type'] = sdr_type
# Claim device for listening audio
# Stop waterfall if it's using the same SDR (SSE path)
if waterfall_running and 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 listening_active_device is None or listening_active_device != device:
if listening_active_device is not None:
app_module.release_sdr_device(listening_active_device)
error = app_module.claim_sdr_device(device, 'listening')
listening_active_device = None
error = None
max_claim_attempts = 6
for attempt in range(max_claim_attempts):
# Force-release a stale waterfall registry entry on each
# attempt — the WebSocket handler may not have finished
# cleanup yet.
device_status = app_module.get_sdr_device_status()
if device_status.get(device) == 'waterfall':
app_module.release_sdr_device(device)
error = app_module.claim_sdr_device(device, 'listening')
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',
@@ -1411,13 +1447,6 @@ def audio_probe() -> Response:
@listening_post_bp.route('/audio/stream')
def stream_audio() -> Response:
"""Stream WAV audio."""
# Optionally restart pipeline so the stream starts with a fresh header
if request.args.get('fresh') == '1' and audio_running:
try:
_start_audio_stream(audio_frequency or 0.0, audio_modulation or 'fm')
except Exception as e:
logger.error(f"Audio stream restart failed: {e}")
# Wait for audio to be ready (up to 2 seconds for modulation/squelch changes)
for _ in range(40):
if audio_running and audio_process:
@@ -1433,13 +1462,30 @@ def stream_audio() -> Response:
if not proc or not proc.stdout:
return
try:
# First byte timeout to avoid hanging clients forever
# 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() + 3.0
while audio_running and proc.poll() is None:
# Use select to avoid blocking forever
ready, _, _ = select.select([proc.stdout], [], [], 2.0)
if ready:
chunk = proc.stdout.read(4096)
chunk = proc.stdout.read(8192)
if chunk:
yield chunk
else:
@@ -1532,9 +1578,51 @@ waterfall_config = {
'bin_size': 10000,
'gain': 40,
'device': 0,
'max_bins': 1024,
'interval': 0.4,
}
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 _waterfall_loop():
"""Continuous rtl_power sweep loop emitting waterfall data."""
global waterfall_running, waterfall_process
@@ -1545,84 +1633,59 @@ def _waterfall_loop():
waterfall_running = False
return
start_hz = int(waterfall_config['start_freq'] * 1e6)
end_hz = int(waterfall_config['end_freq'] * 1e6)
bin_hz = int(waterfall_config['bin_size'])
gain = waterfall_config['gain']
device = waterfall_config['device']
interval = float(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:
while waterfall_running:
start_hz = int(waterfall_config['start_freq'] * 1e6)
end_hz = int(waterfall_config['end_freq'] * 1e6)
bin_hz = int(waterfall_config['bin_size'])
gain = waterfall_config['gain']
device = waterfall_config['device']
waterfall_process = subprocess.Popen(
cmd,
stdout=subprocess.PIPE,
stderr=subprocess.DEVNULL,
bufsize=1,
text=True,
)
cmd = [
rtl_power_path,
'-f', f'{start_hz}:{end_hz}:{bin_hz}',
'-i', '0.5',
'-1',
'-g', str(gain),
'-d', str(device),
]
current_ts = None
all_bins: list[float] = []
sweep_start_hz = start_hz
sweep_end_hz = end_hz
try:
proc = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.DEVNULL)
waterfall_process = proc
stdout, _ = proc.communicate(timeout=15)
except subprocess.TimeoutExpired:
proc.kill()
stdout = b''
finally:
waterfall_process = None
if not waterfall_process.stdout:
return
for line in waterfall_process.stdout:
if not waterfall_running:
break
if not stdout:
time.sleep(0.2)
ts, seg_start, seg_end, bins = _parse_rtl_power_line(line)
if ts is None or not bins:
continue
# Parse rtl_power CSV output
all_bins = []
sweep_start_hz = start_hz
sweep_end_hz = end_hz
if current_ts is None:
current_ts = ts
for line in stdout.decode(errors='ignore').splitlines():
if not line or line.startswith('#'):
continue
parts = [p.strip() for p in line.split(',')]
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:
continue
try:
seg_start = float(parts[start_idx])
seg_end = float(parts[start_idx + 1])
seg_bin = float(parts[start_idx + 2])
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:]
all_bins.extend(raw_values)
sweep_start_hz = min(sweep_start_hz, seg_start)
sweep_end_hz = max(sweep_end_hz, seg_end)
except ValueError:
continue
if all_bins:
if ts != current_ts and all_bins:
max_bins = int(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': all_bins,
'bins': bins_to_send,
'timestamp': datetime.now().isoformat(),
}
try:
@@ -1637,15 +1700,73 @@ def _waterfall_loop():
except queue.Full:
pass
time.sleep(0.1)
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 waterfall_running:
max_bins = int(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:
waterfall_queue.put_nowait(msg)
except queue.Full:
pass
except Exception as e:
logger.error(f"Waterfall loop error: {e}")
finally:
waterfall_running = False
if waterfall_process and waterfall_process.poll() is None:
try:
waterfall_process.terminate()
waterfall_process.wait(timeout=1)
except Exception:
try:
waterfall_process.kill()
except Exception:
pass
waterfall_process = None
logger.info("Waterfall loop stopped")
def _stop_waterfall_internal() -> None:
"""Stop the waterfall display and release resources."""
global waterfall_running, waterfall_process, waterfall_active_device
waterfall_running = False
if waterfall_process and waterfall_process.poll() is None:
try:
waterfall_process.terminate()
waterfall_process.wait(timeout=1)
except Exception:
try:
waterfall_process.kill()
except Exception:
pass
waterfall_process = None
if waterfall_active_device is not None:
app_module.release_sdr_device(waterfall_active_device)
waterfall_active_device = None
@listening_post_bp.route('/waterfall/start', methods=['POST'])
def start_waterfall() -> Response:
"""Start the waterfall/spectrogram display."""
@@ -1666,6 +1787,16 @@ def start_waterfall() -> Response:
waterfall_config['bin_size'] = int(data.get('bin_size', 10000))
waterfall_config['gain'] = int(data.get('gain', 40))
waterfall_config['device'] = int(data.get('device', 0))
if data.get('interval') is not None:
interval = float(data.get('interval', 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
waterfall_config['interval'] = interval
if data.get('max_bins') is not None:
max_bins = int(data.get('max_bins', 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
waterfall_config['max_bins'] = max_bins
except (ValueError, TypeError) as e:
return jsonify({'status': 'error', 'message': f'Invalid parameter: {e}'}), 400
@@ -1695,23 +1826,7 @@ def start_waterfall() -> Response:
@listening_post_bp.route('/waterfall/stop', methods=['POST'])
def stop_waterfall() -> Response:
"""Stop the waterfall display."""
global waterfall_running, waterfall_process, waterfall_active_device
waterfall_running = False
if waterfall_process and waterfall_process.poll() is None:
try:
waterfall_process.terminate()
waterfall_process.wait(timeout=1)
except Exception:
try:
waterfall_process.kill()
except Exception:
pass
waterfall_process = None
if waterfall_active_device is not None:
app_module.release_sdr_device(waterfall_active_device)
waterfall_active_device = None
_stop_waterfall_internal()
return jsonify({'status': 'stopped'})
@@ -1725,6 +1840,10 @@ def stream_waterfall() -> Response:
try:
msg = waterfall_queue.get(timeout=SSE_QUEUE_TIMEOUT)
last_keepalive = time.time()
try:
process_event('waterfall', msg, msg.get('type'))
except Exception:
pass
yield format_sse(msg)
except queue.Empty:
now = time.time()
@@ -1736,3 +1855,20 @@ def stream_waterfall() -> Response:
response.headers['Cache-Control'] = 'no-cache'
response.headers['X-Accel-Buffering'] = 'no'
return response
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
+4 -1
View File
@@ -13,7 +13,7 @@ OFFLINE_DEFAULTS = {
'offline.enabled': False,
'offline.assets_source': 'cdn',
'offline.fonts_source': 'cdn',
'offline.tile_provider': 'cartodb_dark_cyan',
'offline.tile_provider': 'cartodb_dark_cyan',
'offline.tile_server_url': ''
}
@@ -44,6 +44,9 @@ ASSET_PATHS = {
'static/vendor/leaflet/images/marker-shadow.png',
'static/vendor/leaflet/images/layers.png',
'static/vendor/leaflet/images/layers-2x.png'
],
'leaflet_heat': [
'static/vendor/leaflet-heat/leaflet-heat.js'
]
}
+125 -4
View File
@@ -2,12 +2,14 @@
from __future__ import annotations
import math
import os
import pathlib
import re
import pty
import queue
import select
import struct
import subprocess
import threading
import time
@@ -23,7 +25,8 @@ from utils.validation import (
validate_rtl_tcp_host, validate_rtl_tcp_port
)
from utils.sse import format_sse
from utils.process import safe_terminate, register_process
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
@@ -105,6 +108,62 @@ def log_message(msg: dict[str, Any]) -> None:
logger.error(f"Failed to log message: {e}")
def audio_relay_thread(
rtl_stdout,
multimon_stdin,
output_queue: queue.Queue,
stop_event: threading.Event,
) -> None:
"""Relay audio from rtl_fm to multimon-ng while computing signal levels.
Reads raw 16-bit LE PCM from *rtl_stdout*, writes every chunk straight
through to *multimon_stdin*, and every ~100 ms pushes an RMS / peak scope
event onto *output_queue*.
"""
CHUNK = 4096 # bytes 2048 samples at 16-bit mono
INTERVAL = 0.1 # seconds between scope updates
last_scope = time.monotonic()
try:
while not stop_event.is_set():
data = rtl_stdout.read(CHUNK)
if not data:
break
# Forward audio untouched
try:
multimon_stdin.write(data)
multimon_stdin.flush()
except (BrokenPipeError, OSError):
break
# Compute scope levels every ~100 ms
now = time.monotonic()
if now - last_scope >= INTERVAL:
last_scope = now
try:
n_samples = len(data) // 2
if n_samples == 0:
continue
samples = struct.unpack(f'<{n_samples}h', data[:n_samples * 2])
peak = max(abs(s) for s in samples)
rms = int(math.sqrt(sum(s * s for s in samples) / n_samples))
output_queue.put_nowait({
'type': 'scope',
'rms': rms,
'peak': peak,
})
except (struct.error, ValueError, queue.Full):
pass
except Exception as e:
logger.debug(f"Audio relay error: {e}")
finally:
try:
multimon_stdin.close()
except OSError:
pass
def stream_decoder(master_fd: int, process: subprocess.Popen[bytes]) -> None:
"""Stream decoder output to queue using PTY for unbuffered output."""
try:
@@ -146,14 +205,37 @@ def stream_decoder(master_fd: int, process: subprocess.Popen[bytes]) -> None:
except Exception as e:
app_module.output_queue.put({'type': 'error', 'text': str(e)})
finally:
global pager_active_device
try:
os.close(master_fd)
except OSError:
pass
process.wait()
# Signal relay thread to stop
with app_module.process_lock:
stop_relay = getattr(app_module.current_process, '_stop_relay', None)
if stop_relay:
stop_relay.set()
# Cleanup companion rtl_fm process and decoder
with app_module.process_lock:
rtl_proc = getattr(app_module.current_process, '_rtl_process', None)
for proc in [rtl_proc, process]:
if proc:
try:
proc.terminate()
proc.wait(timeout=2)
except Exception:
try:
proc.kill()
except Exception:
pass
unregister_process(proc)
app_module.output_queue.put({'type': 'status', 'text': 'stopped'})
with app_module.process_lock:
app_module.current_process = None
# Release SDR device
if pager_active_device is not None:
app_module.release_sdr_device(pager_active_device)
pager_active_device = None
@pager_bp.route('/start', methods=['POST'])
@@ -281,6 +363,7 @@ def start_decoding() -> Response:
stdout=subprocess.PIPE,
stderr=subprocess.PIPE
)
register_process(rtl_process)
# Start a thread to monitor rtl_fm stderr for errors
def monitor_rtl_stderr():
@@ -299,18 +382,30 @@ def start_decoding() -> Response:
multimon_process = subprocess.Popen(
multimon_cmd,
stdin=rtl_process.stdout,
stdin=subprocess.PIPE,
stdout=slave_fd,
stderr=slave_fd,
close_fds=True
)
register_process(multimon_process)
os.close(slave_fd)
rtl_process.stdout.close()
# Spawn audio relay thread between rtl_fm and multimon-ng
stop_relay = threading.Event()
relay = threading.Thread(
target=audio_relay_thread,
args=(rtl_process.stdout, multimon_process.stdin,
app_module.output_queue, stop_relay),
)
relay.daemon = True
relay.start()
app_module.current_process = multimon_process
app_module.current_process._rtl_process = rtl_process
app_module.current_process._master_fd = master_fd
app_module.current_process._stop_relay = stop_relay
app_module.current_process._relay_thread = relay
# Start output thread with PTY master fd
thread = threading.Thread(target=stream_decoder, args=(master_fd, multimon_process))
@@ -322,12 +417,30 @@ def start_decoding() -> Response:
return jsonify({'status': 'started', 'command': full_cmd})
except FileNotFoundError as e:
# Kill orphaned rtl_fm process
try:
rtl_process.terminate()
rtl_process.wait(timeout=2)
except Exception:
try:
rtl_process.kill()
except Exception:
pass
# Release device on failure
if pager_active_device is not None:
app_module.release_sdr_device(pager_active_device)
pager_active_device = None
return jsonify({'status': 'error', 'message': f'Tool not found: {e.filename}'})
except Exception as e:
# Kill orphaned rtl_fm process if it was started
try:
rtl_process.terminate()
rtl_process.wait(timeout=2)
except Exception:
try:
rtl_process.kill()
except Exception:
pass
# Release device on failure
if pager_active_device is not None:
app_module.release_sdr_device(pager_active_device)
@@ -341,6 +454,10 @@ def stop_decoding() -> Response:
with app_module.process_lock:
if app_module.current_process:
# Signal audio relay thread to stop
if hasattr(app_module.current_process, '_stop_relay'):
app_module.current_process._stop_relay.set()
# Kill rtl_fm process first
if hasattr(app_module.current_process, '_rtl_process'):
try:
@@ -433,6 +550,10 @@ def stream() -> Response:
try:
msg = app_module.output_queue.get(timeout=1)
last_keepalive = time.time()
try:
process_event('pager', msg, msg.get('type'))
except Exception:
pass
yield format_sse(msg)
except queue.Empty:
now = time.time()
+109
View File
@@ -0,0 +1,109 @@
"""Session recording API endpoints."""
from __future__ import annotations
from pathlib import Path
from flask import Blueprint, jsonify, request, send_file
from utils.recording import get_recording_manager, RECORDING_ROOT
recordings_bp = Blueprint('recordings', __name__, url_prefix='/recordings')
@recordings_bp.route('/start', methods=['POST'])
def start_recording():
data = request.get_json() or {}
mode = (data.get('mode') or '').strip()
if not mode:
return jsonify({'status': 'error', 'message': 'mode is required'}), 400
label = data.get('label')
metadata = data.get('metadata') if isinstance(data.get('metadata'), dict) else {}
manager = get_recording_manager()
session = manager.start_recording(mode=mode, label=label, metadata=metadata)
return jsonify({
'status': 'success',
'session': {
'id': session.id,
'mode': session.mode,
'label': session.label,
'started_at': session.started_at.isoformat(),
'file_path': str(session.file_path),
}
})
@recordings_bp.route('/stop', methods=['POST'])
def stop_recording():
data = request.get_json() or {}
mode = data.get('mode')
session_id = data.get('id')
manager = get_recording_manager()
session = manager.stop_recording(mode=mode, session_id=session_id)
if not session:
return jsonify({'status': 'error', 'message': 'No active recording found'}), 404
return jsonify({
'status': 'success',
'session': {
'id': session.id,
'mode': session.mode,
'label': session.label,
'started_at': session.started_at.isoformat(),
'stopped_at': session.stopped_at.isoformat() if session.stopped_at else None,
'event_count': session.event_count,
'size_bytes': session.size_bytes,
'file_path': str(session.file_path),
}
})
@recordings_bp.route('', methods=['GET'])
def list_recordings():
manager = get_recording_manager()
limit = request.args.get('limit', default=50, type=int)
return jsonify({
'status': 'success',
'recordings': manager.list_recordings(limit=limit),
'active': manager.get_active(),
})
@recordings_bp.route('/<session_id>', methods=['GET'])
def get_recording(session_id: str):
manager = get_recording_manager()
rec = manager.get_recording(session_id)
if not rec:
return jsonify({'status': 'error', 'message': 'Recording not found'}), 404
return jsonify({'status': 'success', 'recording': rec})
@recordings_bp.route('/<session_id>/download', methods=['GET'])
def download_recording(session_id: str):
manager = get_recording_manager()
rec = manager.get_recording(session_id)
if not rec:
return jsonify({'status': 'error', 'message': 'Recording not found'}), 404
file_path = Path(rec['file_path'])
try:
resolved_root = RECORDING_ROOT.resolve()
resolved_file = file_path.resolve()
if resolved_root not in resolved_file.parents:
return jsonify({'status': 'error', 'message': 'Invalid recording path'}), 400
except Exception:
return jsonify({'status': 'error', 'message': 'Invalid recording path'}), 400
if not file_path.exists():
return jsonify({'status': 'error', 'message': 'Recording file missing'}), 404
return send_file(
file_path,
mimetype='application/x-ndjson',
as_attachment=True,
download_name=file_path.name,
)
+41 -3
View File
@@ -18,7 +18,8 @@ from utils.validation import (
validate_frequency, validate_device_index, validate_gain, validate_ppm
)
from utils.sse import format_sse
from utils.process import safe_terminate, register_process
from utils.event_pipeline import process_event
from utils.process import safe_terminate, register_process, unregister_process
rtlamr_bp = Blueprint('rtlamr', __name__)
@@ -61,10 +62,37 @@ def stream_rtlamr_output(process: subprocess.Popen[bytes]) -> None:
except Exception as e:
app_module.rtlamr_queue.put({'type': 'error', 'text': str(e)})
finally:
process.wait()
global rtl_tcp_process, rtlamr_active_device
# Ensure rtlamr process is terminated
try:
process.terminate()
process.wait(timeout=2)
except Exception:
try:
process.kill()
except Exception:
pass
unregister_process(process)
# Kill companion rtl_tcp process
with rtl_tcp_lock:
if rtl_tcp_process:
try:
rtl_tcp_process.terminate()
rtl_tcp_process.wait(timeout=2)
except Exception:
try:
rtl_tcp_process.kill()
except Exception:
pass
unregister_process(rtl_tcp_process)
rtl_tcp_process = None
app_module.rtlamr_queue.put({'type': 'status', 'text': 'stopped'})
with app_module.rtlamr_lock:
app_module.rtlamr_process = None
# Release SDR device
if rtlamr_active_device is not None:
app_module.release_sdr_device(rtlamr_active_device)
rtlamr_active_device = None
@rtlamr_bp.route('/start_rtlamr', methods=['POST'])
@@ -133,7 +161,8 @@ def start_rtlamr() -> Response:
stdout=subprocess.PIPE,
stderr=subprocess.PIPE
)
register_process(rtl_tcp_process)
# Wait a moment for rtl_tcp to start
time.sleep(3)
@@ -141,6 +170,10 @@ def start_rtlamr() -> Response:
app_module.rtlamr_queue.put({'type': 'info', 'text': f'rtl_tcp: {" ".join(rtl_tcp_cmd)}'})
except Exception as e:
logger.error(f"Failed to start rtl_tcp: {e}")
# Release SDR device on rtl_tcp failure
if rtlamr_active_device is not None:
app_module.release_sdr_device(rtlamr_active_device)
rtlamr_active_device = None
return jsonify({'status': 'error', 'message': f'Failed to start rtl_tcp: {e}'}), 500
# Build rtlamr command
@@ -174,6 +207,7 @@ def start_rtlamr() -> Response:
stdout=subprocess.PIPE,
stderr=subprocess.PIPE
)
register_process(app_module.rtlamr_process)
# Start output thread
thread = threading.Thread(target=stream_rtlamr_output, args=(app_module.rtlamr_process,))
@@ -262,6 +296,10 @@ def stream_rtlamr() -> Response:
try:
msg = app_module.rtlamr_queue.get(timeout=1)
last_keepalive = time.time()
try:
process_event('rtlamr', msg, msg.get('type'))
except Exception:
pass
yield format_sse(msg)
except queue.Empty:
now = time.time()
+38 -31
View File
@@ -31,6 +31,23 @@ ALLOWED_TLE_HOSTS = ['celestrak.org', 'celestrak.com', 'www.celestrak.org', 'www
_tle_cache = dict(TLE_SATELLITES)
def init_tle_auto_refresh():
"""Initialize TLE auto-refresh. Called by app.py after initialization."""
import threading
def _auto_refresh_tle():
try:
updated = refresh_tle_data()
if updated:
logger.info(f"Auto-refreshed TLE data for: {', '.join(updated)}")
except Exception as e:
logger.warning(f"Auto TLE refresh failed: {e}")
# Start auto-refresh in background
threading.Timer(2.0, _auto_refresh_tle).start()
logger.info("TLE auto-refresh scheduled")
def _fetch_iss_realtime(observer_lat: Optional[float] = None, observer_lon: Optional[float] = None) -> Optional[dict]:
"""
Fetch real-time ISS position from external APIs.
@@ -151,17 +168,13 @@ def predict_passes():
except ValueError as e:
return jsonify({'status': 'error', 'message': str(e)}), 400
norad_to_name = {
25544: 'ISS',
25338: 'NOAA-15',
28654: 'NOAA-18',
33591: 'NOAA-19',
43013: 'NOAA-20',
40069: 'METEOR-M2',
57166: 'METEOR-M2-3'
}
sat_input = data.get('satellites', ['ISS', 'NOAA-15', 'NOAA-18', 'NOAA-19'])
norad_to_name = {
25544: 'ISS',
40069: 'METEOR-M2',
57166: 'METEOR-M2-3'
}
sat_input = data.get('satellites', ['ISS', 'METEOR-M2', 'METEOR-M2-3'])
satellites = []
for sat in sat_input:
if isinstance(sat, int) and sat in norad_to_name:
@@ -170,15 +183,11 @@ def predict_passes():
satellites.append(sat)
passes = []
colors = {
'ISS': '#00ffff',
'NOAA-15': '#00ff00',
'NOAA-18': '#ff6600',
'NOAA-19': '#ff3366',
'NOAA-20': '#00ffaa',
'METEOR-M2': '#9370DB',
'METEOR-M2-3': '#ff00ff'
}
colors = {
'ISS': '#00ffff',
'METEOR-M2': '#9370DB',
'METEOR-M2-3': '#ff00ff'
}
name_to_norad = {v: k for k, v in norad_to_name.items()}
ts = load.timescale()
@@ -310,15 +319,11 @@ def get_satellite_position():
sat_input = data.get('satellites', [])
include_track = bool(data.get('includeTrack', True))
norad_to_name = {
25544: 'ISS',
25338: 'NOAA-15',
28654: 'NOAA-18',
33591: 'NOAA-19',
43013: 'NOAA-20',
40069: 'METEOR-M2',
57166: 'METEOR-M2-3'
}
norad_to_name = {
25544: 'ISS',
40069: 'METEOR-M2',
57166: 'METEOR-M2-3'
}
satellites = []
for sat in sat_input:
@@ -481,7 +486,8 @@ def update_tle():
'updated': updated
})
except Exception as e:
return jsonify({'status': 'error', 'message': str(e)})
logger.error(f"Error updating TLE data: {e}")
return jsonify({'status': 'error', 'message': 'TLE update failed'})
@satellite_bp.route('/celestrak/<category>')
@@ -535,4 +541,5 @@ def fetch_celestrak(category):
})
except Exception as e:
return jsonify({'status': 'error', 'message': str(e)})
logger.error(f"Error fetching CelesTrak data: {e}")
return jsonify({'status': 'error', 'message': 'Failed to fetch satellite data'})
+49 -2
View File
@@ -19,7 +19,8 @@ from utils.validation import (
validate_rtl_tcp_host, validate_rtl_tcp_port
)
from utils.sse import format_sse
from utils.process import safe_terminate, register_process
from utils.event_pipeline import process_event
from utils.process import safe_terminate, register_process, unregister_process
from utils.sdr import SDRFactory, SDRType
sensor_bp = Blueprint('sensor', __name__)
@@ -44,6 +45,21 @@ def stream_sensor_output(process: subprocess.Popen[bytes]) -> None:
data['type'] = 'sensor'
app_module.sensor_queue.put(data)
# Push scope event when signal level data is present
rssi = data.get('rssi')
snr = data.get('snr')
noise = data.get('noise')
if rssi is not None or snr is not None:
try:
app_module.sensor_queue.put_nowait({
'type': 'scope',
'rssi': rssi if rssi is not None else 0,
'snr': snr if snr is not None else 0,
'noise': noise if noise is not None else 0,
})
except queue.Full:
pass
# Log if enabled
if app_module.logging_enabled:
try:
@@ -59,10 +75,32 @@ def stream_sensor_output(process: subprocess.Popen[bytes]) -> None:
except Exception as e:
app_module.sensor_queue.put({'type': 'error', 'text': str(e)})
finally:
process.wait()
global sensor_active_device
# Ensure process is terminated
try:
process.terminate()
process.wait(timeout=2)
except Exception:
try:
process.kill()
except Exception:
pass
unregister_process(process)
app_module.sensor_queue.put({'type': 'status', 'text': 'stopped'})
with app_module.sensor_lock:
app_module.sensor_process = None
# Release SDR device
if sensor_active_device is not None:
app_module.release_sdr_device(sensor_active_device)
sensor_active_device = None
@sensor_bp.route('/sensor/status')
def sensor_status() -> Response:
"""Check if sensor decoder is currently running."""
with app_module.sensor_lock:
running = app_module.sensor_process is not None and app_module.sensor_process.poll() is None
return jsonify({'running': running})
@sensor_bp.route('/start_sensor', methods=['POST'])
@@ -143,12 +181,17 @@ def start_sensor() -> Response:
full_cmd = ' '.join(cmd)
logger.info(f"Running: {full_cmd}")
# Add signal level metadata so the frontend scope can display RSSI/SNR
# Disable stats reporting to suppress "row count limit 50 reached" warnings
cmd.extend(['-M', 'level', '-M', 'stats:0'])
try:
app_module.sensor_process = subprocess.Popen(
cmd,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE
)
register_process(app_module.sensor_process)
# Start output thread
thread = threading.Thread(target=stream_sensor_output, args=(app_module.sensor_process,))
@@ -218,6 +261,10 @@ def stream_sensor() -> Response:
try:
msg = app_module.sensor_queue.get(timeout=1)
last_keepalive = time.time()
try:
process_event('sensor', msg, msg.get('type'))
except Exception:
pass
yield format_sse(msg)
except queue.Empty:
now = time.time()
+103 -7
View File
@@ -13,14 +13,14 @@ from typing import Generator
from flask import Blueprint, jsonify, request, Response, send_file
import app as app_module
from utils.logging import get_logger
from utils.sse import format_sse
from utils.event_pipeline import process_event
from utils.sstv import (
get_sstv_decoder,
is_sstv_available,
ISS_SSTV_FREQ,
DecodeProgress,
DopplerInfo,
)
logger = get_logger('intercept.sstv')
@@ -30,15 +30,18 @@ sstv_bp = Blueprint('sstv', __name__, url_prefix='/sstv')
# Queue for SSE progress streaming
_sstv_queue: queue.Queue = queue.Queue(maxsize=100)
# Track which device is being used
sstv_active_device: int | None = None
def _progress_callback(progress: DecodeProgress) -> None:
"""Callback to queue progress updates for SSE stream."""
def _progress_callback(data: dict) -> None:
"""Callback to queue progress/scope updates for SSE stream."""
try:
_sstv_queue.put_nowait(progress.to_dict())
_sstv_queue.put_nowait(data)
except queue.Full:
try:
_sstv_queue.get_nowait()
_sstv_queue.put_nowait(progress.to_dict())
_sstv_queue.put_nowait(data)
except queue.Empty:
pass
@@ -94,7 +97,7 @@ def start_decoder():
if not is_sstv_available():
return jsonify({
'status': 'error',
'message': 'SSTV decoder not available. Install slowrx: apt install slowrx'
'message': 'SSTV decoder not available. Install numpy and Pillow: pip install numpy Pillow'
}), 400
decoder = get_sstv_decoder()
@@ -158,6 +161,17 @@ def start_decoder():
latitude = None
longitude = None
# Claim SDR device
global sstv_active_device
device_int = int(device_index)
error = app_module.claim_sdr_device(device_int, 'sstv')
if error:
return jsonify({
'status': 'error',
'error_type': 'DEVICE_BUSY',
'message': error
}), 409
# Set callback and start
decoder.set_callback(_progress_callback)
success = decoder.start(
@@ -168,6 +182,8 @@ def start_decoder():
)
if success:
sstv_active_device = device_int
result = {
'status': 'started',
'frequency': frequency,
@@ -181,6 +197,8 @@ def start_decoder():
return jsonify(result)
else:
# Release device on failure
app_module.release_sdr_device(device_int)
return jsonify({
'status': 'error',
'message': 'Failed to start decoder'
@@ -195,8 +213,15 @@ def stop_decoder():
Returns:
JSON confirmation.
"""
global sstv_active_device
decoder = get_sstv_decoder()
decoder.stop()
# Release device from registry
if sstv_active_device is not None:
app_module.release_sdr_device(sstv_active_device)
sstv_active_device = None
return jsonify({'status': 'stopped'})
@@ -287,6 +312,73 @@ def get_image(filename: str):
return send_file(image_path, mimetype='image/png')
@sstv_bp.route('/images/<filename>/download')
def download_image(filename: str):
"""
Download a decoded SSTV image file.
Args:
filename: Image filename
Returns:
Image file as attachment or 404.
"""
decoder = get_sstv_decoder()
# Security: only allow alphanumeric filenames with .png extension
if not filename.replace('_', '').replace('-', '').replace('.', '').isalnum():
return jsonify({'status': 'error', 'message': 'Invalid filename'}), 400
if not filename.endswith('.png'):
return jsonify({'status': 'error', 'message': 'Only PNG files supported'}), 400
image_path = decoder._output_dir / filename
if not image_path.exists():
return jsonify({'status': 'error', 'message': 'Image not found'}), 404
return send_file(image_path, mimetype='image/png', as_attachment=True, download_name=filename)
@sstv_bp.route('/images/<filename>', methods=['DELETE'])
def delete_image(filename: str):
"""
Delete a decoded SSTV image.
Args:
filename: Image filename
Returns:
JSON confirmation.
"""
decoder = get_sstv_decoder()
# Security: only allow alphanumeric filenames with .png extension
if not filename.replace('_', '').replace('-', '').replace('.', '').isalnum():
return jsonify({'status': 'error', 'message': 'Invalid filename'}), 400
if not filename.endswith('.png'):
return jsonify({'status': 'error', 'message': 'Only PNG files supported'}), 400
if decoder.delete_image(filename):
return jsonify({'status': 'ok'})
else:
return jsonify({'status': 'error', 'message': 'Image not found'}), 404
@sstv_bp.route('/images', methods=['DELETE'])
def delete_all_images():
"""
Delete all decoded SSTV images.
Returns:
JSON with count of deleted images.
"""
decoder = get_sstv_decoder()
count = decoder.delete_all_images()
return jsonify({'status': 'ok', 'deleted': count})
@sstv_bp.route('/stream')
def stream_progress():
"""
@@ -308,6 +400,10 @@ def stream_progress():
try:
progress = _sstv_queue.get(timeout=1)
last_keepalive = time.time()
try:
process_event('sstv', progress, progress.get('type'))
except Exception:
pass
yield format_sse(progress)
except queue.Empty:
now = time.time()
+56 -6
View File
@@ -15,8 +15,8 @@ from flask import Blueprint, Response, jsonify, request, send_file
from utils.logging import get_logger
from utils.sse import format_sse
from utils.event_pipeline import process_event
from utils.sstv import (
DecodeProgress,
get_general_sstv_decoder,
)
@@ -48,14 +48,14 @@ SSTV_FREQUENCIES = [
_FREQ_MODULATION_MAP = {entry['frequency']: entry['modulation'] for entry in SSTV_FREQUENCIES}
def _progress_callback(progress: DecodeProgress) -> None:
"""Callback to queue progress updates for SSE stream."""
def _progress_callback(data: dict) -> None:
"""Callback to queue progress/scope updates for SSE stream."""
try:
_sstv_general_queue.put_nowait(progress.to_dict())
_sstv_general_queue.put_nowait(data)
except queue.Full:
try:
_sstv_general_queue.get_nowait()
_sstv_general_queue.put_nowait(progress.to_dict())
_sstv_general_queue.put_nowait(data)
except queue.Empty:
pass
@@ -99,7 +99,7 @@ def start_decoder():
if decoder.decoder_available is None:
return jsonify({
'status': 'error',
'message': 'SSTV decoder not available. Install slowrx: apt install slowrx',
'message': 'SSTV decoder not available. Install numpy and Pillow: pip install numpy Pillow',
}), 400
if decoder.is_running:
@@ -217,6 +217,52 @@ def get_image(filename: str):
return send_file(image_path, mimetype='image/png')
@sstv_general_bp.route('/images/<filename>/download')
def download_image(filename: str):
"""Download a decoded SSTV image file."""
decoder = get_general_sstv_decoder()
# Security: only allow alphanumeric filenames with .png extension
if not filename.replace('_', '').replace('-', '').replace('.', '').isalnum():
return jsonify({'status': 'error', 'message': 'Invalid filename'}), 400
if not filename.endswith('.png'):
return jsonify({'status': 'error', 'message': 'Only PNG files supported'}), 400
image_path = decoder._output_dir / filename
if not image_path.exists():
return jsonify({'status': 'error', 'message': 'Image not found'}), 404
return send_file(image_path, mimetype='image/png', as_attachment=True, download_name=filename)
@sstv_general_bp.route('/images/<filename>', methods=['DELETE'])
def delete_image(filename: str):
"""Delete a decoded SSTV image."""
decoder = get_general_sstv_decoder()
# Security: only allow alphanumeric filenames with .png extension
if not filename.replace('_', '').replace('-', '').replace('.', '').isalnum():
return jsonify({'status': 'error', 'message': 'Invalid filename'}), 400
if not filename.endswith('.png'):
return jsonify({'status': 'error', 'message': 'Only PNG files supported'}), 400
if decoder.delete_image(filename):
return jsonify({'status': 'ok'})
else:
return jsonify({'status': 'error', 'message': 'Image not found'}), 404
@sstv_general_bp.route('/images', methods=['DELETE'])
def delete_all_images():
"""Delete all decoded SSTV images."""
decoder = get_general_sstv_decoder()
count = decoder.delete_all_images()
return jsonify({'status': 'ok', 'deleted': count})
@sstv_general_bp.route('/stream')
def stream_progress():
"""SSE stream of SSTV decode progress."""
@@ -228,6 +274,10 @@ def stream_progress():
try:
progress = _sstv_general_queue.get(timeout=1)
last_keepalive = time.time()
try:
process_event('sstv_general', progress, progress.get('type'))
except Exception:
pass
yield format_sse(progress)
except queue.Empty:
now = time.time()
+424
View File
@@ -0,0 +1,424 @@
"""SubGHz transceiver routes.
Provides endpoints for HackRF-based SubGHz signal capture, protocol decoding,
signal replay/transmit, and wideband spectrum analysis.
"""
from __future__ import annotations
import queue
from flask import Blueprint, jsonify, request, Response, send_file
from utils.logging import get_logger
from utils.sse import sse_stream
from utils.subghz import get_subghz_manager
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')
subghz_bp = Blueprint('subghz', __name__, url_prefix='/subghz')
# SSE queue for streaming events to frontend
_subghz_queue: queue.Queue = queue.Queue(maxsize=200)
def _event_callback(event: dict) -> None:
"""Forward SubGhzManager events to the SSE queue."""
try:
_subghz_queue.put_nowait(event)
except queue.Full:
try:
_subghz_queue.get_nowait()
_subghz_queue.put_nowait(event)
except queue.Empty:
pass
def _validate_frequency_hz(data: dict, key: str = 'frequency_hz') -> tuple[int | None, str | None]:
"""Validate frequency in Hz from request data. Returns (freq_hz, error_msg)."""
raw = data.get(key)
if raw is None:
return None, f'{key} is required'
try:
freq_hz = int(raw)
freq_mhz = freq_hz / 1_000_000
if not (SUBGHZ_FREQ_MIN_MHZ <= freq_mhz <= SUBGHZ_FREQ_MAX_MHZ):
return None, f'Frequency must be between {SUBGHZ_FREQ_MIN_MHZ}-{SUBGHZ_FREQ_MAX_MHZ} MHz'
return freq_hz, None
except (ValueError, TypeError):
return None, f'Invalid {key}'
def _validate_serial(data: dict) -> str | None:
"""Extract and validate optional HackRF device serial."""
serial = data.get('device_serial', '')
if not serial or not isinstance(serial, str):
return None
# HackRF serials are hex strings
serial = serial.strip()
if serial and all(c in '0123456789abcdefABCDEF' for c in serial):
return serial
return None
def _validate_int(data: dict, key: str, default: int, min_val: int, max_val: int) -> int:
"""Validate integer parameter with bounds clamping."""
try:
val = int(data.get(key, default))
return max(min_val, min(max_val, val))
except (ValueError, TypeError):
return default
def _validate_decode_profile(data: dict, default: str = 'weather') -> str:
profile = data.get('decode_profile', default)
if not isinstance(profile, str):
return default
profile = profile.strip().lower()
if profile in {'weather', 'all'}:
return profile
return default
def _validate_optional_float(data: dict, key: str) -> tuple[float | None, str | None]:
raw = data.get(key)
if raw is None or raw == '':
return None, None
try:
return float(raw), None
except (ValueError, TypeError):
return None, f'Invalid {key}'
def _validate_bool(data: dict, key: str, default: bool = False) -> bool:
raw = data.get(key, default)
if isinstance(raw, bool):
return raw
if isinstance(raw, (int, float)):
return bool(raw)
if isinstance(raw, str):
return raw.strip().lower() in {'1', 'true', 'yes', 'on', 'enabled'}
return default
# ------------------------------------------------------------------
# STATUS
# ------------------------------------------------------------------
@subghz_bp.route('/status')
def get_status():
manager = get_subghz_manager()
return jsonify(manager.get_status())
@subghz_bp.route('/presets')
def get_presets():
return jsonify({'presets': SUBGHZ_PRESETS, 'sample_rates': SUBGHZ_SAMPLE_RATES})
# ------------------------------------------------------------------
# RECEIVE
# ------------------------------------------------------------------
@subghz_bp.route('/receive/start', methods=['POST'])
def start_receive():
data = request.get_json(silent=True) or {}
freq_hz, err = _validate_frequency_hz(data)
if err:
return jsonify({'status': 'error', 'message': err}), 400
sample_rate = _validate_int(data, 'sample_rate', 2000000, 2000000, 20000000)
lna_gain = _validate_int(data, 'lna_gain', 32, 0, SUBGHZ_LNA_GAIN_MAX)
vga_gain = _validate_int(data, 'vga_gain', 20, 0, SUBGHZ_VGA_GAIN_MAX)
trigger_enabled = _validate_bool(data, 'trigger_enabled', False)
trigger_pre_ms = _validate_int(data, 'trigger_pre_ms', 350, 50, 5000)
trigger_post_ms = _validate_int(data, 'trigger_post_ms', 700, 100, 10000)
device_serial = _validate_serial(data)
manager = get_subghz_manager()
manager.set_callback(_event_callback)
result = manager.start_receive(
frequency_hz=freq_hz,
sample_rate=sample_rate,
lna_gain=lna_gain,
vga_gain=vga_gain,
trigger_enabled=trigger_enabled,
trigger_pre_ms=trigger_pre_ms,
trigger_post_ms=trigger_post_ms,
device_serial=device_serial,
)
status_code = 200 if result.get('status') != 'error' else 409
return jsonify(result), status_code
@subghz_bp.route('/receive/stop', methods=['POST'])
def stop_receive():
manager = get_subghz_manager()
result = manager.stop_receive()
return jsonify(result)
# ------------------------------------------------------------------
# DECODE
# ------------------------------------------------------------------
@subghz_bp.route('/decode/start', methods=['POST'])
def start_decode():
data = request.get_json(silent=True) or {}
freq_hz, err = _validate_frequency_hz(data)
if err:
return jsonify({'status': 'error', 'message': err}), 400
sample_rate = _validate_int(data, 'sample_rate', 2000000, 2000000, 20000000)
lna_gain = _validate_int(data, 'lna_gain', 32, 0, SUBGHZ_LNA_GAIN_MAX)
vga_gain = _validate_int(data, 'vga_gain', 20, 0, SUBGHZ_VGA_GAIN_MAX)
decode_profile = _validate_decode_profile(data)
device_serial = _validate_serial(data)
manager = get_subghz_manager()
manager.set_callback(_event_callback)
result = manager.start_decode(
frequency_hz=freq_hz,
sample_rate=sample_rate,
lna_gain=lna_gain,
vga_gain=vga_gain,
decode_profile=decode_profile,
device_serial=device_serial,
)
status_code = 200 if result.get('status') != 'error' else 409
return jsonify(result), status_code
@subghz_bp.route('/decode/stop', methods=['POST'])
def stop_decode():
manager = get_subghz_manager()
result = manager.stop_decode()
return jsonify(result)
# ------------------------------------------------------------------
# TRANSMIT
# ------------------------------------------------------------------
@subghz_bp.route('/transmit', methods=['POST'])
def start_transmit():
data = request.get_json(silent=True) or {}
capture_id = data.get('capture_id')
if not capture_id or not isinstance(capture_id, str):
return jsonify({'status': 'error', 'message': 'capture_id is required'}), 400
# Sanitize capture_id
if not capture_id.isalnum():
return jsonify({'status': 'error', 'message': 'Invalid capture_id'}), 400
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)
start_seconds, start_err = _validate_optional_float(data, 'start_seconds')
if start_err:
return jsonify({'status': 'error', 'message': start_err}), 400
duration_seconds, duration_err = _validate_optional_float(data, 'duration_seconds')
if duration_err:
return jsonify({'status': 'error', 'message': duration_err}), 400
device_serial = _validate_serial(data)
manager = get_subghz_manager()
manager.set_callback(_event_callback)
result = manager.transmit(
capture_id=capture_id,
tx_gain=tx_gain,
max_duration=max_duration,
start_seconds=start_seconds,
duration_seconds=duration_seconds,
device_serial=device_serial,
)
status_code = 200 if result.get('status') != 'error' else 400
return jsonify(result), status_code
@subghz_bp.route('/transmit/stop', methods=['POST'])
def stop_transmit():
manager = get_subghz_manager()
result = manager.stop_transmit()
return jsonify(result)
# ------------------------------------------------------------------
# SWEEP
# ------------------------------------------------------------------
@subghz_bp.route('/sweep/start', methods=['POST'])
def start_sweep():
data = request.get_json(silent=True) or {}
try:
freq_start = float(data.get('freq_start_mhz', 300))
freq_end = float(data.get('freq_end_mhz', 928))
if freq_start >= freq_end:
return jsonify({'status': 'error', 'message': 'freq_start must be less than freq_end'}), 400
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
except (ValueError, TypeError):
return jsonify({'status': 'error', 'message': 'Invalid frequency range'}), 400
bin_width = _validate_int(data, 'bin_width', 100000, 10000, 5000000)
device_serial = _validate_serial(data)
manager = get_subghz_manager()
manager.set_callback(_event_callback)
result = manager.start_sweep(
freq_start_mhz=freq_start,
freq_end_mhz=freq_end,
bin_width=bin_width,
device_serial=device_serial,
)
status_code = 200 if result.get('status') != 'error' else 409
return jsonify(result), status_code
@subghz_bp.route('/sweep/stop', methods=['POST'])
def stop_sweep():
manager = get_subghz_manager()
result = manager.stop_sweep()
return jsonify(result)
# ------------------------------------------------------------------
# CAPTURES LIBRARY
# ------------------------------------------------------------------
@subghz_bp.route('/captures')
def list_captures():
manager = get_subghz_manager()
captures = manager.list_captures()
return jsonify({
'status': 'ok',
'captures': [c.to_dict() for c in captures],
'count': len(captures),
})
@subghz_bp.route('/captures/<capture_id>')
def get_capture(capture_id: str):
if not capture_id.isalnum():
return jsonify({'status': 'error', 'message': 'Invalid capture_id'}), 400
manager = get_subghz_manager()
capture = manager.get_capture(capture_id)
if not capture:
return jsonify({'status': 'error', 'message': 'Capture not found'}), 404
return jsonify({'status': 'ok', 'capture': capture.to_dict()})
@subghz_bp.route('/captures/<capture_id>/download')
def download_capture(capture_id: str):
if not capture_id.isalnum():
return jsonify({'status': 'error', 'message': 'Invalid capture_id'}), 400
manager = get_subghz_manager()
path = manager.get_capture_path(capture_id)
if not path:
return jsonify({'status': 'error', 'message': 'Capture not found'}), 404
return send_file(
path,
mimetype='application/octet-stream',
as_attachment=True,
download_name=path.name,
)
@subghz_bp.route('/captures/<capture_id>/trim', methods=['POST'])
def trim_capture(capture_id: str):
if not capture_id.isalnum():
return jsonify({'status': 'error', 'message': 'Invalid capture_id'}), 400
data = request.get_json(silent=True) or {}
start_seconds, start_err = _validate_optional_float(data, 'start_seconds')
if start_err:
return jsonify({'status': 'error', 'message': start_err}), 400
duration_seconds, duration_err = _validate_optional_float(data, 'duration_seconds')
if duration_err:
return jsonify({'status': 'error', 'message': duration_err}), 400
label = data.get('label', '')
if label is None:
label = ''
if not isinstance(label, str) or len(label) > 100:
return jsonify({'status': 'error', 'message': 'Label must be a string (max 100 chars)'}), 400
manager = get_subghz_manager()
result = manager.trim_capture(
capture_id=capture_id,
start_seconds=start_seconds,
duration_seconds=duration_seconds,
label=label,
)
if result.get('status') == 'ok':
return jsonify(result), 200
message = str(result.get('message') or 'Trim failed')
status_code = 404 if 'not found' in message.lower() else 400
return jsonify(result), status_code
@subghz_bp.route('/captures/<capture_id>', methods=['DELETE'])
def delete_capture(capture_id: str):
if not capture_id.isalnum():
return jsonify({'status': 'error', 'message': 'Invalid capture_id'}), 400
manager = get_subghz_manager()
if manager.delete_capture(capture_id):
return jsonify({'status': 'deleted', 'id': capture_id})
return jsonify({'status': 'error', 'message': 'Capture not found'}), 404
@subghz_bp.route('/captures/<capture_id>', methods=['PATCH'])
def update_capture(capture_id: str):
if not capture_id.isalnum():
return jsonify({'status': 'error', 'message': 'Invalid capture_id'}), 400
data = request.get_json(silent=True) or {}
label = data.get('label', '')
if not isinstance(label, str) or len(label) > 100:
return jsonify({'status': 'error', 'message': 'Label must be a string (max 100 chars)'}), 400
manager = get_subghz_manager()
if manager.update_capture_label(capture_id, label):
return jsonify({'status': 'updated', 'id': capture_id, 'label': label})
return jsonify({'status': 'error', 'message': 'Capture not found'}), 404
# ------------------------------------------------------------------
# SSE STREAM
# ------------------------------------------------------------------
@subghz_bp.route('/stream')
def stream():
response = Response(sse_stream(_subghz_queue), mimetype='text/event-stream')
response.headers['Cache-Control'] = 'no-cache'
response.headers['X-Accel-Buffering'] = 'no'
response.headers['Connection'] = 'keep-alive'
return response
+150 -1
View File
@@ -60,6 +60,7 @@ from utils.tscm.device_identity import (
ingest_ble_dict,
ingest_wifi_dict,
)
from utils.event_pipeline import process_event
# Import unified Bluetooth scanner helper for TSCM integration
try:
@@ -550,6 +551,12 @@ def _start_sweep_internal(
}
@tscm_bp.route('/status')
def tscm_status():
"""Check if any TSCM operation is currently running."""
return jsonify({'running': _sweep_running})
@tscm_bp.route('/sweep/start', methods=['POST'])
def start_sweep():
"""Start a TSCM sweep."""
@@ -627,6 +634,10 @@ def sweep_stream():
try:
if tscm_queue:
msg = tscm_queue.get(timeout=1)
try:
process_event('tscm', msg, msg.get('type'))
except Exception:
pass
yield f"data: {json.dumps(msg)}\n\n"
else:
time.sleep(1)
@@ -1072,6 +1083,32 @@ def _scan_wifi_networks(interface: str) -> list[dict]:
return []
def _scan_wifi_clients(interface: str) -> list[dict]:
"""
Get WiFi client observations from the unified WiFi scanner.
Clients are only available when monitor-mode scanning is active.
"""
try:
from utils.wifi import get_wifi_scanner
scanner = get_wifi_scanner()
if interface:
try:
if not scanner._is_monitor_mode_interface(interface):
return []
except Exception:
return []
return [client.to_dict() for client in scanner.clients]
except ImportError as e:
logger.error(f"Failed to import wifi scanner: {e}")
return []
except Exception as e:
logger.exception(f"WiFi client scan failed: {e}")
return []
def _scan_bluetooth_devices(interface: str, duration: int = 10) -> list[dict]:
"""
Scan for Bluetooth devices with manufacturer data detection.
@@ -1606,6 +1643,7 @@ def _run_sweep(
threats_found = 0
severity_counts = {'critical': 0, 'high': 0, 'medium': 0, 'low': 0}
all_wifi = {} # Use dict for deduplication by BSSID
all_wifi_clients = {} # Use dict for deduplication by client MAC
all_bt = {} # Use dict for deduplication by MAC
all_rf = []
@@ -1702,6 +1740,7 @@ def _run_sweep(
'channel': network.get('channel', ''),
'signal': network.get('power', ''),
'security': network.get('privacy', ''),
'vendor': network.get('vendor'),
'is_threat': is_threat,
'is_new': not classification.get('in_baseline', False),
'classification': profile.risk_level.value,
@@ -1715,6 +1754,77 @@ def _run_sweep(
})
except Exception as e:
logger.error(f"WiFi device processing error for {network.get('bssid', '?')}: {e}")
# WiFi clients (monitor mode only)
try:
wifi_clients = _scan_wifi_clients(wifi_interface)
for client in wifi_clients:
mac = (client.get('mac') or '').upper()
if not mac or mac in all_wifi_clients:
continue
all_wifi_clients[mac] = client
rssi_val = client.get('rssi_current')
if rssi_val is None:
rssi_val = client.get('rssi_median') or client.get('rssi_ema')
client_device = {
'mac': mac,
'vendor': client.get('vendor'),
'name': client.get('vendor') or 'WiFi Client',
'rssi': rssi_val,
'associated_bssid': client.get('associated_bssid'),
'probed_ssids': client.get('probed_ssids', []),
'probe_count': client.get('probe_count', len(client.get('probed_ssids', []))),
'is_client': True,
}
try:
timeline_manager.add_observation(
identifier=mac,
protocol='wifi',
rssi=rssi_val,
name=client_device.get('vendor') or f'WiFi Client {mac[-5:]}',
attributes={'client': True, 'associated_bssid': client_device.get('associated_bssid')}
)
except Exception as e:
logger.debug(f"WiFi client timeline observation error: {e}")
_maybe_store_timeline(
identifier=mac,
protocol='wifi',
rssi=rssi_val,
attributes={'client': True, 'associated_bssid': client_device.get('associated_bssid')}
)
profile = correlation.analyze_wifi_device(client_device)
client_device['classification'] = profile.risk_level.value
client_device['score'] = profile.total_score
client_device['score_modifier'] = profile.score_modifier
client_device['known_device'] = profile.known_device
client_device['known_device_name'] = profile.known_device_name
client_device['indicators'] = [
{'type': i.type.value, 'desc': i.description}
for i in profile.indicators
]
client_device['recommended_action'] = profile.recommended_action
# Feed to identity engine for MAC-randomization resistant clustering
try:
wifi_obs = {
'timestamp': datetime.now().isoformat(),
'src_mac': mac,
'bssid': client_device.get('associated_bssid'),
'rssi': rssi_val,
'frame_type': 'probe_request',
'probed_ssids': client_device.get('probed_ssids', []),
}
ingest_wifi_dict(wifi_obs)
except Exception as e:
logger.debug(f"Identity engine WiFi client ingest error: {e}")
_emit_event('wifi_client', client_device)
except Exception as e:
logger.debug(f"WiFi client scan error: {e}")
except Exception as e:
last_wifi_scan = current_time
logger.error(f"WiFi scan error: {e}")
@@ -1793,6 +1903,9 @@ def _run_sweep(
'name': device.get('name', 'Unknown'),
'device_type': device.get('type', ''),
'rssi': device.get('rssi', ''),
'manufacturer': device.get('manufacturer'),
'tracker': device.get('tracker'),
'tracker_type': device.get('tracker_type'),
'is_threat': is_threat,
'is_new': not classification.get('in_baseline', False),
'classification': profile.risk_level.value,
@@ -1921,6 +2034,7 @@ def _run_sweep(
comparator = BaselineComparator(baseline)
baseline_comparison = comparator.compare_all(
wifi_devices=list(all_wifi.values()),
wifi_clients=list(all_wifi_clients.values()),
bt_devices=list(all_bt.values()),
rf_signals=all_rf
)
@@ -1936,6 +2050,7 @@ def _run_sweep(
if verbose_results:
wifi_payload = list(all_wifi.values())
wifi_client_payload = list(all_wifi_clients.values())
bt_payload = list(all_bt.values())
rf_payload = list(all_rf)
else:
@@ -1951,6 +2066,28 @@ def _run_sweep(
}
for d in all_wifi.values()
]
wifi_client_payload = []
for client in all_wifi_clients.values():
mac = client.get('mac') or client.get('address')
if isinstance(mac, str):
mac = mac.upper()
probed_ssids = client.get('probed_ssids') or []
rssi = client.get('rssi')
if rssi is None:
rssi = client.get('rssi_current')
if rssi is None:
rssi = client.get('rssi_median')
if rssi is None:
rssi = client.get('rssi_ema')
wifi_client_payload.append({
'mac': mac,
'vendor': client.get('vendor'),
'rssi': rssi,
'associated_bssid': client.get('associated_bssid'),
'is_associated': client.get('is_associated'),
'probed_ssids': probed_ssids,
'probe_count': client.get('probe_count', len(probed_ssids)),
})
bt_payload = [
{
'mac': d.get('mac') or d.get('address'),
@@ -1975,9 +2112,11 @@ def _run_sweep(
status='completed',
results={
'wifi_devices': wifi_payload,
'wifi_clients': wifi_client_payload,
'bt_devices': bt_payload,
'rf_signals': rf_payload,
'wifi_count': len(all_wifi),
'wifi_client_count': len(all_wifi_clients),
'bt_count': len(all_bt),
'rf_count': len(all_rf),
'severity_counts': severity_counts,
@@ -2005,6 +2144,7 @@ def _run_sweep(
'total_new': baseline_comparison['total_new'],
'total_missing': baseline_comparison['total_missing'],
'wifi': baseline_comparison.get('wifi'),
'wifi_clients': baseline_comparison.get('wifi_clients'),
'bluetooth': baseline_comparison.get('bluetooth'),
'rf': baseline_comparison.get('rf'),
})
@@ -2022,6 +2162,7 @@ def _run_sweep(
'sweep_id': _current_sweep_id,
'threats_found': threats_found,
'wifi_count': len(all_wifi),
'wifi_client_count': len(all_wifi_clients),
'bt_count': len(all_bt),
'rf_count': len(all_rf),
'severity_counts': severity_counts,
@@ -2169,6 +2310,7 @@ def compare_against_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)
@@ -2177,12 +2319,14 @@ def compare_against_baseline():
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
)
@@ -2276,7 +2420,10 @@ def feed_wifi():
"""Feed WiFi device data for baseline recording."""
data = request.get_json()
if data:
_baseline_recorder.add_wifi_device(data)
if data.get('is_client'):
_baseline_recorder.add_wifi_client(data)
else:
_baseline_recorder.add_wifi_device(data)
return jsonify({'status': 'success'})
@@ -2928,12 +3075,14 @@ def get_baseline_diff(baseline_id: int, sweep_id: int):
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
+386
View File
@@ -0,0 +1,386 @@
"""WebSocket-based waterfall streaming with I/Q capture and server-side FFT."""
import json
import queue
import socket
import subprocess
import threading
import time
from flask import Flask
try:
from flask_sock import Sock
WEBSOCKET_AVAILABLE = True
except ImportError:
WEBSOCKET_AVAILABLE = False
Sock = None
from utils.logging import get_logger
from utils.process import safe_terminate, register_process, unregister_process
from utils.waterfall_fft import (
build_binary_frame,
compute_power_spectrum,
cu8_to_complex,
quantize_to_uint8,
)
from utils.sdr import SDRFactory, SDRType
from utils.sdr.base import SDRCapabilities, SDRDevice
logger = get_logger('intercept.waterfall_ws')
# 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 _resolve_sdr_type(sdr_type_str: str) -> SDRType:
"""Convert client sdr_type string to SDRType enum."""
mapping = {
'rtlsdr': SDRType.RTL_SDR,
'rtl_sdr': SDRType.RTL_SDR,
'hackrf': SDRType.HACKRF,
'limesdr': SDRType.LIME_SDR,
'lime_sdr': 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:
"""Build a minimal SDRDevice for command building."""
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 init_waterfall_websocket(app: Flask):
"""Initialize WebSocket waterfall streaming."""
if not WEBSOCKET_AVAILABLE:
logger.warning("flask-sock not installed, WebSocket waterfall disabled")
return
sock = Sock(app)
@sock.route('/ws/waterfall')
def waterfall_stream(ws):
"""WebSocket endpoint for real-time waterfall streaming."""
logger.info("WebSocket waterfall client connected")
# Import app module for device claiming
import app as app_module
iq_process = None
reader_thread = None
stop_event = threading.Event()
claimed_device = None
# Queue for outgoing messages — only the main loop touches ws.send()
send_queue = queue.Queue(maxsize=120)
try:
while True:
# Drain send queue first (non-blocking)
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.1)
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:
# simple-websocket returns None on timeout AND on
# close; check ws.connected to tell them apart.
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_device = None
stop_event.clear()
# Flush stale frames from previous capture
while not send_queue.empty():
try:
send_queue.get_nowait()
except queue.Empty:
break
# Allow USB device to be released by the kernel
if was_restarting:
time.sleep(0.5)
# Parse config
center_freq = float(data.get('center_freq', 100.0))
span_mhz = float(data.get('span_mhz', 2.0))
gain = data.get('gain')
if gain is not None:
gain = float(gain)
device_index = int(data.get('device', 0))
sdr_type_str = data.get('sdr_type', 'rtlsdr')
fft_size = int(data.get('fft_size', 1024))
fps = int(data.get('fps', 25))
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))
# Clamp FFT size to valid powers of 2
fft_size = max(256, min(8192, fft_size))
# Resolve SDR type and bandwidth
sdr_type = _resolve_sdr_type(sdr_type_str)
max_bw = MAX_BANDWIDTH.get(sdr_type, 2400000)
span_hz = int(span_mhz * 1e6)
sample_rate = min(span_hz, max_bw)
# Compute effective frequency range
effective_span_mhz = sample_rate / 1e6
start_freq = center_freq - effective_span_mhz / 2
end_freq = center_freq + effective_span_mhz / 2
# Claim the device
claim_err = app_module.claim_sdr_device(device_index, 'waterfall')
if claim_err:
ws.send(json.dumps({
'status': 'error',
'message': claim_err,
'error_type': 'DEVICE_BUSY',
}))
continue
claimed_device = device_index
# Build I/Q capture command
try:
builder = SDRFactory.get_builder(sdr_type)
device = _build_dummy_device(device_index, sdr_type)
iq_cmd = builder.build_iq_capture_command(
device=device,
frequency_mhz=center_freq,
sample_rate=sample_rate,
gain=gain,
ppm=ppm,
bias_t=bias_t,
)
except NotImplementedError as e:
app_module.release_sdr_device(device_index)
claimed_device = None
ws.send(json.dumps({
'status': 'error',
'message': str(e),
}))
continue
# Spawn I/Q capture process (retry to handle USB release lag)
max_attempts = 3 if was_restarting else 1
try:
for attempt in range(max_attempts):
logger.info(
f"Starting I/Q capture: {center_freq} MHz, "
f"span={effective_span_mhz:.1f} MHz, "
f"sr={sample_rate}, fft={fft_size}"
)
iq_process = subprocess.Popen(
iq_cmd,
stdout=subprocess.PIPE,
stderr=subprocess.DEVNULL,
bufsize=0,
)
register_process(iq_process)
# Brief check that process started
time.sleep(0.3)
if iq_process.poll() is not None:
unregister_process(iq_process)
iq_process = None
if attempt < max_attempts - 1:
logger.info(
f"I/Q process exited immediately, "
f"retrying ({attempt + 1}/{max_attempts})..."
)
time.sleep(0.5)
continue
raise RuntimeError(
"I/Q capture process exited immediately"
)
break # Process started successfully
except Exception as e:
logger.error(f"Failed to start 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)
claimed_device = None
ws.send(json.dumps({
'status': 'error',
'message': f'Failed to start I/Q capture: {e}',
}))
continue
# Send started confirmation
ws.send(json.dumps({
'status': 'started',
'start_freq': start_freq,
'end_freq': end_freq,
'fft_size': fft_size,
'sample_rate': sample_rate,
}))
# Start reader thread — puts frames on queue, never calls ws.send()
def fft_reader(
proc, _send_q, stop_evt,
_fft_size, _avg_count, _fps,
_start_freq, _end_freq,
):
"""Read I/Q from subprocess, compute FFT, enqueue binary frames."""
bytes_per_frame = _fft_size * _avg_count * 2
frame_interval = 1.0 / _fps
try:
while not stop_evt.is_set():
if proc.poll() is not None:
break
frame_start = time.monotonic()
# Read raw I/Q bytes
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
# Process FFT pipeline
samples = cu8_to_complex(raw)
power_db = compute_power_spectrum(
samples,
fft_size=_fft_size,
avg_count=_avg_count,
)
quantized = quantize_to_uint8(power_db)
frame = build_binary_frame(
_start_freq, _end_freq, quantized,
)
try:
_send_q.put_nowait(frame)
except queue.Full:
# Drop frame if main loop can't keep up
pass
# 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"FFT reader stopped: {e}")
reader_thread = threading.Thread(
target=fft_reader,
args=(
iq_process, send_queue, stop_event,
fft_size, avg_count, fps,
start_freq, end_freq,
),
daemon=True,
)
reader_thread.start()
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_device = None
stop_event.clear()
ws.send(json.dumps({'status': 'stopped'}))
except Exception as e:
logger.info(f"WebSocket waterfall closed: {e}")
finally:
# Cleanup
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)
# Complete WebSocket close handshake, then shut down the
# raw socket so Werkzeug cannot write its HTTP 200 response
# on top of the WebSocket stream (which browsers see as
# "Invalid frame header").
try:
ws.close()
except Exception:
pass
try:
ws.sock.shutdown(socket.SHUT_RDWR)
except Exception:
pass
try:
ws.sock.close()
except Exception:
pass
logger.info("WebSocket waterfall client disconnected")
+626
View File
@@ -0,0 +1,626 @@
"""Weather Satellite decoder routes.
Provides endpoints for capturing and decoding weather satellite images
from NOAA (APT) and Meteor (LRPT) satellites using SatDump.
"""
from __future__ import annotations
import queue
from flask import Blueprint, jsonify, request, Response, send_file
from utils.logging import get_logger
from utils.sse import sse_stream
from utils.validation import validate_device_index, validate_gain, validate_latitude, validate_longitude, validate_elevation
from utils.weather_sat import (
get_weather_sat_decoder,
is_weather_sat_available,
CaptureProgress,
WEATHER_SATELLITES,
)
logger = get_logger('intercept.weather_sat')
weather_sat_bp = Blueprint('weather_sat', __name__, url_prefix='/weather-sat')
# Queue for SSE progress streaming
_weather_sat_queue: queue.Queue = queue.Queue(maxsize=100)
def _progress_callback(progress: CaptureProgress) -> None:
"""Callback to queue progress updates for SSE stream."""
try:
_weather_sat_queue.put_nowait(progress.to_dict())
except queue.Full:
try:
_weather_sat_queue.get_nowait()
_weather_sat_queue.put_nowait(progress.to_dict())
except queue.Empty:
pass
@weather_sat_bp.route('/status')
def get_status():
"""Get weather satellite decoder status.
Returns:
JSON with decoder availability and current status.
"""
decoder = get_weather_sat_decoder()
return jsonify(decoder.get_status())
@weather_sat_bp.route('/satellites')
def list_satellites():
"""Get list of supported weather satellites with frequencies.
Returns:
JSON with satellite definitions.
"""
satellites = []
for key, info in WEATHER_SATELLITES.items():
satellites.append({
'key': key,
'name': info['name'],
'frequency': info['frequency'],
'mode': info['mode'],
'description': info['description'],
'active': info['active'],
})
return jsonify({
'status': 'ok',
'satellites': satellites,
})
@weather_sat_bp.route('/start', methods=['POST'])
def start_capture():
"""Start weather satellite capture and decode.
JSON body:
{
"satellite": "NOAA-18", // Required: satellite key
"device": 0, // RTL-SDR device index (default: 0)
"gain": 40.0, // SDR gain in dB (default: 40)
"bias_t": false // Enable bias-T for LNA (default: false)
}
Returns:
JSON with start status.
"""
if not is_weather_sat_available():
return jsonify({
'status': 'error',
'message': 'SatDump not installed. Build from source: https://github.com/SatDump/SatDump'
}), 400
decoder = get_weather_sat_decoder()
if decoder.is_running:
return jsonify({
'status': 'already_running',
'satellite': decoder.current_satellite,
'frequency': decoder.current_frequency,
})
data = request.get_json(silent=True) or {}
# Validate satellite
satellite = data.get('satellite')
if not satellite or satellite not in WEATHER_SATELLITES:
return jsonify({
'status': 'error',
'message': f'Invalid satellite. Must be one of: {", ".join(WEATHER_SATELLITES.keys())}'
}), 400
# Validate device index and gain
try:
device_index = validate_device_index(data.get('device', 0))
gain = validate_gain(data.get('gain', 40.0))
except ValueError as e:
logger.warning('Invalid parameter in start_capture: %s', e)
return jsonify({
'status': 'error',
'message': 'Invalid parameter value'
}), 400
bias_t = bool(data.get('bias_t', False))
# Claim SDR device
try:
import app as app_module
error = app_module.claim_sdr_device(device_index, 'weather_sat')
if error:
return jsonify({
'status': 'error',
'error_type': 'DEVICE_BUSY',
'message': error,
}), 409
except ImportError:
pass
# Clear queue
while not _weather_sat_queue.empty():
try:
_weather_sat_queue.get_nowait()
except queue.Empty:
break
# Set callback and on-complete handler for SDR release
decoder.set_callback(_progress_callback)
def _release_device():
try:
import app as app_module
app_module.release_sdr_device(device_index)
except ImportError:
pass
decoder.set_on_complete(_release_device)
success = decoder.start(
satellite=satellite,
device_index=device_index,
gain=gain,
bias_t=bias_t,
)
if success:
sat_info = WEATHER_SATELLITES[satellite]
return jsonify({
'status': 'started',
'satellite': satellite,
'frequency': sat_info['frequency'],
'mode': sat_info['mode'],
'device': device_index,
})
else:
# Release device on failure
_release_device()
return jsonify({
'status': 'error',
'message': 'Failed to start capture'
}), 500
@weather_sat_bp.route('/test-decode', methods=['POST'])
def test_decode():
"""Start weather satellite decode from a pre-recorded file.
No SDR hardware is required decodes an IQ baseband or WAV file
using SatDump offline mode.
JSON body:
{
"satellite": "NOAA-18", // Required: satellite key
"input_file": "/path/to/file", // Required: server-side file path
"sample_rate": 1000000 // Sample rate in Hz (default: 1000000)
}
Returns:
JSON with start status.
"""
if not is_weather_sat_available():
return jsonify({
'status': 'error',
'message': 'SatDump not installed. Build from source: https://github.com/SatDump/SatDump'
}), 400
decoder = get_weather_sat_decoder()
if decoder.is_running:
return jsonify({
'status': 'already_running',
'satellite': decoder.current_satellite,
'frequency': decoder.current_frequency,
})
data = request.get_json(silent=True) or {}
# Validate satellite
satellite = data.get('satellite')
if not satellite or satellite not in WEATHER_SATELLITES:
return jsonify({
'status': 'error',
'message': f'Invalid satellite. Must be one of: {", ".join(WEATHER_SATELLITES.keys())}'
}), 400
# Validate input file
input_file = data.get('input_file')
if not input_file:
return jsonify({
'status': 'error',
'message': 'input_file is required'
}), 400
from pathlib import Path
input_path = Path(input_file)
# Security: restrict to data directory (anchored to app root, not CWD)
allowed_base = Path(__file__).resolve().parent.parent / 'data'
try:
resolved = input_path.resolve()
if not resolved.is_relative_to(allowed_base):
return jsonify({
'status': 'error',
'message': 'input_file must be under the data/ directory'
}), 403
except (OSError, ValueError):
return jsonify({
'status': 'error',
'message': 'Invalid file path'
}), 400
if not input_path.is_file():
logger.warning("Test-decode file not found")
return jsonify({
'status': 'error',
'message': 'File not found'
}), 404
# Validate sample rate
sample_rate = data.get('sample_rate', 1000000)
try:
sample_rate = int(sample_rate)
if sample_rate < 1000 or sample_rate > 20000000:
raise ValueError
except (TypeError, ValueError):
return jsonify({
'status': 'error',
'message': 'Invalid sample_rate (1000-20000000)'
}), 400
# Clear queue
while not _weather_sat_queue.empty():
try:
_weather_sat_queue.get_nowait()
except queue.Empty:
break
# Set callback — no on_complete needed (no SDR to release)
decoder.set_callback(_progress_callback)
decoder.set_on_complete(None)
success = decoder.start_from_file(
satellite=satellite,
input_file=input_file,
sample_rate=sample_rate,
)
if success:
sat_info = WEATHER_SATELLITES[satellite]
return jsonify({
'status': 'started',
'satellite': satellite,
'frequency': sat_info['frequency'],
'mode': sat_info['mode'],
'source': 'file',
'input_file': str(input_file),
})
else:
return jsonify({
'status': 'error',
'message': 'Failed to start file decode'
}), 500
@weather_sat_bp.route('/stop', methods=['POST'])
def stop_capture():
"""Stop weather satellite capture.
Returns:
JSON confirmation.
"""
decoder = get_weather_sat_decoder()
device_index = decoder.device_index
decoder.stop()
# Release SDR device
try:
import app as app_module
app_module.release_sdr_device(device_index)
except ImportError:
pass
return jsonify({'status': 'stopped'})
@weather_sat_bp.route('/images')
def list_images():
"""Get list of decoded weather satellite images.
Query parameters:
limit: Maximum number of images (default: all)
satellite: Filter by satellite key (optional)
Returns:
JSON with list of decoded images.
"""
decoder = get_weather_sat_decoder()
images = decoder.get_images()
# Filter by satellite if specified
satellite_filter = request.args.get('satellite')
if satellite_filter:
images = [img for img in images if img.satellite == satellite_filter]
# Apply limit
limit = request.args.get('limit', type=int)
if limit and limit > 0:
images = images[-limit:]
return jsonify({
'status': 'ok',
'images': [img.to_dict() for img in images],
'count': len(images),
})
@weather_sat_bp.route('/images/<filename>')
def get_image(filename: str):
"""Serve a decoded weather satellite image file.
Args:
filename: Image filename
Returns:
Image file or 404.
"""
decoder = get_weather_sat_decoder()
# Security: only allow safe filenames
if not filename.replace('_', '').replace('-', '').replace('.', '').isalnum():
return jsonify({'status': 'error', 'message': 'Invalid filename'}), 400
if not (filename.endswith('.png') or filename.endswith('.jpg') or filename.endswith('.jpeg')):
return jsonify({'status': 'error', 'message': 'Only PNG/JPG files supported'}), 400
image_path = decoder._output_dir / filename
if not image_path.exists():
return jsonify({'status': 'error', 'message': 'Image not found'}), 404
mimetype = 'image/png' if filename.endswith('.png') else 'image/jpeg'
return send_file(image_path, mimetype=mimetype)
@weather_sat_bp.route('/images/<filename>', methods=['DELETE'])
def delete_image(filename: str):
"""Delete a decoded image.
Args:
filename: Image filename
Returns:
JSON confirmation.
"""
decoder = get_weather_sat_decoder()
if not filename.replace('_', '').replace('-', '').replace('.', '').isalnum():
return jsonify({'status': 'error', 'message': 'Invalid filename'}), 400
if decoder.delete_image(filename):
return jsonify({'status': 'deleted', 'filename': filename})
else:
return jsonify({'status': 'error', 'message': 'Image not found'}), 404
@weather_sat_bp.route('/images', methods=['DELETE'])
def delete_all_images():
"""Delete all decoded weather satellite images.
Returns:
JSON with count of deleted images.
"""
decoder = get_weather_sat_decoder()
count = decoder.delete_all_images()
return jsonify({'status': 'ok', 'deleted': count})
@weather_sat_bp.route('/stream')
def stream_progress():
"""SSE stream of capture/decode progress.
Returns:
SSE stream (text/event-stream)
"""
response = Response(sse_stream(_weather_sat_queue), mimetype='text/event-stream')
response.headers['Cache-Control'] = 'no-cache'
response.headers['X-Accel-Buffering'] = 'no'
response.headers['Connection'] = 'keep-alive'
return response
@weather_sat_bp.route('/passes')
def get_passes():
"""Get upcoming weather satellite passes for observer location.
Query parameters:
latitude: Observer latitude (required)
longitude: Observer longitude (required)
hours: Hours to predict ahead (default: 24, max: 72)
min_elevation: Minimum elevation in degrees (default: 15)
trajectory: Include az/el trajectory points (default: false)
ground_track: Include lat/lon ground track points (default: false)
Returns:
JSON with upcoming passes for all weather satellites.
"""
include_trajectory = request.args.get('trajectory', 'false').lower() in ('true', '1')
include_ground_track = request.args.get('ground_track', 'false').lower() in ('true', '1')
raw_lat = request.args.get('latitude')
raw_lon = request.args.get('longitude')
if raw_lat is None or raw_lon is None:
return jsonify({
'status': 'error',
'message': 'latitude and longitude parameters required'
}), 400
try:
lat = validate_latitude(raw_lat)
lon = validate_longitude(raw_lon)
except ValueError as e:
logger.warning('Invalid coordinates in get_passes: %s', e)
return jsonify({'status': 'error', 'message': 'Invalid coordinates'}), 400
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))
try:
from utils.weather_sat_predict import predict_passes
all_passes = predict_passes(
lat=lat,
lon=lon,
hours=hours,
min_elevation=min_elevation,
include_trajectory=include_trajectory,
include_ground_track=include_ground_track,
)
return jsonify({
'status': 'ok',
'passes': all_passes,
'count': len(all_passes),
'observer': {'latitude': lat, 'longitude': lon},
'prediction_hours': hours,
'min_elevation': min_elevation,
})
except ImportError:
return jsonify({
'status': 'error',
'message': 'skyfield library not installed'
}), 503
except Exception as e:
logger.error(f"Error predicting passes: {e}")
return jsonify({
'status': 'error',
'message': 'Pass prediction failed'
}), 500
# ========================
# Auto-Scheduler Endpoints
# ========================
def _scheduler_event_callback(event: dict) -> None:
"""Forward scheduler events to the SSE queue."""
try:
_weather_sat_queue.put_nowait(event)
except queue.Full:
try:
_weather_sat_queue.get_nowait()
_weather_sat_queue.put_nowait(event)
except queue.Empty:
pass
@weather_sat_bp.route('/schedule/enable', methods=['POST'])
def enable_schedule():
"""Enable auto-scheduling of weather satellite captures.
JSON body:
{
"latitude": 51.5, // Required
"longitude": -0.1, // Required
"min_elevation": 15, // Minimum pass elevation (default: 15)
"device": 0, // RTL-SDR device index (default: 0)
"gain": 40.0, // SDR gain (default: 40)
"bias_t": false // Enable bias-T (default: false)
}
Returns:
JSON with scheduler status.
"""
from utils.weather_sat_scheduler import get_weather_sat_scheduler
data = request.get_json(silent=True) or {}
if data.get('latitude') is None or data.get('longitude') is None:
return jsonify({
'status': 'error',
'message': 'latitude and longitude required'
}), 400
try:
lat = validate_latitude(data.get('latitude'))
lon = validate_longitude(data.get('longitude'))
min_elev = validate_elevation(data.get('min_elevation', 15))
device = validate_device_index(data.get('device', 0))
gain_val = validate_gain(data.get('gain', 40.0))
except ValueError as e:
logger.warning('Invalid parameter in enable_schedule: %s', e)
return jsonify({
'status': 'error',
'message': 'Invalid parameter value'
}), 400
scheduler = get_weather_sat_scheduler()
scheduler.set_callbacks(_progress_callback, _scheduler_event_callback)
result = scheduler.enable(
lat=lat,
lon=lon,
min_elevation=min_elev,
device=device,
gain=gain_val,
bias_t=bool(data.get('bias_t', False)),
)
return jsonify({'status': 'ok', **result})
@weather_sat_bp.route('/schedule/disable', methods=['POST'])
def disable_schedule():
"""Disable auto-scheduling."""
from utils.weather_sat_scheduler import get_weather_sat_scheduler
scheduler = get_weather_sat_scheduler()
result = scheduler.disable()
return jsonify(result)
@weather_sat_bp.route('/schedule/status')
def schedule_status():
"""Get current scheduler state."""
from utils.weather_sat_scheduler import get_weather_sat_scheduler
scheduler = get_weather_sat_scheduler()
return jsonify(scheduler.get_status())
@weather_sat_bp.route('/schedule/passes')
def schedule_passes():
"""List scheduled passes."""
from utils.weather_sat_scheduler import get_weather_sat_scheduler
scheduler = get_weather_sat_scheduler()
passes = scheduler.get_passes()
return jsonify({
'status': 'ok',
'passes': passes,
'count': len(passes),
})
@weather_sat_bp.route('/schedule/skip/<pass_id>', methods=['POST'])
def skip_pass(pass_id: str):
"""Skip a scheduled pass."""
from utils.weather_sat_scheduler import get_weather_sat_scheduler
if not pass_id.replace('_', '').replace('-', '').isalnum():
return jsonify({'status': 'error', 'message': 'Invalid pass ID'}), 400
scheduler = get_weather_sat_scheduler()
if scheduler.skip_pass(pass_id):
return jsonify({'status': 'skipped', 'pass_id': pass_id})
else:
return jsonify({'status': 'error', 'message': 'Pass not found or already processed'}), 404
+97 -36
View File
@@ -17,11 +17,12 @@ from flask import Blueprint, jsonify, request, Response
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
from data.oui import get_manufacturer
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
from utils.event_pipeline import process_event
from data.oui import get_manufacturer
from utils.constants import (
WIFI_TERMINATE_TIMEOUT,
PMKID_TERMINATE_TIMEOUT,
@@ -46,8 +47,33 @@ from utils.constants import (
wifi_bp = Blueprint('wifi', __name__, url_prefix='/wifi')
# PMKID process state
pmkid_process = None
pmkid_lock = threading.Lock()
pmkid_process = None
pmkid_lock = threading.Lock()
def _parse_channel_list(raw_channels: Any) -> list[int] | None:
"""Parse a channel list from string/list input."""
if raw_channels in (None, '', []):
return None
if isinstance(raw_channels, str):
parts = [p.strip() for p in re.split(r'[\s,]+', raw_channels) if p.strip()]
elif isinstance(raw_channels, (list, tuple, set)):
parts = list(raw_channels)
else:
parts = [raw_channels]
channels: list[int] = []
seen = set()
for part in parts:
if part in (None, ''):
continue
ch = validate_wifi_channel(part)
if ch not in seen:
channels.append(ch)
seen.add(ch)
return channels or None
def detect_wifi_interfaces():
@@ -607,8 +633,9 @@ def start_wifi_scan():
return jsonify({'status': 'error', 'message': 'Scan already running'})
data = request.json
channel = data.get('channel')
band = data.get('band', 'abg')
channel = data.get('channel')
channels = data.get('channels')
band = data.get('band', 'abg')
# Use provided interface or fall back to stored monitor interface
interface = data.get('interface')
@@ -658,8 +685,17 @@ def start_wifi_scan():
interface
]
if channel:
cmd.extend(['-c', str(channel)])
channel_list = None
if channels:
try:
channel_list = _parse_channel_list(channels)
except ValueError as e:
return jsonify({'status': 'error', 'message': str(e)}), 400
if channel_list:
cmd.extend(['-c', ','.join(str(c) for c in channel_list)])
elif channel:
cmd.extend(['-c', str(channel)])
logger.info(f"Running: {' '.join(cmd)}")
@@ -851,32 +887,53 @@ def check_handshake_status():
return jsonify({'status': 'stopped', 'file_exists': False, 'handshake_found': False})
file_size = os.path.getsize(capture_file)
handshake_found = False
handshake_found = False
handshake_valid: bool | None = None
handshake_checked = False
handshake_reason: str | None = None
try:
if target_bssid and is_valid_mac(target_bssid):
aircrack_path = get_tool_path('aircrack-ng')
if aircrack_path:
result = subprocess.run(
[aircrack_path, '-a', '2', '-b', target_bssid, capture_file],
capture_output=True, text=True, timeout=10
)
output = result.stdout + result.stderr
if '1 handshake' in output or ('handshake' in output.lower() and 'wpa' in output.lower()):
if '0 handshake' not in output:
handshake_found = True
if target_bssid and is_valid_mac(target_bssid):
aircrack_path = get_tool_path('aircrack-ng')
if aircrack_path:
result = subprocess.run(
[aircrack_path, '-a', '2', '-b', target_bssid, capture_file],
capture_output=True, text=True, timeout=10
)
output = result.stdout + result.stderr
output_lower = output.lower()
handshake_checked = True
if 'no valid wpa handshakes found' in output_lower:
handshake_valid = False
handshake_reason = 'No valid WPA handshake found'
elif '0 handshake' in output_lower:
handshake_valid = False
elif '1 handshake' in output_lower or ('handshake' in output_lower and 'wpa' in output_lower):
handshake_valid = True
else:
handshake_valid = False
except subprocess.TimeoutExpired:
pass
except Exception as e:
logger.error(f"Error checking handshake: {e}")
return jsonify({
'status': 'running' if app_module.wifi_process and app_module.wifi_process.poll() is None else 'stopped',
'file_exists': True,
'file_size': file_size,
'file': capture_file,
'handshake_found': handshake_found
})
except Exception as e:
logger.error(f"Error checking handshake: {e}")
if handshake_valid:
handshake_found = True
normalized_bssid = target_bssid.upper() if target_bssid else None
if normalized_bssid and normalized_bssid not in app_module.wifi_handshakes:
app_module.wifi_handshakes.append(normalized_bssid)
return jsonify({
'status': 'running' if app_module.wifi_process and app_module.wifi_process.poll() is None else 'stopped',
'file_exists': True,
'file_size': file_size,
'file': capture_file,
'handshake_found': handshake_found,
'handshake_valid': handshake_valid,
'handshake_checked': handshake_checked,
'handshake_reason': handshake_reason
})
@wifi_bp.route('/pmkid/capture', methods=['POST'])
@@ -1084,9 +1141,13 @@ def stream_wifi():
while True:
try:
msg = app_module.wifi_queue.get(timeout=1)
last_keepalive = time.time()
yield format_sse(msg)
msg = app_module.wifi_queue.get(timeout=1)
last_keepalive = time.time()
try:
process_event('wifi', msg, msg.get('type'))
except Exception:
pass
yield format_sse(msg)
except queue.Empty:
now = time.time()
if now - last_keepalive >= keepalive_interval:
+50 -28
View File
@@ -16,14 +16,16 @@ from typing import Generator
from flask import Blueprint, jsonify, request, Response
from utils.wifi import (
get_wifi_scanner,
analyze_channels,
get_hidden_correlator,
SCAN_MODE_QUICK,
SCAN_MODE_DEEP,
)
from utils.sse import format_sse
from utils.wifi import (
get_wifi_scanner,
analyze_channels,
get_hidden_correlator,
SCAN_MODE_QUICK,
SCAN_MODE_DEEP,
)
from utils.sse import format_sse
from utils.validation import validate_wifi_channel
from utils.event_pipeline import process_event
logger = logging.getLogger(__name__)
@@ -85,28 +87,44 @@ def start_deep_scan():
Requires monitor mode interface and root privileges.
Request body:
interface: Monitor mode interface (e.g., 'wlan0mon')
band: Band to scan ('2.4', '5', 'all')
channel: Optional specific channel to monitor
Request body:
interface: Monitor mode interface (e.g., 'wlan0mon')
band: Band to scan ('2.4', '5', 'all')
channel: Optional specific channel to monitor
channels: Optional list or comma-separated channels to monitor
"""
data = request.get_json() or {}
interface = data.get('interface')
band = data.get('band', 'all')
channel = data.get('channel')
if channel:
try:
channel = int(channel)
except ValueError:
return jsonify({'error': 'Invalid channel'}), 400
channel = data.get('channel')
channels = data.get('channels')
channel_list = None
if channels:
if isinstance(channels, str):
channel_list = [c.strip() for c in channels.split(',') if c.strip()]
elif isinstance(channels, (list, tuple, set)):
channel_list = list(channels)
else:
channel_list = [channels]
try:
channel_list = [validate_wifi_channel(c) for c in channel_list]
except (TypeError, ValueError):
return jsonify({'error': 'Invalid channels'}), 400
if channel:
try:
channel = validate_wifi_channel(channel)
except ValueError:
return jsonify({'error': 'Invalid channel'}), 400
scanner = get_wifi_scanner()
success = scanner.start_deep_scan(
interface=interface,
band=band,
channel=channel,
)
success = scanner.start_deep_scan(
interface=interface,
band=band,
channel=channel,
channels=channel_list,
)
if success:
return jsonify({
@@ -388,10 +406,14 @@ def event_stream():
- keepalive: Periodic keepalive
"""
def generate() -> Generator[str, None, None]:
scanner = get_wifi_scanner()
for event in scanner.get_event_stream():
yield format_sse(event)
scanner = get_wifi_scanner()
for event in scanner.get_event_stream():
try:
process_event('wifi', event, event.get('type'))
except Exception:
pass
yield format_sse(event)
response = Response(generate(), mimetype='text/event-stream')
response.headers['Cache-Control'] = 'no-cache'
+160 -75
View File
@@ -165,6 +165,7 @@ detect_dragonos() {
# Required tool checks (with alternates)
# ----------------------------
missing_required=()
missing_recommended=()
check_required() {
local label="$1"; shift
@@ -178,6 +179,18 @@ check_required() {
fi
}
check_recommended() {
local label="$1"; shift
local desc="$1"; shift
if have_any "$@"; then
ok "${label} - ${desc}"
else
warn "${label} - ${desc} (missing, recommended)"
missing_recommended+=("$label")
fi
}
check_optional() {
local label="$1"; shift
local desc="$1"; shift
@@ -201,11 +214,12 @@ check_tools() {
check_required "multimon-ng" "Pager decoder" multimon-ng
check_required "rtl_433" "433MHz sensor decoder" rtl_433 rtl433
check_optional "rtlamr" "Utility meter decoder (requires Go)" rtlamr
check_optional "hackrf_transfer" "HackRF SubGHz transceiver" hackrf_transfer
check_optional "hackrf_sweep" "HackRF spectrum analyzer" hackrf_sweep
check_required "dump1090" "ADS-B decoder" dump1090
check_required "acarsdec" "ACARS decoder" acarsdec
check_required "AIS-catcher" "AIS vessel decoder" AIS-catcher aiscatcher
check_optional "slowrx" "SSTV decoder (ISS images)" slowrx
check_optional "satdump" "Weather satellite decoder (NOAA/Meteor)" satdump
echo
info "GPS:"
check_required "gpsd" "GPS daemon" gpsd
@@ -390,42 +404,6 @@ install_rtlamr_from_source() {
fi
}
install_slowrx_from_source_macos() {
info "slowrx not available via Homebrew. Building from source..."
# Ensure build dependencies are installed
brew_install fftw
brew_install libsndfile
brew_install gtk+3
brew_install pkg-config
(
tmp_dir="$(mktemp -d)"
trap 'rm -rf "$tmp_dir"' EXIT
info "Cloning slowrx..."
git clone --depth 1 https://github.com/windytan/slowrx.git "$tmp_dir/slowrx" >/dev/null 2>&1 \
|| { warn "Failed to clone slowrx"; exit 1; }
cd "$tmp_dir/slowrx"
info "Compiling slowrx..."
# slowrx uses a plain Makefile, not CMake
local make_log
make_log=$(make 2>&1) || {
warn "make failed for slowrx:"
echo "$make_log" | tail -20
exit 1
}
# Install to /usr/local/bin
if [[ -w /usr/local/bin ]]; then
install -m 0755 slowrx /usr/local/bin/slowrx
else
sudo install -m 0755 slowrx /usr/local/bin/slowrx
fi
ok "slowrx installed successfully from source"
)
}
install_multimon_ng_from_source_macos() {
info "multimon-ng not available via Homebrew. Building from source..."
@@ -642,8 +620,93 @@ install_aiscatcher_from_source_macos() {
)
}
install_satdump_from_source_debian() {
info "Building SatDump v1.2.2 from source (weather satellite decoder)..."
apt_install build-essential git cmake pkg-config \
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 libfftw3-dev
# Run in subshell to isolate EXIT trap
(
tmp_dir="$(mktemp -d)"
trap 'rm -rf "$tmp_dir"' EXIT
info "Cloning SatDump v1.2.2..."
git clone --depth 1 --branch 1.2.2 https://github.com/SatDump/SatDump.git "$tmp_dir/SatDump" >/dev/null 2>&1 \
|| { warn "Failed to clone SatDump"; exit 1; }
cd "$tmp_dir/SatDump"
mkdir -p build && cd build
info "Compiling SatDump (this may take a while)..."
if cmake -DCMAKE_BUILD_TYPE=Release -DBUILD_GUI=OFF -DCMAKE_INSTALL_LIBDIR=lib .. >/dev/null 2>&1 \
&& make -j "$(nproc)" >/dev/null 2>&1; then
$SUDO make install >/dev/null 2>&1
$SUDO ldconfig
# Ensure plugins are in the expected path (handles multiarch differences)
$SUDO 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
$SUDO ln -sf "$dir"/*.so /usr/local/lib/satdump/plugins/
break
fi
done
fi
ok "SatDump installed successfully."
else
warn "Failed to build SatDump from source. Weather satellite decoding will not be available."
fi
)
}
install_satdump_from_source_macos() {
info "Building SatDump v1.2.2 from source (weather satellite decoder)..."
brew_install cmake
brew_install libpng
brew_install libtiff
brew_install jemalloc
brew_install libvolk
brew_install nng
brew_install zstd
brew_install soapysdr
brew_install hackrf
brew_install fftw
# Run in subshell to isolate EXIT trap
(
tmp_dir="$(mktemp -d)"
trap 'rm -rf "$tmp_dir"' EXIT
info "Cloning SatDump v1.2.2..."
git clone --depth 1 --branch 1.2.2 https://github.com/SatDump/SatDump.git "$tmp_dir/SatDump" >/dev/null 2>&1 \
|| { warn "Failed to clone SatDump"; exit 1; }
cd "$tmp_dir/SatDump"
mkdir -p build && cd build
info "Compiling SatDump (this may take a while)..."
if cmake -DCMAKE_BUILD_TYPE=Release -DBUILD_GUI=OFF .. >/dev/null 2>&1 \
&& make -j "$(sysctl -n hw.ncpu)" >/dev/null 2>&1; then
if [[ -w /usr/local/bin ]]; then
make install >/dev/null 2>&1
else
sudo make install >/dev/null 2>&1
fi
ok "SatDump installed successfully."
else
warn "Failed to build SatDump from source. Weather satellite decoding will not be available."
fi
)
}
install_macos_packages() {
TOTAL_STEPS=17
TOTAL_STEPS=19
CURRENT_STEP=0
progress "Checking Homebrew"
@@ -663,8 +726,8 @@ install_macos_packages() {
progress "Installing direwolf (APRS decoder)"
(brew_install direwolf) || warn "direwolf not available via Homebrew"
progress "Skipping slowrx (SSTV decoder)"
warn "slowrx requires ALSA (Linux-only) and cannot build on macOS. Skipping."
progress "SSTV decoder"
ok "SSTV uses built-in pure Python decoder (no external tools needed)"
progress "Installing DSD (Digital Speech Decoder, optional)"
if ! cmd_exists dsd && ! cmd_exists dsd-fme; then
@@ -685,6 +748,9 @@ install_macos_packages() {
progress "Installing rtl_433"
brew_install rtl_433
progress "Installing HackRF tools"
brew_install hackrf
progress "Installing rtlamr (optional)"
# rtlamr is optional - used for utility meter monitoring
if ! cmd_exists rtlamr; then
@@ -720,6 +786,19 @@ install_macos_packages() {
ok "AIS-catcher already installed"
fi
progress "Installing SatDump (optional)"
if ! cmd_exists satdump; then
echo
info "SatDump is used for weather satellite imagery (NOAA APT & Meteor LRPT)."
if ask_yes_no "Do you want to install SatDump?"; then
install_satdump_from_source_macos || warn "SatDump build failed. Weather satellite decoding will not be available."
else
warn "Skipping SatDump installation. You can install it later if needed."
fi
else
ok "SatDump already installed"
fi
progress "Installing aircrack-ng"
brew_install aircrack-ng
@@ -882,37 +961,6 @@ install_aiscatcher_from_source_debian() {
)
}
install_slowrx_from_source_debian() {
info "slowrx not available via APT. Building from source..."
# slowrx uses a simple Makefile, not CMake
apt_install build-essential git pkg-config \
libfftw3-dev libsndfile1-dev libgtk-3-dev libasound2-dev libpulse-dev
# Run in subshell to isolate EXIT trap
(
tmp_dir="$(mktemp -d)"
trap 'rm -rf "$tmp_dir"' EXIT
info "Cloning slowrx..."
git clone --depth 1 https://github.com/windytan/slowrx.git "$tmp_dir/slowrx" >/dev/null 2>&1 \
|| { warn "Failed to clone slowrx"; exit 1; }
cd "$tmp_dir/slowrx"
info "Compiling slowrx..."
local make_log
make_log=$(make 2>&1) || {
warn "make failed for slowrx:"
echo "$make_log" | tail -20
warn "ISS SSTV decoding will not be available."
exit 1
}
$SUDO install -m 0755 slowrx /usr/local/bin/slowrx
ok "slowrx installed successfully."
)
}
install_ubertooth_from_source_debian() {
info "Building Ubertooth from source..."
@@ -1048,7 +1096,7 @@ install_debian_packages() {
export NEEDRESTART_MODE=a
fi
TOTAL_STEPS=22
TOTAL_STEPS=26
CURRENT_STEP=0
progress "Updating APT package lists"
@@ -1104,8 +1152,8 @@ install_debian_packages() {
progress "Installing direwolf (APRS decoder)"
apt_install direwolf || true
progress "Installing slowrx (SSTV decoder)"
apt_install slowrx || cmd_exists slowrx || install_slowrx_from_source_debian || warn "slowrx not available. ISS SSTV decoding will not be available."
progress "SSTV decoder"
ok "SSTV uses built-in pure Python decoder (no external tools needed)"
progress "Installing DSD (Digital Speech Decoder, optional)"
if ! cmd_exists dsd && ! cmd_exists dsd-fme; then
@@ -1126,6 +1174,9 @@ install_debian_packages() {
progress "Installing rtl_433"
apt_try_install_any rtl-433 rtl433 || warn "rtl-433 not available"
progress "Installing HackRF tools"
apt_install hackrf || warn "hackrf tools not available"
progress "Installing rtlamr (optional)"
# rtlamr is optional - used for utility meter monitoring
if ! cmd_exists rtlamr; then
@@ -1205,6 +1256,19 @@ install_debian_packages() {
ok "AIS-catcher already installed"
fi
progress "Installing SatDump (optional)"
if ! cmd_exists satdump; then
echo
info "SatDump is used for weather satellite imagery (NOAA APT & Meteor LRPT)."
if ask_yes_no "Do you want to install SatDump?"; then
install_satdump_from_source_debian || warn "SatDump build failed. Weather satellite decoding will not be available."
else
warn "Skipping SatDump installation. You can install it later if needed."
fi
else
ok "SatDump already installed"
fi
progress "Configuring udev rules"
setup_udev_rules_debian
@@ -1254,6 +1318,14 @@ final_summary_and_hard_fail() {
exit 1
fi
fi
if [[ "${#missing_recommended[@]}" -gt 0 ]]; then
echo
warn "Missing RECOMMENDED tools (some features will not work):"
for t in "${missing_recommended[@]}"; do echo " - $t"; done
echo
warn "Install these for full functionality"
fi
}
# ----------------------------
@@ -1300,6 +1372,19 @@ main() {
fi
install_python_deps
# Download leaflet-heat plugin (offline mode)
if [ ! -f "static/vendor/leaflet-heat/leaflet-heat.js" ]; then
info "Downloading leaflet-heat plugin..."
mkdir -p static/vendor/leaflet-heat
if curl -sL "https://unpkg.com/leaflet.heat@0.2.0/dist/leaflet-heat.js" \
-o static/vendor/leaflet-heat/leaflet-heat.js; then
ok "leaflet-heat plugin downloaded"
else
warn "Failed to download leaflet-heat plugin. Heatmap will use CDN."
fi
fi
final_summary_and_hard_fail
}
+11
View File
@@ -19,6 +19,17 @@
min-width: max-content;
}
/* Strip title badge */
.function-strip .strip-title {
font-size: 9px;
font-weight: 700;
letter-spacing: 1.5px;
text-transform: uppercase;
color: var(--text-muted);
white-space: nowrap;
padding: 4px 0;
}
/* Stats */
.function-strip .strip-stat {
display: flex;
+132 -3
View File
@@ -1448,6 +1448,7 @@ header h1 .tagline {
height: calc(100dvh - 96px);
height: calc(100vh - 96px); /* Fallback */
overflow: hidden;
position: relative;
}
@media (min-width: 1024px) {
@@ -1457,6 +1458,18 @@ header h1 .tagline {
height: calc(100dvh - 96px);
height: calc(100vh - 96px); /* Fallback */
}
.main-content.sidebar-collapsed {
grid-template-columns: 0 1fr;
}
.main-content.sidebar-collapsed > .sidebar {
width: 0;
min-width: 0;
padding: 0;
border-right: 0;
overflow: hidden;
}
}
.sidebar {
@@ -1480,6 +1493,63 @@ header h1 .tagline {
display: none;
}
.sidebar-collapse-btn {
width: 100%;
display: flex;
align-items: center;
justify-content: center;
gap: 6px;
padding: 8px 10px;
margin-bottom: 6px;
border: 1px solid var(--border-color);
background: var(--bg-tertiary);
color: var(--text-secondary);
border-radius: 6px;
cursor: pointer;
font-family: var(--font-mono);
font-size: 10px;
text-transform: uppercase;
letter-spacing: 0.06em;
}
.sidebar-collapse-btn:hover {
border-color: var(--accent-cyan);
color: var(--accent-cyan);
}
.sidebar-expand-handle {
display: none;
position: absolute;
top: 12px;
left: 10px;
z-index: 12;
width: 28px;
height: 28px;
align-items: center;
justify-content: center;
border: 1px solid var(--border-color);
background: var(--bg-tertiary);
color: var(--accent-cyan);
border-radius: 6px;
cursor: pointer;
}
.main-content.sidebar-collapsed .sidebar-expand-handle {
display: inline-flex;
}
/* Reserve space for the expand handle so it doesn't overlap mode titles */
.main-content.sidebar-collapsed .output-header {
padding-left: 48px;
}
@media (max-width: 1023px) {
.sidebar-collapse-btn,
.sidebar-expand-handle {
display: none !important;
}
}
.section {
background: var(--bg-tertiary);
border: 1px solid var(--border-color);
@@ -1528,8 +1598,10 @@ header h1 .tagline {
.section.collapsed h3 {
border-bottom: none;
margin-bottom: 0;
padding-bottom: 10px;
margin-bottom: 0 !important;
min-height: 0 !important;
padding-top: 10px !important;
padding-bottom: 10px !important;
}
.section.collapsed h3::after {
@@ -1538,7 +1610,8 @@ header h1 .tagline {
}
.section.collapsed {
padding-bottom: 0;
padding-bottom: 0 !important;
min-height: 0;
}
.section.collapsed>*:not(h3) {
@@ -2313,6 +2386,45 @@ header h1 .tagline {
display: block;
}
/* Normalize spacing for all sidebar mode panels */
.sidebar .mode-content.active:not(#meshtasticMode) {
display: flex;
flex-direction: column;
gap: 10px;
}
.sidebar .mode-content.active:not(#meshtasticMode) > * {
margin: 0 !important;
}
.sidebar .mode-content.active:not(#meshtasticMode) > .section {
margin: 0 !important;
}
.mode-actions-bottom {
display: flex;
flex-direction: column;
gap: 8px;
}
.sidebar .mode-content.active:not(#meshtasticMode) > .mode-actions-bottom {
margin-top: auto !important;
}
#btMessageContainer:empty {
display: none;
}
.alpha-mode-notice {
padding: 8px 10px;
border: 1px solid rgba(245, 158, 11, 0.45);
background: rgba(245, 158, 11, 0.12);
color: var(--accent-yellow);
border-radius: 6px;
font-size: 10px;
line-height: 1.45;
}
/* Aircraft (ADS-B) Styles */
.aircraft-card {
padding: 12px;
@@ -4201,6 +4313,12 @@ header h1 .tagline {
color: #000;
}
.bt-detail-btn.active {
background: rgba(34, 197, 94, 0.2);
border-color: rgba(34, 197, 94, 0.6);
color: #9fffd1;
}
/* Selected device highlight */
.bt-device-row.selected {
background: rgba(0, 212, 255, 0.1);
@@ -4392,6 +4510,17 @@ header h1 .tagline {
border: 1px solid rgba(139, 92, 246, 0.3);
}
.bt-history-badge {
display: inline-block;
padding: 1px 4px;
border-radius: 3px;
font-size: 8px;
font-weight: 600;
letter-spacing: 0.2px;
background: rgba(34, 197, 94, 0.15);
color: #22c55e;
}
.bt-device-name {
font-size: 13px;
font-weight: 600;
+47
View File
@@ -326,3 +326,50 @@
.aprs-meter-status.no-signal {
color: var(--accent-yellow);
}
/* APRS map markers (flat SVG icons) */
.aprs-map-marker-wrap {
background: transparent;
border: none;
}
.aprs-map-marker {
display: inline-flex;
align-items: center;
gap: 5px;
padding: 2px 7px 2px 5px;
border-radius: 999px;
border: 1px solid rgba(74, 158, 255, 0.35);
background: rgba(10, 18, 28, 0.88);
color: var(--text-primary);
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.35);
}
.aprs-map-marker-icon {
display: inline-flex;
width: 14px;
height: 14px;
color: var(--accent-cyan);
}
.aprs-map-marker-icon svg {
width: 14px;
height: 14px;
display: block;
fill: currentColor;
}
.aprs-map-marker-label {
font-size: 9px;
font-weight: 600;
line-height: 1;
letter-spacing: 0.02em;
}
.aprs-map-marker.vehicle .aprs-map-marker-icon {
color: var(--accent-green);
}
.aprs-map-marker.tower .aprs-map-marker-icon {
color: var(--accent-cyan);
}
+3 -1
View File
@@ -340,7 +340,9 @@
MODE VISIBILITY - Ensure sidebar shows when active
============================================ */
#spystationsMode.active {
display: block !important;
display: flex !important;
flex-direction: column;
gap: 10px;
}
/* ============================================
+195 -2
View File
@@ -7,7 +7,9 @@
MODE VISIBILITY
============================================ */
#sstvGeneralMode.active {
display: block !important;
display: flex !important;
flex-direction: column;
gap: 10px;
}
/* ============================================
@@ -329,12 +331,12 @@
}
.sstv-general-image-card {
position: relative;
background: var(--bg-secondary);
border: 1px solid var(--border-color);
border-radius: 6px;
overflow: hidden;
transition: all 0.15s ease;
cursor: pointer;
}
.sstv-general-image-card:hover {
@@ -343,6 +345,10 @@
box-shadow: 0 4px 12px rgba(0, 212, 255, 0.2);
}
.sstv-general-image-card-inner {
cursor: pointer;
}
.sstv-general-image-preview {
width: 100%;
aspect-ratio: 4/3;
@@ -351,6 +357,48 @@
display: block;
}
.sstv-general-image-actions {
position: absolute;
bottom: 0;
left: 0;
right: 0;
display: flex;
justify-content: flex-end;
gap: 4px;
padding: 6px;
background: linear-gradient(transparent, rgba(0, 0, 0, 0.8));
opacity: 0;
transition: opacity 0.15s;
}
.sstv-general-image-card:hover .sstv-general-image-actions {
opacity: 1;
}
.sstv-general-image-actions button {
display: flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
padding: 0;
background: rgba(255, 255, 255, 0.1);
border: 1px solid rgba(255, 255, 255, 0.2);
border-radius: 4px;
color: white;
cursor: pointer;
transition: all 0.15s;
}
.sstv-general-image-actions button:hover {
background: rgba(255, 255, 255, 0.25);
}
.sstv-general-image-actions button:last-child:hover {
background: var(--accent-red, #ff3366);
border-color: var(--accent-red, #ff3366);
}
.sstv-general-image-info {
padding: 8px 10px;
border-top: 1px solid var(--border-color);
@@ -389,6 +437,96 @@
margin-bottom: 12px;
}
/* ============================================
SIGNAL MONITOR
============================================ */
.sstv-general-signal-monitor {
width: 100%;
max-width: 320px;
padding: 16px;
background: var(--bg-secondary);
border: 1px solid var(--border-color);
border-radius: 8px;
}
.sstv-general-signal-monitor-header {
display: flex;
align-items: center;
gap: 8px;
font-family: var(--font-mono);
font-size: 10px;
font-weight: 600;
color: var(--text-secondary);
text-transform: uppercase;
letter-spacing: 0.5px;
margin-bottom: 14px;
}
.sstv-general-signal-monitor-header svg {
color: var(--accent-cyan);
}
.sstv-general-signal-level-row {
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 10px;
}
.sstv-general-signal-level-label {
font-family: var(--font-mono);
font-size: 9px;
color: var(--text-dim);
text-transform: uppercase;
letter-spacing: 0.5px;
flex-shrink: 0;
}
.sstv-general-signal-bar-track {
flex: 1;
height: 6px;
background: var(--bg-primary);
border-radius: 3px;
overflow: hidden;
}
.sstv-general-signal-bar-fill {
height: 100%;
border-radius: 3px;
transition: width 0.3s ease, background 0.3s ease;
background: var(--text-dim);
}
.sstv-general-signal-level-value {
font-family: var(--font-mono);
font-size: 11px;
font-weight: 600;
color: var(--text-primary);
min-width: 24px;
text-align: right;
}
.sstv-general-signal-status-text {
font-family: var(--font-mono);
font-size: 10px;
color: var(--text-dim);
text-align: center;
}
.sstv-general-signal-vis-state {
font-family: var(--font-mono);
font-size: 9px;
color: var(--text-dim);
text-align: center;
margin-top: 6px;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.sstv-general-signal-vis-state.active {
color: var(--accent-cyan);
}
/* ============================================
IMAGE MODAL
============================================ */
@@ -417,6 +555,40 @@
border-radius: 4px;
}
.sstv-general-modal-toolbar {
position: absolute;
top: 20px;
right: 60px;
display: flex;
gap: 8px;
z-index: 1;
}
.sstv-general-modal-btn {
display: flex;
align-items: center;
gap: 6px;
font-family: var(--font-mono);
font-size: 10px;
padding: 6px 12px;
background: rgba(255, 255, 255, 0.1);
border: 1px solid rgba(255, 255, 255, 0.2);
border-radius: 4px;
color: white;
cursor: pointer;
transition: all 0.15s;
text-transform: uppercase;
}
.sstv-general-modal-btn:hover {
background: rgba(255, 255, 255, 0.2);
}
.sstv-general-modal-btn.delete:hover {
background: var(--accent-red, #ff3366);
border-color: var(--accent-red, #ff3366);
}
.sstv-general-modal-close {
position: absolute;
top: 20px;
@@ -428,12 +600,33 @@
cursor: pointer;
opacity: 0.7;
transition: opacity 0.15s;
z-index: 1;
}
.sstv-general-modal-close:hover {
opacity: 1;
}
/* Clear All button */
.sstv-general-gallery-clear-btn {
font-family: var(--font-mono);
font-size: 9px;
text-transform: uppercase;
padding: 3px 8px;
border-radius: 4px;
background: transparent;
border: 1px solid var(--border-color);
color: var(--text-dim);
cursor: pointer;
transition: all 0.15s;
margin-left: 8px;
}
.sstv-general-gallery-clear-btn:hover {
color: var(--accent-red, #ff3366);
border-color: var(--accent-red, #ff3366);
}
/* ============================================
RESPONSIVE
============================================ */
+195 -2
View File
@@ -7,7 +7,9 @@
MODE VISIBILITY
============================================ */
#sstvMode.active {
display: block !important;
display: flex !important;
flex-direction: column;
gap: 10px;
}
/* ============================================
@@ -388,12 +390,12 @@
}
.sstv-image-card {
position: relative;
background: var(--bg-secondary);
border: 1px solid var(--border-color);
border-radius: 6px;
overflow: hidden;
transition: all 0.15s ease;
cursor: pointer;
}
.sstv-image-card:hover {
@@ -402,6 +404,10 @@
box-shadow: 0 4px 12px rgba(0, 212, 255, 0.2);
}
.sstv-image-card-inner {
cursor: pointer;
}
.sstv-image-preview {
width: 100%;
aspect-ratio: 4/3;
@@ -410,6 +416,48 @@
display: block;
}
.sstv-image-actions {
position: absolute;
bottom: 0;
left: 0;
right: 0;
display: flex;
justify-content: flex-end;
gap: 4px;
padding: 6px;
background: linear-gradient(transparent, rgba(0, 0, 0, 0.8));
opacity: 0;
transition: opacity 0.15s;
}
.sstv-image-card:hover .sstv-image-actions {
opacity: 1;
}
.sstv-image-actions button {
display: flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
padding: 0;
background: rgba(255, 255, 255, 0.1);
border: 1px solid rgba(255, 255, 255, 0.2);
border-radius: 4px;
color: white;
cursor: pointer;
transition: all 0.15s;
}
.sstv-image-actions button:hover {
background: rgba(255, 255, 255, 0.25);
}
.sstv-image-actions button:last-child:hover {
background: var(--accent-red, #ff3366);
border-color: var(--accent-red, #ff3366);
}
.sstv-image-info {
padding: 8px 10px;
border-top: 1px solid var(--border-color);
@@ -736,6 +784,96 @@
animation: pulse 0.5s infinite;
}
/* ============================================
SIGNAL MONITOR
============================================ */
.sstv-signal-monitor {
width: 100%;
max-width: 320px;
padding: 16px;
background: var(--bg-secondary);
border: 1px solid var(--border-color);
border-radius: 8px;
}
.sstv-signal-monitor-header {
display: flex;
align-items: center;
gap: 8px;
font-family: var(--font-mono);
font-size: 10px;
font-weight: 600;
color: var(--text-secondary);
text-transform: uppercase;
letter-spacing: 0.5px;
margin-bottom: 14px;
}
.sstv-signal-monitor-header svg {
color: var(--accent-cyan);
}
.sstv-signal-level-row {
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 10px;
}
.sstv-signal-level-label {
font-family: var(--font-mono);
font-size: 9px;
color: var(--text-dim);
text-transform: uppercase;
letter-spacing: 0.5px;
flex-shrink: 0;
}
.sstv-signal-bar-track {
flex: 1;
height: 6px;
background: var(--bg-primary);
border-radius: 3px;
overflow: hidden;
}
.sstv-signal-bar-fill {
height: 100%;
border-radius: 3px;
transition: width 0.3s ease, background 0.3s ease;
background: var(--text-dim);
}
.sstv-signal-level-value {
font-family: var(--font-mono);
font-size: 11px;
font-weight: 600;
color: var(--text-primary);
min-width: 24px;
text-align: right;
}
.sstv-signal-status-text {
font-family: var(--font-mono);
font-size: 10px;
color: var(--text-dim);
text-align: center;
}
.sstv-signal-vis-state {
font-family: var(--font-mono);
font-size: 9px;
color: var(--text-dim);
text-align: center;
margin-top: 6px;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.sstv-signal-vis-state.active {
color: var(--accent-cyan);
}
/* ============================================
IMAGE MODAL
============================================ */
@@ -764,6 +902,40 @@
border-radius: 4px;
}
.sstv-modal-toolbar {
position: absolute;
top: 20px;
right: 60px;
display: flex;
gap: 8px;
z-index: 1;
}
.sstv-modal-btn {
display: flex;
align-items: center;
gap: 6px;
font-family: var(--font-mono);
font-size: 10px;
padding: 6px 12px;
background: rgba(255, 255, 255, 0.1);
border: 1px solid rgba(255, 255, 255, 0.2);
border-radius: 4px;
color: white;
cursor: pointer;
transition: all 0.15s;
text-transform: uppercase;
}
.sstv-modal-btn:hover {
background: rgba(255, 255, 255, 0.2);
}
.sstv-modal-btn.delete:hover {
background: var(--accent-red, #ff3366);
border-color: var(--accent-red, #ff3366);
}
.sstv-modal-close {
position: absolute;
top: 20px;
@@ -775,12 +947,33 @@
cursor: pointer;
opacity: 0.7;
transition: opacity 0.15s;
z-index: 1;
}
.sstv-modal-close:hover {
opacity: 1;
}
/* Clear All button */
.sstv-gallery-clear-btn {
font-family: var(--font-mono);
font-size: 9px;
text-transform: uppercase;
padding: 3px 8px;
border-radius: 4px;
background: transparent;
border: 1px solid var(--border-color);
color: var(--text-dim);
cursor: pointer;
transition: all 0.15s;
margin-left: 8px;
}
.sstv-gallery-clear-btn:hover {
color: var(--accent-red, #ff3366);
border-color: var(--accent-red, #ff3366);
}
/* ============================================
RESPONSIVE
============================================ */
File diff suppressed because it is too large Load Diff
+22
View File
@@ -196,6 +196,28 @@
margin-left: 6px;
font-size: 10px;
}
.tracker-badge {
margin-left: 6px;
font-size: 9px;
padding: 1px 4px;
border-radius: 3px;
background: rgba(255, 51, 102, 0.2);
color: #ff3366;
border: 1px solid rgba(255, 51, 102, 0.4);
text-transform: uppercase;
letter-spacing: 0.4px;
}
.client-badge {
margin-left: 6px;
font-size: 9px;
padding: 1px 4px;
border-radius: 3px;
background: rgba(74, 158, 255, 0.2);
color: #4a9eff;
border: 1px solid rgba(74, 158, 255, 0.4);
text-transform: uppercase;
letter-spacing: 0.4px;
}
.known-badge {
margin-left: 6px;
font-size: 9px;
File diff suppressed because it is too large Load Diff
+28 -1
View File
@@ -114,7 +114,7 @@
position: fixed;
top: 0;
left: 0;
width: min(320px, 85vw);
width: min(360px, 100vw);
height: 100dvh;
height: 100vh; /* Fallback */
background: var(--bg-secondary, #0f1218);
@@ -381,6 +381,33 @@
-webkit-overflow-scrolling: touch;
}
.sidebar {
padding: 10px;
gap: 10px;
}
.output-panel {
min-height: 58vh;
}
.output-header {
flex-direction: column;
align-items: flex-start;
gap: 8px;
}
.header-controls {
width: 100%;
gap: 8px;
overflow-x: auto;
-webkit-overflow-scrolling: touch;
padding-bottom: 2px;
}
.header-controls .stats {
min-width: max-content;
}
/* Container should not clip content */
.container {
overflow: visible;
+75 -4
View File
@@ -24,8 +24,11 @@
background: var(--bg-dark, #0a0a0f);
border: 1px solid var(--border-color, #1a1a2e);
border-radius: 8px;
max-width: 600px;
max-width: 900px;
width: 100%;
max-height: calc(100vh - 80px);
display: flex;
flex-direction: column;
position: relative;
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.5);
}
@@ -71,22 +74,28 @@
/* Settings Tabs */
.settings-tabs {
display: flex;
display: grid;
grid-template-columns: repeat(8, minmax(0, 1fr));
border-bottom: 1px solid var(--border-color, #1a1a2e);
padding: 0 20px;
gap: 4px;
gap: 0;
}
.settings-tab {
background: none;
border: none;
padding: 12px 16px;
padding: 12px 10px;
color: var(--text-muted, #666);
font-size: 13px;
font-weight: 500;
cursor: pointer;
position: relative;
transition: color 0.2s;
min-width: 0;
text-align: center;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.settings-tab:hover {
@@ -115,6 +124,9 @@
.settings-section.active {
display: block;
overflow-y: auto;
flex: 1;
min-height: 0;
}
.settings-group {
@@ -163,6 +175,47 @@
color: var(--text-muted, #666);
}
/* Settings Feed Lists */
.settings-feed {
background: var(--bg-tertiary, #12121f);
border: 1px solid var(--border-color, #1a1a2e);
border-radius: 6px;
padding: 8px;
max-height: 240px;
overflow-y: auto;
}
.settings-feed-item {
padding: 8px;
border-bottom: 1px solid rgba(255, 255, 255, 0.05);
font-size: 11px;
}
.settings-feed-item:last-child {
border-bottom: none;
}
.settings-feed-title {
display: flex;
justify-content: space-between;
align-items: center;
font-weight: 600;
color: var(--text-primary, #e0e0e0);
margin-bottom: 4px;
}
.settings-feed-meta {
color: var(--text-muted, #666);
font-size: 10px;
}
.settings-feed-empty {
color: var(--text-dim, #666);
text-align: center;
padding: 20px 10px;
font-size: 11px;
}
/* Toggle Switch */
.toggle-switch {
position: relative;
@@ -427,6 +480,12 @@
}
/* Responsive */
@media (max-width: 960px) {
.settings-tabs {
grid-template-columns: repeat(4, minmax(0, 1fr));
}
}
@media (max-width: 640px) {
.settings-modal.active {
padding: 20px 10px;
@@ -436,6 +495,18 @@
max-width: 100%;
}
.settings-tabs {
padding: 0 10px;
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.settings-tab {
padding: 10px 6px;
font-size: 11px;
white-space: normal;
line-height: 1.2;
}
.settings-row {
flex-direction: column;
align-items: flex-start;
+194
View File
@@ -0,0 +1,194 @@
const AlertCenter = (function() {
'use strict';
let alerts = [];
let rules = [];
let eventSource = null;
const TRACKER_RULE_NAME = 'Tracker Detected';
function init() {
loadRules();
loadFeed();
connect();
}
function connect() {
if (eventSource) {
eventSource.close();
}
eventSource = new EventSource('/alerts/stream');
eventSource.onmessage = function(e) {
try {
const data = JSON.parse(e.data);
if (data.type === 'keepalive') return;
handleAlert(data);
} catch (err) {
console.error('[Alerts] SSE parse error', err);
}
};
eventSource.onerror = function() {
console.warn('[Alerts] SSE connection error');
};
}
function handleAlert(alert) {
alerts.unshift(alert);
alerts = alerts.slice(0, 50);
updateFeedUI();
if (typeof showNotification === 'function') {
const severity = (alert.severity || '').toLowerCase();
if (['high', 'critical'].includes(severity)) {
showNotification(alert.title || 'Alert', alert.message || 'Alert triggered');
}
}
}
function updateFeedUI() {
const list = document.getElementById('alertsFeedList');
const countEl = document.getElementById('alertsFeedCount');
if (countEl) countEl.textContent = `(${alerts.length})`;
if (!list) return;
if (alerts.length === 0) {
list.innerHTML = '<div class="settings-feed-empty">No alerts yet</div>';
return;
}
list.innerHTML = alerts.map(alert => {
const title = escapeHtml(alert.title || 'Alert');
const message = escapeHtml(alert.message || '');
const severity = escapeHtml(alert.severity || 'medium');
const createdAt = alert.created_at ? new Date(alert.created_at).toLocaleString() : '';
return `
<div class="settings-feed-item">
<div class="settings-feed-title">
<span>${title}</span>
<span style="color: var(--text-dim);">${severity.toUpperCase()}</span>
</div>
<div class="settings-feed-meta">${message}</div>
<div class="settings-feed-meta" style="margin-top: 4px;">${createdAt}</div>
</div>
`;
}).join('');
}
function loadFeed() {
fetch('/alerts/events?limit=20')
.then(r => r.json())
.then(data => {
if (data.status === 'success') {
alerts = data.events || [];
updateFeedUI();
}
})
.catch(err => console.error('[Alerts] Load feed failed', err));
}
function loadRules() {
fetch('/alerts/rules?all=1')
.then(r => r.json())
.then(data => {
if (data.status === 'success') {
rules = data.rules || [];
}
})
.catch(err => console.error('[Alerts] Load rules failed', err));
}
function enableTrackerAlerts() {
ensureTrackerRule(true);
}
function disableTrackerAlerts() {
ensureTrackerRule(false);
}
function ensureTrackerRule(enabled) {
loadRules();
setTimeout(() => {
const existing = rules.find(r => r.name === TRACKER_RULE_NAME);
if (existing) {
fetch(`/alerts/rules/${existing.id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ enabled })
}).then(() => loadRules());
} else if (enabled) {
fetch('/alerts/rules', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
name: TRACKER_RULE_NAME,
mode: 'bluetooth',
event_type: 'device_update',
match: { is_tracker: true },
severity: 'high',
enabled: true,
notify: { webhook: true }
})
}).then(() => loadRules());
}
}, 150);
}
function addBluetoothWatchlist(address, name) {
if (!address) return;
const existing = rules.find(r => r.mode === 'bluetooth' && r.match && r.match.address === address);
if (existing) {
return;
}
fetch('/alerts/rules', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
name: name ? `Watchlist ${name}` : `Watchlist ${address}`,
mode: 'bluetooth',
event_type: 'device_update',
match: { address: address },
severity: 'medium',
enabled: true,
notify: { webhook: true }
})
}).then(() => loadRules());
}
function removeBluetoothWatchlist(address) {
if (!address) return;
const existing = rules.find(r => r.mode === 'bluetooth' && r.match && r.match.address === address);
if (!existing) return;
fetch(`/alerts/rules/${existing.id}`, { method: 'DELETE' })
.then(() => loadRules());
}
function isWatchlisted(address) {
return rules.some(r => r.mode === 'bluetooth' && r.match && r.match.address === address && r.enabled);
}
function escapeHtml(str) {
if (!str) return '';
return String(str)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#039;');
}
return {
init,
loadFeed,
enableTrackerAlerts,
disableTrackerAlerts,
addBluetoothWatchlist,
removeBluetoothWatchlist,
isWatchlisted,
};
})();
document.addEventListener('DOMContentLoaded', () => {
if (typeof AlertCenter !== 'undefined') {
AlertCenter.init();
}
});
+5 -3
View File
@@ -488,10 +488,12 @@ function initApp() {
});
});
// Collapse all sections by default (except SDR Device which is first)
document.querySelectorAll('.section').forEach((section, index) => {
if (index > 0) {
// Collapse sidebar menu sections by default, but skip headerless utility blocks.
document.querySelectorAll('.sidebar .section').forEach((section) => {
if (section.querySelector('h3')) {
section.classList.add('collapsed');
} else {
section.classList.remove('collapsed');
}
});
+8 -1
View File
@@ -1,7 +1,9 @@
// Shared observer location helper for map-based modules.
// Default: shared location enabled unless explicitly disabled via config.
window.ObserverLocation = (function() {
const DEFAULT_LOCATION = { lat: 51.5074, lon: -0.1278 };
const DEFAULT_LOCATION = (window.INTERCEPT_DEFAULT_LAT && window.INTERCEPT_DEFAULT_LON)
? { lat: window.INTERCEPT_DEFAULT_LAT, lon: window.INTERCEPT_DEFAULT_LON }
: { lat: 51.5074, lon: -0.1278 };
const SHARED_KEY = 'observerLocation';
const AIS_KEY = 'ais_observerLocation';
const LEGACY_LAT_KEY = 'observerLat';
@@ -41,6 +43,10 @@ window.ObserverLocation = (function() {
return normalize(lat, lon);
}
function hasStoredLocation() {
return !!(readKey(SHARED_KEY) || readKey(AIS_KEY) || readLegacyLatLon());
}
function getShared() {
const current = readKey(SHARED_KEY);
if (current) return current;
@@ -93,6 +99,7 @@ window.ObserverLocation = (function() {
return {
isSharedEnabled,
hasStoredLocation,
getShared,
setShared,
getForModule,
+136
View File
@@ -0,0 +1,136 @@
const RecordingUI = (function() {
'use strict';
let recordings = [];
let active = [];
function init() {
refresh();
}
function refresh() {
fetch('/recordings')
.then(r => r.json())
.then(data => {
if (data.status !== 'success') return;
recordings = data.recordings || [];
active = data.active || [];
renderActive();
renderRecordings();
})
.catch(err => console.error('[Recording] Load failed', err));
}
function start() {
const modeSelect = document.getElementById('recordingModeSelect');
const labelInput = document.getElementById('recordingLabelInput');
const mode = modeSelect ? modeSelect.value : '';
const label = labelInput ? labelInput.value : '';
if (!mode) return;
fetch('/recordings/start', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ mode, label })
})
.then(r => r.json())
.then(() => {
refresh();
})
.catch(err => console.error('[Recording] Start failed', err));
}
function stop() {
const modeSelect = document.getElementById('recordingModeSelect');
const mode = modeSelect ? modeSelect.value : '';
if (!mode) return;
fetch('/recordings/stop', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ mode })
})
.then(r => r.json())
.then(() => refresh())
.catch(err => console.error('[Recording] Stop failed', err));
}
function stopById(sessionId) {
fetch('/recordings/stop', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ id: sessionId })
}).then(() => refresh());
}
function renderActive() {
const container = document.getElementById('recordingActiveList');
if (!container) return;
if (!active.length) {
container.innerHTML = '<div class="settings-feed-empty">No active recordings</div>';
return;
}
container.innerHTML = active.map(session => {
return `
<div class="settings-feed-item">
<div class="settings-feed-title">
<span>${escapeHtml(session.mode)}</span>
<button class="preset-btn" style="font-size: 9px; padding: 2px 6px;" onclick="RecordingUI.stopById('${session.id}')">Stop</button>
</div>
<div class="settings-feed-meta">Started: ${new Date(session.started_at).toLocaleString()}</div>
<div class="settings-feed-meta">Events: ${session.event_count || 0}</div>
</div>
`;
}).join('');
}
function renderRecordings() {
const container = document.getElementById('recordingList');
if (!container) return;
if (!recordings.length) {
container.innerHTML = '<div class="settings-feed-empty">No recordings yet</div>';
return;
}
container.innerHTML = recordings.map(rec => {
return `
<div class="settings-feed-item">
<div class="settings-feed-title">
<span>${escapeHtml(rec.mode)}${rec.label ? `${escapeHtml(rec.label)}` : ''}</span>
<button class="preset-btn" style="font-size: 9px; padding: 2px 6px;" onclick="RecordingUI.download('${rec.id}')">Download</button>
</div>
<div class="settings-feed-meta">${new Date(rec.started_at).toLocaleString()}${rec.stopped_at ? `${new Date(rec.stopped_at).toLocaleString()}` : ''}</div>
<div class="settings-feed-meta">Events: ${rec.event_count || 0} ${(rec.size_bytes || 0) / 1024.0 > 0 ? (rec.size_bytes / 1024).toFixed(1) + ' KB' : '0 KB'}</div>
</div>
`;
}).join('');
}
function download(sessionId) {
window.open(`/recordings/${sessionId}/download`, '_blank');
}
function escapeHtml(str) {
if (!str) return '';
return String(str)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#039;');
}
return {
init,
refresh,
start,
stop,
stopById,
download,
};
})();
document.addEventListener('DOMContentLoaded', () => {
if (typeof RecordingUI !== 'undefined') {
RecordingUI.init();
}
});
+59
View File
@@ -922,5 +922,64 @@ function switchSettingsTab(tabName) {
loadUpdateStatus();
} else if (tabName === 'location') {
loadObserverLocation();
} else if (tabName === 'alerts') {
if (typeof AlertCenter !== 'undefined') {
AlertCenter.loadFeed();
}
} else if (tabName === 'recording') {
if (typeof RecordingUI !== 'undefined') {
RecordingUI.refresh();
}
} else if (tabName === 'apikeys') {
loadApiKeyStatus();
}
}
/**
* Load API key status into the API Keys settings tab
*/
function loadApiKeyStatus() {
const badge = document.getElementById('apiKeyStatusBadge');
const desc = document.getElementById('apiKeyStatusDesc');
const usage = document.getElementById('apiKeyUsageCount');
const bar = document.getElementById('apiKeyUsageBar');
if (!badge) return;
badge.textContent = 'Not available';
badge.className = 'asset-badge missing';
desc.textContent = 'GSM feature removed';
}
/**
* Save API key from the settings input
*/
function saveApiKey() {
const input = document.getElementById('apiKeyInput');
const result = document.getElementById('apiKeySaveResult');
if (!input || !result) return;
const key = input.value.trim();
if (!key) {
result.style.display = 'block';
result.style.color = 'var(--accent-red)';
result.textContent = 'Please enter an API key.';
return;
}
result.style.display = 'block';
result.style.color = 'var(--text-dim)';
result.textContent = 'Saving...';
result.style.color = 'var(--accent-red)';
result.textContent = 'GSM feature has been removed.';
}
/**
* Toggle API key input visibility
*/
function toggleApiKeyVisibility() {
const input = document.getElementById('apiKeyInput');
if (!input) return;
input.type = input.type === 'password' ? 'text' : 'password';
}
+75 -32
View File
@@ -366,7 +366,10 @@ const BluetoothMode = (function() {
// Badges
const badgesEl = document.getElementById('btDetailBadges');
let badgesHtml = `<span class="bt-detail-badge ${protocol}">${protocol.toUpperCase()}</span>`;
badgesHtml += `<span class="bt-detail-badge ${device.in_baseline ? 'baseline' : 'new'}">${device.in_baseline ? '✓ KNOWN' : '● NEW'}</span>`;
badgesHtml += `<span class="bt-detail-badge ${device.in_baseline ? 'baseline' : 'new'}">${device.in_baseline ? '✓ KNOWN' : '● NEW'}</span>`;
if (device.seen_before) {
badgesHtml += `<span class="bt-detail-badge flag">SEEN BEFORE</span>`;
}
// Tracker badge
if (device.is_tracker) {
@@ -448,12 +451,14 @@ const BluetoothMode = (function() {
? minMax[0] + '/' + minMax[1]
: '--';
document.getElementById('btDetailFirstSeen').textContent = device.first_seen
? new Date(device.first_seen).toLocaleTimeString()
: '--';
document.getElementById('btDetailLastSeen').textContent = device.last_seen
? new Date(device.last_seen).toLocaleTimeString()
: '--';
document.getElementById('btDetailFirstSeen').textContent = device.first_seen
? new Date(device.first_seen).toLocaleTimeString()
: '--';
document.getElementById('btDetailLastSeen').textContent = device.last_seen
? new Date(device.last_seen).toLocaleTimeString()
: '--';
updateWatchlistButton(device);
// Services
const servicesContainer = document.getElementById('btDetailServices');
@@ -465,13 +470,29 @@ const BluetoothMode = (function() {
servicesContainer.style.display = 'none';
}
// Show content, hide placeholder
placeholder.style.display = 'none';
content.style.display = 'block';
// Show content, hide placeholder
placeholder.style.display = 'none';
content.style.display = 'block';
// Highlight selected device in list
highlightSelectedDevice(deviceId);
}
}
/**
* Update watchlist button state
*/
function updateWatchlistButton(device) {
const btn = document.getElementById('btDetailWatchBtn');
if (!btn) return;
if (typeof AlertCenter === 'undefined') {
btn.style.display = 'none';
return;
}
btn.style.display = '';
const watchlisted = AlertCenter.isWatchlisted(device.address);
btn.textContent = watchlisted ? 'Watching' : 'Watchlist';
btn.classList.toggle('active', watchlisted);
}
/**
* Clear device selection
@@ -525,24 +546,43 @@ const BluetoothMode = (function() {
/**
* Copy selected device address to clipboard
*/
function copyAddress() {
if (!selectedDeviceId) return;
const device = devices.get(selectedDeviceId);
if (!device) return;
function copyAddress() {
if (!selectedDeviceId) return;
const device = devices.get(selectedDeviceId);
if (!device) return;
navigator.clipboard.writeText(device.address).then(() => {
const btn = document.querySelector('.bt-detail-btn');
if (btn) {
const originalText = btn.textContent;
btn.textContent = 'Copied!';
btn.style.background = '#22c55e';
navigator.clipboard.writeText(device.address).then(() => {
const btn = document.getElementById('btDetailCopyBtn');
if (btn) {
const originalText = btn.textContent;
btn.textContent = 'Copied!';
btn.style.background = '#22c55e';
setTimeout(() => {
btn.textContent = originalText;
btn.style.background = '';
}, 1500);
}
});
}
});
}
/**
* Toggle Bluetooth watchlist for selected device
*/
function toggleWatchlist() {
if (!selectedDeviceId) return;
const device = devices.get(selectedDeviceId);
if (!device || typeof AlertCenter === 'undefined') return;
if (AlertCenter.isWatchlisted(device.address)) {
AlertCenter.removeBluetoothWatchlist(device.address);
showInfo('Removed from watchlist');
} else {
AlertCenter.addBluetoothWatchlist(device.address, device.name || device.address);
showInfo('Added to watchlist');
}
setTimeout(() => updateWatchlistButton(device), 200);
}
/**
* Select a device - opens modal with details
@@ -1090,10 +1130,11 @@ const BluetoothMode = (function() {
const isNew = !inBaseline;
const hasName = !!device.name;
const isTracker = device.is_tracker === true;
const trackerType = device.tracker_type;
const trackerConfidence = device.tracker_confidence;
const riskScore = device.risk_score || 0;
const agentName = device._agent || 'Local';
const trackerType = device.tracker_type;
const trackerConfidence = device.tracker_confidence;
const riskScore = device.risk_score || 0;
const agentName = device._agent || 'Local';
const seenBefore = device.seen_before === true;
// Calculate RSSI bar width (0-100%)
// RSSI typically ranges from -100 (weak) to -30 (very strong)
@@ -1145,8 +1186,9 @@ const BluetoothMode = (function() {
// Build secondary info line
let secondaryParts = [addr];
if (mfr) secondaryParts.push(mfr);
secondaryParts.push('Seen ' + seenCount + '×');
if (mfr) secondaryParts.push(mfr);
secondaryParts.push('Seen ' + seenCount + '×');
if (seenBefore) secondaryParts.push('<span class="bt-history-badge">SEEN BEFORE</span>');
// Add agent name if not Local
if (agentName !== 'Local') {
secondaryParts.push('<span class="agent-badge agent-remote" style="font-size:8px;padding:1px 4px;">' + escapeHtml(agentName) + '</span>');
@@ -1358,9 +1400,10 @@ const BluetoothMode = (function() {
setBaseline,
clearBaseline,
exportData,
selectDevice,
clearSelection,
copyAddress,
selectDevice,
clearSelection,
copyAddress,
toggleWatchlist,
// Agent handling
handleAgentChange,
+413 -15
View File
@@ -10,6 +10,14 @@ let dmrCallCount = 0;
let dmrSyncCount = 0;
let dmrCallHistory = [];
let dmrCurrentProtocol = '--';
let dmrModeLabel = 'dmr'; // Protocol label for device reservation
let dmrHasAudio = false;
// ============== BOOKMARKS ==============
let dmrBookmarks = [];
const DMR_BOOKMARKS_KEY = 'dmrBookmarks';
const DMR_SETTINGS_KEY = 'dmrSettings';
const DMR_BOOKMARK_PROTOCOLS = new Set(['auto', 'dmr', 'p25', 'nxdn', 'dstar', 'provoice']);
// ============== SYNTHESIZER STATE ==============
let dmrSynthCanvas = null;
@@ -37,9 +45,17 @@ function checkDmrTools() {
const warningText = document.getElementById('dmrToolsWarningText');
if (!warning) return;
const selectedType = (typeof getSelectedSDRType === 'function')
? getSelectedSDRType()
: 'rtlsdr';
const missing = [];
if (!data.dsd) missing.push('dsd (Digital Speech Decoder)');
if (!data.rtl_fm) missing.push('rtl_fm (RTL-SDR)');
if (selectedType === 'rtlsdr') {
if (!data.rtl_fm) missing.push('rtl_fm (RTL-SDR)');
} else if (!data.rx_fm) {
missing.push('rx_fm (SoapySDR demodulator)');
}
if (!data.ffmpeg) missing.push('ffmpeg (audio output — optional)');
if (missing.length > 0) {
warning.style.display = 'block';
@@ -47,6 +63,9 @@ function checkDmrTools() {
} else {
warning.style.display = 'none';
}
// Update audio panel availability
updateDmrAudioStatus(data.ffmpeg ? 'OFF' : 'UNAVAILABLE');
})
.catch(() => {});
}
@@ -57,12 +76,30 @@ function startDmr() {
const frequency = parseFloat(document.getElementById('dmrFrequency')?.value || 462.5625);
const protocol = document.getElementById('dmrProtocol')?.value || 'auto';
const gain = parseInt(document.getElementById('dmrGain')?.value || 40);
const ppm = parseInt(document.getElementById('dmrPPM')?.value || 0);
const relaxCrc = document.getElementById('dmrRelaxCrc')?.checked || false;
const device = typeof getSelectedDevice === 'function' ? getSelectedDevice() : 0;
const sdrType = (typeof getSelectedSDRType === 'function') ? getSelectedSDRType() : 'rtlsdr';
// Use protocol name for device reservation so panel shows "D-STAR", "P25", etc.
dmrModeLabel = protocol !== 'auto' ? protocol : 'dmr';
// Check device availability before starting
if (typeof checkDeviceAvailability === 'function' && !checkDeviceAvailability(dmrModeLabel)) {
return;
}
// Save settings to localStorage for persistence
try {
localStorage.setItem(DMR_SETTINGS_KEY, JSON.stringify({
frequency, protocol, gain, ppm, relaxCrc
}));
} catch (e) { /* localStorage unavailable */ }
fetch('/dmr/start', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ frequency, protocol, gain, device })
body: JSON.stringify({ frequency, protocol, gain, device, ppm, relaxCrc, sdr_type: sdrType })
})
.then(r => r.json())
.then(data => {
@@ -80,8 +117,30 @@ function startDmr() {
updateDmrSynthStatus();
const statusEl = document.getElementById('dmrStatus');
if (statusEl) statusEl.textContent = 'DECODING';
if (typeof reserveDevice === 'function') {
reserveDevice(parseInt(device), dmrModeLabel);
}
// Start audio if available
dmrHasAudio = !!data.has_audio;
if (dmrHasAudio) startDmrAudio();
updateDmrAudioStatus(dmrHasAudio ? 'STREAMING' : 'UNAVAILABLE');
if (typeof showNotification === 'function') {
showNotification('DMR', `Decoding ${frequency} MHz (${protocol.toUpperCase()})`);
showNotification('Digital Voice', `Decoding ${frequency} MHz (${protocol.toUpperCase()})`);
}
} else if (data.status === 'error' && data.message === 'Already running') {
// Backend has an active session the frontend lost track of — resync
isDmrRunning = true;
updateDmrUI();
connectDmrSSE();
if (!dmrSynthInitialized) initDmrSynthesizer();
dmrEventType = 'idle';
dmrActivityTarget = 0.1;
dmrLastEventTime = Date.now();
updateDmrSynthStatus();
const statusEl = document.getElementById('dmrStatus');
if (statusEl) statusEl.textContent = 'DECODING';
if (typeof showNotification === 'function') {
showNotification('DMR', 'Reconnected to active session');
}
} else {
if (typeof showNotification === 'function') {
@@ -93,6 +152,7 @@ function startDmr() {
}
function stopDmr() {
stopDmrAudio();
fetch('/dmr/stop', { method: 'POST' })
.then(r => r.json())
.then(() => {
@@ -102,8 +162,12 @@ function stopDmr() {
dmrEventType = 'stopped';
dmrActivityTarget = 0;
updateDmrSynthStatus();
updateDmrAudioStatus('OFF');
const statusEl = document.getElementById('dmrStatus');
if (statusEl) statusEl.textContent = 'STOPPED';
if (typeof releaseDevice === 'function') {
releaseDevice(dmrModeLabel);
}
})
.catch(err => console.error('[DMR] Stop error:', err));
}
@@ -146,6 +210,11 @@ function handleDmrMessage(msg) {
if (mainCountEl) mainCountEl.textContent = dmrCallCount;
// Update current call display
const slotInfo = msg.slot != null ? `
<div style="display: flex; justify-content: space-between; margin-bottom: 4px;">
<span style="color: var(--text-muted);">Slot</span>
<span style="color: var(--accent-orange); font-family: var(--font-mono);">${msg.slot}</span>
</div>` : '';
const callEl = document.getElementById('dmrCurrentCall');
if (callEl) {
callEl.innerHTML = `
@@ -156,7 +225,7 @@ function handleDmrMessage(msg) {
<div style="display: flex; justify-content: space-between; margin-bottom: 4px;">
<span style="color: var(--text-muted);">Source ID</span>
<span style="color: var(--accent-cyan); font-family: var(--font-mono);">${msg.source_id}</span>
</div>
</div>${slotInfo}
<div style="display: flex; justify-content: space-between;">
<span style="color: var(--text-muted);">Time</span>
<span style="color: var(--text-primary);">${msg.timestamp}</span>
@@ -176,14 +245,46 @@ function handleDmrMessage(msg) {
} else if (msg.type === 'slot') {
// Update slot info in current call
} else if (msg.type === 'raw') {
// Raw DSD output — triggers synthesizer activity via dmrSynthPulse
} else if (msg.type === 'heartbeat') {
// Decoder is alive and listening — keep synthesizer in listening state
if (isDmrRunning && dmrSynthInitialized) {
if (dmrEventType === 'idle' || dmrEventType === 'raw') {
dmrEventType = 'raw';
dmrActivityTarget = Math.max(dmrActivityTarget, 0.15);
dmrLastEventTime = Date.now();
updateDmrSynthStatus();
}
}
} else if (msg.type === 'status') {
const statusEl = document.getElementById('dmrStatus');
if (statusEl) {
statusEl.textContent = msg.text === 'started' ? 'DECODING' : 'IDLE';
}
if (msg.text === 'stopped') {
if (msg.text === 'started') {
if (statusEl) statusEl.textContent = 'DECODING';
} else if (msg.text === 'crashed') {
isDmrRunning = false;
stopDmrAudio();
updateDmrUI();
dmrEventType = 'stopped';
dmrActivityTarget = 0;
updateDmrSynthStatus();
updateDmrAudioStatus('OFF');
if (statusEl) statusEl.textContent = 'CRASHED';
if (typeof releaseDevice === 'function') releaseDevice(dmrModeLabel);
const detail = msg.detail || `Decoder exited (code ${msg.exit_code})`;
if (typeof showNotification === 'function') {
showNotification('DMR Error', detail);
}
} else if (msg.text === 'stopped') {
isDmrRunning = false;
stopDmrAudio();
updateDmrUI();
dmrEventType = 'stopped';
dmrActivityTarget = 0;
updateDmrSynthStatus();
updateDmrAudioStatus('OFF');
if (statusEl) statusEl.textContent = 'STOPPED';
if (typeof releaseDevice === 'function') releaseDevice(dmrModeLabel);
}
}
}
@@ -262,12 +363,14 @@ function drawDmrSynthesizer() {
dmrSynthCtx.fillStyle = 'rgba(0, 0, 0, 0.3)';
dmrSynthCtx.fillRect(0, 0, width, height);
// Decay activity toward target
// Decay activity toward target. Window must exceed the backend
// heartbeat interval (3s) so the status doesn't flip-flop between
// LISTENING and IDLE on every heartbeat cycle.
const timeSinceEvent = now - dmrLastEventTime;
if (timeSinceEvent > 2000) {
// No events for 2s — decay target toward idle
if (timeSinceEvent > 5000) {
// No events for 5s — decay target toward idle
dmrActivityTarget = Math.max(0, dmrActivityTarget - DMR_DECAY_RATE);
if (dmrActivityTarget < 0.05 && dmrEventType !== 'stopped') {
if (dmrActivityTarget < 0.1 && dmrEventType !== 'stopped') {
dmrEventType = 'idle';
updateDmrSynthStatus();
}
@@ -280,9 +383,9 @@ function drawDmrSynthesizer() {
let effectiveActivity = dmrActivityLevel;
if (dmrEventType === 'stopped') {
effectiveActivity = 0;
} else if (effectiveActivity < 0.05 && isDmrRunning) {
// Gentle idle breathing
effectiveActivity = 0.05 + Math.sin(now / 800) * 0.035;
} else if (effectiveActivity < 0.1 && isDmrRunning) {
// Visible idle breathing — shows decoder is alive and listening
effectiveActivity = 0.12 + Math.sin(now / 1000) * 0.06;
}
// Ripple timing for sync events
@@ -399,6 +502,10 @@ function dmrSynthPulse(type) {
dmrEventType = 'voice';
} else if (type === 'slot' || type === 'nac') {
dmrActivityTarget = Math.max(dmrActivityTarget, 0.5);
} else if (type === 'raw') {
// Any DSD output means the decoder is alive and processing
dmrActivityTarget = Math.max(dmrActivityTarget, 0.25);
if (dmrEventType === 'idle') dmrEventType = 'raw';
}
// keepalive and status don't change visuals
@@ -412,6 +519,7 @@ function updateDmrSynthStatus() {
const labels = {
stopped: 'STOPPED',
idle: 'IDLE',
raw: 'LISTENING',
sync: 'SYNC',
call: 'CALL',
voice: 'VOICE'
@@ -419,6 +527,7 @@ function updateDmrSynthStatus() {
const colors = {
stopped: 'var(--text-muted)',
idle: 'var(--text-muted)',
raw: '#607d8b',
sync: '#00e5ff',
call: '#4caf50',
voice: '#ff9800'
@@ -446,9 +555,298 @@ function stopDmrSynthesizer() {
window.addEventListener('resize', resizeDmrSynthesizer);
// ============== AUDIO ==============
function startDmrAudio() {
const audioPlayer = document.getElementById('dmrAudioPlayer');
if (!audioPlayer) return;
const streamUrl = `/dmr/audio/stream?t=${Date.now()}`;
audioPlayer.src = streamUrl;
const volSlider = document.getElementById('dmrAudioVolume');
if (volSlider) audioPlayer.volume = volSlider.value / 100;
audioPlayer.onplaying = () => updateDmrAudioStatus('STREAMING');
audioPlayer.onerror = () => {
// Retry if decoder is still running (stream may have dropped)
if (isDmrRunning && dmrHasAudio) {
console.warn('[DMR] Audio stream error, retrying in 2s...');
updateDmrAudioStatus('RECONNECTING');
setTimeout(() => {
if (isDmrRunning && dmrHasAudio) startDmrAudio();
}, 2000);
} else {
updateDmrAudioStatus('OFF');
}
};
audioPlayer.play().catch(e => {
console.warn('[DMR] Audio autoplay blocked:', e);
if (typeof showNotification === 'function') {
showNotification('Audio Ready', 'Click the page or interact to enable audio playback');
}
});
}
function stopDmrAudio() {
const audioPlayer = document.getElementById('dmrAudioPlayer');
if (audioPlayer) {
audioPlayer.pause();
audioPlayer.src = '';
}
dmrHasAudio = false;
}
function setDmrAudioVolume(value) {
const audioPlayer = document.getElementById('dmrAudioPlayer');
if (audioPlayer) audioPlayer.volume = value / 100;
}
function updateDmrAudioStatus(status) {
const el = document.getElementById('dmrAudioStatus');
if (!el) return;
el.textContent = status;
const colors = {
'OFF': 'var(--text-muted)',
'STREAMING': 'var(--accent-green)',
'ERROR': 'var(--accent-red)',
'UNAVAILABLE': 'var(--text-muted)',
};
el.style.color = colors[status] || 'var(--text-muted)';
}
// ============== SETTINGS PERSISTENCE ==============
function restoreDmrSettings() {
try {
const saved = localStorage.getItem(DMR_SETTINGS_KEY);
if (!saved) return;
const s = JSON.parse(saved);
const freqEl = document.getElementById('dmrFrequency');
const protoEl = document.getElementById('dmrProtocol');
const gainEl = document.getElementById('dmrGain');
const ppmEl = document.getElementById('dmrPPM');
const crcEl = document.getElementById('dmrRelaxCrc');
if (freqEl && s.frequency != null) freqEl.value = s.frequency;
if (protoEl && s.protocol) protoEl.value = s.protocol;
if (gainEl && s.gain != null) gainEl.value = s.gain;
if (ppmEl && s.ppm != null) ppmEl.value = s.ppm;
if (crcEl && s.relaxCrc != null) crcEl.checked = s.relaxCrc;
} catch (e) { /* localStorage unavailable */ }
}
// ============== BOOKMARKS ==============
function loadDmrBookmarks() {
try {
const saved = localStorage.getItem(DMR_BOOKMARKS_KEY);
const parsed = saved ? JSON.parse(saved) : [];
if (!Array.isArray(parsed)) {
dmrBookmarks = [];
} else {
dmrBookmarks = parsed
.map((entry) => {
const freq = Number(entry?.freq);
if (!Number.isFinite(freq) || freq <= 0) return null;
const protocol = sanitizeDmrBookmarkProtocol(entry?.protocol);
const rawLabel = String(entry?.label || '').trim();
const label = rawLabel || `${freq.toFixed(4)} MHz`;
return {
freq,
protocol,
label,
added: entry?.added,
};
})
.filter(Boolean);
}
} catch (e) {
dmrBookmarks = [];
}
renderDmrBookmarks();
}
function saveDmrBookmarks() {
try {
localStorage.setItem(DMR_BOOKMARKS_KEY, JSON.stringify(dmrBookmarks));
} catch (e) { /* localStorage unavailable */ }
}
function sanitizeDmrBookmarkProtocol(protocol) {
const value = String(protocol || 'auto').toLowerCase();
return DMR_BOOKMARK_PROTOCOLS.has(value) ? value : 'auto';
}
function addDmrBookmark() {
const freqInput = document.getElementById('dmrBookmarkFreq');
const labelInput = document.getElementById('dmrBookmarkLabel');
if (!freqInput) return;
const freq = parseFloat(freqInput.value);
if (isNaN(freq) || freq <= 0) {
if (typeof showNotification === 'function') {
showNotification('Invalid Frequency', 'Enter a valid frequency');
}
return;
}
const protocol = sanitizeDmrBookmarkProtocol(document.getElementById('dmrProtocol')?.value || 'auto');
const label = (labelInput?.value || '').trim() || `${freq.toFixed(4)} MHz`;
// Duplicate check
if (dmrBookmarks.some(b => b.freq === freq && b.protocol === protocol)) {
if (typeof showNotification === 'function') {
showNotification('Duplicate', 'This frequency/protocol is already bookmarked');
}
return;
}
dmrBookmarks.push({ freq, protocol, label, added: new Date().toISOString() });
saveDmrBookmarks();
renderDmrBookmarks();
freqInput.value = '';
if (labelInput) labelInput.value = '';
if (typeof showNotification === 'function') {
showNotification('Bookmark Added', `${freq.toFixed(4)} MHz saved`);
}
}
function addCurrentDmrFreqBookmark() {
const freqEl = document.getElementById('dmrFrequency');
const freqInput = document.getElementById('dmrBookmarkFreq');
if (freqEl && freqInput) {
freqInput.value = freqEl.value;
}
addDmrBookmark();
}
function removeDmrBookmark(index) {
dmrBookmarks.splice(index, 1);
saveDmrBookmarks();
renderDmrBookmarks();
}
function dmrQuickTune(freq, protocol) {
const freqEl = document.getElementById('dmrFrequency');
const protoEl = document.getElementById('dmrProtocol');
if (freqEl && Number.isFinite(freq)) freqEl.value = freq;
if (protoEl) protoEl.value = sanitizeDmrBookmarkProtocol(protocol);
}
function renderDmrBookmarks() {
const container = document.getElementById('dmrBookmarksList');
if (!container) return;
container.replaceChildren();
if (dmrBookmarks.length === 0) {
const emptyEl = document.createElement('div');
emptyEl.style.color = 'var(--text-muted)';
emptyEl.style.textAlign = 'center';
emptyEl.style.padding = '10px';
emptyEl.style.fontSize = '11px';
emptyEl.textContent = 'No bookmarks saved';
container.appendChild(emptyEl);
return;
}
dmrBookmarks.forEach((b, i) => {
const row = document.createElement('div');
row.style.display = 'flex';
row.style.justifyContent = 'space-between';
row.style.alignItems = 'center';
row.style.padding = '4px 6px';
row.style.background = 'rgba(0,0,0,0.2)';
row.style.borderRadius = '3px';
row.style.marginBottom = '3px';
const tuneBtn = document.createElement('button');
tuneBtn.type = 'button';
tuneBtn.style.cursor = 'pointer';
tuneBtn.style.color = 'var(--accent-cyan)';
tuneBtn.style.fontSize = '11px';
tuneBtn.style.flex = '1';
tuneBtn.style.background = 'none';
tuneBtn.style.border = 'none';
tuneBtn.style.textAlign = 'left';
tuneBtn.style.padding = '0';
tuneBtn.textContent = b.label;
tuneBtn.title = `${b.freq.toFixed(4)} MHz (${b.protocol.toUpperCase()})`;
tuneBtn.addEventListener('click', () => dmrQuickTune(b.freq, b.protocol));
const protocolEl = document.createElement('span');
protocolEl.style.color = 'var(--text-muted)';
protocolEl.style.fontSize = '9px';
protocolEl.style.margin = '0 6px';
protocolEl.textContent = b.protocol.toUpperCase();
const deleteBtn = document.createElement('button');
deleteBtn.type = 'button';
deleteBtn.style.background = 'none';
deleteBtn.style.border = 'none';
deleteBtn.style.color = 'var(--accent-red)';
deleteBtn.style.cursor = 'pointer';
deleteBtn.style.fontSize = '12px';
deleteBtn.style.padding = '0 4px';
deleteBtn.textContent = '\u00d7';
deleteBtn.addEventListener('click', () => removeDmrBookmark(i));
row.appendChild(tuneBtn);
row.appendChild(protocolEl);
row.appendChild(deleteBtn);
container.appendChild(row);
});
}
// ============== STATUS SYNC ==============
function checkDmrStatus() {
fetch('/dmr/status')
.then(r => r.json())
.then(data => {
if (data.running && !isDmrRunning) {
// Backend is running but frontend lost track — resync
isDmrRunning = true;
updateDmrUI();
connectDmrSSE();
if (!dmrSynthInitialized) initDmrSynthesizer();
dmrEventType = 'idle';
dmrActivityTarget = 0.1;
dmrLastEventTime = Date.now();
updateDmrSynthStatus();
const statusEl = document.getElementById('dmrStatus');
if (statusEl) statusEl.textContent = 'DECODING';
} else if (!data.running && isDmrRunning) {
// Backend stopped but frontend didn't know
isDmrRunning = false;
if (dmrEventSource) { dmrEventSource.close(); dmrEventSource = null; }
updateDmrUI();
dmrEventType = 'stopped';
dmrActivityTarget = 0;
updateDmrSynthStatus();
const statusEl = document.getElementById('dmrStatus');
if (statusEl) statusEl.textContent = 'STOPPED';
}
})
.catch(() => {});
}
// ============== INIT ==============
document.addEventListener('DOMContentLoaded', () => {
restoreDmrSettings();
loadDmrBookmarks();
});
// ============== EXPORTS ==============
window.startDmr = startDmr;
window.stopDmr = stopDmr;
window.checkDmrTools = checkDmrTools;
window.checkDmrStatus = checkDmrStatus;
window.initDmrSynthesizer = initDmrSynthesizer;
window.setDmrAudioVolume = setDmrAudioVolume;
window.addDmrBookmark = addDmrBookmark;
window.addCurrentDmrFreqBookmark = addCurrentDmrFreqBookmark;
window.removeDmrBookmark = removeDmrBookmark;
window.dmrQuickTune = dmrQuickTune;
File diff suppressed because it is too large Load Diff
+361 -15
View File
@@ -11,6 +11,18 @@ const SSTVGeneral = (function() {
let currentMode = null;
let progress = 0;
// Signal scope state
let sstvGeneralScopeCtx = null;
let sstvGeneralScopeAnim = null;
let sstvGeneralScopeHistory = [];
const SSTV_GENERAL_SCOPE_LEN = 200;
let sstvGeneralScopeRms = 0;
let sstvGeneralScopePeak = 0;
let sstvGeneralScopeTargetRms = 0;
let sstvGeneralScopeTargetPeak = 0;
let sstvGeneralScopeMsgBurst = 0;
let sstvGeneralScopeTone = null;
/**
* Initialize the SSTV General mode
*/
@@ -52,7 +64,7 @@ const SSTVGeneral = (function() {
if (!data.available) {
updateStatusUI('unavailable', 'Decoder not installed');
showStatusMessage('SSTV decoder not available. Install slowrx: apt install slowrx', 'warning');
showStatusMessage('SSTV decoder not available. Install numpy and Pillow: pip install numpy Pillow', 'warning');
return;
}
@@ -190,6 +202,136 @@ const SSTVGeneral = (function() {
`;
}
/**
* Initialize signal scope canvas
*/
function initSstvGeneralScope() {
const canvas = document.getElementById('sstvGeneralScopeCanvas');
if (!canvas) return;
const rect = canvas.getBoundingClientRect();
canvas.width = rect.width * (window.devicePixelRatio || 1);
canvas.height = rect.height * (window.devicePixelRatio || 1);
sstvGeneralScopeCtx = canvas.getContext('2d');
sstvGeneralScopeHistory = new Array(SSTV_GENERAL_SCOPE_LEN).fill(0);
sstvGeneralScopeRms = 0;
sstvGeneralScopePeak = 0;
sstvGeneralScopeTargetRms = 0;
sstvGeneralScopeTargetPeak = 0;
sstvGeneralScopeMsgBurst = 0;
sstvGeneralScopeTone = null;
drawSstvGeneralScope();
}
/**
* Draw signal scope animation frame
*/
function drawSstvGeneralScope() {
const ctx = sstvGeneralScopeCtx;
if (!ctx) return;
const W = ctx.canvas.width;
const H = ctx.canvas.height;
const midY = H / 2;
// Phosphor persistence
ctx.fillStyle = 'rgba(5, 5, 16, 0.3)';
ctx.fillRect(0, 0, W, H);
// Smooth towards target
sstvGeneralScopeRms += (sstvGeneralScopeTargetRms - sstvGeneralScopeRms) * 0.25;
sstvGeneralScopePeak += (sstvGeneralScopeTargetPeak - sstvGeneralScopePeak) * 0.15;
// Push to history
sstvGeneralScopeHistory.push(Math.min(sstvGeneralScopeRms / 32768, 1.0));
if (sstvGeneralScopeHistory.length > SSTV_GENERAL_SCOPE_LEN) sstvGeneralScopeHistory.shift();
// Grid lines
ctx.strokeStyle = 'rgba(60, 40, 80, 0.4)';
ctx.lineWidth = 0.5;
for (let i = 1; i < 4; i++) {
const y = (H / 4) * i;
ctx.beginPath(); ctx.moveTo(0, y); ctx.lineTo(W, y); ctx.stroke();
}
for (let i = 1; i < 8; i++) {
const x = (W / 8) * i;
ctx.beginPath(); ctx.moveTo(x, 0); ctx.lineTo(x, H); ctx.stroke();
}
// Waveform
const stepX = W / (SSTV_GENERAL_SCOPE_LEN - 1);
ctx.strokeStyle = '#c080ff';
ctx.lineWidth = 1.5;
ctx.shadowColor = '#c080ff';
ctx.shadowBlur = 4;
// Upper half
ctx.beginPath();
for (let i = 0; i < sstvGeneralScopeHistory.length; i++) {
const x = i * stepX;
const amp = sstvGeneralScopeHistory[i] * midY * 0.9;
const y = midY - amp;
if (i === 0) ctx.moveTo(x, y); else ctx.lineTo(x, y);
}
ctx.stroke();
// Lower half (mirror)
ctx.beginPath();
for (let i = 0; i < sstvGeneralScopeHistory.length; i++) {
const x = i * stepX;
const amp = sstvGeneralScopeHistory[i] * midY * 0.9;
const y = midY + amp;
if (i === 0) ctx.moveTo(x, y); else ctx.lineTo(x, y);
}
ctx.stroke();
ctx.shadowBlur = 0;
// Peak indicator
const peakNorm = Math.min(sstvGeneralScopePeak / 32768, 1.0);
if (peakNorm > 0.01) {
const peakY = midY - peakNorm * midY * 0.9;
ctx.strokeStyle = 'rgba(255, 68, 68, 0.6)';
ctx.lineWidth = 1;
ctx.setLineDash([4, 4]);
ctx.beginPath(); ctx.moveTo(0, peakY); ctx.lineTo(W, peakY); ctx.stroke();
ctx.setLineDash([]);
}
// Image decode flash
if (sstvGeneralScopeMsgBurst > 0.01) {
ctx.fillStyle = `rgba(0, 255, 100, ${sstvGeneralScopeMsgBurst * 0.15})`;
ctx.fillRect(0, 0, W, H);
sstvGeneralScopeMsgBurst *= 0.88;
}
// Update labels
const rmsLabel = document.getElementById('sstvGeneralScopeRmsLabel');
const peakLabel = document.getElementById('sstvGeneralScopePeakLabel');
const toneLabel = document.getElementById('sstvGeneralScopeToneLabel');
const statusLabel = document.getElementById('sstvGeneralScopeStatusLabel');
if (rmsLabel) rmsLabel.textContent = Math.round(sstvGeneralScopeRms);
if (peakLabel) peakLabel.textContent = Math.round(sstvGeneralScopePeak);
if (toneLabel) {
if (sstvGeneralScopeTone === 'leader') { toneLabel.textContent = 'LEADER'; toneLabel.style.color = '#0f0'; }
else if (sstvGeneralScopeTone === 'sync') { toneLabel.textContent = 'SYNC'; toneLabel.style.color = '#0ff'; }
else if (sstvGeneralScopeTone === 'decoding') { toneLabel.textContent = 'DECODING'; toneLabel.style.color = '#fa0'; }
else if (sstvGeneralScopeTone === 'noise') { toneLabel.textContent = 'NOISE'; toneLabel.style.color = '#555'; }
else { toneLabel.textContent = 'QUIET'; toneLabel.style.color = '#444'; }
}
if (statusLabel) {
if (sstvGeneralScopeRms > 500) { statusLabel.textContent = 'SIGNAL'; statusLabel.style.color = '#0f0'; }
else { statusLabel.textContent = 'MONITORING'; statusLabel.style.color = '#555'; }
}
sstvGeneralScopeAnim = requestAnimationFrame(drawSstvGeneralScope);
}
/**
* Stop signal scope
*/
function stopSstvGeneralScope() {
if (sstvGeneralScopeAnim) { cancelAnimationFrame(sstvGeneralScopeAnim); sstvGeneralScopeAnim = null; }
sstvGeneralScopeCtx = null;
}
/**
* Start SSE stream
*/
@@ -198,6 +340,11 @@ const SSTVGeneral = (function() {
eventSource.close();
}
// Show and init scope
const scopePanel = document.getElementById('sstvGeneralScopePanel');
if (scopePanel) scopePanel.style.display = 'block';
initSstvGeneralScope();
eventSource = new EventSource('/sstv-general/stream');
eventSource.onmessage = (e) => {
@@ -205,6 +352,10 @@ const SSTVGeneral = (function() {
const data = JSON.parse(e.data);
if (data.type === 'sstv_progress') {
handleProgress(data);
} else if (data.type === 'sstv_scope') {
sstvGeneralScopeTargetRms = data.rms;
sstvGeneralScopeTargetPeak = data.peak;
if (data.tone !== undefined) sstvGeneralScopeTone = data.tone;
}
} catch (err) {
console.error('Failed to parse SSE message:', err);
@@ -227,6 +378,9 @@ const SSTVGeneral = (function() {
eventSource.close();
eventSource = null;
}
stopSstvGeneralScope();
const scopePanel = document.getElementById('sstvGeneralScopePanel');
if (scopePanel) scopePanel.style.display = 'none';
}
/**
@@ -245,8 +399,97 @@ const SSTVGeneral = (function() {
renderGallery();
showNotification('SSTV', 'New image decoded!');
updateStatusUI('listening', 'Listening...');
sstvGeneralScopeMsgBurst = 1.0;
// Clear decode progress so signal monitor can take over
const liveContent = document.getElementById('sstvGeneralLiveContent');
if (liveContent) liveContent.innerHTML = '';
} else if (data.status === 'detecting') {
// Ignore detecting events if currently decoding (e.g. Doppler updates)
const dot = document.getElementById('sstvGeneralStripDot');
if (dot && dot.classList.contains('decoding')) return;
updateStatusUI('listening', data.message || 'Listening...');
if (data.signal_level !== undefined) {
renderSignalMonitor(data);
}
}
}
/**
* Render signal monitor in live area during detecting mode
*/
function renderSignalMonitor(data) {
const container = document.getElementById('sstvGeneralLiveContent');
if (!container) return;
const level = data.signal_level || 0;
const tone = data.sstv_tone;
let barColor, statusText;
if (tone === 'leader') {
barColor = 'var(--accent-green)';
statusText = 'SSTV leader tone detected';
} else if (tone === 'sync') {
barColor = 'var(--accent-cyan)';
statusText = 'SSTV sync pulse detected';
} else if (tone === 'noise') {
barColor = 'var(--text-dim)';
statusText = 'Audio signal present';
} else if (level > 10) {
barColor = 'var(--text-dim)';
statusText = 'Audio signal present';
} else {
barColor = 'var(--text-dim)';
statusText = 'No signal';
}
let monitor = container.querySelector('.sstv-general-signal-monitor');
if (!monitor) {
container.innerHTML = `
<div class="sstv-general-signal-monitor">
<div class="sstv-general-signal-monitor-header">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M2 12L5 12M5 12C5 12 6 3 12 3C18 3 19 12 19 12M19 12L22 12"/>
<circle cx="12" cy="18" r="2"/>
<path d="M12 16V12"/>
</svg>
Signal Monitor
</div>
<div class="sstv-general-signal-level-row">
<span class="sstv-general-signal-level-label">LEVEL</span>
<div class="sstv-general-signal-bar-track">
<div class="sstv-general-signal-bar-fill" style="width: 0%"></div>
</div>
<span class="sstv-general-signal-level-value">0</span>
</div>
<div class="sstv-general-signal-status-text">No signal</div>
<div class="sstv-general-signal-vis-state">VIS: idle</div>
</div>`;
monitor = container.querySelector('.sstv-general-signal-monitor');
}
const fill = monitor.querySelector('.sstv-general-signal-bar-fill');
fill.style.width = level + '%';
fill.style.background = barColor;
monitor.querySelector('.sstv-general-signal-status-text').textContent = statusText;
monitor.querySelector('.sstv-general-signal-level-value').textContent = level;
const visStateEl = monitor.querySelector('.sstv-general-signal-vis-state');
if (visStateEl && data.vis_state) {
const stateLabels = {
'idle': 'Idle',
'leader_1': 'Leader',
'break': 'Break',
'leader_2': 'Leader 2',
'start_bit': 'Start bit',
'data_bits': 'Data bits',
'parity': 'Parity',
'stop_bit': 'Stop bit',
};
const label = stateLabels[data.vis_state] || data.vis_state;
visStateEl.textContent = 'VIS: ' + label;
visStateEl.className = 'sstv-general-signal-vis-state' +
(data.vis_state !== 'idle' ? ' active' : '');
}
}
@@ -257,18 +500,33 @@ const SSTVGeneral = (function() {
const liveContent = document.getElementById('sstvGeneralLiveContent');
if (!liveContent) return;
liveContent.innerHTML = `
<div class="sstv-general-canvas-container">
<canvas id="sstvGeneralCanvas" width="320" height="256"></canvas>
</div>
<div class="sstv-general-decode-info">
<div class="sstv-general-mode-label">${data.mode || 'Detecting mode...'}</div>
<div class="sstv-general-progress-bar">
<div class="progress" style="width: ${data.progress || 0}%"></div>
let container = liveContent.querySelector('.sstv-general-decode-container');
if (!container) {
liveContent.innerHTML = `
<div class="sstv-general-decode-container">
<div class="sstv-general-canvas-container">
<img id="sstvGeneralDecodeImg" width="320" height="256" alt="Decoding..." style="display:block;background:#000;">
</div>
<div class="sstv-general-decode-info">
<div class="sstv-general-mode-label"></div>
<div class="sstv-general-progress-bar">
<div class="progress" style="width: 0%"></div>
</div>
<div class="sstv-general-status-message"></div>
</div>
</div>
<div class="sstv-general-status-message">${data.message || 'Decoding...'}</div>
</div>
`;
`;
container = liveContent.querySelector('.sstv-general-decode-container');
}
container.querySelector('.sstv-general-mode-label').textContent = data.mode || 'Detecting mode...';
container.querySelector('.progress').style.width = (data.progress || 0) + '%';
container.querySelector('.sstv-general-status-message').textContent = data.message || 'Decoding...';
if (data.partial_image) {
const img = container.querySelector('#sstvGeneralDecodeImg');
if (img) img.src = data.partial_image;
}
}
/**
@@ -322,12 +580,22 @@ const SSTVGeneral = (function() {
}
gallery.innerHTML = images.map(img => `
<div class="sstv-general-image-card" onclick="SSTVGeneral.showImage('${escapeHtml(img.url)}')">
<img src="${escapeHtml(img.url)}" alt="SSTV Image" class="sstv-general-image-preview" loading="lazy">
<div class="sstv-general-image-card">
<div class="sstv-general-image-card-inner" onclick="SSTVGeneral.showImage('${escapeHtml(img.url)}', '${escapeHtml(img.filename)}')">
<img src="${escapeHtml(img.url)}" alt="SSTV Image" class="sstv-general-image-preview" loading="lazy">
</div>
<div class="sstv-general-image-info">
<div class="sstv-general-image-mode">${escapeHtml(img.mode || 'Unknown')}</div>
<div class="sstv-general-image-timestamp">${formatTimestamp(img.timestamp)}</div>
</div>
<div class="sstv-general-image-actions">
<button onclick="event.stopPropagation(); SSTVGeneral.downloadImage('${escapeHtml(img.url)}', '${escapeHtml(img.filename)}')" title="Download">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="14" height="14"><path d="M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>
</button>
<button onclick="event.stopPropagation(); SSTVGeneral.deleteImage('${escapeHtml(img.filename)}')" title="Delete">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="14" height="14"><polyline points="3 6 5 6 21 6"/><path d="M19 6v14a2 2 0 01-2 2H7a2 2 0 01-2-2V6m3 0V4a2 2 0 012-2h4a2 2 0 012 2v2"/></svg>
</button>
</div>
</div>
`).join('');
}
@@ -335,19 +603,45 @@ const SSTVGeneral = (function() {
/**
* Show full-size image in modal
*/
function showImage(url) {
let currentModalUrl = null;
let currentModalFilename = null;
function showImage(url, filename) {
currentModalUrl = url;
currentModalFilename = filename || null;
let modal = document.getElementById('sstvGeneralImageModal');
if (!modal) {
modal = document.createElement('div');
modal.id = 'sstvGeneralImageModal';
modal.className = 'sstv-general-image-modal';
modal.innerHTML = `
<div class="sstv-general-modal-toolbar">
<button class="sstv-general-modal-btn" id="sstvGeneralModalDownload" title="Download">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="14" height="14"><path d="M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>
Download
</button>
<button class="sstv-general-modal-btn delete" id="sstvGeneralModalDelete" title="Delete">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="14" height="14"><polyline points="3 6 5 6 21 6"/><path d="M19 6v14a2 2 0 01-2 2H7a2 2 0 01-2-2V6m3 0V4a2 2 0 012-2h4a2 2 0 012 2v2"/></svg>
Delete
</button>
</div>
<button class="sstv-general-modal-close" onclick="SSTVGeneral.closeImage()">&times;</button>
<img src="" alt="SSTV Image">
`;
modal.addEventListener('click', (e) => {
if (e.target === modal) closeImage();
});
modal.querySelector('#sstvGeneralModalDownload').addEventListener('click', () => {
if (currentModalUrl && currentModalFilename) {
downloadImage(currentModalUrl, currentModalFilename);
}
});
modal.querySelector('#sstvGeneralModalDelete').addEventListener('click', () => {
if (currentModalFilename) {
deleteImage(currentModalFilename);
}
});
document.body.appendChild(modal);
}
@@ -386,6 +680,55 @@ const SSTVGeneral = (function() {
return div.innerHTML;
}
/**
* Delete a single image
*/
async function deleteImage(filename) {
if (!confirm('Delete this image?')) return;
try {
const response = await fetch(`/sstv-general/images/${encodeURIComponent(filename)}`, { method: 'DELETE' });
const data = await response.json();
if (data.status === 'ok') {
images = images.filter(img => img.filename !== filename);
updateImageCount(images.length);
renderGallery();
closeImage();
showNotification('SSTV', 'Image deleted');
}
} catch (err) {
console.error('Failed to delete image:', err);
}
}
/**
* Delete all images
*/
async function deleteAllImages() {
if (!confirm('Delete all decoded images?')) return;
try {
const response = await fetch('/sstv-general/images', { method: 'DELETE' });
const data = await response.json();
if (data.status === 'ok') {
images = [];
updateImageCount(0);
renderGallery();
showNotification('SSTV', `${data.deleted} image${data.deleted !== 1 ? 's' : ''} deleted`);
}
} catch (err) {
console.error('Failed to delete images:', err);
}
}
/**
* Download an image
*/
function downloadImage(url, filename) {
const a = document.createElement('a');
a.href = url + '/download';
a.download = filename;
a.click();
}
/**
* Show status message
*/
@@ -405,6 +748,9 @@ const SSTVGeneral = (function() {
loadImages,
showImage,
closeImage,
deleteImage,
deleteAllImages,
downloadImage,
selectPreset
};
})();
+377 -20
View File
@@ -21,6 +21,18 @@ const SSTV = (function() {
// ISS frequency
const ISS_FREQ = 145.800;
// Signal scope state
let sstvScopeCtx = null;
let sstvScopeAnim = null;
let sstvScopeHistory = [];
const SSTV_SCOPE_LEN = 200;
let sstvScopeRms = 0;
let sstvScopePeak = 0;
let sstvScopeTargetRms = 0;
let sstvScopeTargetPeak = 0;
let sstvScopeMsgBurst = 0;
let sstvScopeTone = null;
/**
* Initialize the SSTV mode
*/
@@ -183,11 +195,11 @@ const SSTV = (function() {
Settings.registerMap(issMap);
} else {
// Fallback to dark theme tiles
L.tileLayer('https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png', {
maxZoom: 19,
className: 'tile-layer-cyan'
}).addTo(issMap);
}
L.tileLayer('https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png', {
maxZoom: 19,
className: 'tile-layer-cyan'
}).addTo(issMap);
}
// Create ISS icon
const issIcon = L.divIcon({
@@ -491,7 +503,7 @@ const SSTV = (function() {
if (!data.available) {
updateStatusUI('unavailable', 'Decoder not installed');
showStatusMessage('SSTV decoder not available. Install slowrx: apt install slowrx', 'warning');
showStatusMessage('SSTV decoder not available. Install numpy and Pillow: pip install numpy Pillow', 'warning');
return;
}
@@ -521,6 +533,11 @@ const SSTV = (function() {
const frequency = parseFloat(freqInput?.value || ISS_FREQ);
const device = parseInt(deviceSelect?.value || '0', 10);
// Check if device is available
if (typeof checkDeviceAvailability === 'function' && !checkDeviceAvailability('sstv')) {
return;
}
updateStatusUI('connecting', 'Starting...');
try {
@@ -534,6 +551,9 @@ const SSTV = (function() {
if (data.status === 'started' || data.status === 'already_running') {
isRunning = true;
if (typeof reserveDevice === 'function') {
reserveDevice(device, 'sstv');
}
updateStatusUI('listening', `${frequency} MHz`);
startStream();
showNotification('SSTV', `Listening on ${frequency} MHz`);
@@ -555,6 +575,9 @@ const SSTV = (function() {
try {
await fetch('/sstv/stop', { method: 'POST' });
isRunning = false;
if (typeof releaseDevice === 'function') {
releaseDevice('sstv');
}
stopStream();
updateStatusUI('idle', 'Stopped');
showNotification('SSTV', 'Decoder stopped');
@@ -623,6 +646,136 @@ const SSTV = (function() {
`;
}
/**
* Initialize signal scope canvas
*/
function initSstvScope() {
const canvas = document.getElementById('sstvScopeCanvas');
if (!canvas) return;
const rect = canvas.getBoundingClientRect();
canvas.width = rect.width * (window.devicePixelRatio || 1);
canvas.height = rect.height * (window.devicePixelRatio || 1);
sstvScopeCtx = canvas.getContext('2d');
sstvScopeHistory = new Array(SSTV_SCOPE_LEN).fill(0);
sstvScopeRms = 0;
sstvScopePeak = 0;
sstvScopeTargetRms = 0;
sstvScopeTargetPeak = 0;
sstvScopeMsgBurst = 0;
sstvScopeTone = null;
drawSstvScope();
}
/**
* Draw signal scope animation frame
*/
function drawSstvScope() {
const ctx = sstvScopeCtx;
if (!ctx) return;
const W = ctx.canvas.width;
const H = ctx.canvas.height;
const midY = H / 2;
// Phosphor persistence
ctx.fillStyle = 'rgba(5, 5, 16, 0.3)';
ctx.fillRect(0, 0, W, H);
// Smooth towards target
sstvScopeRms += (sstvScopeTargetRms - sstvScopeRms) * 0.25;
sstvScopePeak += (sstvScopeTargetPeak - sstvScopePeak) * 0.15;
// Push to history
sstvScopeHistory.push(Math.min(sstvScopeRms / 32768, 1.0));
if (sstvScopeHistory.length > SSTV_SCOPE_LEN) sstvScopeHistory.shift();
// Grid lines
ctx.strokeStyle = 'rgba(60, 40, 80, 0.4)';
ctx.lineWidth = 0.5;
for (let i = 1; i < 4; i++) {
const y = (H / 4) * i;
ctx.beginPath(); ctx.moveTo(0, y); ctx.lineTo(W, y); ctx.stroke();
}
for (let i = 1; i < 8; i++) {
const x = (W / 8) * i;
ctx.beginPath(); ctx.moveTo(x, 0); ctx.lineTo(x, H); ctx.stroke();
}
// Waveform
const stepX = W / (SSTV_SCOPE_LEN - 1);
ctx.strokeStyle = '#c080ff';
ctx.lineWidth = 1.5;
ctx.shadowColor = '#c080ff';
ctx.shadowBlur = 4;
// Upper half
ctx.beginPath();
for (let i = 0; i < sstvScopeHistory.length; i++) {
const x = i * stepX;
const amp = sstvScopeHistory[i] * midY * 0.9;
const y = midY - amp;
if (i === 0) ctx.moveTo(x, y); else ctx.lineTo(x, y);
}
ctx.stroke();
// Lower half (mirror)
ctx.beginPath();
for (let i = 0; i < sstvScopeHistory.length; i++) {
const x = i * stepX;
const amp = sstvScopeHistory[i] * midY * 0.9;
const y = midY + amp;
if (i === 0) ctx.moveTo(x, y); else ctx.lineTo(x, y);
}
ctx.stroke();
ctx.shadowBlur = 0;
// Peak indicator
const peakNorm = Math.min(sstvScopePeak / 32768, 1.0);
if (peakNorm > 0.01) {
const peakY = midY - peakNorm * midY * 0.9;
ctx.strokeStyle = 'rgba(255, 68, 68, 0.6)';
ctx.lineWidth = 1;
ctx.setLineDash([4, 4]);
ctx.beginPath(); ctx.moveTo(0, peakY); ctx.lineTo(W, peakY); ctx.stroke();
ctx.setLineDash([]);
}
// Image decode flash
if (sstvScopeMsgBurst > 0.01) {
ctx.fillStyle = `rgba(0, 255, 100, ${sstvScopeMsgBurst * 0.15})`;
ctx.fillRect(0, 0, W, H);
sstvScopeMsgBurst *= 0.88;
}
// Update labels
const rmsLabel = document.getElementById('sstvScopeRmsLabel');
const peakLabel = document.getElementById('sstvScopePeakLabel');
const toneLabel = document.getElementById('sstvScopeToneLabel');
const statusLabel = document.getElementById('sstvScopeStatusLabel');
if (rmsLabel) rmsLabel.textContent = Math.round(sstvScopeRms);
if (peakLabel) peakLabel.textContent = Math.round(sstvScopePeak);
if (toneLabel) {
if (sstvScopeTone === 'leader') { toneLabel.textContent = 'LEADER'; toneLabel.style.color = '#0f0'; }
else if (sstvScopeTone === 'sync') { toneLabel.textContent = 'SYNC'; toneLabel.style.color = '#0ff'; }
else if (sstvScopeTone === 'decoding') { toneLabel.textContent = 'DECODING'; toneLabel.style.color = '#fa0'; }
else if (sstvScopeTone === 'noise') { toneLabel.textContent = 'NOISE'; toneLabel.style.color = '#555'; }
else { toneLabel.textContent = 'QUIET'; toneLabel.style.color = '#444'; }
}
if (statusLabel) {
if (sstvScopeRms > 500) { statusLabel.textContent = 'SIGNAL'; statusLabel.style.color = '#0f0'; }
else { statusLabel.textContent = 'MONITORING'; statusLabel.style.color = '#555'; }
}
sstvScopeAnim = requestAnimationFrame(drawSstvScope);
}
/**
* Stop signal scope
*/
function stopSstvScope() {
if (sstvScopeAnim) { cancelAnimationFrame(sstvScopeAnim); sstvScopeAnim = null; }
sstvScopeCtx = null;
}
/**
* Start SSE stream
*/
@@ -631,6 +784,11 @@ const SSTV = (function() {
eventSource.close();
}
// Show and init scope
const scopePanel = document.getElementById('sstvScopePanel');
if (scopePanel) scopePanel.style.display = 'block';
initSstvScope();
eventSource = new EventSource('/sstv/stream');
eventSource.onmessage = (e) => {
@@ -638,6 +796,10 @@ const SSTV = (function() {
const data = JSON.parse(e.data);
if (data.type === 'sstv_progress') {
handleProgress(data);
} else if (data.type === 'sstv_scope') {
sstvScopeTargetRms = data.rms;
sstvScopeTargetPeak = data.peak;
if (data.tone !== undefined) sstvScopeTone = data.tone;
}
} catch (err) {
console.error('Failed to parse SSE message:', err);
@@ -660,6 +822,9 @@ const SSTV = (function() {
eventSource.close();
eventSource = null;
}
stopSstvScope();
const scopePanel = document.getElementById('sstvScopePanel');
if (scopePanel) scopePanel.style.display = 'none';
}
/**
@@ -680,8 +845,97 @@ const SSTV = (function() {
renderGallery();
showNotification('SSTV', 'New image decoded!');
updateStatusUI('listening', 'Listening...');
sstvScopeMsgBurst = 1.0;
// Clear decode progress so signal monitor can take over
const liveContent = document.getElementById('sstvLiveContent');
if (liveContent) liveContent.innerHTML = '';
} else if (data.status === 'detecting') {
// Ignore detecting events if currently decoding (e.g. Doppler updates)
const dot = document.getElementById('sstvStripDot');
if (dot && dot.classList.contains('decoding')) return;
updateStatusUI('listening', data.message || 'Listening...');
if (data.signal_level !== undefined) {
renderSignalMonitor(data);
}
}
}
/**
* Render signal monitor in live area during detecting mode
*/
function renderSignalMonitor(data) {
const container = document.getElementById('sstvLiveContent');
if (!container) return;
const level = data.signal_level || 0;
const tone = data.sstv_tone;
let barColor, statusText;
if (tone === 'leader') {
barColor = 'var(--accent-green)';
statusText = 'SSTV leader tone detected';
} else if (tone === 'sync') {
barColor = 'var(--accent-cyan)';
statusText = 'SSTV sync pulse detected';
} else if (tone === 'noise') {
barColor = 'var(--text-dim)';
statusText = 'Audio signal present';
} else if (level > 10) {
barColor = 'var(--text-dim)';
statusText = 'Audio signal present';
} else {
barColor = 'var(--text-dim)';
statusText = 'No signal';
}
let monitor = container.querySelector('.sstv-signal-monitor');
if (!monitor) {
container.innerHTML = `
<div class="sstv-signal-monitor">
<div class="sstv-signal-monitor-header">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M2 12L5 12M5 12C5 12 6 3 12 3C18 3 19 12 19 12M19 12L22 12"/>
<circle cx="12" cy="18" r="2"/>
<path d="M12 16V12"/>
</svg>
Signal Monitor
</div>
<div class="sstv-signal-level-row">
<span class="sstv-signal-level-label">LEVEL</span>
<div class="sstv-signal-bar-track">
<div class="sstv-signal-bar-fill" style="width: 0%"></div>
</div>
<span class="sstv-signal-level-value">0</span>
</div>
<div class="sstv-signal-status-text">No signal</div>
<div class="sstv-signal-vis-state">VIS: idle</div>
</div>`;
monitor = container.querySelector('.sstv-signal-monitor');
}
const fill = monitor.querySelector('.sstv-signal-bar-fill');
fill.style.width = level + '%';
fill.style.background = barColor;
monitor.querySelector('.sstv-signal-status-text').textContent = statusText;
monitor.querySelector('.sstv-signal-level-value').textContent = level;
const visStateEl = monitor.querySelector('.sstv-signal-vis-state');
if (visStateEl && data.vis_state) {
const stateLabels = {
'idle': 'Idle',
'leader_1': 'Leader',
'break': 'Break',
'leader_2': 'Leader 2',
'start_bit': 'Start bit',
'data_bits': 'Data bits',
'parity': 'Parity',
'stop_bit': 'Stop bit',
};
const label = stateLabels[data.vis_state] || data.vis_state;
visStateEl.textContent = 'VIS: ' + label;
visStateEl.className = 'sstv-signal-vis-state' +
(data.vis_state !== 'idle' ? ' active' : '');
}
}
@@ -692,18 +946,33 @@ const SSTV = (function() {
const liveContent = document.getElementById('sstvLiveContent');
if (!liveContent) return;
liveContent.innerHTML = `
<div class="sstv-canvas-container">
<canvas id="sstvCanvas" width="320" height="256"></canvas>
</div>
<div class="sstv-decode-info">
<div class="sstv-mode-label">${data.mode || 'Detecting mode...'}</div>
<div class="sstv-progress-bar">
<div class="progress" style="width: ${data.progress || 0}%"></div>
let container = liveContent.querySelector('.sstv-decode-container');
if (!container) {
liveContent.innerHTML = `
<div class="sstv-decode-container">
<div class="sstv-canvas-container">
<img id="sstvDecodeImg" width="320" height="256" alt="Decoding..." style="display:block;background:#000;">
</div>
<div class="sstv-decode-info">
<div class="sstv-mode-label"></div>
<div class="sstv-progress-bar">
<div class="progress" style="width: 0%"></div>
</div>
<div class="sstv-status-message"></div>
</div>
</div>
<div class="sstv-status-message">${data.message || 'Decoding...'}</div>
</div>
`;
`;
container = liveContent.querySelector('.sstv-decode-container');
}
container.querySelector('.sstv-mode-label').textContent = data.mode || 'Detecting mode...';
container.querySelector('.progress').style.width = (data.progress || 0) + '%';
container.querySelector('.sstv-status-message').textContent = data.message || 'Decoding...';
if (data.partial_image) {
const img = container.querySelector('#sstvDecodeImg');
if (img) img.src = data.partial_image;
}
}
/**
@@ -757,12 +1026,22 @@ const SSTV = (function() {
}
gallery.innerHTML = images.map(img => `
<div class="sstv-image-card" onclick="SSTV.showImage('${escapeHtml(img.url)}')">
<img src="${escapeHtml(img.url)}" alt="SSTV Image" class="sstv-image-preview" loading="lazy">
<div class="sstv-image-card">
<div class="sstv-image-card-inner" onclick="SSTV.showImage('${escapeHtml(img.url)}', '${escapeHtml(img.filename)}')">
<img src="${escapeHtml(img.url)}" alt="SSTV Image" class="sstv-image-preview" loading="lazy">
</div>
<div class="sstv-image-info">
<div class="sstv-image-mode">${escapeHtml(img.mode || 'Unknown')}</div>
<div class="sstv-image-timestamp">${formatTimestamp(img.timestamp)}</div>
</div>
<div class="sstv-image-actions">
<button onclick="event.stopPropagation(); SSTV.downloadImage('${escapeHtml(img.url)}', '${escapeHtml(img.filename)}')" title="Download">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="14" height="14"><path d="M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>
</button>
<button onclick="event.stopPropagation(); SSTV.deleteImage('${escapeHtml(img.filename)}')" title="Delete">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="14" height="14"><polyline points="3 6 5 6 21 6"/><path d="M19 6v14a2 2 0 01-2 2H7a2 2 0 01-2-2V6m3 0V4a2 2 0 012-2h4a2 2 0 012 2v2"/></svg>
</button>
</div>
</div>
`).join('');
}
@@ -894,19 +1173,45 @@ const SSTV = (function() {
/**
* Show full-size image in modal
*/
function showImage(url) {
let currentModalUrl = null;
let currentModalFilename = null;
function showImage(url, filename) {
currentModalUrl = url;
currentModalFilename = filename || null;
let modal = document.getElementById('sstvImageModal');
if (!modal) {
modal = document.createElement('div');
modal.id = 'sstvImageModal';
modal.className = 'sstv-image-modal';
modal.innerHTML = `
<div class="sstv-modal-toolbar">
<button class="sstv-modal-btn" id="sstvModalDownload" title="Download">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="14" height="14"><path d="M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>
Download
</button>
<button class="sstv-modal-btn delete" id="sstvModalDelete" title="Delete">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="14" height="14"><polyline points="3 6 5 6 21 6"/><path d="M19 6v14a2 2 0 01-2 2H7a2 2 0 01-2-2V6m3 0V4a2 2 0 012-2h4a2 2 0 012 2v2"/></svg>
Delete
</button>
</div>
<button class="sstv-modal-close" onclick="SSTV.closeImage()">&times;</button>
<img src="" alt="SSTV Image">
`;
modal.addEventListener('click', (e) => {
if (e.target === modal) closeImage();
});
modal.querySelector('#sstvModalDownload').addEventListener('click', () => {
if (currentModalUrl && currentModalFilename) {
downloadImage(currentModalUrl, currentModalFilename);
}
});
modal.querySelector('#sstvModalDelete').addEventListener('click', () => {
if (currentModalFilename) {
deleteImage(currentModalFilename);
}
});
document.body.appendChild(modal);
}
@@ -945,6 +1250,55 @@ const SSTV = (function() {
return div.innerHTML;
}
/**
* Delete a single image
*/
async function deleteImage(filename) {
if (!confirm('Delete this image?')) return;
try {
const response = await fetch(`/sstv/images/${encodeURIComponent(filename)}`, { method: 'DELETE' });
const data = await response.json();
if (data.status === 'ok') {
images = images.filter(img => img.filename !== filename);
updateImageCount(images.length);
renderGallery();
closeImage();
showNotification('SSTV', 'Image deleted');
}
} catch (err) {
console.error('Failed to delete image:', err);
}
}
/**
* Delete all images
*/
async function deleteAllImages() {
if (!confirm('Delete all decoded images?')) return;
try {
const response = await fetch('/sstv/images', { method: 'DELETE' });
const data = await response.json();
if (data.status === 'ok') {
images = [];
updateImageCount(0);
renderGallery();
showNotification('SSTV', `${data.deleted} image${data.deleted !== 1 ? 's' : ''} deleted`);
}
} catch (err) {
console.error('Failed to delete images:', err);
}
}
/**
* Download an image
*/
function downloadImage(url, filename) {
const a = document.createElement('a');
a.href = url + '/download';
a.download = filename;
a.click();
}
/**
* Show status message
*/
@@ -965,6 +1319,9 @@ const SSTV = (function() {
loadIssSchedule,
showImage,
closeImage,
deleteImage,
deleteAllImages,
downloadImage,
useGPS,
updateTLE,
stopIssTracking,
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+12 -4
View File
@@ -38,10 +38,17 @@ function initWebSDR() {
const mapEl = document.getElementById('websdrMap');
if (!mapEl || typeof L === 'undefined') return;
// Calculate minimum zoom so tiles fill the container vertically
const mapHeight = mapEl.clientHeight || 500;
const minZoom = Math.ceil(Math.log2(mapHeight / 256));
websdrMap = L.map('websdrMap', {
center: [30, 0],
zoom: 2,
center: [20, 0],
zoom: Math.max(minZoom, 2),
minZoom: Math.max(minZoom, 2),
zoomControl: true,
maxBounds: [[-85, -360], [85, 360]],
maxBoundsViscosity: 1.0,
});
L.tileLayer('https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png', {
@@ -50,6 +57,9 @@ function initWebSDR() {
maxZoom: 19,
}).addTo(websdrMap);
// Match background to tile ocean color so any remaining edge is seamless
mapEl.style.background = '#1a1d29';
websdrInitialized = true;
if (!websdrSpyStationsLoaded) {
@@ -82,8 +92,6 @@ function searchReceivers(refresh) {
const countEl = document.getElementById('websdrReceiverCount');
if (countEl) countEl.textContent = `${websdrReceivers.length} found`;
const sidebarCount = document.getElementById('websdrSidebarCount');
if (sidebarCount) sidebarCount.textContent = websdrReceivers.length;
}
})
.catch(err => console.error('[WEBSDR] Search error:', err));
+69 -33
View File
@@ -28,9 +28,9 @@ const WiFiMode = (function() {
maxProbes: 1000,
};
// ==========================================================================
// Agent Support
// ==========================================================================
// ==========================================================================
// Agent Support
// ==========================================================================
/**
* Get the API base URL, routing through agent proxy if agent is selected.
@@ -59,15 +59,49 @@ const WiFiMode = (function() {
/**
* Check for agent mode conflicts before starting WiFi scan.
*/
function checkAgentConflicts() {
if (typeof currentAgent === 'undefined' || currentAgent === 'local') {
return true;
}
if (typeof checkAgentModeConflict === 'function') {
return checkAgentModeConflict('wifi');
}
return true;
}
function checkAgentConflicts() {
if (typeof currentAgent === 'undefined' || currentAgent === 'local') {
return true;
}
if (typeof checkAgentModeConflict === 'function') {
return checkAgentModeConflict('wifi');
}
return true;
}
function getChannelPresetList(preset) {
switch (preset) {
case '2.4-common':
return '1,6,11';
case '2.4-all':
return '1,2,3,4,5,6,7,8,9,10,11,12,13';
case '5-low':
return '36,40,44,48';
case '5-mid':
return '52,56,60,64';
case '5-high':
return '149,153,157,161,165';
default:
return '';
}
}
function buildChannelConfig() {
const preset = document.getElementById('wifiChannelPreset')?.value || '';
const listInput = document.getElementById('wifiChannelList')?.value || '';
const singleInput = document.getElementById('wifiChannel')?.value || '';
const listValue = listInput.trim();
const presetValue = getChannelPresetList(preset);
const channels = listValue || presetValue || '';
const channel = channels ? null : (singleInput.trim() ? parseInt(singleInput.trim()) : null);
return {
channels: channels || null,
channel: Number.isFinite(channel) ? channel : null,
};
}
// ==========================================================================
// State
@@ -461,10 +495,10 @@ const WiFiMode = (function() {
setScanning(true, 'deep');
try {
const iface = elements.interfaceSelect?.value || null;
const band = document.getElementById('wifiBand')?.value || 'all';
const channel = document.getElementById('wifiChannel')?.value || null;
const isAgentMode = typeof currentAgent !== 'undefined' && currentAgent !== 'local';
const iface = elements.interfaceSelect?.value || null;
const band = document.getElementById('wifiBand')?.value || 'all';
const channelConfig = buildChannelConfig();
const isAgentMode = typeof currentAgent !== 'undefined' && currentAgent !== 'local';
let response;
if (isAgentMode) {
@@ -473,23 +507,25 @@ const WiFiMode = (function() {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
interface: iface,
scan_type: 'deep',
band: band === 'abg' ? 'all' : band === 'bg' ? '2.4' : '5',
channel: channel ? parseInt(channel) : null,
}),
});
} else {
response = await fetch(`${CONFIG.apiBase}/scan/start`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
interface: iface,
band: band === 'abg' ? 'all' : band === 'bg' ? '2.4' : '5',
channel: channel ? parseInt(channel) : null,
}),
});
}
interface: iface,
scan_type: 'deep',
band: band === 'abg' ? 'all' : band === 'bg' ? '2.4' : '5',
channel: channelConfig.channel,
channels: channelConfig.channels,
}),
});
} else {
response = await fetch(`${CONFIG.apiBase}/scan/start`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
interface: iface,
band: band === 'abg' ? 'all' : band === 'bg' ? '2.4' : '5',
channel: channelConfig.channel,
channels: channelConfig.channels,
}),
});
}
if (!response.ok) {
const error = await response.json();
+11
View File
@@ -0,0 +1,11 @@
/*
(c) 2014, Vladimir Agafonkin
simpleheat, a tiny JavaScript library for drawing heatmaps with Canvas
https://github.com/mourner/simpleheat
*/
!function(){"use strict";function t(i){return this instanceof t?(this._canvas=i="string"==typeof i?document.getElementById(i):i,this._ctx=i.getContext("2d"),this._width=i.width,this._height=i.height,this._max=1,void this.clear()):new t(i)}t.prototype={defaultRadius:25,defaultGradient:{.4:"blue",.6:"cyan",.7:"lime",.8:"yellow",1:"red"},data:function(t,i){return this._data=t,this},max:function(t){return this._max=t,this},add:function(t){return this._data.push(t),this},clear:function(){return this._data=[],this},radius:function(t,i){i=i||15;var a=this._circle=document.createElement("canvas"),s=a.getContext("2d"),e=this._r=t+i;return a.width=a.height=2*e,s.shadowOffsetX=s.shadowOffsetY=200,s.shadowBlur=i,s.shadowColor="black",s.beginPath(),s.arc(e-200,e-200,t,0,2*Math.PI,!0),s.closePath(),s.fill(),this},gradient:function(t){var i=document.createElement("canvas"),a=i.getContext("2d"),s=a.createLinearGradient(0,0,0,256);i.width=1,i.height=256;for(var e in t)s.addColorStop(e,t[e]);return a.fillStyle=s,a.fillRect(0,0,1,256),this._grad=a.getImageData(0,0,1,256).data,this},draw:function(t){this._circle||this.radius(this.defaultRadius),this._grad||this.gradient(this.defaultGradient);var i=this._ctx;i.clearRect(0,0,this._width,this._height);for(var a,s=0,e=this._data.length;e>s;s++)a=this._data[s],i.globalAlpha=Math.max(a[2]/this._max,t||.05),i.drawImage(this._circle,a[0]-this._r,a[1]-this._r);var n=i.getImageData(0,0,this._width,this._height);return this._colorize(n.data,this._grad),i.putImageData(n,0,0),this},_colorize:function(t,i){for(var a,s=3,e=t.length;e>s;s+=4)a=4*t[s],a&&(t[s-3]=i[a],t[s-2]=i[a+1],t[s-1]=i[a+2])}},window.simpleheat=t}(),/*
(c) 2014, Vladimir Agafonkin
Leaflet.heat, a tiny and fast heatmap plugin for Leaflet.
https://github.com/Leaflet/Leaflet.heat
*/
L.HeatLayer=(L.Layer?L.Layer:L.Class).extend({initialize:function(t,i){this._latlngs=t,L.setOptions(this,i)},setLatLngs:function(t){return this._latlngs=t,this.redraw()},addLatLng:function(t){return this._latlngs.push(t),this.redraw()},setOptions:function(t){return L.setOptions(this,t),this._heat&&this._updateOptions(),this.redraw()},redraw:function(){return!this._heat||this._frame||this._map._animating||(this._frame=L.Util.requestAnimFrame(this._redraw,this)),this},onAdd:function(t){this._map=t,this._canvas||this._initCanvas(),t._panes.overlayPane.appendChild(this._canvas),t.on("moveend",this._reset,this),t.options.zoomAnimation&&L.Browser.any3d&&t.on("zoomanim",this._animateZoom,this),this._reset()},onRemove:function(t){t.getPanes().overlayPane.removeChild(this._canvas),t.off("moveend",this._reset,this),t.options.zoomAnimation&&t.off("zoomanim",this._animateZoom,this)},addTo:function(t){return t.addLayer(this),this},_initCanvas:function(){var t=this._canvas=L.DomUtil.create("canvas","leaflet-heatmap-layer leaflet-layer"),i=L.DomUtil.testProp(["transformOrigin","WebkitTransformOrigin","msTransformOrigin"]);t.style[i]="50% 50%";var a=this._map.getSize();t.width=a.x,t.height=a.y;var s=this._map.options.zoomAnimation&&L.Browser.any3d;L.DomUtil.addClass(t,"leaflet-zoom-"+(s?"animated":"hide")),this._heat=simpleheat(t),this._updateOptions()},_updateOptions:function(){this._heat.radius(this.options.radius||this._heat.defaultRadius,this.options.blur),this.options.gradient&&this._heat.gradient(this.options.gradient),this.options.max&&this._heat.max(this.options.max)},_reset:function(){var t=this._map.containerPointToLayerPoint([0,0]);L.DomUtil.setPosition(this._canvas,t);var i=this._map.getSize();this._heat._width!==i.x&&(this._canvas.width=this._heat._width=i.x),this._heat._height!==i.y&&(this._canvas.height=this._heat._height=i.y),this._redraw()},_redraw:function(){var t,i,a,s,e,n,h,o,r,d=[],_=this._heat._r,l=this._map.getSize(),m=new L.Bounds(L.point([-_,-_]),l.add([_,_])),c=void 0===this.options.max?1:this.options.max,u=void 0===this.options.maxZoom?this._map.getMaxZoom():this.options.maxZoom,f=1/Math.pow(2,Math.max(0,Math.min(u-this._map.getZoom(),12))),g=_/2,p=[],v=this._map._getMapPanePos(),w=v.x%g,y=v.y%g;for(t=0,i=this._latlngs.length;i>t;t++)if(a=this._map.latLngToContainerPoint(this._latlngs[t]),m.contains(a)){e=Math.floor((a.x-w)/g)+2,n=Math.floor((a.y-y)/g)+2;var x=void 0!==this._latlngs[t].alt?this._latlngs[t].alt:void 0!==this._latlngs[t][2]?+this._latlngs[t][2]:1;r=x*f,p[n]=p[n]||[],s=p[n][e],s?(s[0]=(s[0]*s[2]+a.x*r)/(s[2]+r),s[1]=(s[1]*s[2]+a.y*r)/(s[2]+r),s[2]+=r):p[n][e]=[a.x,a.y,r]}for(t=0,i=p.length;i>t;t++)if(p[t])for(h=0,o=p[t].length;o>h;h++)s=p[t][h],s&&d.push([Math.round(s[0]),Math.round(s[1]),Math.min(s[2],c)]);this._heat.data(d).draw(this.options.minOpacity),this._frame=null},_animateZoom:function(t){var i=this._map.getZoomScale(t.zoom),a=this._map._getCenterOffset(t.center)._multiplyBy(-i).subtract(this._map._getMapPanePos());L.DomUtil.setTransform?L.DomUtil.setTransform(this._canvas,a,i):this._canvas.style[L.DomUtil.TRANSFORM]=L.DomUtil.getTranslateString(a)+" scale("+i+")"}}),L.heatLayer=function(t,i){return new L.HeatLayer(t,i)};
+82 -2
View File
@@ -223,6 +223,88 @@
</div>
</div>
</div>
<!-- Antenna Guide Panel -->
<div class="panel" id="antennaGuidePanel">
<div class="panel-header" style="cursor: pointer;" onclick="document.getElementById('antennaGuideContent').style.display = document.getElementById('antennaGuideContent').style.display === 'none' ? 'block' : 'none'; this.querySelector('.panel-toggle').textContent = document.getElementById('antennaGuideContent').style.display === 'none' ? '&#9654;' : '&#9660;';">
<span>ANTENNA GUIDE</span>
<span class="panel-toggle" style="font-size: 10px; color: var(--text-muted);">&#9654;</span>
</div>
<div id="antennaGuideContent" style="display: none; padding: 10px; font-size: 11px; color: var(--text-secondary); line-height: 1.5;">
<p style="margin-bottom: 8px; color: var(--accent-cyan); font-weight: 600;">
1090 MHz &mdash; stock SDR antenna can work but is not ideal
</p>
<div style="background: rgba(0,0,0,0.3); border: 1px solid var(--border-color); border-radius: 4px; padding: 8px; margin-bottom: 8px;">
<strong style="color: var(--accent-cyan); font-size: 11px;">Stock Telescopic Antenna</strong>
<ul style="margin: 4px 0 0 14px; padding: 0; font-size: 10px;">
<li><strong style="color: var(--text-primary);">1090 MHz:</strong> Collapse to ~6.9 cm (quarter-wave). It works for nearby aircraft</li>
<li><strong style="color: var(--text-primary);">Range:</strong> Expect ~50 NM (90 km) indoors, ~100 NM outdoors</li>
</ul>
</div>
<div style="background: rgba(0,0,0,0.3); border: 1px solid var(--border-color); border-radius: 4px; padding: 8px; margin-bottom: 8px;">
<strong style="color: #00ff88; font-size: 11px;">Recommended: 1090 MHz Collinear (~$10-20 DIY)</strong>
<ul style="margin: 4px 0 0 14px; padding: 0; font-size: 10px;">
<li><strong style="color: var(--text-primary);">Design:</strong> 8 coaxial collinear elements from RG-6 coax cable</li>
<li><strong style="color: var(--text-primary);">Element length:</strong> ~6.9 cm segments soldered alternating center/shield</li>
<li><strong style="color: var(--text-primary);">Gain:</strong> ~5&ndash;7 dBi omnidirectional, ideal for 360&deg; coverage</li>
<li><strong style="color: var(--text-primary);">Range:</strong> 150&ndash;250+ NM depending on height and LOS</li>
</ul>
</div>
<div style="background: rgba(0,0,0,0.3); border: 1px solid var(--border-color); border-radius: 4px; padding: 8px; margin-bottom: 8px;">
<strong style="color: var(--accent-cyan); font-size: 11px;">Commercial Options</strong>
<ul style="margin: 4px 0 0 14px; padding: 0; font-size: 10px;">
<li><strong style="color: var(--text-primary);">FlightAware antenna:</strong> ~$35, 1090 MHz tuned, 66cm fiberglass whip</li>
<li><strong style="color: var(--text-primary);">ADSBexchange whip:</strong> ~$40, similar performance</li>
<li><strong style="color: var(--text-primary);">Jetvision A3:</strong> ~$50, high-gain 1090 MHz collinear</li>
</ul>
</div>
<div style="background: rgba(0,0,0,0.3); border: 1px solid var(--border-color); border-radius: 4px; padding: 8px; margin-bottom: 8px;">
<strong style="color: var(--accent-cyan); font-size: 11px;">Placement & LNA</strong>
<ul style="margin: 4px 0 0 14px; padding: 0; font-size: 10px;">
<li><strong style="color: var(--text-primary);">Location:</strong> OUTDOORS, as high as possible. Roof or mast mount</li>
<li><strong style="color: var(--text-primary);">Height:</strong> Every 3m higher adds ~10 NM range (line-of-sight)</li>
<li><strong style="color: var(--text-primary);">LNA:</strong> 1090 MHz filtered LNA at antenna feed (e.g. Uputronics, ~$30)</li>
<li><strong style="color: var(--text-primary);">Filter:</strong> A 1090 MHz bandpass filter removes cell/FM interference</li>
<li><strong style="color: var(--text-primary);">Coax:</strong> Keep short. At 1090 MHz, RG-58 loses ~10 dB per 10m</li>
<li><strong style="color: var(--text-primary);">Bias-T:</strong> Enable Bias-T in controls above if LNA is powered via coax</li>
</ul>
</div>
<div style="background: rgba(0,0,0,0.3); border: 1px solid var(--border-color); border-radius: 4px; padding: 8px;">
<strong style="color: var(--accent-cyan); font-size: 11px;">Quick Reference</strong>
<table style="width: 100%; margin-top: 4px; font-size: 10px; border-collapse: collapse;">
<tr style="border-bottom: 1px solid var(--border-color);">
<td style="padding: 2px 4px; color: var(--text-dim);">ADS-B frequency</td>
<td style="padding: 2px 4px; color: var(--text-primary); text-align: right;">1090 MHz</td>
</tr>
<tr style="border-bottom: 1px solid var(--border-color);">
<td style="padding: 2px 4px; color: var(--text-dim);">Quarter-wave length</td>
<td style="padding: 2px 4px; color: var(--text-primary); text-align: right;">6.9 cm</td>
</tr>
<tr style="border-bottom: 1px solid var(--border-color);">
<td style="padding: 2px 4px; color: var(--text-dim);">Modulation</td>
<td style="padding: 2px 4px; color: var(--text-primary); text-align: right;">PPM (pulse)</td>
</tr>
<tr style="border-bottom: 1px solid var(--border-color);">
<td style="padding: 2px 4px; color: var(--text-dim);">Polarization</td>
<td style="padding: 2px 4px; color: var(--text-primary); text-align: right;">Vertical</td>
</tr>
<tr style="border-bottom: 1px solid var(--border-color);">
<td style="padding: 2px 4px; color: var(--text-dim);">Bandwidth</td>
<td style="padding: 2px 4px; color: var(--text-primary); text-align: right;">~2 MHz</td>
</tr>
<tr>
<td style="padding: 2px 4px; color: var(--text-dim);">Typical range (outdoor)</td>
<td style="padding: 2px 4px; color: var(--text-primary); text-align: right;">100&ndash;250 NM</td>
</tr>
</table>
</div>
</div>
</div>
</div>
<!-- Controls Bar - Reorganized -->
@@ -2455,7 +2537,6 @@ sudo make install</code>
// Check for remote dump1090 config (only for local mode)
const remoteConfig = !useAgent ? getRemoteDump1090Config() : null;
if (remoteConfig === false) return;
// Check for agent SDR conflicts
if (useAgent && typeof checkAgentModeConflict === 'function') {
if (!checkAgentModeConflict('adsb')) {
@@ -2474,7 +2555,6 @@ sudo make install</code>
requestBody.remote_sbs_host = remoteConfig.host;
requestBody.remote_sbs_port = remoteConfig.port;
}
try {
// Route through agent proxy if using remote agent
const url = useAgent
+1307 -76
View File
File diff suppressed because it is too large Load Diff
+69
View File
@@ -26,6 +26,75 @@
</div>
</div>
<!-- Antenna Guide -->
<div class="section">
<h3>Antenna Guide</h3>
<div style="font-size: 11px; color: var(--text-dim); line-height: 1.5;">
<p style="margin-bottom: 8px; color: var(--accent-cyan); font-weight: 600;">
Marine VHF band (162 MHz) &mdash; stock SDR antenna will NOT work well
</p>
<div style="background: var(--bg-primary); border: 1px solid var(--border-color); border-radius: 4px; padding: 10px; margin-bottom: 10px;">
<strong style="color: var(--accent-cyan); font-size: 12px;">Simple Dipole (Cheapest)</strong>
<ul style="margin: 6px 0 0 14px; padding: 0;">
<li><strong style="color: var(--text-primary);">Element length:</strong> ~46 cm each (quarter-wave at 162 MHz)</li>
<li><strong style="color: var(--text-primary);">Material:</strong> Wire, coat hanger, or copper rod</li>
<li><strong style="color: var(--text-primary);">Orientation:</strong> Vertical (AIS is vertically polarized)</li>
<li><strong style="color: var(--text-primary);">Placement:</strong> As high as possible with clear view of the water/harbor</li>
</ul>
</div>
<div style="background: var(--bg-primary); border: 1px solid var(--border-color); border-radius: 4px; padding: 10px; margin-bottom: 10px;">
<strong style="color: var(--accent-cyan); font-size: 12px;">Commercial Options</strong>
<ul style="margin: 6px 0 0 14px; padding: 0;">
<li><strong style="color: var(--text-primary);">Marine VHF whip:</strong> ~$20&ndash;50, designed for 156&ndash;163 MHz band</li>
<li><strong style="color: var(--text-primary);">Discone:</strong> ~$30&ndash;50, wideband coverage including marine VHF</li>
<li><strong style="color: var(--text-primary);">Collinear:</strong> Higher gain (~6 dBi), best for coastal monitoring</li>
</ul>
</div>
<div style="background: var(--bg-primary); border: 1px solid var(--border-color); border-radius: 4px; padding: 10px; margin-bottom: 10px;">
<strong style="color: var(--accent-cyan); font-size: 12px;">Placement Tips</strong>
<ul style="margin: 6px 0 0 14px; padding: 0;">
<li><strong style="color: var(--text-primary);">Height is critical:</strong> AIS is line-of-sight. Roof or mast mount is ideal</li>
<li><strong style="color: var(--text-primary);">Range:</strong> At 10m height, expect ~25 NM (46 km) range over water</li>
<li><strong style="color: var(--text-primary);">LNA:</strong> Nooelec Lana or similar broadband LNA, mount at antenna</li>
<li><strong style="color: var(--text-primary);">Coax:</strong> Keep cable short. RG-58 loses ~4 dB per 10m at 162 MHz</li>
</ul>
</div>
<div style="background: var(--bg-primary); border: 1px solid var(--border-color); border-radius: 4px; padding: 10px;">
<strong style="color: var(--accent-cyan); font-size: 12px;">Quick Reference</strong>
<table style="width: 100%; margin-top: 6px; font-size: 10px; border-collapse: collapse;">
<tr style="border-bottom: 1px solid var(--border-color);">
<td style="padding: 3px 4px; color: var(--text-dim);">AIS Channel A</td>
<td style="padding: 3px 4px; color: var(--text-primary); text-align: right;">161.975 MHz</td>
</tr>
<tr style="border-bottom: 1px solid var(--border-color);">
<td style="padding: 3px 4px; color: var(--text-dim);">AIS Channel B</td>
<td style="padding: 3px 4px; color: var(--text-primary); text-align: right;">162.025 MHz</td>
</tr>
<tr style="border-bottom: 1px solid var(--border-color);">
<td style="padding: 3px 4px; color: var(--text-dim);">Quarter-wave length</td>
<td style="padding: 3px 4px; color: var(--text-primary); text-align: right;">46 cm</td>
</tr>
<tr style="border-bottom: 1px solid var(--border-color);">
<td style="padding: 3px 4px; color: var(--text-dim);">Modulation</td>
<td style="padding: 3px 4px; color: var(--text-primary); text-align: right;">GMSK 9600 baud</td>
</tr>
<tr style="border-bottom: 1px solid var(--border-color);">
<td style="padding: 3px 4px; color: var(--text-dim);">Bandwidth</td>
<td style="padding: 3px 4px; color: var(--text-primary); text-align: right;">25 kHz</td>
</tr>
<tr>
<td style="padding: 3px 4px; color: var(--text-dim);">Polarization</td>
<td style="padding: 3px 4px; color: var(--text-primary); text-align: right;">Vertical</td>
</tr>
</table>
</div>
</div>
</div>
<button class="run-btn" id="startAisBtn" onclick="startAisTracking()">
Start AIS Tracking
</button>
+55
View File
@@ -13,4 +13,59 @@
<span style="color: var(--accent-cyan);">Controls in function bar above map</span>
</div>
</div>
<!-- Antenna Guide -->
<div class="section">
<h3>Antenna Guide</h3>
<div style="font-size: 11px; color: var(--text-dim); line-height: 1.5;">
<p style="margin-bottom: 8px; color: var(--accent-cyan); font-weight: 600;">
2m band (144&ndash;148 MHz) &mdash; stock SDR antenna will NOT work
</p>
<div style="background: var(--bg-primary); border: 1px solid var(--border-color); border-radius: 4px; padding: 10px; margin-bottom: 10px;">
<strong style="color: var(--accent-cyan); font-size: 12px;">Simple Dipole (Easiest)</strong>
<ul style="margin: 6px 0 0 14px; padding: 0;">
<li><strong style="color: var(--text-primary);">Element length:</strong> ~51.5 cm each (quarter-wave at 144.39 MHz)</li>
<li><strong style="color: var(--text-primary);">Material:</strong> Wire, coat hanger, or copper rod</li>
<li><strong style="color: var(--text-primary);">Orientation:</strong> Vertical (APRS is FM, vertically polarized)</li>
<li><strong style="color: var(--text-primary);">Connection:</strong> Center conductor to one element, shield to the other</li>
</ul>
</div>
<div style="background: var(--bg-primary); border: 1px solid var(--border-color); border-radius: 4px; padding: 10px; margin-bottom: 10px;">
<strong style="color: var(--accent-cyan); font-size: 12px;">Commercial Options</strong>
<ul style="margin: 6px 0 0 14px; padding: 0;">
<li><strong style="color: var(--text-primary);">Mag-mount 2m whip:</strong> ~$15&ndash;25, good mobile/portable option</li>
<li><strong style="color: var(--text-primary);">2m/70cm dual-band:</strong> ~$20&ndash;40, also covers 70cm ham band</li>
<li><strong style="color: var(--text-primary);">Discone:</strong> ~$30&ndash;50, wideband but lower gain on 2m</li>
</ul>
</div>
<div style="background: var(--bg-primary); border: 1px solid var(--border-color); border-radius: 4px; padding: 10px;">
<strong style="color: var(--accent-cyan); font-size: 12px;">Quick Reference</strong>
<table style="width: 100%; margin-top: 6px; font-size: 10px; border-collapse: collapse;">
<tr style="border-bottom: 1px solid var(--border-color);">
<td style="padding: 3px 4px; color: var(--text-dim);">APRS freq (N. America)</td>
<td style="padding: 3px 4px; color: var(--text-primary); text-align: right;">144.390 MHz</td>
</tr>
<tr style="border-bottom: 1px solid var(--border-color);">
<td style="padding: 3px 4px; color: var(--text-dim);">APRS freq (Europe)</td>
<td style="padding: 3px 4px; color: var(--text-primary); text-align: right;">144.800 MHz</td>
</tr>
<tr style="border-bottom: 1px solid var(--border-color);">
<td style="padding: 3px 4px; color: var(--text-dim);">Quarter-wave length</td>
<td style="padding: 3px 4px; color: var(--text-primary); text-align: right;">51.5 cm</td>
</tr>
<tr style="border-bottom: 1px solid var(--border-color);">
<td style="padding: 3px 4px; color: var(--text-dim);">Modulation</td>
<td style="padding: 3px 4px; color: var(--text-primary); text-align: right;">FM 1200 baud</td>
</tr>
<tr>
<td style="padding: 3px 4px; color: var(--text-dim);">Polarization</td>
<td style="padding: 3px 4px; color: var(--text-primary); text-align: right;">Vertical</td>
</tr>
</table>
</div>
</div>
</div>
</div>
+12 -14
View File
@@ -1,20 +1,18 @@
<!-- BLUETOOTH MODE -->
<div id="bluetoothMode" class="mode-content">
<!-- Capability Status -->
<div id="btCapabilityStatus" class="section" style="display: none;">
<!-- Populated by JavaScript with capability warnings -->
</div>
<!-- Show All Agents option (visible when agents are available) -->
<div id="btShowAllAgentsContainer" class="section" style="display: none; padding: 8px;">
<label class="inline-checkbox" style="font-size: 10px;">
<input type="checkbox" id="btShowAllAgents" onchange="if(typeof BluetoothMode !== 'undefined') BluetoothMode.toggleShowAllAgents(this.checked)">
Show devices from all agents
</label>
</div>
<div class="section">
<h3>Scanner Configuration</h3>
<!-- Populated by JavaScript with capability warnings -->
<div id="btCapabilityStatus" style="display: none; margin-bottom: 8px;"></div>
<!-- Show All Agents option (visible when agents are available) -->
<div id="btShowAllAgentsContainer" style="display: none; margin-bottom: 8px;">
<label class="inline-checkbox" style="font-size: 10px;">
<input type="checkbox" id="btShowAllAgents" onchange="if(typeof BluetoothMode !== 'undefined') BluetoothMode.toggleShowAllAgents(this.checked)">
Show devices from all agents
</label>
</div>
<div class="form-group">
<label>Adapter</label>
<select id="btAdapterSelect">
@@ -61,7 +59,7 @@
Stop Scanning
</button>
<div class="section" style="margin-top: 10px;">
<div class="section">
<h3>Export</h3>
<div style="display: flex; gap: 8px;">
<button class="preset-btn" onclick="btExport('csv')" style="flex: 1;">
+51 -8
View File
@@ -2,6 +2,9 @@
<div id="dmrMode" class="mode-content">
<div class="section">
<h3>Digital Voice</h3>
<div class="alpha-mode-notice">
ALPHA: Digital Voice decoding is still in active development. Expect occasional decode instability and false protocol locks.
</div>
<!-- Dependency Warning -->
<div id="dmrToolsWarning" style="display: none; background: rgba(255, 100, 100, 0.1); border: 1px solid var(--accent-red); border-radius: 4px; padding: 10px; margin-bottom: 10px;">
@@ -19,28 +22,59 @@
<div class="form-group">
<label>Protocol</label>
<select id="dmrProtocol">
<option value="auto" selected>Auto Detect</option>
<option value="auto" selected>Auto Detect (DMR/P25/D-STAR)</option>
<option value="dmr">DMR</option>
<option value="p25">P25</option>
<option value="nxdn">NXDN</option>
<option value="dstar">D-STAR</option>
<option value="provoice">ProVoice</option>
</select>
<span style="font-size: 0.75em; color: var(--text-muted); display: block; margin-top: 2px;">
For NXDN and ProVoice, use manual protocol selection for best lock reliability
</span>
</div>
<div class="form-group">
<label>Gain</label>
<input type="number" id="dmrGain" value="40" min="0" max="50" style="width: 100%;">
</div>
<div class="form-group">
<label>PPM Correction</label>
<input type="number" id="dmrPPM" value="0" min="-200" max="200" step="1" style="width: 100%;"
title="Frequency error correction for your RTL-SDR dongle. Digital voice is very sensitive to frequency offset.">
</div>
<div class="form-group" style="margin-top: 4px;">
<label style="display: flex; align-items: center; gap: 8px; cursor: pointer;">
<input type="checkbox" id="dmrRelaxCrc" style="width: auto; accent-color: var(--accent-cyan);">
<span>Relax CRC (weak signals)</span>
</label>
<span style="font-size: 0.75em; color: var(--text-muted); display: block; margin-top: 2px;">
Allows more frames through on marginal signals at the cost of occasional errors
</span>
</div>
</div>
<!-- Actions -->
<button class="run-btn" id="startDmrBtn" onclick="startDmr()" style="margin-top: 12px;">
Start Decoder
</button>
<button class="stop-btn" id="stopDmrBtn" onclick="stopDmr()" style="display: none; margin-top: 12px;">
Stop Decoder
</button>
<!-- Bookmarks -->
<div class="section" style="margin-top: 8px;">
<h3>Bookmarks</h3>
<div style="display: flex; gap: 4px; margin-bottom: 6px;">
<input type="number" id="dmrBookmarkFreq" placeholder="Freq MHz" step="0.0001"
style="flex: 1; font-size: 11px; padding: 4px 6px;">
<button class="preset-btn" onclick="addDmrBookmark()" style="font-size: 10px; padding: 4px 8px;"
title="Add bookmark">+</button>
</div>
<div style="display: flex; gap: 4px; margin-bottom: 6px;">
<input type="text" id="dmrBookmarkLabel" placeholder="Label (optional)"
style="flex: 1; font-size: 11px; padding: 4px 6px;">
<button class="preset-btn" onclick="addCurrentDmrFreqBookmark()" style="font-size: 9px; padding: 4px 6px;"
title="Save current frequency">Save current</button>
</div>
<div id="dmrBookmarksList" style="max-height: 150px; overflow-y: auto;">
<div style="color: var(--text-muted); text-align: center; padding: 10px; font-size: 11px;">No bookmarks saved</div>
</div>
</div>
<!-- Current Call -->
<div class="section" style="margin-top: 12px;">
@@ -68,4 +102,13 @@
</div>
</div>
</div>
<div class="mode-actions-bottom">
<button class="run-btn" id="startDmrBtn" onclick="startDmr()">
Start Decoder
</button>
<button class="stop-btn" id="stopDmrBtn" onclick="stopDmr()" style="display: none;">
Stop Decoder
</button>
</div>
</div>
+1 -28
View File
@@ -61,35 +61,8 @@
<div id="signalGuessExplanation" style="color: var(--text-muted); font-size: 10px; margin-bottom: 6px;"></div>
<div id="signalGuessTags" style="display: flex; flex-wrap: wrap; gap: 3px;"></div>
<div id="signalGuessAlternatives" style="margin-top: 6px; font-size: 10px; color: var(--text-muted);"></div>
<div id="signalGuessSendTo" style="margin-top: 8px; display: none;"></div>
</div>
</div>
<!-- Waterfall Controls -->
<div class="section">
<h3>Waterfall</h3>
<div class="form-group" style="margin-bottom: 6px;">
<label style="font-size: 10px;">Start (MHz)</label>
<input type="number" id="waterfallStartFreq" value="88" step="0.1" style="width: 100%; padding: 5px; background: var(--bg-secondary); border: 1px solid var(--border-color); color: var(--text-primary); border-radius: 4px; font-size: 11px;">
</div>
<div class="form-group" style="margin-bottom: 6px;">
<label style="font-size: 10px;">End (MHz)</label>
<input type="number" id="waterfallEndFreq" value="108" step="0.1" style="width: 100%; padding: 5px; background: var(--bg-secondary); border: 1px solid var(--border-color); color: var(--text-primary); border-radius: 4px; font-size: 11px;">
</div>
<div class="form-group" style="margin-bottom: 6px;">
<label style="font-size: 10px;">Bin Size</label>
<select id="waterfallBinSize" style="width: 100%; padding: 5px; background: var(--bg-secondary); border: 1px solid var(--border-color); color: var(--text-primary); border-radius: 4px; font-size: 11px;">
<option value="5000">5 kHz</option>
<option value="10000" selected>10 kHz</option>
<option value="25000">25 kHz</option>
<option value="100000">100 kHz</option>
</select>
</div>
<div class="form-group" style="margin-bottom: 8px;">
<label style="font-size: 10px;">Gain</label>
<input type="number" id="waterfallGain" value="40" min="0" max="50" style="width: 100%; padding: 5px; background: var(--bg-secondary); border: 1px solid var(--border-color); color: var(--text-primary); border-radius: 4px; font-size: 11px;">
</div>
<button class="run-btn" id="startWaterfallBtn" onclick="startWaterfall()" style="width: 100%; padding: 8px;">Start Waterfall</button>
<button class="stop-btn" id="stopWaterfallBtn" onclick="stopWaterfall()" style="display: none; width: 100%; padding: 8px; margin-top: 4px;">Stop Waterfall</button>
</div>
</div>
+68
View File
@@ -55,6 +55,74 @@
</a>
</div>
</div>
<!-- Antenna Guide -->
<div class="section">
<h3>Antenna Guide</h3>
<div style="font-size: 11px; color: var(--text-dim); line-height: 1.5;">
<p style="margin-bottom: 8px; color: var(--accent-cyan); font-weight: 600;">
LoRa ISM band &mdash; frequency depends on region
</p>
<div style="background: var(--bg-primary); border: 1px solid var(--border-color); border-radius: 4px; padding: 10px; margin-bottom: 10px;">
<strong style="color: var(--accent-cyan); font-size: 12px;">Stock Device Antenna</strong>
<ul style="margin: 6px 0 0 14px; padding: 0;">
<li><strong style="color: var(--text-primary);">Most devices:</strong> Ship with a small 915/868 MHz stubby antenna</li>
<li><strong style="color: var(--text-primary);">Works for:</strong> Short range (&lt; 1 km) urban, indoor testing</li>
<li><strong style="color: var(--text-primary);">Upgrade:</strong> Replace with tuned antenna for 5&ndash;20x range improvement</li>
</ul>
</div>
<div style="background: var(--bg-primary); border: 1px solid var(--border-color); border-radius: 4px; padding: 10px; margin-bottom: 10px;">
<strong style="color: #00ff88; font-size: 12px;">Recommended Upgrades</strong>
<ul style="margin: 6px 0 0 14px; padding: 0;">
<li><strong style="color: var(--text-primary);">Whip antenna:</strong> ~$8&ndash;15, tuned 915/868 MHz, SMA connector</li>
<li><strong style="color: var(--text-primary);">Ground plane:</strong> 8.2 cm vertical + 4 radials (915 MHz) on SMA</li>
<li><strong style="color: var(--text-primary);">Yagi:</strong> ~$15&ndash;30, directional, great for point-to-point links</li>
<li><strong style="color: var(--text-primary);">Collinear:</strong> ~$20&ndash;40, omnidirectional with higher gain (~5&ndash;8 dBi)</li>
</ul>
</div>
<div style="background: var(--bg-primary); border: 1px solid var(--border-color); border-radius: 4px; padding: 10px; margin-bottom: 10px;">
<strong style="color: var(--accent-cyan); font-size: 12px;">Placement Tips</strong>
<ul style="margin: 6px 0 0 14px; padding: 0;">
<li><strong style="color: var(--text-primary);">Height wins:</strong> Elevating antenna 10m can double or triple range</li>
<li><strong style="color: var(--text-primary);">Line of sight:</strong> LoRa works best with clear LOS to other nodes</li>
<li><strong style="color: var(--text-primary);">Connector:</strong> Most devices use SMA or RP-SMA &mdash; check before buying</li>
</ul>
</div>
<div style="background: var(--bg-primary); border: 1px solid var(--border-color); border-radius: 4px; padding: 10px;">
<strong style="color: var(--accent-cyan); font-size: 12px;">Quick Reference</strong>
<table style="width: 100%; margin-top: 6px; font-size: 10px; border-collapse: collapse;">
<tr style="border-bottom: 1px solid var(--border-color);">
<td style="padding: 3px 4px; color: var(--text-dim);">US / Americas</td>
<td style="padding: 3px 4px; color: var(--text-primary); text-align: right;">915 MHz</td>
</tr>
<tr style="border-bottom: 1px solid var(--border-color);">
<td style="padding: 3px 4px; color: var(--text-dim);">EU / UK / India</td>
<td style="padding: 3px 4px; color: var(--text-primary); text-align: right;">868 MHz</td>
</tr>
<tr style="border-bottom: 1px solid var(--border-color);">
<td style="padding: 3px 4px; color: var(--text-dim);">915 MHz &lambda;/4</td>
<td style="padding: 3px 4px; color: var(--text-primary); text-align: right;">8.2 cm</td>
</tr>
<tr style="border-bottom: 1px solid var(--border-color);">
<td style="padding: 3px 4px; color: var(--text-dim);">868 MHz &lambda;/4</td>
<td style="padding: 3px 4px; color: var(--text-primary); text-align: right;">8.6 cm</td>
</tr>
<tr style="border-bottom: 1px solid var(--border-color);">
<td style="padding: 3px 4px; color: var(--text-dim);">Modulation</td>
<td style="padding: 3px 4px; color: var(--text-primary); text-align: right;">LoRa (CSS)</td>
</tr>
<tr>
<td style="padding: 3px 4px; color: var(--text-dim);">Typical range</td>
<td style="padding: 3px 4px; color: var(--text-primary); text-align: right;">1&ndash;15 km</td>
</tr>
</table>
</div>
</div>
</div>
</div>
</div>
+56
View File
@@ -75,6 +75,62 @@
</div>
</div>
<!-- Antenna Guide -->
<div class="section">
<h3>Antenna Guide</h3>
<div style="font-size: 11px; color: var(--text-dim); line-height: 1.5;">
<p style="margin-bottom: 8px; color: var(--accent-cyan); font-weight: 600;">
Pager frequencies vary by region (130&ndash;930 MHz)
</p>
<div style="background: var(--bg-primary); border: 1px solid var(--border-color); border-radius: 4px; padding: 10px; margin-bottom: 10px;">
<strong style="color: var(--accent-cyan); font-size: 12px;">Stock Telescopic Antenna</strong>
<ul style="margin: 6px 0 0 14px; padding: 0;">
<li><strong style="color: var(--text-primary);">Works for:</strong> UHF pager bands (~900 MHz) &mdash; the stock antenna is tuned near 1 GHz</li>
<li><strong style="color: var(--text-primary);">Extend to:</strong> ~8 cm for 929 MHz (quarter-wave)</li>
<li><strong style="color: var(--text-primary);">For VHF (~150 MHz):</strong> Stock antenna is too short. Build a dipole (see below)</li>
</ul>
</div>
<div style="background: var(--bg-primary); border: 1px solid var(--border-color); border-radius: 4px; padding: 10px; margin-bottom: 10px;">
<strong style="color: var(--accent-cyan); font-size: 12px;">Simple Dipole (Best for VHF Pagers)</strong>
<ul style="margin: 6px 0 0 14px; padding: 0;">
<li><strong style="color: var(--text-primary);">For 153 MHz:</strong> Two elements, each ~49 cm (quarter-wave)</li>
<li><strong style="color: var(--text-primary);">For 929 MHz:</strong> Two elements, each ~8 cm</li>
<li><strong style="color: var(--text-primary);">Formula:</strong> Element length (cm) = 7500 / frequency (MHz)</li>
<li><strong style="color: var(--text-primary);">Material:</strong> Any wire, coat hanger, or copper rod</li>
<li><strong style="color: var(--text-primary);">Orientation:</strong> Vertical (pager signals are vertically polarized)</li>
</ul>
</div>
<div style="background: var(--bg-primary); border: 1px solid var(--border-color); border-radius: 4px; padding: 10px;">
<strong style="color: var(--accent-cyan); font-size: 12px;">Quick Reference</strong>
<table style="width: 100%; margin-top: 6px; font-size: 10px; border-collapse: collapse;">
<tr style="border-bottom: 1px solid var(--border-color);">
<td style="padding: 3px 4px; color: var(--text-dim);">Common UHF freq</td>
<td style="padding: 3px 4px; color: var(--text-primary); text-align: right;">929 MHz</td>
</tr>
<tr style="border-bottom: 1px solid var(--border-color);">
<td style="padding: 3px 4px; color: var(--text-dim);">Common VHF freq</td>
<td style="padding: 3px 4px; color: var(--text-primary); text-align: right;">153.350 MHz</td>
</tr>
<tr style="border-bottom: 1px solid var(--border-color);">
<td style="padding: 3px 4px; color: var(--text-dim);">Modulation</td>
<td style="padding: 3px 4px; color: var(--text-primary); text-align: right;">FM (NFM)</td>
</tr>
<tr style="border-bottom: 1px solid var(--border-color);">
<td style="padding: 3px 4px; color: var(--text-dim);">Bandwidth</td>
<td style="padding: 3px 4px; color: var(--text-primary); text-align: right;">~12.5 kHz</td>
</tr>
<tr>
<td style="padding: 3px 4px; color: var(--text-dim);">Polarization</td>
<td style="padding: 3px 4px; color: var(--text-primary); text-align: right;">Vertical</td>
</tr>
</table>
</div>
</div>
</div>
<button class="run-btn" id="startBtn" onclick="startDecoding()">
Start Decoding
</button>
+53
View File
@@ -58,6 +58,59 @@
</div>
</div>
<!-- Antenna Guide -->
<div class="section">
<h3>Antenna Guide</h3>
<div style="font-size: 11px; color: var(--text-dim); line-height: 1.5;">
<p style="margin-bottom: 8px; color: var(--accent-cyan); font-weight: 600;">
ISM 900 MHz band &mdash; stock antenna is close but not optimal
</p>
<div style="background: var(--bg-primary); border: 1px solid var(--border-color); border-radius: 4px; padding: 10px; margin-bottom: 10px;">
<strong style="color: var(--accent-cyan); font-size: 12px;">Stock Telescopic Antenna</strong>
<ul style="margin: 6px 0 0 14px; padding: 0;">
<li><strong style="color: var(--text-primary);">912 MHz:</strong> Extend to ~8.2 cm (quarter-wave). The stock antenna is close enough to work</li>
<li><strong style="color: var(--text-primary);">Range:</strong> Most meters transmit at ~100 mW, expect 50&ndash;200 m range with stock antenna</li>
</ul>
</div>
<div style="background: var(--bg-primary); border: 1px solid var(--border-color); border-radius: 4px; padding: 10px; margin-bottom: 10px;">
<strong style="color: var(--accent-cyan); font-size: 12px;">Upgraded Options</strong>
<ul style="margin: 6px 0 0 14px; padding: 0;">
<li><strong style="color: var(--text-primary);">Ground Plane:</strong> 8.2 cm vertical + four 8.2 cm radials at 45&deg; on SMA connector</li>
<li><strong style="color: var(--text-primary);">Yagi:</strong> Directional for targeting specific meters at distance (~$15&ndash;25)</li>
<li><strong style="color: var(--text-primary);">Placement:</strong> Near a window facing the meters. Line-of-sight matters most</li>
</ul>
</div>
<div style="background: var(--bg-primary); border: 1px solid var(--border-color); border-radius: 4px; padding: 10px;">
<strong style="color: var(--accent-cyan); font-size: 12px;">Quick Reference</strong>
<table style="width: 100%; margin-top: 6px; font-size: 10px; border-collapse: collapse;">
<tr style="border-bottom: 1px solid var(--border-color);">
<td style="padding: 3px 4px; color: var(--text-dim);">Frequency (NA)</td>
<td style="padding: 3px 4px; color: var(--text-primary); text-align: right;">912 MHz</td>
</tr>
<tr style="border-bottom: 1px solid var(--border-color);">
<td style="padding: 3px 4px; color: var(--text-dim);">Frequency (EU)</td>
<td style="padding: 3px 4px; color: var(--text-primary); text-align: right;">868 MHz</td>
</tr>
<tr style="border-bottom: 1px solid var(--border-color);">
<td style="padding: 3px 4px; color: var(--text-dim);">912 MHz &lambda;/4</td>
<td style="padding: 3px 4px; color: var(--text-primary); text-align: right;">8.2 cm</td>
</tr>
<tr style="border-bottom: 1px solid var(--border-color);">
<td style="padding: 3px 4px; color: var(--text-dim);">Meter TX power</td>
<td style="padding: 3px 4px; color: var(--text-primary); text-align: right;">~100 mW</td>
</tr>
<tr>
<td style="padding: 3px 4px; color: var(--text-dim);">Polarization</td>
<td style="padding: 3px 4px; color: var(--text-primary); text-align: right;">Vertical</td>
</tr>
</table>
</div>
</div>
</div>
<button class="run-btn" id="startRtlamrBtn" onclick="startRtlamrDecoding()">
Start Listening
</button>
+55
View File
@@ -39,6 +39,61 @@
</div>
</div>
<!-- Antenna Guide -->
<div class="section">
<h3>Antenna Guide</h3>
<div style="font-size: 11px; color: var(--text-dim); line-height: 1.5;">
<p style="margin-bottom: 8px; color: var(--accent-cyan); font-weight: 600;">
ISM band devices (433 / 868 / 915 MHz)
</p>
<div style="background: var(--bg-primary); border: 1px solid var(--border-color); border-radius: 4px; padding: 10px; margin-bottom: 10px;">
<strong style="color: var(--accent-cyan); font-size: 12px;">Stock Telescopic Antenna</strong>
<ul style="margin: 6px 0 0 14px; padding: 0;">
<li><strong style="color: var(--text-primary);">433 MHz:</strong> Extend to ~17 cm (quarter-wave). Stock antenna works but isn't ideal</li>
<li><strong style="color: var(--text-primary);">868/915 MHz:</strong> Extend to ~8 cm. Stock antenna is nearly tuned for this</li>
</ul>
</div>
<div style="background: var(--bg-primary); border: 1px solid var(--border-color); border-radius: 4px; padding: 10px; margin-bottom: 10px;">
<strong style="color: var(--accent-cyan); font-size: 12px;">Quarter-Wave Ground Plane (Best)</strong>
<ul style="margin: 6px 0 0 14px; padding: 0;">
<li><strong style="color: var(--text-primary);">433 MHz:</strong> Vertical element 17.3 cm + four 17.3 cm radials at 45&deg;</li>
<li><strong style="color: var(--text-primary);">868 MHz:</strong> Vertical element 8.6 cm + four 8.6 cm radials</li>
<li><strong style="color: var(--text-primary);">915 MHz:</strong> Vertical element 8.2 cm + four 8.2 cm radials</li>
<li><strong style="color: var(--text-primary);">Material:</strong> Stiff copper wire soldered to an SMA connector</li>
<li><strong style="color: var(--text-primary);">Placement:</strong> Outdoors or near a window. Higher is better for range</li>
</ul>
</div>
<div style="background: var(--bg-primary); border: 1px solid var(--border-color); border-radius: 4px; padding: 10px;">
<strong style="color: var(--accent-cyan); font-size: 12px;">Quick Reference</strong>
<table style="width: 100%; margin-top: 6px; font-size: 10px; border-collapse: collapse;">
<tr style="border-bottom: 1px solid var(--border-color);">
<td style="padding: 3px 4px; color: var(--text-dim);">433 MHz &lambda;/4</td>
<td style="padding: 3px 4px; color: var(--text-primary); text-align: right;">17.3 cm</td>
</tr>
<tr style="border-bottom: 1px solid var(--border-color);">
<td style="padding: 3px 4px; color: var(--text-dim);">868 MHz &lambda;/4</td>
<td style="padding: 3px 4px; color: var(--text-primary); text-align: right;">8.6 cm</td>
</tr>
<tr style="border-bottom: 1px solid var(--border-color);">
<td style="padding: 3px 4px; color: var(--text-dim);">915 MHz &lambda;/4</td>
<td style="padding: 3px 4px; color: var(--text-primary); text-align: right;">8.2 cm</td>
</tr>
<tr style="border-bottom: 1px solid var(--border-color);">
<td style="padding: 3px 4px; color: var(--text-dim);">Typical range</td>
<td style="padding: 3px 4px; color: var(--text-primary); text-align: right;">50&ndash;300 m</td>
</tr>
<tr>
<td style="padding: 3px 4px; color: var(--text-dim);">Polarization</td>
<td style="padding: 3px 4px; color: var(--text-primary); text-align: right;">Vertical</td>
</tr>
</table>
</div>
</div>
</div>
<button class="run-btn" id="startSensorBtn" onclick="startSensorDecoding()">
Start Listening
</button>
+60
View File
@@ -39,4 +39,64 @@
Common modes: PD120, PD180, Martin1, Scottie1
</p>
</div>
<!-- Antenna Guide -->
<div class="section">
<h3>Antenna Guide</h3>
<div style="font-size: 11px; color: var(--text-dim); line-height: 1.5;">
<p style="margin-bottom: 8px; color: var(--accent-cyan); font-weight: 600;">
2m band (145.800 MHz) &mdash; stock SDR antenna will NOT work
</p>
<div style="background: var(--bg-primary); border: 1px solid var(--border-color); border-radius: 4px; padding: 10px; margin-bottom: 10px;">
<strong style="color: var(--accent-cyan); font-size: 12px;">V-Dipole (Easiest &mdash; ~$5)</strong>
<ul style="margin: 6px 0 0 14px; padding: 0;">
<li><strong style="color: var(--text-primary);">Element length:</strong> ~51 cm each (quarter-wave at 145.8 MHz)</li>
<li><strong style="color: var(--text-primary);">Angle:</strong> 120&deg; between elements for partial RHCP</li>
<li><strong style="color: var(--text-primary);">Orientation:</strong> Lay flat, angled toward the ISS pass direction</li>
<li><strong style="color: var(--text-primary);">Material:</strong> Wire, coat hanger, or copper rod</li>
</ul>
<p style="margin-top: 6px; color: var(--text-dim); font-style: italic;">
Same antenna as weather satellites (similar frequency). A QFH or turnstile for 137 MHz also works well here.
</p>
</div>
<div style="background: var(--bg-primary); border: 1px solid var(--border-color); border-radius: 4px; padding: 10px; margin-bottom: 10px;">
<strong style="color: var(--accent-cyan); font-size: 12px;">Tips for ISS Reception</strong>
<ul style="margin: 6px 0 0 14px; padding: 0;">
<li><strong style="color: var(--text-primary);">ISS altitude:</strong> ~420 km, overhead passes last 5&ndash;10 minutes</li>
<li><strong style="color: var(--text-primary);">Best passes:</strong> Elevation &gt; 30&deg; for clear signal</li>
<li><strong style="color: var(--text-primary);">Outdoors:</strong> Clear sky view is essential. Roof or open field</li>
<li><strong style="color: var(--text-primary);">LNA:</strong> Optional but helps &mdash; 2m filtered LNA at antenna feed</li>
<li><strong style="color: var(--text-primary);">Doppler:</strong> ISS moves fast &mdash; signal shifts &plusmn;3.5 kHz during pass</li>
</ul>
</div>
<div style="background: var(--bg-primary); border: 1px solid var(--border-color); border-radius: 4px; padding: 10px;">
<strong style="color: var(--accent-cyan); font-size: 12px;">Quick Reference</strong>
<table style="width: 100%; margin-top: 6px; font-size: 10px; border-collapse: collapse;">
<tr style="border-bottom: 1px solid var(--border-color);">
<td style="padding: 3px 4px; color: var(--text-dim);">ISS SSTV frequency</td>
<td style="padding: 3px 4px; color: var(--text-primary); text-align: right;">145.800 MHz</td>
</tr>
<tr style="border-bottom: 1px solid var(--border-color);">
<td style="padding: 3px 4px; color: var(--text-dim);">Quarter-wave length</td>
<td style="padding: 3px 4px; color: var(--text-primary); text-align: right;">51 cm</td>
</tr>
<tr style="border-bottom: 1px solid var(--border-color);">
<td style="padding: 3px 4px; color: var(--text-dim);">Modulation</td>
<td style="padding: 3px 4px; color: var(--text-primary); text-align: right;">FM (25 kHz)</td>
</tr>
<tr style="border-bottom: 1px solid var(--border-color);">
<td style="padding: 3px 4px; color: var(--text-dim);">Polarization</td>
<td style="padding: 3px 4px; color: var(--text-primary); text-align: right;">RHCP (circular)</td>
</tr>
<tr>
<td style="padding: 3px 4px; color: var(--text-dim);">Typical pass duration</td>
<td style="padding: 3px 4px; color: var(--text-primary); text-align: right;">5&ndash;10 min</td>
</tr>
</table>
</div>
</div>
</div>
</div>
+175
View File
@@ -0,0 +1,175 @@
<!-- SUBGHZ TRANSCEIVER MODE -->
<div id="subghzMode" class="mode-content">
<div class="section">
<h3>SubGHz Transceiver</h3>
<p class="info-text" style="font-size: 11px; color: var(--text-dim); margin-bottom: 12px;">
HackRF One SubGHz transceiver. Capture raw signals, replay saved bursts,
and scan wideband activity with frequency analysis.
</p>
</div>
<!-- Device -->
<div class="section">
<h3>HackRF Device</h3>
<div class="subghz-device-status" id="subghzDeviceStatus">
<div class="subghz-device-row">
<span class="subghz-device-dot" id="subghzDeviceDot"></span>
<span class="subghz-device-label" id="subghzDeviceLabel">Checking...</span>
</div>
<div class="subghz-device-tools" id="subghzDeviceTools">
<span class="subghz-tool-badge" id="subghzToolHackrf" title="hackrf_transfer">HackRF</span>
<span class="subghz-tool-badge" id="subghzToolSweep" title="hackrf_sweep">Sweep</span>
</div>
</div>
<div class="form-group" style="margin-top: 8px;">
<label>Device Serial <span style="color: var(--text-dim); font-weight: normal;">(optional)</span></label>
<input type="text" id="subghzDeviceSerial" placeholder="auto-detect" style="font-family: 'JetBrains Mono', monospace; font-size: 11px;">
</div>
</div>
<!-- Status -->
<div class="subghz-status-row" id="subghzStatusRow">
<div class="subghz-status-dot" id="subghzStatusDot"></div>
<span class="subghz-status-text" id="subghzStatusText">Idle</span>
<span class="subghz-status-timer" id="subghzStatusTimer"></span>
</div>
<!-- Frequency -->
<div class="section">
<h3>Frequency</h3>
<div class="form-group">
<label>Frequency (MHz)</label>
<input type="number" id="subghzFrequency" value="433.92" step="0.001" min="1" max="6000">
</div>
<div class="subghz-preset-btns">
<button class="subghz-preset-btn" onclick="SubGhz.setFreq(315)">315M</button>
<button class="subghz-preset-btn" onclick="SubGhz.setFreq(433.92)">433.92M</button>
<button class="subghz-preset-btn" onclick="SubGhz.setFreq(868)">868M</button>
<button class="subghz-preset-btn" onclick="SubGhz.setFreq(915)">915M</button>
</div>
</div>
<!-- Gain -->
<div class="section">
<h3>Gain</h3>
<div class="form-group">
<label>LNA Gain (0-40 dB)</label>
<input type="range" id="subghzLnaGain" min="0" max="40" value="24" step="8" oninput="document.getElementById('subghzLnaVal').textContent=this.value">
<span id="subghzLnaVal" style="font-family: 'JetBrains Mono', monospace; font-size: 11px; color: var(--text-secondary);">24</span>
</div>
<div class="form-group">
<label>VGA Gain (0-62 dB)</label>
<input type="range" id="subghzVgaGain" min="0" max="62" value="20" step="2" oninput="document.getElementById('subghzVgaVal').textContent=this.value">
<span id="subghzVgaVal" style="font-family: 'JetBrains Mono', monospace; font-size: 11px; color: var(--text-secondary);">20</span>
</div>
<div class="form-group">
<label>Sample Rate</label>
<select id="subghzSampleRate" class="mode-select">
<option value="2000000" selected>2 MHz</option>
<option value="4000000">4 MHz</option>
<option value="8000000">8 MHz</option>
<option value="10000000">10 MHz</option>
<option value="20000000">20 MHz</option>
</select>
</div>
</div>
<!-- Tabs: Receive RAW / Sweep -->
<div class="section">
<div class="subghz-tabs">
<button class="subghz-tab active" data-tab="rx" onclick="SubGhz.switchTab('rx')">Read RAW</button>
<button class="subghz-tab" data-tab="sweep" onclick="SubGhz.switchTab('sweep')">Sweep</button>
</div>
<!-- RX Tab -->
<div class="subghz-tab-content active" id="subghzTabRx">
<p style="font-size: 11px; color: var(--text-dim); margin-bottom: 10px;">
Capture raw IQ data to file. Saved captures can be replayed or analyzed.
</p>
<div class="subghz-trigger-box">
<label class="subghz-trigger-toggle">
<input type="checkbox" id="subghzTriggerEnabled" onchange="SubGhz.syncTriggerControls()">
Smart Trigger Capture
</label>
<div class="subghz-trigger-grid">
<label>Pre-roll (ms)</label>
<input type="number" id="subghzTriggerPreMs" min="50" max="5000" step="50" value="350">
<label>Post-roll (ms)</label>
<input type="number" id="subghzTriggerPostMs" min="100" max="10000" step="50" value="700">
</div>
<p class="subghz-trigger-help">Auto-stops after burst + post-roll and trims capture window.</p>
</div>
<div class="subghz-btn-row">
<button class="subghz-btn start" id="subghzRxStartBtn" onclick="SubGhz.startRx()">Start Capture</button>
<button class="subghz-btn stop" id="subghzRxStopBtn" onclick="SubGhz.stopRx()" disabled>Stop Capture</button>
</div>
</div>
<!-- Sweep Tab -->
<div class="subghz-tab-content" id="subghzTabSweep">
<p style="font-size: 11px; color: var(--text-dim); margin-bottom: 10px;">
Wideband spectrum analyzer using hackrf_sweep.
</p>
<div class="form-group">
<label>Frequency Range (MHz)</label>
<div class="subghz-sweep-range">
<input type="number" id="subghzSweepStart" value="300" min="1" max="6000" step="1">
<span>to</span>
<input type="number" id="subghzSweepEnd" value="928" min="1" max="6000" step="1">
</div>
</div>
<div class="subghz-btn-row">
<button class="subghz-btn start" id="subghzSweepStartBtn" onclick="SubGhz.startSweep()">Start Sweep</button>
<button class="subghz-btn stop" id="subghzSweepStopBtn" onclick="SubGhz.stopSweep()" disabled>Stop Sweep</button>
</div>
<div style="margin-top: 10px;">
<label style="font-size: 10px; color: var(--text-dim); text-transform: uppercase; letter-spacing: 0.5px;">Detected Peaks</label>
<div class="subghz-peak-list" id="subghzPeakList"></div>
</div>
</div>
</div>
<!-- TX Settings (collapsible) -->
<div class="section">
<h3 style="cursor: pointer;" onclick="document.getElementById('subghzTxSection').classList.toggle('active')">
Transmit Settings <span style="font-size: 10px; color: var(--text-dim);">&#9660;</span>
</h3>
<div id="subghzTxSection" style="display: none;">
<div class="subghz-tx-warning">
WARNING: Transmitting radio signals may be illegal without proper authorization.
Only transmit on frequencies you are licensed for and within ISM band limits.
TX is restricted to ISM bands: 300-348, 387-464, 779-928 MHz.
</div>
<div class="form-group">
<label>TX VGA Gain (0-47 dB)</label>
<input type="range" id="subghzTxGain" min="0" max="47" value="20" step="1" oninput="document.getElementById('subghzTxGainVal').textContent=this.value">
<span id="subghzTxGainVal" style="font-family: 'JetBrains Mono', monospace; font-size: 11px; color: var(--text-secondary);">20</span>
</div>
<div class="form-group">
<label>Max Duration (seconds)</label>
<input type="number" id="subghzTxMaxDuration" value="10" min="1" max="30" step="1">
</div>
</div>
</div>
<!-- Saved Signals Library -->
<div class="section">
<h3>Saved Signals</h3>
<div class="subghz-captures-list" id="subghzSidebarCaptures" style="max-height: 220px; overflow-y: auto;">
<div class="subghz-empty" id="subghzSidebarCapturesEmpty">No saved captures yet</div>
</div>
</div>
</div>
<script>
// Toggle TX section visibility
document.addEventListener('DOMContentLoaded', function() {
const h3 = document.querySelector('#subghzTxSection')?.previousElementSibling;
if (h3) {
h3.addEventListener('click', function() {
const section = document.getElementById('subghzTxSection');
if (section) section.style.display = section.style.display === 'none' ? 'block' : 'none';
});
}
});
</script>
+14 -13
View File
@@ -2,7 +2,7 @@
<div id="tscmMode" class="mode-content">
<!-- Configuration -->
<div class="section">
<h3 style="display: flex; align-items: center; gap: 8px; margin-bottom: 12px;">TSCM Sweep <span style="font-size: 9px; font-weight: normal; background: var(--accent-orange); color: #000; padding: 2px 6px; border-radius: 3px; text-transform: uppercase; letter-spacing: 0.5px;">Alpha</span></h3>
<h3>TSCM Sweep <span style="font-size: 9px; font-weight: normal; background: var(--accent-orange); color: #000; padding: 2px 6px; border-radius: 3px; text-transform: uppercase; letter-spacing: 0.5px;">Alpha</span></h3>
<div class="form-group">
<label>Sweep Type</label>
@@ -65,14 +65,6 @@
</div>
</div>
<!-- Actions -->
<button class="run-btn" id="startTscmBtn" onclick="startTscmSweep()" style="margin-top: 12px;">
Start Sweep
</button>
<button class="stop-btn" id="stopTscmBtn" onclick="stopTscmSweep()" style="display: none; margin-top: 12px;">
Stop Sweep
</button>
<!-- Futuristic Scanner Progress -->
<div id="tscmProgress" class="tscm-scanner-progress" style="display: none; margin-top: 12px;">
<div class="scanner-ring">
@@ -115,8 +107,8 @@
</div>
<!-- Advanced -->
<div class="section" style="margin-top: 12px;">
<h3 style="margin-bottom: 12px;">Advanced</h3>
<div class="section">
<h3>Advanced</h3>
<div style="margin-bottom: 16px;">
<label style="display: block; font-size: 11px; font-weight: 600; margin-bottom: 6px; color: var(--text-secondary);">Baseline Recording</label>
@@ -156,8 +148,8 @@
</div>
<!-- Tools -->
<div class="section" style="margin-top: 12px;">
<h3 style="margin-bottom: 10px;">Tools</h3>
<div class="section">
<h3>Tools</h3>
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 6px;">
<button class="preset-btn" onclick="tscmShowCapabilities()" style="font-size: 10px; padding: 8px;">
Capabilities
@@ -182,4 +174,13 @@
<!-- Device Warnings -->
<div id="tscmDeviceWarnings" style="display: none; margin-top: 8px; padding: 8px; background: rgba(255,153,51,0.1); border: 1px solid rgba(255,153,51,0.3); border-radius: 4px;"></div>
<div class="mode-actions-bottom">
<button class="run-btn" id="startTscmBtn" onclick="startTscmSweep()">
Start Sweep
</button>
<button class="stop-btn" id="stopTscmBtn" onclick="stopTscmSweep()" style="display: none;">
Stop Sweep
</button>
</div>
</div>
@@ -0,0 +1,249 @@
<!-- WEATHER SATELLITE MODE -->
<div id="weatherSatMode" class="mode-content">
<div class="section">
<h3>Weather Satellite Decoder</h3>
<div class="alpha-mode-notice">
ALPHA: Weather Satellite capture is experimental and may fail depending on SatDump version, SDR driver support, and pass conditions.
</div>
<p class="info-text" style="font-size: 11px; color: var(--text-dim); margin-bottom: 12px;">
Receive and decode weather images from NOAA and Meteor satellites.
Uses SatDump for live SDR capture and image processing.
</p>
</div>
<div class="section">
<h3>Satellite</h3>
<div class="form-group">
<label>Select Satellite</label>
<select id="weatherSatSelect" class="mode-select">
<option value="METEOR-M2-3" selected>Meteor-M2-3 (137.900 MHz LRPT)</option>
<option value="METEOR-M2-4">Meteor-M2-4 (137.900 MHz LRPT)</option>
<option value="NOAA-15" disabled>NOAA-15 (137.620 MHz APT) [DEFUNCT]</option>
<option value="NOAA-18" disabled>NOAA-18 (137.9125 MHz APT) [DEFUNCT]</option>
<option value="NOAA-19" disabled>NOAA-19 (137.100 MHz APT) [DEFUNCT]</option>
</select>
</div>
<div class="form-group">
<label>Gain (dB)</label>
<input type="number" id="weatherSatGain" value="40" step="0.1" min="0" max="50">
</div>
<div class="form-group">
<label style="display: flex; align-items: center; gap: 6px;">
<input type="checkbox" id="weatherSatBiasT" style="width: auto;">
Bias-T (power LNA)
</label>
</div>
</div>
<!-- Antenna Guide - detailed -->
<div class="section">
<h3>Antenna Guide</h3>
<div style="font-size: 11px; color: var(--text-dim); line-height: 1.5;">
<p style="margin-bottom: 10px; color: var(--accent-cyan); font-weight: 600;">
137 MHz band &mdash; your stock SDR antenna will NOT work.
</p>
<p style="margin-bottom: 10px;">
Weather satellites transmit at 137.1&ndash;137.9 MHz. The quarter-wave
at this frequency is <strong style="color: var(--text-primary);">~53 cm</strong>,
far longer than the small telescopic antenna shipped with most SDRs
(tuned for ~1 GHz). You need a purpose-built antenna.
</p>
<!-- V-Dipole -->
<div style="background: var(--bg-primary); border: 1px solid var(--border-color); border-radius: 4px; padding: 10px; margin-bottom: 10px;">
<strong style="color: var(--accent-cyan); font-size: 12px;">V-Dipole (Easiest &mdash; ~$5)</strong>
<div style="margin: 8px 0; padding: 8px; background: var(--bg-tertiary); border-radius: 3px; font-family: 'JetBrains Mono', monospace; font-size: 10px; color: var(--text-secondary); white-space: pre; line-height: 1.3; text-align: center;"> coax to SDR
|
===+=== feed point
/ \
/ 120 \
/ \
/ deg \
53.4cm 53.4cm</div>
<ul style="margin: 6px 0 0 14px; padding: 0;">
<li><strong style="color: var(--text-primary);">Element length:</strong> 53.4 cm each (quarter wavelength at 137 MHz)</li>
<li><strong style="color: var(--text-primary);">Angle:</strong> 120&deg; between elements (not 180&deg;)</li>
<li><strong style="color: var(--text-primary);">Material:</strong> Any stiff wire, coat hanger, or copper rod</li>
<li><strong style="color: var(--text-primary);">Orientation:</strong> Lay flat or tilt 30&deg; toward expected pass direction</li>
<li><strong style="color: var(--text-primary);">Polarization:</strong> The 120&deg; angle gives partial RHCP match to satellite signal</li>
<li><strong style="color: var(--text-primary);">Connection:</strong> Solder elements to coax center + shield, connect to SDR via SMA</li>
</ul>
<p style="margin-top: 6px; color: var(--text-dim); font-style: italic;">
Best starter antenna. Good enough for clear NOAA images with a direct overhead pass.
</p>
</div>
<!-- Turnstile -->
<div style="background: var(--bg-primary); border: 1px solid var(--border-color); border-radius: 4px; padding: 10px; margin-bottom: 10px;">
<strong style="color: var(--accent-cyan); font-size: 12px;">Turnstile / Crossed Dipole (~$10-15)</strong>
<div style="margin: 8px 0; padding: 8px; background: var(--bg-tertiary); border-radius: 3px; font-family: 'JetBrains Mono', monospace; font-size: 10px; color: var(--text-secondary); white-space: pre; line-height: 1.3; text-align: center;"> 53.4cm
&lt;---------&gt;
====+==== dipole 1
|
====+==== dipole 2
&lt;---------&gt;
90 deg rotated
+ reflector below</div>
<ul style="margin: 6px 0 0 14px; padding: 0;">
<li><strong style="color: var(--text-primary);">Elements:</strong> Two crossed dipoles, each 53.4 cm per side (4 elements total)</li>
<li><strong style="color: var(--text-primary);">Angle:</strong> 90&deg; between the two dipole pairs</li>
<li><strong style="color: var(--text-primary);">Phasing:</strong> Feed dipole 2 with a 90&deg; delay (quarter-wave coax section ~37 cm of RG-58)</li>
<li><strong style="color: var(--text-primary);">Reflector:</strong> Place ~52 cm below elements (ground plane or wire grid)</li>
<li><strong style="color: var(--text-primary);">Polarization:</strong> Circular (RHCP) &mdash; matches satellite transmission</li>
</ul>
<p style="margin-top: 6px; color: var(--text-dim); font-style: italic;">
Better than V-dipole. The reflector rejects ground noise and the RHCP phasing matches the satellite signal.
</p>
</div>
<!-- QFH -->
<div style="background: var(--bg-primary); border: 1px solid var(--border-color); border-radius: 4px; padding: 10px; margin-bottom: 10px;">
<strong style="color: #00ff88; font-size: 12px;">QFH &mdash; Quadrifilar Helix (Best &mdash; ~$20-30)</strong>
<div style="margin: 8px 0; padding: 8px; background: var(--bg-tertiary); border-radius: 3px; font-family: 'JetBrains Mono', monospace; font-size: 10px; color: var(--text-secondary); white-space: pre; line-height: 1.3; text-align: center;"> ___
/ \ two helix loops
| | | twisted 90 deg
| | | around a mast
\___/
|
coax</div>
<ul style="margin: 6px 0 0 14px; padding: 0;">
<li><strong style="color: var(--text-primary);">Design:</strong> Two bifilar helical loops, offset 90&deg;</li>
<li><strong style="color: var(--text-primary);">Material:</strong> Copper pipe (10mm), copper wire, or coax outer shield</li>
<li><strong style="color: var(--text-primary);">Total height:</strong> ~46 cm (for 137 MHz)</li>
<li><strong style="color: var(--text-primary);">Loop dimensions:</strong> Use a QFH calculator for exact bending measurements</li>
<li><strong style="color: var(--text-primary);">Polarization:</strong> True RHCP omnidirectional &mdash; ideal for overhead satellite passes</li>
<li><strong style="color: var(--text-primary);">Gain pattern:</strong> Hemispherical upward coverage, rejects ground interference</li>
</ul>
<p style="margin-top: 6px; color: var(--text-dim); font-style: italic;">
Gold standard for weather satellite reception. No tracking needed &mdash; covers the whole sky.
</p>
</div>
<!-- Placement & LNA -->
<div style="background: var(--bg-primary); border: 1px solid var(--border-color); border-radius: 4px; padding: 10px; margin-bottom: 10px;">
<strong style="color: var(--accent-cyan); font-size: 12px;">Placement & LNA</strong>
<ul style="margin: 6px 0 0 14px; padding: 0;">
<li><strong style="color: var(--text-primary);">Location:</strong> OUTDOORS with clear sky view is critical. Roof/balcony/open field.</li>
<li><strong style="color: var(--text-primary);">Height:</strong> Higher is better but not critical &mdash; clear horizon line matters more</li>
<li><strong style="color: var(--text-primary);">Antenna up:</strong> Point the antenna straight UP (zenith) for best overhead coverage</li>
<li><strong style="color: var(--text-primary);">Avoid:</strong> Metal roofs, power lines, buildings blocking the sky</li>
<li><strong style="color: var(--text-primary);">Coax length:</strong> Keep short (&lt;10m). Signal loss at 137 MHz is ~3 dB per 10m of RG-58</li>
<li><strong style="color: var(--text-primary);">LNA:</strong> Mount at the antenna feed point, NOT at the SDR end.
Recommended: Nooelec SAWbird+ NOAA (137 MHz filtered LNA, ~$30)</li>
<li><strong style="color: var(--text-primary);">Bias-T:</strong> Enable the Bias-T checkbox above if your LNA is powered via the coax from the SDR</li>
</ul>
</div>
<!-- Quick reference -->
<div style="background: var(--bg-primary); border: 1px solid var(--border-color); border-radius: 4px; padding: 10px;">
<strong style="color: var(--accent-cyan); font-size: 12px;">Quick Reference</strong>
<table style="width: 100%; margin-top: 6px; font-size: 10px; border-collapse: collapse;">
<tr style="border-bottom: 1px solid var(--border-color);">
<td style="padding: 3px 4px; color: var(--text-dim);">Wavelength (137 MHz)</td>
<td style="padding: 3px 4px; color: var(--text-primary); text-align: right;">218.8 cm</td>
</tr>
<tr style="border-bottom: 1px solid var(--border-color);">
<td style="padding: 3px 4px; color: var(--text-dim);">Quarter wave (element length)</td>
<td style="padding: 3px 4px; color: var(--text-primary); text-align: right;">53.4 cm</td>
</tr>
<tr style="border-bottom: 1px solid var(--border-color);">
<td style="padding: 3px 4px; color: var(--text-dim);">Best pass elevation</td>
<td style="padding: 3px 4px; color: var(--text-primary); text-align: right;">&gt; 30&deg;</td>
</tr>
<tr style="border-bottom: 1px solid var(--border-color);">
<td style="padding: 3px 4px; color: var(--text-dim);">Typical pass duration</td>
<td style="padding: 3px 4px; color: var(--text-primary); text-align: right;">10-15 min</td>
</tr>
<tr style="border-bottom: 1px solid var(--border-color);">
<td style="padding: 3px 4px; color: var(--text-dim);">Polarization</td>
<td style="padding: 3px 4px; color: var(--text-primary); text-align: right;">RHCP</td>
</tr>
<tr style="border-bottom: 1px solid var(--border-color);">
<td style="padding: 3px 4px; color: var(--text-dim);">NOAA (APT) bandwidth</td>
<td style="padding: 3px 4px; color: var(--text-primary); text-align: right;">~40 kHz</td>
</tr>
<tr>
<td style="padding: 3px 4px; color: var(--text-dim);">Meteor (LRPT) bandwidth</td>
<td style="padding: 3px 4px; color: var(--text-primary); text-align: right;">~140 kHz</td>
</tr>
</table>
</div>
</div>
</div>
<div class="section">
<h3 onclick="this.parentElement.querySelector('.wxsat-test-decode-body').classList.toggle('collapsed'); this.querySelector('.wxsat-collapse-icon').classList.toggle('collapsed')" style="cursor: pointer; display: flex; align-items: center; justify-content: space-between; user-select: none;">
Test Decode (File)
<span class="wxsat-collapse-icon collapsed" style="font-size: 10px; transition: transform 0.2s; display: inline-block;">&#9660;</span>
</h3>
<div class="wxsat-test-decode-body collapsed" style="overflow: hidden;">
<p class="info-text" style="font-size: 11px; color: var(--text-dim); margin-bottom: 8px;">
Decode a pre-recorded IQ or WAV file without SDR hardware.
Run <code style="font-size: 10px;">./download-weather-sat-samples.sh</code> to fetch sample files.
</p>
<div class="form-group">
<label>Satellite</label>
<select id="wxsatTestSatSelect" class="mode-select">
<option value="METEOR-M2-3" selected>Meteor-M2-3 (LRPT)</option>
<option value="METEOR-M2-4">Meteor-M2-4 (LRPT)</option>
<option value="NOAA-15">NOAA-15 (APT)</option>
<option value="NOAA-18">NOAA-18 (APT)</option>
<option value="NOAA-19">NOAA-19 (APT)</option>
</select>
</div>
<div class="form-group">
<label>File Path (server-side)</label>
<input type="text" id="wxsatTestFilePath" value="data/weather_sat/samples/noaa_apt_argentina.wav" style="font-family: 'JetBrains Mono', monospace; font-size: 11px;">
</div>
<div class="form-group">
<label>Sample Rate</label>
<select id="wxsatTestSampleRate" class="mode-select">
<option value="11025">11025 Hz (WAV audio APT)</option>
<option value="48000">48000 Hz (WAV audio APT)</option>
<option value="500000">500 kHz (IQ LRPT)</option>
<option value="1000000" selected>1 MHz (IQ default)</option>
<option value="2000000">2 MHz (IQ wideband)</option>
</select>
</div>
<button class="mode-btn" onclick="WeatherSat.testDecode()" style="width: 100%; margin-top: 4px;">
Test Decode
</button>
</div>
</div>
<div class="section">
<h3>Auto-Scheduler</h3>
<p class="info-text" style="font-size: 11px; color: var(--text-dim); margin-bottom: 8px;">
Automatically capture satellite passes based on predictions.
Set your location above and toggle AUTO in the strip bar.
</p>
<div class="form-group">
<label style="display: flex; align-items: center; gap: 6px;">
<input type="checkbox" id="wxsatSidebarAutoSchedule" onchange="WeatherSat.toggleScheduler()" style="width: auto;">
Enable Auto-Capture
</label>
</div>
<div id="wxsatSchedulerStatus" style="font-size: 11px; color: var(--text-dim); font-family: 'JetBrains Mono', monospace; margin-top: 4px;">
Disabled
</div>
</div>
<div class="section">
<h3>Resources</h3>
<div style="display: flex; flex-direction: column; gap: 6px;">
<a href="https://github.com/SatDump/SatDump" target="_blank" rel="noopener" class="preset-btn" style="text-decoration: none; text-align: center;">
SatDump Documentation
</a>
<a href="https://www.rtl-sdr.com/rtl-sdr-tutorial-receiving-noaa-weather-satellite-images/" target="_blank" rel="noopener" class="preset-btn" style="text-decoration: none; text-align: center;">
NOAA Reception Guide
</a>
</div>
</div>
</div>
-8
View File
@@ -67,12 +67,4 @@
<div style="color: var(--text-muted); text-align: center; padding: 10px;">Loading...</div>
</div>
</div>
<!-- Receiver Count -->
<div class="section" style="margin-top: 12px;">
<div style="display: flex; justify-content: space-between; align-items: center;">
<span style="font-size: 10px; color: var(--text-muted); text-transform: uppercase;">Receivers</span>
<span id="websdrSidebarCount" style="font-size: 11px; color: var(--accent-cyan);">0</span>
</div>
</div>
</div>
+42 -24
View File
@@ -1,7 +1,8 @@
<!-- WiFi MODE -->
<div id="wifiMode" class="mode-content">
<!-- Scan Mode Tabs -->
<div class="section" style="padding: 8px;">
<div class="section">
<h3>Signal Source</h3>
<div class="wifi-scan-mode-tabs" style="display: flex; gap: 4px;">
<button id="wifiScanModeQuick" class="wifi-mode-tab active" style="flex: 1; padding: 8px; font-size: 11px; background: var(--accent-green); color: #000; border: none; border-radius: 4px; cursor: pointer;">
Quick Scan
@@ -69,7 +70,22 @@
</select>
</div>
<div class="form-group">
<label>Channel (empty = hop)</label>
<label>Channel Preset</label>
<select id="wifiChannelPreset">
<option value="">Auto hop (all)</option>
<option value="2.4-common">2.4 GHz Common (1,6,11)</option>
<option value="2.4-all">2.4 GHz All (1-13)</option>
<option value="5-low">5 GHz Low (36-48)</option>
<option value="5-mid">5 GHz Mid/DFS (52-64)</option>
<option value="5-high">5 GHz High (149-165)</option>
</select>
</div>
<div class="form-group">
<label>Channel List (overrides preset)</label>
<input type="text" id="wifiChannelList" placeholder="e.g., 1,6,11 or 36,40,44,48">
</div>
<div class="form-group">
<label>Channel (single)</label>
<input type="text" id="wifiChannel" placeholder="e.g., 6 or 36">
</div>
</div>
@@ -153,29 +169,8 @@
</div>
</div>
<!-- v2 Scan Buttons -->
<div style="display: flex; gap: 8px; margin-bottom: 8px;">
<button class="run-btn" id="wifiQuickScanBtn" onclick="WiFiMode.startQuickScan()" style="flex: 1;">
Quick Scan
</button>
<button class="run-btn" id="wifiDeepScanBtn" onclick="WiFiMode.startDeepScan()" style="flex: 1; background: var(--accent-orange);">
Deep Scan
</button>
</div>
<button class="stop-btn" id="wifiStopScanBtn" onclick="WiFiMode.stopScan()" style="display: none; width: 100%;">
Stop Scanning
</button>
<!-- Legacy Scan Buttons (hidden, for backwards compatibility) -->
<button class="run-btn" id="startWifiBtn" onclick="startWifiScan()" style="display: none;">
Start Scanning (Legacy)
</button>
<button class="stop-btn" id="stopWifiBtn" onclick="stopWifiScan()" style="display: none;">
Stop Scanning (Legacy)
</button>
<!-- Export Section -->
<div class="section" style="margin-top: 10px;">
<div class="section">
<h3>Export</h3>
<div style="display: flex; gap: 8px;">
<button class="preset-btn" onclick="WiFiMode.exportData('csv')" style="flex: 1;">
@@ -186,4 +181,27 @@
</button>
</div>
</div>
<div class="mode-actions-bottom">
<!-- v2 Scan Buttons -->
<div style="display: flex; gap: 8px;">
<button class="run-btn" id="wifiQuickScanBtn" onclick="WiFiMode.startQuickScan()" style="flex: 1;">
Quick Scan
</button>
<button class="run-btn" id="wifiDeepScanBtn" onclick="WiFiMode.startDeepScan()" style="flex: 1; background: var(--accent-orange);">
Deep Scan
</button>
</div>
<button class="stop-btn" id="wifiStopScanBtn" onclick="WiFiMode.stopScan()" style="display: none; width: 100%;">
Stop Scanning
</button>
<!-- Legacy Scan Buttons (hidden, for backwards compatibility) -->
<button class="run-btn" id="startWifiBtn" onclick="startWifiScan()" style="display: none;">
Start Scanning (Legacy)
</button>
<button class="stop-btn" id="stopWifiBtn" onclick="stopWifiScan()" style="display: none;">
Stop Scanning (Legacy)
</button>
</div>
</div>
+4 -2
View File
@@ -71,8 +71,8 @@
{{ mode_item('listening', 'Listening Post', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="18" height="18" rx="2"/><path d="M3 9h18"/><path d="M9 21V9"/></svg>') }}
{{ mode_item('spystations', 'Spy Stations', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4.9 19.1C1 15.2 1 8.8 4.9 4.9"/><path d="M7.8 16.2c-2.3-2.3-2.3-6.1 0-8.5"/><circle cx="12" cy="12" r="2"/><path d="M16.2 7.8c2.3 2.3 2.3 6.1 0 8.5"/><path d="M19.1 4.9C23 8.8 23 15.1 19.1 19"/></svg>') }}
{{ mode_item('meshtastic', 'Meshtastic', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><circle cx="12" cy="12" r="3"/><path d="M12 2v4m0 12v4M2 12h4m12 0h4"/></svg>') }}
{{ mode_item('dmr', 'Digital Voice', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 2a3 3 0 0 0-3 3v7a3 3 0 0 0 6 0V5a3 3 0 0 0-3-3Z"/><path d="M19 10v2a7 7 0 0 1-14 0v-2"/><line x1="12" y1="19" x2="12" y2="22"/></svg>') }}
{{ mode_item('websdr', 'WebSDR', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><line x1="2" y1="12" x2="22" y2="12"/><path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"/></svg>') }}
{{ mode_item('subghz', 'SubGHz', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M2 12h6l3-9 3 18 3-9h5"/></svg>') }}
</div>
</div>
@@ -118,6 +118,7 @@
{{ mode_item('satellite', 'Satellite', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M13 7L9 3 5 7l4 4"/><path d="m17 11 4 4-4 4-4-4"/><path d="m8 12 4 4 6-6-4-4-6 6"/><path d="m16 8 3-3"/><path d="M9 21a6 6 0 0 0-6-6"/></svg>', '/satellite/dashboard') }}
{% endif %}
{{ mode_item('sstv', 'ISS SSTV', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="18" height="18" rx="2"/><circle cx="12" cy="12" r="3"/><path d="M3 9h2"/><path d="M19 9h2"/><path d="M3 15h2"/><path d="M19 15h2"/></svg>') }}
{{ mode_item('weathersat', 'Weather Sat', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><path d="M2 12h20"/><path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"/></svg>') }}
{{ mode_item('sstv_general', 'HF SSTV', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="18" height="18" rx="2"/><circle cx="12" cy="12" r="3"/><path d="M16.24 7.76a6 6 0 0 1 0 8.49m-8.48-.01a6 6 0 0 1 0-8.49"/></svg>') }}
</div>
</div>
@@ -185,12 +186,13 @@
{{ mobile_item('satellite', 'Sat', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M13 7L9 3 5 7l4 4"/><path d="m17 11 4 4-4 4-4-4"/><path d="m8 12 4 4 6-6-4-4-6 6"/></svg>', '/satellite/dashboard') }}
{% endif %}
{{ mobile_item('sstv', 'SSTV', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="3" width="18" height="18" rx="2"/><circle cx="12" cy="12" r="3"/></svg>') }}
{{ mobile_item('weathersat', 'WxSat', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><path d="M2 12h20"/><path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"/></svg>') }}
{{ mobile_item('sstv_general', 'HF SSTV', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="3" width="18" height="18" rx="2"/><circle cx="12" cy="12" r="3"/></svg>') }}
{{ mobile_item('listening', 'Scanner', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="3" width="18" height="18" rx="2"/><path d="M3 9h18"/><path d="M9 21V9"/></svg>') }}
{{ mobile_item('spystations', 'Spy', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M4.9 19.1C1 15.2 1 8.8 4.9 4.9"/><circle cx="12" cy="12" r="2"/><path d="M19.1 4.9C23 8.8 23 15.1 19.1 19"/></svg>') }}
{{ mobile_item('meshtastic', 'Mesh', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><circle cx="12" cy="12" r="3"/><path d="M12 2v4m0 12v4M2 12h4m12 0h4"/></svg>') }}
{{ mobile_item('dmr', 'DMR', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 2a3 3 0 0 0-3 3v7a3 3 0 0 0 6 0V5a3 3 0 0 0-3-3Z"/><path d="M19 10v2a7 7 0 0 1-14 0v-2"/><line x1="12" y1="19" x2="12" y2="22"/></svg>') }}
{{ mobile_item('websdr', 'WebSDR', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><line x1="2" y1="12" x2="22" y2="12"/><path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"/></svg>') }}
{{ mobile_item('subghz', 'SubGHz', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M2 12h6l3-9 3 18 3-9h5"/></svg>') }}
</nav>
{# JavaScript stub for pages that don't have switchMode defined #}
+78
View File
@@ -15,6 +15,8 @@
<button class="settings-tab" data-tab="display" onclick="switchSettingsTab('display')">Display</button>
<button class="settings-tab" data-tab="updates" onclick="switchSettingsTab('updates')">Updates</button>
<button class="settings-tab" data-tab="tools" onclick="switchSettingsTab('tools')">Tools</button>
<button class="settings-tab" data-tab="alerts" onclick="switchSettingsTab('alerts')">Alerts</button>
<button class="settings-tab" data-tab="recording" onclick="switchSettingsTab('recording')">Recording</button>
<button class="settings-tab" data-tab="about" onclick="switchSettingsTab('about')">About</button>
</div>
@@ -280,6 +282,82 @@
</div>
</div>
<!-- Alerts Section -->
<div id="settings-alerts" class="settings-section">
<div class="settings-group">
<div class="settings-group-title">Alert Feed <span id="alertsFeedCount" style="color: var(--text-dim); font-weight: 500;"></span></div>
<div id="alertsFeedList" class="settings-feed">
<div class="settings-feed-empty">No alerts yet</div>
</div>
</div>
<div class="settings-group">
<div class="settings-group-title">Quick Rules</div>
<div style="display: flex; gap: 10px; flex-wrap: wrap;">
<button class="check-assets-btn" onclick="AlertCenter.enableTrackerAlerts()">Enable Tracker Alerts</button>
<button class="check-assets-btn" onclick="AlertCenter.disableTrackerAlerts()">Disable Tracker Alerts</button>
</div>
<div class="settings-info" style="margin-top: 10px;">
Use Bluetooth device details to add specific device watchlist alerts.
</div>
</div>
</div>
<!-- Recording Section -->
<div id="settings-recording" class="settings-section">
<div class="settings-group">
<div class="settings-group-title">Start Recording</div>
<div class="settings-row" style="border-bottom: none; padding-top: 0;">
<div class="settings-label">
<span class="settings-label-text">Mode</span>
<span class="settings-label-desc">Record live events for a mode</span>
</div>
<select id="recordingModeSelect" class="settings-select" style="width: 200px;">
<option value="pager">Pager</option>
<option value="sensor">433 Sensors</option>
<option value="wifi">WiFi</option>
<option value="bluetooth">Bluetooth</option>
<option value="adsb">ADS-B</option>
<option value="ais">AIS</option>
<option value="dsc">DSC</option>
<option value="acars">ACARS</option>
<option value="aprs">APRS</option>
<option value="rtlamr">RTLAMR</option>
<option value="tscm">TSCM</option>
<option value="sstv">SSTV</option>
<option value="sstv_general">SSTV General</option>
<option value="listening_scanner">Listening Post</option>
<option value="waterfall">Waterfall</option>
</select>
</div>
<div class="settings-row" style="border-bottom: none;">
<div class="settings-label">
<span class="settings-label-text">Label</span>
<span class="settings-label-desc">Optional note for the session</span>
</div>
<input type="text" id="recordingLabelInput" class="settings-input" placeholder="Morning sweep" style="width: 200px;">
</div>
<div style="display: flex; gap: 10px; margin-top: 10px;">
<button class="check-assets-btn" onclick="RecordingUI.start()">Start</button>
<button class="check-assets-btn" onclick="RecordingUI.stop()">Stop</button>
</div>
</div>
<div class="settings-group">
<div class="settings-group-title">Active Sessions</div>
<div id="recordingActiveList" class="settings-feed">
<div class="settings-feed-empty">No active recordings</div>
</div>
</div>
<div class="settings-group">
<div class="settings-group-title">Recent Recordings</div>
<div id="recordingList" class="settings-feed">
<div class="settings-feed-empty">No recordings yet</div>
</div>
</div>
</div>
<!-- About Section -->
<div id="settings-about" class="settings-section">
<div class="settings-group">
+2 -10
View File
@@ -107,12 +107,8 @@
<label>TARGET:</label>
<select id="satSelect" onchange="onSatelliteChange()">
<option value="25544">ISS (ZARYA)</option>
<option value="25338">NOAA 15</option>
<option value="28654">NOAA 18</option>
<option value="33591">NOAA 19</option>
<option value="40069">METEOR-M2</option>
<option value="43013">NOAA 20</option>
<option value="54234">METEOR-M2-3</option>
<option value="57166">METEOR-M2-3</option>
</select>
</div>
@@ -275,12 +271,8 @@
const satellites = {
25544: { name: 'ISS (ZARYA)', color: '#00ffff' },
25338: { name: 'NOAA 15', color: '#00ff00' },
28654: { name: 'NOAA 18', color: '#ff6600' },
33591: { name: 'NOAA 19', color: '#ff3366' },
40069: { name: 'METEOR-M2', color: '#9370DB' },
43013: { name: 'NOAA 20', color: '#00ffaa' },
54234: { name: 'METEOR-M2-3', color: '#ff00ff' }
57166: { name: 'METEOR-M2-3', color: '#ff00ff' }
};
function onSatelliteChange() {
+172 -6
View File
@@ -1,8 +1,10 @@
"""Tests for the DMR / Digital Voice decoding module."""
import queue
from unittest.mock import patch, MagicMock
import pytest
from routes.dmr import parse_dsd_output
import routes.dmr as dmr_module
from routes.dmr import parse_dsd_output, _DSD_PROTOCOL_FLAGS, _DSD_FME_PROTOCOL_FLAGS, _DSD_FME_MODULATION
# ============================================
@@ -57,6 +59,43 @@ def test_parse_nac():
assert result['nac'] == '293'
def test_parse_talkgroup_dsd_fme_format():
"""Should parse dsd-fme comma-separated TG/Src format."""
result = parse_dsd_output('TG: 12345, Src: 67890')
assert result is not None
assert result['type'] == 'call'
assert result['talkgroup'] == 12345
assert result['source_id'] == 67890
def test_parse_talkgroup_dsd_fme_tgt_src_format():
"""Should parse dsd-fme TGT/SRC pipe-delimited format."""
result = parse_dsd_output('Slot 1 | TGT: 12345 | SRC: 67890')
assert result is not None
assert result['type'] == 'call'
assert result['talkgroup'] == 12345
assert result['source_id'] == 67890
assert result['slot'] == 1
def test_parse_talkgroup_with_slot():
"""TG line with slot info should capture both."""
result = parse_dsd_output('Slot 1 Voice LC, TG: 100, Src: 200')
assert result is not None
assert result['type'] == 'call'
assert result['talkgroup'] == 100
assert result['source_id'] == 200
assert result['slot'] == 1
def test_parse_voice_with_slot():
"""Voice frame with slot info should be voice, not slot."""
result = parse_dsd_output('Slot 2 Voice Frame')
assert result is not None
assert result['type'] == 'voice'
assert result['slot'] == 2
def test_parse_empty_line():
"""Empty lines should return None."""
assert parse_dsd_output('') is None
@@ -64,8 +103,63 @@ def test_parse_empty_line():
def test_parse_unrecognized():
"""Unrecognized lines should return None."""
assert parse_dsd_output('some random text') is None
"""Unrecognized lines should return raw event for diagnostics."""
result = parse_dsd_output('some random text')
assert result is not None
assert result['type'] == 'raw'
assert result['text'] == 'some random text'
def test_parse_banner_filtered():
"""Pure box-drawing lines (banners) should be filtered."""
assert parse_dsd_output('╔══════════════╗') is None
assert parse_dsd_output('║ ║') is None
assert parse_dsd_output('╚══════════════╝') is None
assert parse_dsd_output('───────────────') is None
def test_parse_box_drawing_with_data_not_filtered():
"""Lines with box-drawing separators AND data should NOT be filtered."""
result = parse_dsd_output('DMR BS │ Slot 1 │ TG: 12345 │ SRC: 67890')
assert result is not None
assert result['type'] == 'call'
assert result['talkgroup'] == 12345
assert result['source_id'] == 67890
def test_dsd_fme_flags_differ_from_classic():
"""dsd-fme remapped several flags; tables must NOT be identical."""
assert _DSD_FME_PROTOCOL_FLAGS != _DSD_PROTOCOL_FLAGS
def test_dsd_fme_protocol_flags_known_values():
"""dsd-fme flags use its own flag names (NOT classic DSD mappings)."""
assert _DSD_FME_PROTOCOL_FLAGS['auto'] == ['-fa'] # Broad auto
assert _DSD_FME_PROTOCOL_FLAGS['dmr'] == ['-fs'] # Simplex (-fd is D-STAR!)
assert _DSD_FME_PROTOCOL_FLAGS['p25'] == ['-ft'] # P25 P1/P2 coverage
assert _DSD_FME_PROTOCOL_FLAGS['nxdn'] == ['-fn']
assert _DSD_FME_PROTOCOL_FLAGS['dstar'] == ['-fd'] # -fd is D-STAR in dsd-fme
assert _DSD_FME_PROTOCOL_FLAGS['provoice'] == ['-fp'] # NOT -fv
def test_dsd_protocol_flags_known_values():
"""Classic DSD protocol flags should map to the correct -f flags."""
assert _DSD_PROTOCOL_FLAGS['dmr'] == ['-fd']
assert _DSD_PROTOCOL_FLAGS['p25'] == ['-fp']
assert _DSD_PROTOCOL_FLAGS['nxdn'] == ['-fn']
assert _DSD_PROTOCOL_FLAGS['dstar'] == ['-fi']
assert _DSD_PROTOCOL_FLAGS['provoice'] == ['-fv']
assert _DSD_PROTOCOL_FLAGS['auto'] == []
def test_dsd_fme_modulation_hints():
"""C4FM modulation hints should be set for C4FM protocols."""
assert _DSD_FME_MODULATION['dmr'] == ['-mc']
assert _DSD_FME_MODULATION['nxdn'] == ['-mc']
# P25, D-Star and ProVoice should not have forced modulation
assert 'p25' not in _DSD_FME_MODULATION
assert 'dstar' not in _DSD_FME_MODULATION
assert 'provoice' not in _DSD_FME_MODULATION
# ============================================
@@ -80,6 +174,40 @@ def auth_client(client):
return client
@pytest.fixture(autouse=True)
def reset_dmr_globals():
"""Reset DMR globals before/after each test to avoid cross-test bleed."""
dmr_module.dmr_rtl_process = None
dmr_module.dmr_dsd_process = None
dmr_module.dmr_thread = None
dmr_module.dmr_running = False
dmr_module.dmr_has_audio = False
dmr_module.dmr_active_device = None
with dmr_module._ffmpeg_sinks_lock:
dmr_module._ffmpeg_sinks.clear()
try:
while True:
dmr_module.dmr_queue.get_nowait()
except queue.Empty:
pass
yield
dmr_module.dmr_rtl_process = None
dmr_module.dmr_dsd_process = None
dmr_module.dmr_thread = None
dmr_module.dmr_running = False
dmr_module.dmr_has_audio = False
dmr_module.dmr_active_device = None
with dmr_module._ffmpeg_sinks_lock:
dmr_module._ffmpeg_sinks.clear()
try:
while True:
dmr_module.dmr_queue.get_nowait()
except queue.Empty:
pass
def test_dmr_tools(auth_client):
"""Tools endpoint should return availability info."""
resp = auth_client.get('/dmr/tools')
@@ -100,7 +228,7 @@ def test_dmr_status(auth_client):
def test_dmr_start_no_dsd(auth_client):
"""Start should fail gracefully when dsd is not installed."""
with patch('routes.dmr.find_dsd', return_value=None):
with patch('routes.dmr.find_dsd', return_value=(None, False)):
resp = auth_client.post('/dmr/start', json={
'frequency': 462.5625,
'protocol': 'auto',
@@ -112,7 +240,7 @@ def test_dmr_start_no_dsd(auth_client):
def test_dmr_start_no_rtl_fm(auth_client):
"""Start should fail when rtl_fm is missing."""
with patch('routes.dmr.find_dsd', return_value='/usr/bin/dsd'), \
with patch('routes.dmr.find_dsd', return_value=('/usr/bin/dsd', False)), \
patch('routes.dmr.find_rtl_fm', return_value=None):
resp = auth_client.post('/dmr/start', json={
'frequency': 462.5625,
@@ -122,7 +250,7 @@ def test_dmr_start_no_rtl_fm(auth_client):
def test_dmr_start_invalid_protocol(auth_client):
"""Start should reject invalid protocol."""
with patch('routes.dmr.find_dsd', return_value='/usr/bin/dsd'), \
with patch('routes.dmr.find_dsd', return_value=('/usr/bin/dsd', False)), \
patch('routes.dmr.find_rtl_fm', return_value='/usr/bin/rtl_fm'):
resp = auth_client.post('/dmr/start', json={
'frequency': 462.5625,
@@ -143,3 +271,41 @@ def test_dmr_stream_mimetype(auth_client):
"""Stream should return event-stream content type."""
resp = auth_client.get('/dmr/stream')
assert resp.content_type.startswith('text/event-stream')
def test_dmr_start_exception_cleans_up_resources(auth_client):
"""If startup fails after rtl_fm launch, process/device state should be reset."""
rtl_proc = MagicMock()
rtl_proc.poll.return_value = None
rtl_proc.wait.return_value = 0
rtl_proc.stdout = MagicMock()
rtl_proc.stderr = MagicMock()
builder = MagicMock()
builder.build_fm_demod_command.return_value = ['rtl_fm', '-f', '462.5625M']
with patch('routes.dmr.find_dsd', return_value=('/usr/bin/dsd', False)), \
patch('routes.dmr.find_rtl_fm', return_value='/usr/bin/rtl_fm'), \
patch('routes.dmr.find_ffmpeg', return_value=None), \
patch('routes.dmr.SDRFactory.create_default_device', return_value=MagicMock()), \
patch('routes.dmr.SDRFactory.get_builder', return_value=builder), \
patch('routes.dmr.app_module.claim_sdr_device', return_value=None), \
patch('routes.dmr.app_module.release_sdr_device') as release_mock, \
patch('routes.dmr.register_process') as register_mock, \
patch('routes.dmr.unregister_process') as unregister_mock, \
patch('routes.dmr.subprocess.Popen', side_effect=[rtl_proc, RuntimeError('dsd launch failed')]):
resp = auth_client.post('/dmr/start', json={
'frequency': 462.5625,
'protocol': 'auto',
'device': 0,
})
assert resp.status_code == 500
assert 'dsd launch failed' in resp.get_json()['message']
register_mock.assert_called_once_with(rtl_proc)
rtl_proc.terminate.assert_called_once()
unregister_mock.assert_called_once_with(rtl_proc)
release_mock.assert_called_once_with(0)
assert dmr_module.dmr_running is False
assert dmr_module.dmr_rtl_process is None
assert dmr_module.dmr_dsd_process is None
+20 -10
View File
@@ -46,20 +46,30 @@ class TestHealthEndpoint:
assert 'processes' in data
assert 'data' in data
def test_health_process_status(self, client):
"""Test health endpoint reports process status."""
response = client.get('/health')
data = json.loads(response.data)
def test_health_process_status(self, client):
"""Test health endpoint reports process status."""
response = client.get('/health')
data = json.loads(response.data)
processes = data['processes']
assert 'pager' in processes
assert 'sensor' in processes
assert 'adsb' in processes
assert 'wifi' in processes
assert 'bluetooth' in processes
class TestDevicesEndpoint:
assert 'adsb' in processes
assert 'wifi' in processes
assert 'bluetooth' in processes
def test_health_reports_dmr_route_process(self, client):
"""Health should reflect DMR route module state (not stale app globals)."""
mock_proc = MagicMock()
mock_proc.poll.return_value = None
with patch('routes.dmr.dmr_running', True), \
patch('routes.dmr.dmr_dsd_process', mock_proc):
response = client.get('/health')
data = json.loads(response.data)
assert data['processes']['dmr'] is True
class TestDevicesEndpoint:
"""Tests for devices endpoint."""
def test_get_devices(self, client):
+38
View File
@@ -0,0 +1,38 @@
"""Tests for rtl_fm modulation token mapping."""
from routes.listening_post import _rtl_fm_demod_mode as listening_post_rtl_mode
from utils.sdr.base import SDRDevice, SDRType
from utils.sdr.rtlsdr import RTLSDRCommandBuilder, _rtl_fm_demod_mode as builder_rtl_mode
def _dummy_rtlsdr_device() -> SDRDevice:
return SDRDevice(
sdr_type=SDRType.RTL_SDR,
index=0,
name='RTL-SDR',
serial='00000001',
driver='rtlsdr',
capabilities=RTLSDRCommandBuilder.CAPABILITIES,
)
def test_rtl_fm_modulation_maps_wfm_to_wbfm() -> None:
assert listening_post_rtl_mode('wfm') == 'wbfm'
assert builder_rtl_mode('wfm') == 'wbfm'
def test_rtl_fm_modulation_keeps_other_modes() -> None:
assert listening_post_rtl_mode('fm') == 'fm'
assert builder_rtl_mode('am') == 'am'
def test_rtlsdr_builder_uses_wbfm_token_for_wfm() -> None:
builder = RTLSDRCommandBuilder()
cmd = builder.build_fm_demod_command(
device=_dummy_rtlsdr_device(),
frequency_mhz=98.1,
modulation='wfm',
)
mode_index = cmd.index('-M')
assert cmd[mode_index + 1] == 'wbfm'
+798
View File
@@ -0,0 +1,798 @@
"""Tests for the pure-Python SSTV decoder.
Covers VIS detection, Goertzel accuracy, mode specs, synthetic image
decoding, and integration with the SSTVDecoder orchestrator.
"""
from __future__ import annotations
import math
import tempfile
import wave
from pathlib import Path
from unittest.mock import MagicMock, patch
import numpy as np
import pytest
from utils.sstv.constants import (
FREQ_BLACK,
FREQ_LEADER,
FREQ_PIXEL_HIGH,
FREQ_PIXEL_LOW,
FREQ_SYNC,
FREQ_VIS_BIT_0,
FREQ_VIS_BIT_1,
FREQ_WHITE,
SAMPLE_RATE,
)
from utils.sstv.dsp import (
estimate_frequency,
freq_to_pixel,
goertzel,
goertzel_batch,
goertzel_mag,
normalize_audio,
samples_for_duration,
)
from utils.sstv.modes import (
ALL_MODES,
MARTIN_1,
PD_120,
PD_180,
ROBOT_36,
ROBOT_72,
SCOTTIE_1,
ColorModel,
SyncPosition,
get_mode,
get_mode_by_name,
)
from utils.sstv.sstv_decoder import (
DecodeProgress,
DopplerInfo,
SSTVDecoder,
SSTVImage,
get_sstv_decoder,
is_sstv_available,
)
from utils.sstv.vis import VISDetector, VISState
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def generate_tone(freq: float, duration_s: float,
sample_rate: int = SAMPLE_RATE,
amplitude: float = 0.8) -> np.ndarray:
"""Generate a pure sine tone."""
t = np.arange(int(duration_s * sample_rate)) / sample_rate
return amplitude * np.sin(2 * np.pi * freq * t)
def generate_vis_header(vis_code: int, sample_rate: int = SAMPLE_RATE) -> np.ndarray:
"""Generate a synthetic VIS header for a given code.
Structure: leader1 (300ms) + break (10ms) + leader2 (300ms)
+ start_bit (30ms) + 8 data bits (30ms each)
+ parity bit (30ms) + stop_bit (30ms)
"""
parts = []
# Leader 1 (1900 Hz, 300ms)
parts.append(generate_tone(FREQ_LEADER, 0.300, sample_rate))
# Break (1200 Hz, 10ms)
parts.append(generate_tone(FREQ_SYNC, 0.010, sample_rate))
# Leader 2 (1900 Hz, 300ms)
parts.append(generate_tone(FREQ_LEADER, 0.300, sample_rate))
# Start bit (1200 Hz, 30ms)
parts.append(generate_tone(FREQ_SYNC, 0.030, sample_rate))
# 8 data bits (LSB first)
ones_count = 0
for i in range(8):
bit = (vis_code >> i) & 1
if bit:
ones_count += 1
parts.append(generate_tone(FREQ_VIS_BIT_1, 0.030, sample_rate))
else:
parts.append(generate_tone(FREQ_VIS_BIT_0, 0.030, sample_rate))
# Even parity bit
parity = ones_count % 2
if parity:
parts.append(generate_tone(FREQ_VIS_BIT_1, 0.030, sample_rate))
else:
parts.append(generate_tone(FREQ_VIS_BIT_0, 0.030, sample_rate))
# Stop bit (1200 Hz, 30ms)
parts.append(generate_tone(FREQ_SYNC, 0.030, sample_rate))
return np.concatenate(parts)
# ---------------------------------------------------------------------------
# Goertzel / DSP tests
# ---------------------------------------------------------------------------
class TestGoertzel:
"""Tests for the Goertzel algorithm."""
def test_detects_exact_frequency(self):
"""Goertzel should have peak energy at the generated frequency."""
tone = generate_tone(1200.0, 0.01)
energy_1200 = goertzel(tone, 1200.0)
energy_1500 = goertzel(tone, 1500.0)
energy_1900 = goertzel(tone, 1900.0)
assert energy_1200 > energy_1500 * 5
assert energy_1200 > energy_1900 * 5
def test_different_frequencies(self):
"""Each candidate frequency should produce peak at its own freq."""
for freq in [1100, 1200, 1300, 1500, 1900, 2300]:
tone = generate_tone(float(freq), 0.01)
energy = goertzel(tone, float(freq))
# Should have significant energy at the target
assert energy > 0
def test_empty_samples(self):
"""Goertzel on empty array should return 0."""
assert goertzel(np.array([], dtype=np.float64), 1200.0) == 0.0
def test_goertzel_mag(self):
"""goertzel_mag should return sqrt of energy."""
tone = generate_tone(1200.0, 0.01)
energy = goertzel(tone, 1200.0)
mag = goertzel_mag(tone, 1200.0)
assert abs(mag - math.sqrt(energy)) < 1e-10
class TestEstimateFrequency:
"""Tests for frequency estimation."""
def test_estimates_known_frequency(self):
"""Should accurately estimate a known tone frequency."""
tone = generate_tone(1900.0, 0.02)
estimated = estimate_frequency(tone, 1000.0, 2500.0)
assert abs(estimated - 1900.0) <= 30.0
def test_estimates_black_level(self):
"""Should detect the black level frequency."""
tone = generate_tone(FREQ_BLACK, 0.02)
estimated = estimate_frequency(tone, 1400.0, 1600.0)
assert abs(estimated - FREQ_BLACK) <= 30.0
def test_estimates_white_level(self):
"""Should detect the white level frequency."""
tone = generate_tone(FREQ_WHITE, 0.02)
estimated = estimate_frequency(tone, 2200.0, 2400.0)
assert abs(estimated - FREQ_WHITE) <= 30.0
def test_empty_samples(self):
"""Should return 0 for empty input."""
assert estimate_frequency(np.array([], dtype=np.float64)) == 0.0
class TestFreqToPixel:
"""Tests for frequency-to-pixel mapping."""
def test_black_level(self):
"""1500 Hz should map to 0 (black)."""
assert freq_to_pixel(FREQ_PIXEL_LOW) == 0
def test_white_level(self):
"""2300 Hz should map to 255 (white)."""
assert freq_to_pixel(FREQ_PIXEL_HIGH) == 255
def test_midpoint(self):
"""Middle frequency should map to approximately 128."""
mid_freq = (FREQ_PIXEL_LOW + FREQ_PIXEL_HIGH) / 2
pixel = freq_to_pixel(mid_freq)
assert 120 <= pixel <= 135
def test_below_black_clamps(self):
"""Frequencies below black level should clamp to 0."""
assert freq_to_pixel(1000.0) == 0
def test_above_white_clamps(self):
"""Frequencies above white level should clamp to 255."""
assert freq_to_pixel(3000.0) == 255
class TestNormalizeAudio:
"""Tests for int16 to float64 normalization."""
def test_max_positive(self):
"""int16 max should normalize to ~1.0."""
raw = np.array([32767], dtype=np.int16)
result = normalize_audio(raw)
assert abs(result[0] - (32767.0 / 32768.0)) < 1e-10
def test_zero(self):
"""int16 zero should normalize to 0.0."""
raw = np.array([0], dtype=np.int16)
result = normalize_audio(raw)
assert result[0] == 0.0
def test_negative(self):
"""int16 min should normalize to -1.0."""
raw = np.array([-32768], dtype=np.int16)
result = normalize_audio(raw)
assert result[0] == -1.0
class TestSamplesForDuration:
"""Tests for duration-to-samples calculation."""
def test_one_second(self):
"""1 second at 48kHz should be 48000 samples."""
assert samples_for_duration(1.0) == 48000
def test_five_ms(self):
"""5ms at 48kHz should be 240 samples."""
assert samples_for_duration(0.005) == 240
def test_custom_rate(self):
"""Should work with custom sample rates."""
assert samples_for_duration(1.0, 22050) == 22050
class TestGoertzelBatch:
"""Tests for the vectorized batch Goertzel function."""
def test_matches_scalar_goertzel(self):
"""Batch result should match individual goertzel calls."""
rng = np.random.default_rng(42)
# 10 pixel windows of 20 samples each
audio_matrix = rng.standard_normal((10, 20))
freqs = np.array([1200.0, 1500.0, 1900.0, 2300.0])
batch_result = goertzel_batch(audio_matrix, freqs)
assert batch_result.shape == (10, 4)
for i in range(10):
for j, f in enumerate(freqs):
scalar = goertzel(audio_matrix[i], f)
assert abs(batch_result[i, j] - scalar) < 1e-6, \
f"Mismatch at pixel {i}, freq {f}"
def test_detects_correct_frequency(self):
"""Batch should find peak at the correct frequency for each pixel.
Uses 96-sample windows (2ms at 48kHz) matching the decoder's
minimum analysis window, with 5Hz resolution.
"""
freqs = np.arange(1400.0, 2405.0, 5.0) # 5Hz step, same as decoder
window_size = 96 # Matches _MIN_ANALYSIS_WINDOW
pixels = []
for target in [1500.0, 1900.0, 2300.0]:
t = np.arange(window_size) / SAMPLE_RATE
pixels.append(0.8 * np.sin(2 * np.pi * target * t))
audio_matrix = np.array(pixels)
energies = goertzel_batch(audio_matrix, freqs)
best_idx = np.argmax(energies, axis=1)
best_freqs = freqs[best_idx]
# With 96 samples, frequency accuracy is within ~25 Hz
assert abs(best_freqs[0] - 1500.0) <= 30.0
assert abs(best_freqs[1] - 1900.0) <= 30.0
assert abs(best_freqs[2] - 2300.0) <= 30.0
def test_empty_input(self):
"""Should handle empty inputs gracefully."""
result = goertzel_batch(np.zeros((0, 10)), np.array([1200.0]))
assert result.shape == (0, 1)
result = goertzel_batch(np.zeros((5, 10)), np.array([]))
assert result.shape == (5, 0)
# ---------------------------------------------------------------------------
# VIS detection tests
# ---------------------------------------------------------------------------
class TestVISDetector:
"""Tests for VIS header detection."""
def test_initial_state(self):
"""Detector should start in IDLE state."""
detector = VISDetector()
assert detector.state == VISState.IDLE
def test_reset(self):
"""Reset should return to IDLE state."""
detector = VISDetector()
# Feed some leader tone to change state
detector.feed(generate_tone(FREQ_LEADER, 0.250))
detector.reset()
assert detector.state == VISState.IDLE
def test_detect_robot36(self):
"""Should detect Robot36 VIS code (8)."""
detector = VISDetector()
header = generate_vis_header(8) # Robot36
# Add some silence before and after
audio = np.concatenate([
np.zeros(2400),
header,
np.zeros(2400),
])
result = detector.feed(audio)
assert result is not None
vis_code, mode_name = result
assert vis_code == 8
assert mode_name == 'Robot36'
def test_detect_martin1(self):
"""Should detect Martin1 VIS code (44)."""
detector = VISDetector()
header = generate_vis_header(44) # Martin1
audio = np.concatenate([np.zeros(2400), header, np.zeros(2400)])
result = detector.feed(audio)
assert result is not None
vis_code, mode_name = result
assert vis_code == 44
assert mode_name == 'Martin1'
def test_detect_scottie1(self):
"""Should detect Scottie1 VIS code (60)."""
detector = VISDetector()
header = generate_vis_header(60) # Scottie1
audio = np.concatenate([np.zeros(2400), header, np.zeros(2400)])
result = detector.feed(audio)
assert result is not None
vis_code, mode_name = result
assert vis_code == 60
assert mode_name == 'Scottie1'
def test_detect_pd120(self):
"""Should detect PD120 VIS code (93)."""
detector = VISDetector()
header = generate_vis_header(93) # PD120
audio = np.concatenate([np.zeros(2400), header, np.zeros(2400)])
result = detector.feed(audio)
assert result is not None
vis_code, mode_name = result
assert vis_code == 93
assert mode_name == 'PD120'
def test_noise_rejection(self):
"""Should not falsely detect VIS in noise."""
detector = VISDetector()
rng = np.random.default_rng(42)
noise = rng.standard_normal(48000) * 0.1 # 1 second of noise
result = detector.feed(noise)
assert result is None
def test_incremental_feeding(self):
"""Should work with small chunks fed incrementally."""
detector = VISDetector()
header = generate_vis_header(8)
audio = np.concatenate([np.zeros(2400), header, np.zeros(2400)])
# Feed in small chunks (100 samples each)
chunk_size = 100
result = None
offset = 0
while offset < len(audio):
chunk = audio[offset:offset + chunk_size]
offset += chunk_size
result = detector.feed(chunk)
if result is not None:
break
assert result is not None
vis_code, mode_name = result
assert vis_code == 8
assert mode_name == 'Robot36'
# ---------------------------------------------------------------------------
# Mode spec tests
# ---------------------------------------------------------------------------
class TestModes:
"""Tests for SSTV mode specifications."""
def test_all_vis_codes_have_modes(self):
"""All defined VIS codes should have matching mode specs."""
for vis_code in [8, 12, 44, 40, 60, 56, 93, 95]:
mode = get_mode(vis_code)
assert mode is not None, f"No mode for VIS code {vis_code}"
def test_robot36_spec(self):
"""Robot36 should have correct dimensions and timing."""
assert ROBOT_36.width == 320
assert ROBOT_36.height == 240
assert ROBOT_36.vis_code == 8
assert ROBOT_36.color_model == ColorModel.YCRCB
assert ROBOT_36.has_half_rate_chroma is True
assert ROBOT_36.sync_position == SyncPosition.FRONT
def test_martin1_spec(self):
"""Martin1 should have correct dimensions."""
assert MARTIN_1.width == 320
assert MARTIN_1.height == 256
assert MARTIN_1.vis_code == 44
assert MARTIN_1.color_model == ColorModel.RGB
assert len(MARTIN_1.channels) == 3
def test_scottie1_spec(self):
"""Scottie1 should have middle sync position."""
assert SCOTTIE_1.sync_position == SyncPosition.MIDDLE
assert SCOTTIE_1.width == 320
assert SCOTTIE_1.height == 256
def test_pd120_spec(self):
"""PD120 should have dual-luminance YCrCb."""
assert PD_120.width == 640
assert PD_120.height == 496
assert PD_120.color_model == ColorModel.YCRCB_DUAL
assert len(PD_120.channels) == 4 # Y1, Cr, Cb, Y2
def test_get_mode_unknown(self):
"""Unknown VIS code should return None."""
assert get_mode(999) is None
def test_get_mode_by_name(self):
"""Should look up modes by name."""
mode = get_mode_by_name('Robot36')
assert mode is not None
assert mode.vis_code == 8
def test_mode_by_name_unknown(self):
"""Unknown mode name should return None."""
assert get_mode_by_name('FakeMode') is None
def test_robot72_spec(self):
"""Robot72 should have 3 channels and full-rate chroma."""
assert ROBOT_72.width == 320
assert ROBOT_72.height == 240
assert ROBOT_72.vis_code == 12
assert ROBOT_72.color_model == ColorModel.YCRCB
assert ROBOT_72.has_half_rate_chroma is False
assert len(ROBOT_72.channels) == 3 # Y, Cr, Cb
assert ROBOT_72.channel_separator_ms == 6.0
def test_robot36_separator(self):
"""Robot36 should have a 6ms separator between Y and chroma."""
assert ROBOT_36.channel_separator_ms == 6.0
assert ROBOT_36.has_half_rate_chroma is True
assert len(ROBOT_36.channels) == 2 # Y, alternating Cr/Cb
def test_pd120_channel_timings(self):
"""PD120 channel durations should sum to line_duration minus sync+porch."""
channel_sum = sum(ch.duration_ms for ch in PD_120.channels)
expected = PD_120.line_duration_ms - PD_120.sync_duration_ms - PD_120.sync_porch_ms
assert abs(channel_sum - expected) < 0.1, \
f"PD120 channels sum to {channel_sum}ms, expected {expected}ms"
def test_pd180_channel_timings(self):
"""PD180 channel durations should sum to line_duration minus sync+porch."""
channel_sum = sum(ch.duration_ms for ch in PD_180.channels)
expected = PD_180.line_duration_ms - PD_180.sync_duration_ms - PD_180.sync_porch_ms
assert abs(channel_sum - expected) < 0.1, \
f"PD180 channels sum to {channel_sum}ms, expected {expected}ms"
def test_robot36_timing_consistency(self):
"""Robot36 total channel + sync + porch + separator should equal line_duration."""
total = (ROBOT_36.sync_duration_ms + ROBOT_36.sync_porch_ms
+ sum(ch.duration_ms for ch in ROBOT_36.channels)
+ ROBOT_36.channel_separator_ms) # 1 separator for 2 channels
assert abs(total - ROBOT_36.line_duration_ms) < 0.1
def test_robot72_timing_consistency(self):
"""Robot72 total should equal line_duration."""
# 3 channels with 2 separators
total = (ROBOT_72.sync_duration_ms + ROBOT_72.sync_porch_ms
+ sum(ch.duration_ms for ch in ROBOT_72.channels)
+ ROBOT_72.channel_separator_ms * 2)
assert abs(total - ROBOT_72.line_duration_ms) < 0.1
def test_all_modes_have_positive_dimensions(self):
"""All modes should have positive width and height."""
for _vis_code, mode in ALL_MODES.items():
assert mode.width > 0, f"{mode.name} has invalid width"
assert mode.height > 0, f"{mode.name} has invalid height"
assert mode.line_duration_ms > 0, f"{mode.name} has invalid line duration"
# ---------------------------------------------------------------------------
# Image decoder tests
# ---------------------------------------------------------------------------
class TestImageDecoder:
"""Tests for the SSTV image decoder."""
def test_creates_decoder(self):
"""Should create an image decoder for any supported mode."""
from utils.sstv.image_decoder import SSTVImageDecoder
decoder = SSTVImageDecoder(ROBOT_36)
assert decoder.is_complete is False
assert decoder.current_line == 0
assert decoder.total_lines == 240
def test_pd120_dual_luminance_lines(self):
"""PD120 decoder should expect half the image height in audio lines."""
from utils.sstv.image_decoder import SSTVImageDecoder
decoder = SSTVImageDecoder(PD_120)
assert decoder.total_lines == 248 # 496 / 2
def test_progress_percent(self):
"""Progress should start at 0."""
from utils.sstv.image_decoder import SSTVImageDecoder
decoder = SSTVImageDecoder(ROBOT_36)
assert decoder.progress_percent == 0
def test_synthetic_robot36_decode(self):
"""Should decode a synthetic Robot36 image (all white)."""
pytest.importorskip('PIL')
from utils.sstv.image_decoder import SSTVImageDecoder
decoder = SSTVImageDecoder(ROBOT_36)
# Generate synthetic scanlines (all white = 2300 Hz)
# Each line: sync(9ms) + porch(3ms) + Y(88ms) + separator(6ms) + Cr/Cb(44ms)
for _line in range(240):
parts = []
# Sync pulse
parts.append(generate_tone(FREQ_SYNC, 0.009))
# Porch
parts.append(generate_tone(FREQ_BLACK, 0.003))
# Y channel (white = 2300 Hz)
parts.append(generate_tone(FREQ_WHITE, 0.088))
# Separator + porch (6ms)
parts.append(generate_tone(FREQ_BLACK, 0.006))
# Chroma channel (mid value = 1900 Hz ~ 128)
parts.append(generate_tone(1900.0, 0.044))
# Pad to line duration
line_audio = np.concatenate(parts)
line_samples = samples_for_duration(ROBOT_36.line_duration_ms / 1000.0)
if len(line_audio) < line_samples:
line_audio = np.concatenate([
line_audio,
np.zeros(line_samples - len(line_audio))
])
decoder.feed(line_audio)
assert decoder.is_complete
img = decoder.get_image()
assert img is not None
assert img.size == (320, 240)
# ---------------------------------------------------------------------------
# SSTVDecoder orchestrator tests
# ---------------------------------------------------------------------------
class TestSSTVDecoder:
"""Tests for the SSTVDecoder orchestrator."""
def test_decoder_available(self):
"""Python decoder should always be available."""
decoder = SSTVDecoder(output_dir=tempfile.mkdtemp())
assert decoder.decoder_available == 'python-sstv'
def test_is_sstv_available(self):
"""is_sstv_available() should always return True."""
assert is_sstv_available() is True
def test_not_running_initially(self):
"""Decoder should not be running on creation."""
decoder = SSTVDecoder(output_dir=tempfile.mkdtemp())
assert decoder.is_running is False
def test_doppler_disabled_by_default(self):
"""Doppler should be disabled by default."""
decoder = SSTVDecoder(output_dir=tempfile.mkdtemp())
assert decoder.doppler_enabled is False
assert decoder.last_doppler_info is None
def test_stop_when_not_running(self):
"""Stop should be safe to call when not running."""
decoder = SSTVDecoder(output_dir=tempfile.mkdtemp())
decoder.stop() # Should not raise
def test_set_callback(self):
"""Should accept a callback function."""
decoder = SSTVDecoder(output_dir=tempfile.mkdtemp())
cb = MagicMock()
decoder.set_callback(cb)
# Trigger a progress emit
decoder._emit_progress(DecodeProgress(status='detecting'))
cb.assert_called_once()
def test_get_images_empty(self):
"""Should return empty list initially."""
decoder = SSTVDecoder(output_dir=tempfile.mkdtemp())
images = decoder.get_images()
assert images == []
def test_decode_file_not_found(self):
"""Should raise FileNotFoundError for missing file."""
decoder = SSTVDecoder(output_dir=tempfile.mkdtemp())
with pytest.raises(FileNotFoundError):
decoder.decode_file('/nonexistent/audio.wav')
def test_decode_file_with_synthetic_wav(self):
"""Should process a WAV file through the decode pipeline."""
pytest.importorskip('PIL')
output_dir = tempfile.mkdtemp()
decoder = SSTVDecoder(output_dir=output_dir)
# Generate a synthetic WAV with a VIS header + short image data
vis_header = generate_vis_header(8) # Robot36
# Add 240 lines of image data after the header
image_lines = []
for _line in range(240):
parts = []
parts.append(generate_tone(FREQ_SYNC, 0.009))
parts.append(generate_tone(FREQ_BLACK, 0.003))
parts.append(generate_tone(1900.0, 0.088)) # mid-gray Y
parts.append(generate_tone(FREQ_BLACK, 0.006)) # separator
parts.append(generate_tone(1900.0, 0.044)) # chroma
line_audio = np.concatenate(parts)
line_samples = samples_for_duration(ROBOT_36.line_duration_ms / 1000.0)
if len(line_audio) < line_samples:
line_audio = np.concatenate([
line_audio,
np.zeros(line_samples - len(line_audio))
])
image_lines.append(line_audio)
audio = np.concatenate([
np.zeros(4800), # 100ms silence
vis_header,
*image_lines,
np.zeros(4800),
])
# Write WAV file
wav_path = Path(output_dir) / 'test_input.wav'
raw_int16 = (audio * 32767).astype(np.int16)
with wave.open(str(wav_path), 'wb') as wf:
wf.setnchannels(1)
wf.setsampwidth(2)
wf.setframerate(SAMPLE_RATE)
wf.writeframes(raw_int16.tobytes())
images = decoder.decode_file(wav_path)
assert len(images) >= 1
assert images[0].mode == 'Robot36'
assert Path(images[0].path).exists()
# ---------------------------------------------------------------------------
# Dataclass tests
# ---------------------------------------------------------------------------
class TestDataclasses:
"""Tests for dataclass serialization."""
def test_decode_progress_to_dict(self):
"""DecodeProgress should serialize correctly."""
progress = DecodeProgress(
status='decoding',
mode='Robot36',
progress_percent=50,
message='Halfway done',
)
d = progress.to_dict()
assert d['type'] == 'sstv_progress'
assert d['status'] == 'decoding'
assert d['mode'] == 'Robot36'
assert d['progress'] == 50
assert d['message'] == 'Halfway done'
def test_decode_progress_minimal(self):
"""DecodeProgress with only status should omit optional fields."""
progress = DecodeProgress(status='detecting')
d = progress.to_dict()
assert 'mode' not in d
assert 'message' not in d
assert 'image' not in d
def test_sstv_image_to_dict(self):
"""SSTVImage should serialize with URL."""
from datetime import datetime, timezone
image = SSTVImage(
filename='test.png',
path=Path('/tmp/test.png'),
mode='Robot36',
timestamp=datetime(2024, 1, 1, tzinfo=timezone.utc),
frequency=145.800,
size_bytes=1234,
)
d = image.to_dict()
assert d['filename'] == 'test.png'
assert d['mode'] == 'Robot36'
assert d['url'] == '/sstv/images/test.png'
def test_doppler_info_to_dict(self):
"""DopplerInfo should serialize with rounding."""
from datetime import datetime, timezone
info = DopplerInfo(
frequency_hz=145800123.456,
shift_hz=123.456,
range_rate_km_s=-1.23456,
elevation=45.678,
azimuth=180.123,
timestamp=datetime(2024, 1, 1, tzinfo=timezone.utc),
)
d = info.to_dict()
assert d['shift_hz'] == 123.5
assert d['range_rate_km_s'] == -1.235
assert d['elevation'] == 45.7
# ---------------------------------------------------------------------------
# Integration tests
# ---------------------------------------------------------------------------
class TestIntegration:
"""Integration tests verifying the package works as a drop-in replacement."""
def test_import_from_utils_sstv(self):
"""Routes should be able to import from utils.sstv."""
from utils.sstv import (
ISS_SSTV_FREQ,
is_sstv_available,
)
assert ISS_SSTV_FREQ == 145.800
assert is_sstv_available() is True
def test_sstv_modes_constant(self):
"""SSTV_MODES list should be importable."""
from utils.sstv import SSTV_MODES
assert 'Robot36' in SSTV_MODES
assert 'Martin1' in SSTV_MODES
assert 'PD120' in SSTV_MODES
def test_decoder_singleton(self):
"""get_sstv_decoder should return a valid decoder."""
# Reset the global singleton for test isolation
import utils.sstv.sstv_decoder as mod
old = mod._decoder
mod._decoder = None
try:
decoder = get_sstv_decoder()
assert decoder is not None
assert decoder.decoder_available == 'python-sstv'
finally:
mod._decoder = old
@patch('subprocess.Popen')
def test_start_creates_subprocess(self, mock_popen):
"""start() should create an rtl_fm subprocess."""
mock_process = MagicMock()
mock_process.stdout = MagicMock()
mock_process.stdout.read = MagicMock(return_value=b'')
mock_process.stderr = MagicMock()
mock_popen.return_value = mock_process
decoder = SSTVDecoder(output_dir=tempfile.mkdtemp())
success = decoder.start(frequency=145.800, device_index=0)
assert success is True
assert decoder.is_running is True
# Verify rtl_fm was called
mock_popen.assert_called_once()
cmd = mock_popen.call_args[0][0]
assert cmd[0] == 'rtl_fm'
assert '-f' in cmd
assert '-M' in cmd
decoder.stop()
assert decoder.is_running is False
+608
View File
@@ -0,0 +1,608 @@
"""Tests for SubGhzManager utility module."""
from __future__ import annotations
import json
import os
import subprocess
import tempfile
from pathlib import Path
from unittest.mock import patch, MagicMock
import pytest
from utils.subghz import SubGhzManager, SubGhzCapture
@pytest.fixture
def tmp_data_dir(tmp_path):
"""Create a temporary data directory for SubGhz captures."""
data_dir = tmp_path / 'subghz'
data_dir.mkdir()
(data_dir / 'captures').mkdir()
return data_dir
@pytest.fixture
def manager(tmp_data_dir):
"""Create a SubGhzManager with temp directory."""
return SubGhzManager(data_dir=tmp_data_dir)
class TestSubGhzManagerInit:
def test_creates_data_dirs(self, tmp_path):
data_dir = tmp_path / 'new_subghz'
mgr = SubGhzManager(data_dir=data_dir)
assert (data_dir / 'captures').is_dir()
def test_active_mode_idle(self, manager):
assert manager.active_mode == 'idle'
def test_get_status_idle(self, manager):
status = manager.get_status()
assert status['mode'] == 'idle'
class TestToolDetection:
def test_check_hackrf_found(self, manager):
with patch('shutil.which', return_value='/usr/bin/hackrf_transfer'):
assert manager.check_hackrf() is True
def test_check_hackrf_not_found(self, manager):
with patch('shutil.which', return_value=None):
manager._hackrf_available = None # reset cache
assert manager.check_hackrf() is False
def test_check_rtl433_found(self, manager):
with patch('shutil.which', return_value='/usr/bin/rtl_433'):
assert manager.check_rtl433() is True
def test_check_sweep_found(self, manager):
with patch('shutil.which', return_value='/usr/bin/hackrf_sweep'):
assert manager.check_sweep() is True
class TestReceive:
def test_start_receive_no_hackrf(self, manager):
with patch('shutil.which', return_value=None):
manager._hackrf_available = None
result = manager.start_receive(frequency_hz=433920000)
assert result['status'] == 'error'
assert 'not found' in result['message']
def test_start_receive_success(self, manager):
mock_proc = MagicMock()
mock_proc.poll.return_value = None
mock_proc.stderr = MagicMock()
mock_proc.stderr.readline = MagicMock(return_value=b'')
with patch('shutil.which', return_value='/usr/bin/hackrf_transfer'), \
patch('subprocess.Popen', return_value=mock_proc), \
patch.object(manager, 'check_hackrf_device', return_value=True), \
patch('utils.subghz.register_process'):
manager._hackrf_available = None
result = manager.start_receive(
frequency_hz=433920000,
sample_rate=2000000,
lna_gain=32,
vga_gain=20,
)
assert result['status'] == 'started'
assert result['frequency_hz'] == 433920000
assert manager.active_mode == 'rx'
def test_start_receive_already_running(self, manager):
mock_proc = MagicMock()
mock_proc.poll.return_value = None
manager._rx_process = mock_proc
result = manager.start_receive(frequency_hz=433920000)
assert result['status'] == 'error'
assert 'Already running' in result['message']
def test_stop_receive_not_running(self, manager):
result = manager.stop_receive()
assert result['status'] == 'not_running'
def test_stop_receive_creates_metadata(self, manager, tmp_data_dir):
# Create a fake IQ file
iq_file = tmp_data_dir / 'captures' / 'test.iq'
iq_file.write_bytes(b'\x00' * 1024)
mock_proc = MagicMock()
mock_proc.poll.return_value = None
manager._rx_process = mock_proc
manager._rx_file = iq_file
manager._rx_frequency_hz = 433920000
manager._rx_sample_rate = 2000000
manager._rx_lna_gain = 32
manager._rx_vga_gain = 20
manager._rx_start_time = 1000.0
manager._rx_bursts = [{'start_seconds': 1.23, 'duration_seconds': 0.15, 'peak_level': 42}]
with patch('utils.subghz.safe_terminate'), \
patch('time.time', return_value=1005.0):
result = manager.stop_receive()
assert result['status'] == 'stopped'
assert 'capture' in result
assert result['capture']['frequency_hz'] == 433920000
# Verify JSON sidecar was written
meta_path = iq_file.with_suffix('.json')
assert meta_path.exists()
meta = json.loads(meta_path.read_text())
assert meta['frequency_hz'] == 433920000
assert isinstance(meta.get('bursts'), list)
assert meta['bursts'][0]['peak_level'] == 42
class TestTxSafety:
def test_validate_tx_frequency_ism_433(self):
result = SubGhzManager.validate_tx_frequency(433920000)
assert result is None # Valid
def test_validate_tx_frequency_ism_315(self):
result = SubGhzManager.validate_tx_frequency(315000000)
assert result is None
def test_validate_tx_frequency_ism_915(self):
result = SubGhzManager.validate_tx_frequency(915000000)
assert result is None
def test_validate_tx_frequency_out_of_band(self):
result = SubGhzManager.validate_tx_frequency(100000000) # 100 MHz
assert result is not None
assert 'outside allowed TX bands' in result
def test_validate_tx_frequency_between_bands(self):
result = SubGhzManager.validate_tx_frequency(500000000) # 500 MHz
assert result is not None
def test_transmit_no_hackrf(self, manager):
with patch('shutil.which', return_value=None):
manager._hackrf_available = None
result = manager.transmit(capture_id='abc123')
assert result['status'] == 'error'
def test_transmit_capture_not_found(self, manager):
with patch('shutil.which', return_value='/usr/bin/hackrf_transfer'), \
patch.object(manager, 'check_hackrf_device', return_value=True):
manager._hackrf_available = None
result = manager.transmit(capture_id='nonexistent')
assert result['status'] == 'error'
assert 'not found' in result['message']
def test_transmit_out_of_band_rejected(self, manager, tmp_data_dir):
# Create a capture with out-of-band frequency
meta = {
'id': 'test123',
'filename': 'test.iq',
'frequency_hz': 100000000, # 100 MHz - out of ISM
'sample_rate': 2000000,
'lna_gain': 32,
'vga_gain': 20,
'timestamp': '2026-01-01T00:00:00Z',
}
meta_path = tmp_data_dir / 'captures' / 'test.json'
meta_path.write_text(json.dumps(meta))
(tmp_data_dir / 'captures' / 'test.iq').write_bytes(b'\x00' * 100)
with patch('shutil.which', return_value='/usr/bin/hackrf_transfer'), \
patch.object(manager, 'check_hackrf_device', return_value=True):
manager._hackrf_available = None
result = manager.transmit(capture_id='test123')
assert result['status'] == 'error'
assert 'outside allowed TX bands' in result['message']
def test_transmit_already_running(self, manager):
mock_proc = MagicMock()
mock_proc.poll.return_value = None
manager._rx_process = mock_proc
result = manager.transmit(capture_id='test123')
assert result['status'] == 'error'
assert 'Already running' in result['message']
def test_transmit_segment_extracts_range(self, manager, tmp_data_dir):
meta = {
'id': 'seg001',
'filename': 'seg.iq',
'frequency_hz': 433920000,
'sample_rate': 1000,
'lna_gain': 24,
'vga_gain': 20,
'timestamp': '2026-01-01T00:00:00Z',
'duration_seconds': 1.0,
'size_bytes': 2000,
}
(tmp_data_dir / 'captures' / 'seg.json').write_text(json.dumps(meta))
(tmp_data_dir / 'captures' / 'seg.iq').write_bytes(bytes(range(200)) * 10)
mock_proc = MagicMock()
mock_proc.poll.return_value = None
mock_timer = MagicMock()
mock_timer.start = MagicMock()
with patch('shutil.which', return_value='/usr/bin/hackrf_transfer'), \
patch.object(manager, 'check_hackrf_device', return_value=True), \
patch('subprocess.Popen', return_value=mock_proc), \
patch('utils.subghz.register_process'), \
patch('threading.Timer', return_value=mock_timer), \
patch('threading.Thread') as mock_thread_cls:
mock_thread = MagicMock()
mock_thread.start = MagicMock()
mock_thread_cls.return_value = mock_thread
manager._hackrf_available = None
result = manager.transmit(
capture_id='seg001',
start_seconds=0.2,
duration_seconds=0.3,
)
assert result['status'] == 'transmitting'
assert result['segment'] is not None
assert result['segment']['duration_seconds'] == pytest.approx(0.3, abs=0.01)
assert manager._tx_temp_file is not None
assert manager._tx_temp_file.exists()
class TestCaptureLibrary:
def test_list_captures_empty(self, manager):
captures = manager.list_captures()
assert captures == []
def test_list_captures_with_data(self, manager, tmp_data_dir):
meta = {
'id': 'cap001',
'filename': 'test.iq',
'frequency_hz': 433920000,
'sample_rate': 2000000,
'lna_gain': 32,
'vga_gain': 20,
'timestamp': '2026-01-01T00:00:00Z',
'duration_seconds': 5.0,
'size_bytes': 1024,
'label': 'test capture',
}
(tmp_data_dir / 'captures' / 'test.json').write_text(json.dumps(meta))
captures = manager.list_captures()
assert len(captures) == 1
assert captures[0].capture_id == 'cap001'
assert captures[0].label == 'test capture'
def test_get_capture(self, manager, tmp_data_dir):
meta = {
'id': 'cap002',
'filename': 'test2.iq',
'frequency_hz': 315000000,
'sample_rate': 2000000,
'timestamp': '2026-01-01T00:00:00Z',
}
(tmp_data_dir / 'captures' / 'test2.json').write_text(json.dumps(meta))
cap = manager.get_capture('cap002')
assert cap is not None
assert cap.frequency_hz == 315000000
def test_get_capture_not_found(self, manager):
cap = manager.get_capture('nonexistent')
assert cap is None
def test_delete_capture(self, manager, tmp_data_dir):
captures_dir = tmp_data_dir / 'captures'
iq_path = captures_dir / 'delete_me.iq'
meta_path = captures_dir / 'delete_me.json'
iq_path.write_bytes(b'\x00' * 100)
meta_path.write_text(json.dumps({
'id': 'del001',
'filename': 'delete_me.iq',
'frequency_hz': 433920000,
'sample_rate': 2000000,
'timestamp': '2026-01-01T00:00:00Z',
}))
assert manager.delete_capture('del001') is True
assert not iq_path.exists()
assert not meta_path.exists()
def test_delete_capture_not_found(self, manager):
assert manager.delete_capture('nonexistent') is False
def test_update_label(self, manager, tmp_data_dir):
meta = {
'id': 'lbl001',
'filename': 'label_test.iq',
'frequency_hz': 433920000,
'sample_rate': 2000000,
'timestamp': '2026-01-01T00:00:00Z',
'label': '',
}
meta_path = tmp_data_dir / 'captures' / 'label_test.json'
meta_path.write_text(json.dumps(meta))
assert manager.update_capture_label('lbl001', 'Garage Remote') is True
updated = json.loads(meta_path.read_text())
assert updated['label'] == 'Garage Remote'
assert updated['label_source'] == 'manual'
def test_update_label_not_found(self, manager):
assert manager.update_capture_label('nonexistent', 'test') is False
def test_get_capture_path(self, manager, tmp_data_dir):
captures_dir = tmp_data_dir / 'captures'
iq_path = captures_dir / 'path_test.iq'
iq_path.write_bytes(b'\x00' * 100)
(captures_dir / 'path_test.json').write_text(json.dumps({
'id': 'pth001',
'filename': 'path_test.iq',
'frequency_hz': 433920000,
'sample_rate': 2000000,
'timestamp': '2026-01-01T00:00:00Z',
}))
path = manager.get_capture_path('pth001')
assert path is not None
assert path.name == 'path_test.iq'
def test_get_capture_path_not_found(self, manager):
assert manager.get_capture_path('nonexistent') is None
def test_trim_capture_manual_segment(self, manager, tmp_data_dir):
captures_dir = tmp_data_dir / 'captures'
iq_path = captures_dir / 'trim_src.iq'
iq_path.write_bytes(bytes(range(200)) * 20) # 4000 bytes at 1000 sps => 2.0s
(captures_dir / 'trim_src.json').write_text(json.dumps({
'id': 'trim001',
'filename': 'trim_src.iq',
'frequency_hz': 433920000,
'sample_rate': 1000,
'lna_gain': 24,
'vga_gain': 20,
'timestamp': '2026-01-01T00:00:00Z',
'duration_seconds': 2.0,
'size_bytes': 4000,
'label': 'Weather Burst',
'bursts': [
{
'start_seconds': 0.55,
'duration_seconds': 0.2,
'peak_level': 67,
'fingerprint': 'abc123',
'modulation_hint': 'OOK/ASK',
'modulation_confidence': 0.9,
}
],
}))
result = manager.trim_capture(
capture_id='trim001',
start_seconds=0.5,
duration_seconds=0.4,
)
assert result['status'] == 'ok'
assert result['capture']['id'] != 'trim001'
assert result['capture']['size_bytes'] == 800
assert result['capture']['label'].endswith('(Trim)')
trimmed_iq = captures_dir / result['capture']['filename']
assert trimmed_iq.exists()
trimmed_meta = trimmed_iq.with_suffix('.json')
assert trimmed_meta.exists()
def test_trim_capture_auto_burst(self, manager, tmp_data_dir):
captures_dir = tmp_data_dir / 'captures'
iq_path = captures_dir / 'auto_src.iq'
iq_path.write_bytes(bytes(range(100)) * 40) # 4000 bytes
(captures_dir / 'auto_src.json').write_text(json.dumps({
'id': 'trim002',
'filename': 'auto_src.iq',
'frequency_hz': 433920000,
'sample_rate': 1000,
'lna_gain': 24,
'vga_gain': 20,
'timestamp': '2026-01-01T00:00:00Z',
'duration_seconds': 2.0,
'size_bytes': 4000,
'bursts': [
{'start_seconds': 0.2, 'duration_seconds': 0.1, 'peak_level': 12},
{'start_seconds': 1.2, 'duration_seconds': 0.25, 'peak_level': 88},
],
}))
result = manager.trim_capture(capture_id='trim002')
assert result['status'] == 'ok'
assert result['segment']['auto_selected'] is True
assert result['capture']['duration_seconds'] > 0.25
def test_list_captures_groups_same_fingerprint(self, manager, tmp_data_dir):
cap_a = {
'id': 'grp001',
'filename': 'a.iq',
'frequency_hz': 433920000,
'sample_rate': 2000000,
'timestamp': '2026-01-01T00:00:00Z',
'dominant_fingerprint': 'deadbeefcafebabe',
}
cap_b = {
'id': 'grp002',
'filename': 'b.iq',
'frequency_hz': 433920000,
'sample_rate': 2000000,
'timestamp': '2026-01-01T00:01:00Z',
'dominant_fingerprint': 'deadbeefcafebabe',
}
(tmp_data_dir / 'captures' / 'a.json').write_text(json.dumps(cap_a))
(tmp_data_dir / 'captures' / 'b.json').write_text(json.dumps(cap_b))
captures = manager.list_captures()
assert len(captures) == 2
assert all(c.fingerprint_group.startswith('SIG-') for c in captures)
assert all(c.fingerprint_group_size == 2 for c in captures)
class TestSweep:
def test_start_sweep_no_tool(self, manager):
with patch('shutil.which', return_value=None):
manager._sweep_available = None
result = manager.start_sweep()
assert result['status'] == 'error'
def test_start_sweep_success(self, manager):
mock_proc = MagicMock()
mock_proc.poll.return_value = None
mock_proc.stdout = MagicMock()
with patch('shutil.which', return_value='/usr/bin/hackrf_sweep'), \
patch('subprocess.Popen', return_value=mock_proc), \
patch('utils.subghz.register_process'):
manager._sweep_available = None
result = manager.start_sweep(freq_start_mhz=300, freq_end_mhz=928)
assert result['status'] == 'started'
# Signal daemon threads to stop so they don't outlive the test
manager._sweep_running = False
def test_stop_sweep_not_running(self, manager):
result = manager.stop_sweep()
assert result['status'] == 'not_running'
class TestDecode:
def test_start_decode_no_hackrf(self, manager):
with patch('shutil.which', return_value=None):
manager._hackrf_available = None
manager._rtl433_available = None
result = manager.start_decode(frequency_hz=433920000)
assert result['status'] == 'error'
assert 'hackrf_transfer' in result['message']
def test_start_decode_no_rtl433(self, manager):
def which_side_effect(name):
if name == 'hackrf_transfer':
return '/usr/bin/hackrf_transfer'
return None
with patch('shutil.which', side_effect=which_side_effect):
manager._hackrf_available = None
manager._rtl433_available = None
result = manager.start_decode(frequency_hz=433920000)
assert result['status'] == 'error'
assert 'rtl_433' in result['message']
def test_start_decode_success(self, manager):
mock_hackrf_proc = MagicMock()
mock_hackrf_proc.poll.return_value = None
mock_hackrf_proc.stdout = MagicMock()
mock_hackrf_proc.stderr = MagicMock()
mock_hackrf_proc.stderr.readline = MagicMock(return_value=b'')
mock_rtl433_proc = MagicMock()
mock_rtl433_proc.poll.return_value = None
mock_rtl433_proc.stdout = MagicMock()
mock_rtl433_proc.stderr = MagicMock()
mock_rtl433_proc.stderr.readline = MagicMock(return_value=b'')
call_count = [0]
def popen_side_effect(*args, **kwargs):
call_count[0] += 1
if call_count[0] == 1:
return mock_hackrf_proc
return mock_rtl433_proc
with patch('shutil.which', return_value='/usr/bin/tool'), \
patch('subprocess.Popen', side_effect=popen_side_effect) as mock_popen, \
patch('utils.subghz.register_process'):
manager._hackrf_available = None
manager._rtl433_available = None
result = manager.start_decode(
frequency_hz=433920000,
sample_rate=2000000,
)
assert result['status'] == 'started'
assert result['frequency_hz'] == 433920000
assert manager.active_mode == 'decode'
# Two processes: hackrf_transfer + rtl_433
assert mock_popen.call_count == 2
# Verify hackrf_transfer command
hackrf_cmd = mock_popen.call_args_list[0][0][0]
assert hackrf_cmd[0] == 'hackrf_transfer'
assert '-r' in hackrf_cmd
# Verify rtl_433 command
rtl433_cmd = mock_popen.call_args_list[1][0][0]
assert rtl433_cmd[0] == 'rtl_433'
assert '-r' in rtl433_cmd
assert 'cs8:-' in rtl433_cmd
# Both processes tracked
assert manager._decode_hackrf_process is mock_hackrf_proc
assert manager._decode_process is mock_rtl433_proc
# Signal daemon threads to stop so they don't outlive the test
manager._decode_stop = True
def test_stop_decode_not_running(self, manager):
result = manager.stop_decode()
assert result['status'] == 'not_running'
def test_stop_decode_terminates_both(self, manager):
mock_hackrf = MagicMock()
mock_hackrf.poll.return_value = None
mock_rtl433 = MagicMock()
mock_rtl433.poll.return_value = None
manager._decode_hackrf_process = mock_hackrf
manager._decode_process = mock_rtl433
manager._decode_frequency_hz = 433920000
with patch('utils.subghz.safe_terminate') as mock_term, \
patch('utils.subghz.unregister_process'):
result = manager.stop_decode()
assert result['status'] == 'stopped'
assert manager._decode_hackrf_process is None
assert manager._decode_process is None
assert mock_term.call_count == 2
class TestStopAll:
def test_stop_all_clears_processes(self, manager):
mock_proc = MagicMock()
mock_proc.poll.return_value = None
manager._rx_process = mock_proc
with patch('utils.subghz.safe_terminate'):
manager.stop_all()
assert manager._rx_process is None
assert manager._decode_hackrf_process is None
assert manager._decode_process is None
assert manager._tx_process is None
assert manager._sweep_process is None
class TestSubGhzCapture:
def test_to_dict(self):
cap = SubGhzCapture(
capture_id='abc123',
filename='test.iq',
frequency_hz=433920000,
sample_rate=2000000,
lna_gain=32,
vga_gain=20,
timestamp='2026-01-01T00:00:00Z',
duration_seconds=5.0,
size_bytes=1024,
label='Test',
)
d = cap.to_dict()
assert d['id'] == 'abc123'
assert d['frequency_hz'] == 433920000
assert d['label'] == 'Test'
+433
View File
@@ -0,0 +1,433 @@
"""Tests for SubGHz transceiver routes."""
from __future__ import annotations
import json
from unittest.mock import patch, MagicMock
import pytest
from utils.subghz import SubGhzCapture
@pytest.fixture
def auth_client(client):
"""Client with logged-in session."""
with client.session_transaction() as sess:
sess['logged_in'] = True
return client
class TestSubGhzRoutes:
"""Tests for /subghz/ endpoints."""
def test_get_status(self, client, auth_client):
"""GET /subghz/status returns manager status."""
with patch('routes.subghz.get_subghz_manager') as mock_get:
mock_mgr = MagicMock()
mock_mgr.get_status.return_value = {
'mode': 'idle',
'hackrf_available': True,
'rtl433_available': True,
'sweep_available': True,
}
mock_get.return_value = mock_mgr
response = auth_client.get('/subghz/status')
assert response.status_code == 200
data = response.get_json()
assert data['mode'] == 'idle'
assert data['hackrf_available'] is True
def test_get_presets(self, client, auth_client):
"""GET /subghz/presets returns frequency presets."""
response = auth_client.get('/subghz/presets')
assert response.status_code == 200
data = response.get_json()
assert 'presets' in data
assert '433.92 MHz' in data['presets']
assert 'sample_rates' in data
# ------ RECEIVE ------
def test_start_receive_success(self, client, auth_client):
with patch('routes.subghz.get_subghz_manager') as mock_get:
mock_mgr = MagicMock()
mock_mgr.start_receive.return_value = {
'status': 'started',
'frequency_hz': 433920000,
'sample_rate': 2000000,
}
mock_get.return_value = mock_mgr
response = auth_client.post('/subghz/receive/start', json={
'frequency_hz': 433920000,
'sample_rate': 2000000,
'lna_gain': 32,
'vga_gain': 20,
})
assert response.status_code == 200
data = response.get_json()
assert data['status'] == 'started'
def test_start_receive_missing_frequency(self, client, auth_client):
response = auth_client.post('/subghz/receive/start', json={})
assert response.status_code == 400
data = response.get_json()
assert data['status'] == 'error'
def test_start_receive_invalid_frequency(self, client, auth_client):
response = auth_client.post('/subghz/receive/start', json={
'frequency_hz': 'not_a_number',
})
assert response.status_code == 400
def test_stop_receive(self, client, auth_client):
with patch('routes.subghz.get_subghz_manager') as mock_get:
mock_mgr = MagicMock()
mock_mgr.stop_receive.return_value = {'status': 'stopped'}
mock_get.return_value = mock_mgr
response = auth_client.post('/subghz/receive/stop')
assert response.status_code == 200
def test_start_receive_trigger_params(self, client, auth_client):
with patch('routes.subghz.get_subghz_manager') as mock_get:
mock_mgr = MagicMock()
mock_mgr.start_receive.return_value = {'status': 'started'}
mock_get.return_value = mock_mgr
response = auth_client.post('/subghz/receive/start', json={
'frequency_hz': 433920000,
'trigger_enabled': True,
'trigger_pre_ms': 400,
'trigger_post_ms': 900,
})
assert response.status_code == 200
kwargs = mock_mgr.start_receive.call_args.kwargs
assert kwargs['trigger_enabled'] is True
assert kwargs['trigger_pre_ms'] == 400
assert kwargs['trigger_post_ms'] == 900
# ------ DECODE ------
def test_start_decode_success(self, client, auth_client):
with patch('routes.subghz.get_subghz_manager') as mock_get:
mock_mgr = MagicMock()
mock_mgr.start_decode.return_value = {
'status': 'started',
'frequency_hz': 433920000,
}
mock_get.return_value = mock_mgr
response = auth_client.post('/subghz/decode/start', json={
'frequency_hz': 433920000,
})
assert response.status_code == 200
data = response.get_json()
assert data['status'] == 'started'
mock_mgr.start_decode.assert_called_once()
kwargs = mock_mgr.start_decode.call_args.kwargs
assert kwargs['decode_profile'] == 'weather'
def test_start_decode_profile_all(self, client, auth_client):
with patch('routes.subghz.get_subghz_manager') as mock_get:
mock_mgr = MagicMock()
mock_mgr.start_decode.return_value = {
'status': 'started',
'frequency_hz': 433920000,
}
mock_get.return_value = mock_mgr
response = auth_client.post('/subghz/decode/start', json={
'frequency_hz': 433920000,
'decode_profile': 'all',
})
assert response.status_code == 200
kwargs = mock_mgr.start_decode.call_args.kwargs
assert kwargs['decode_profile'] == 'all'
def test_start_decode_missing_freq(self, client, auth_client):
response = auth_client.post('/subghz/decode/start', json={})
assert response.status_code == 400
def test_stop_decode(self, client, auth_client):
with patch('routes.subghz.get_subghz_manager') as mock_get:
mock_mgr = MagicMock()
mock_mgr.stop_decode.return_value = {'status': 'stopped'}
mock_get.return_value = mock_mgr
response = auth_client.post('/subghz/decode/stop')
assert response.status_code == 200
# ------ TRANSMIT ------
def test_transmit_missing_capture_id(self, client, auth_client):
response = auth_client.post('/subghz/transmit', json={})
assert response.status_code == 400
data = response.get_json()
assert 'capture_id is required' in data['message']
def test_transmit_invalid_capture_id(self, client, auth_client):
response = auth_client.post('/subghz/transmit', json={
'capture_id': '../../../etc/passwd',
})
assert response.status_code == 400
data = response.get_json()
assert 'Invalid' in data['message']
def test_transmit_success(self, client, auth_client):
with patch('routes.subghz.get_subghz_manager') as mock_get:
mock_mgr = MagicMock()
mock_mgr.transmit.return_value = {
'status': 'transmitting',
'capture_id': 'abc123',
'frequency_hz': 433920000,
'max_duration': 10,
}
mock_get.return_value = mock_mgr
response = auth_client.post('/subghz/transmit', json={
'capture_id': 'abc123',
'tx_gain': 20,
'max_duration': 10,
})
assert response.status_code == 200
data = response.get_json()
assert data['status'] == 'transmitting'
kwargs = mock_mgr.transmit.call_args.kwargs
assert kwargs['start_seconds'] is None
assert kwargs['duration_seconds'] is None
def test_transmit_segment_params(self, client, auth_client):
with patch('routes.subghz.get_subghz_manager') as mock_get:
mock_mgr = MagicMock()
mock_mgr.transmit.return_value = {
'status': 'transmitting',
'capture_id': 'abc123',
'frequency_hz': 433920000,
'max_duration': 10,
'segment': {'start_seconds': 0.1, 'duration_seconds': 0.4},
}
mock_get.return_value = mock_mgr
response = auth_client.post('/subghz/transmit', json={
'capture_id': 'abc123',
'tx_gain': 20,
'max_duration': 10,
'start_seconds': 0.1,
'duration_seconds': 0.4,
})
assert response.status_code == 200
kwargs = mock_mgr.transmit.call_args.kwargs
assert kwargs['start_seconds'] == 0.1
assert kwargs['duration_seconds'] == 0.4
def test_transmit_invalid_segment_param(self, client, auth_client):
response = auth_client.post('/subghz/transmit', json={
'capture_id': 'abc123',
'start_seconds': 'not-a-number',
})
assert response.status_code == 400
def test_stop_transmit(self, client, auth_client):
with patch('routes.subghz.get_subghz_manager') as mock_get:
mock_mgr = MagicMock()
mock_mgr.stop_transmit.return_value = {'status': 'stopped'}
mock_get.return_value = mock_mgr
response = auth_client.post('/subghz/transmit/stop')
assert response.status_code == 200
# ------ SWEEP ------
def test_start_sweep_success(self, client, auth_client):
with patch('routes.subghz.get_subghz_manager') as mock_get:
mock_mgr = MagicMock()
mock_mgr.start_sweep.return_value = {
'status': 'started',
'freq_start_mhz': 300,
'freq_end_mhz': 928,
}
mock_get.return_value = mock_mgr
response = auth_client.post('/subghz/sweep/start', json={
'freq_start_mhz': 300,
'freq_end_mhz': 928,
})
assert response.status_code == 200
data = response.get_json()
assert data['status'] == 'started'
def test_start_sweep_invalid_range(self, client, auth_client):
response = auth_client.post('/subghz/sweep/start', json={
'freq_start_mhz': 928,
'freq_end_mhz': 300, # start > end
})
assert response.status_code == 400
def test_stop_sweep(self, client, auth_client):
with patch('routes.subghz.get_subghz_manager') as mock_get:
mock_mgr = MagicMock()
mock_mgr.stop_sweep.return_value = {'status': 'stopped'}
mock_get.return_value = mock_mgr
response = auth_client.post('/subghz/sweep/stop')
assert response.status_code == 200
# ------ CAPTURES ------
def test_list_captures_empty(self, client, auth_client):
with patch('routes.subghz.get_subghz_manager') as mock_get:
mock_mgr = MagicMock()
mock_mgr.list_captures.return_value = []
mock_get.return_value = mock_mgr
response = auth_client.get('/subghz/captures')
assert response.status_code == 200
data = response.get_json()
assert data['count'] == 0
assert data['captures'] == []
def test_list_captures_with_data(self, client, auth_client):
with patch('routes.subghz.get_subghz_manager') as mock_get:
mock_mgr = MagicMock()
cap = SubGhzCapture(
capture_id='cap1',
filename='test.iq',
frequency_hz=433920000,
sample_rate=2000000,
lna_gain=32,
vga_gain=20,
timestamp='2026-01-01T00:00:00Z',
)
mock_mgr.list_captures.return_value = [cap]
mock_get.return_value = mock_mgr
response = auth_client.get('/subghz/captures')
assert response.status_code == 200
data = response.get_json()
assert data['count'] == 1
assert data['captures'][0]['id'] == 'cap1'
def test_get_capture(self, client, auth_client):
with patch('routes.subghz.get_subghz_manager') as mock_get:
mock_mgr = MagicMock()
cap = SubGhzCapture(
capture_id='cap2',
filename='test2.iq',
frequency_hz=315000000,
sample_rate=2000000,
lna_gain=32,
vga_gain=20,
timestamp='2026-01-01T00:00:00Z',
)
mock_mgr.get_capture.return_value = cap
mock_get.return_value = mock_mgr
response = auth_client.get('/subghz/captures/cap2')
assert response.status_code == 200
data = response.get_json()
assert data['capture']['frequency_hz'] == 315000000
def test_get_capture_not_found(self, client, auth_client):
with patch('routes.subghz.get_subghz_manager') as mock_get:
mock_mgr = MagicMock()
mock_mgr.get_capture.return_value = None
mock_get.return_value = mock_mgr
response = auth_client.get('/subghz/captures/nonexistent')
assert response.status_code == 404
def test_get_capture_invalid_id(self, client, auth_client):
response = auth_client.get('/subghz/captures/bad-id!')
assert response.status_code == 400
def test_delete_capture(self, client, auth_client):
with patch('routes.subghz.get_subghz_manager') as mock_get:
mock_mgr = MagicMock()
mock_mgr.delete_capture.return_value = True
mock_get.return_value = mock_mgr
response = auth_client.delete('/subghz/captures/cap1')
assert response.status_code == 200
data = response.get_json()
assert data['status'] == 'deleted'
def test_trim_capture_success(self, client, auth_client):
with patch('routes.subghz.get_subghz_manager') as mock_get:
mock_mgr = MagicMock()
mock_mgr.trim_capture.return_value = {
'status': 'ok',
'capture': {
'id': 'trim_new',
'filename': 'trimmed.iq',
'frequency_hz': 433920000,
'sample_rate': 2000000,
},
}
mock_get.return_value = mock_mgr
response = auth_client.post('/subghz/captures/cap1/trim', json={
'start_seconds': 0.1,
'duration_seconds': 0.3,
})
assert response.status_code == 200
kwargs = mock_mgr.trim_capture.call_args.kwargs
assert kwargs['capture_id'] == 'cap1'
assert kwargs['start_seconds'] == 0.1
assert kwargs['duration_seconds'] == 0.3
def test_trim_capture_invalid_param(self, client, auth_client):
response = auth_client.post('/subghz/captures/cap1/trim', json={
'start_seconds': 'bad',
})
assert response.status_code == 400
def test_delete_capture_not_found(self, client, auth_client):
with patch('routes.subghz.get_subghz_manager') as mock_get:
mock_mgr = MagicMock()
mock_mgr.delete_capture.return_value = False
mock_get.return_value = mock_mgr
response = auth_client.delete('/subghz/captures/nonexistent')
assert response.status_code == 404
def test_update_capture_label(self, client, auth_client):
with patch('routes.subghz.get_subghz_manager') as mock_get:
mock_mgr = MagicMock()
mock_mgr.update_capture_label.return_value = True
mock_get.return_value = mock_mgr
response = auth_client.patch('/subghz/captures/cap1', json={
'label': 'Garage Remote',
})
assert response.status_code == 200
data = response.get_json()
assert data['label'] == 'Garage Remote'
def test_update_capture_label_too_long(self, client, auth_client):
response = auth_client.patch('/subghz/captures/cap1', json={
'label': 'x' * 200,
})
assert response.status_code == 400
def test_update_capture_not_found(self, client, auth_client):
with patch('routes.subghz.get_subghz_manager') as mock_get:
mock_mgr = MagicMock()
mock_mgr.update_capture_label.return_value = False
mock_get.return_value = mock_mgr
response = auth_client.patch('/subghz/captures/nonexistent', json={
'label': 'test',
})
assert response.status_code == 404
# ------ SSE STREAM ------
def test_stream_endpoint(self, client, auth_client):
"""GET /subghz/stream returns SSE response."""
with patch('routes.subghz.sse_stream', return_value=iter([])):
response = auth_client.get('/subghz/stream')
assert response.status_code == 200
assert response.content_type.startswith('text/event-stream')
+168
View File
@@ -0,0 +1,168 @@
"""Tests for the waterfall FFT pipeline."""
import struct
import numpy as np
import pytest
from utils.waterfall_fft import (
build_binary_frame,
compute_power_spectrum,
cu8_to_complex,
quantize_to_uint8,
)
class TestCu8ToComplex:
"""Tests for cu8_to_complex conversion."""
def test_zero_maps_to_negative_one(self):
# I=0, Q=0 -> approximately -1 - 1j
result = cu8_to_complex(bytes([0, 0]))
assert result[0].real == pytest.approx(-1.0, abs=0.01)
assert result[0].imag == pytest.approx(-1.0, abs=0.01)
def test_255_maps_to_positive_one(self):
# I=255, Q=255 -> approximately +1 + 1j
result = cu8_to_complex(bytes([255, 255]))
assert result[0].real == pytest.approx(1.0, abs=0.01)
assert result[0].imag == pytest.approx(1.0, abs=0.01)
def test_128_maps_to_near_zero(self):
# I=128, Q=128 -> approximately 0 + 0j
result = cu8_to_complex(bytes([128, 128]))
assert abs(result[0].real) < 0.01
assert abs(result[0].imag) < 0.01
def test_output_length(self):
raw = bytes(range(256)) * 4 # 1024 bytes -> 512 complex samples
result = cu8_to_complex(raw)
assert len(result) == 512
def test_output_dtype(self):
result = cu8_to_complex(bytes([100, 200, 50, 150]))
assert result.dtype == np.complex64 or np.issubdtype(result.dtype, np.complexfloating)
class TestComputePowerSpectrum:
"""Tests for compute_power_spectrum."""
def test_output_length_matches_fft_size(self):
samples = np.zeros(4096, dtype=np.complex64)
result = compute_power_spectrum(samples, fft_size=1024, avg_count=4)
assert len(result) == 1024
def test_output_dtype(self):
samples = np.zeros(4096, dtype=np.complex64)
result = compute_power_spectrum(samples, fft_size=1024, avg_count=4)
assert result.dtype == np.float32
def test_pure_tone_peak_at_correct_bin(self):
fft_size = 1024
avg_count = 4
n = fft_size * avg_count
# Generate a pure tone at bin 256 (1/4 of sample rate)
t = np.arange(n, dtype=np.float32)
freq_bin = 256
tone = np.exp(2j * np.pi * freq_bin / fft_size * t).astype(np.complex64)
result = compute_power_spectrum(tone, fft_size=fft_size, avg_count=avg_count)
# After fftshift, bin 256 maps to index 256 + 512 = 768
peak_idx = np.argmax(result)
expected_idx = fft_size // 2 + freq_bin
assert peak_idx == expected_idx
def test_insufficient_samples_returns_default(self):
# Not enough samples for even one segment
samples = np.zeros(100, dtype=np.complex64)
result = compute_power_spectrum(samples, fft_size=1024, avg_count=4)
assert len(result) == 1024
assert np.all(result == -100.0)
def test_partial_avg_count(self):
# Only enough for 2 of 4 requested averages
fft_size = 1024
samples = np.random.randn(2048).astype(np.float32).view(np.complex64)
result = compute_power_spectrum(samples, fft_size=fft_size, avg_count=4)
assert len(result) == fft_size
# Should still return valid dB values (not -100 default)
assert np.any(result != -100.0)
class TestQuantizeToUint8:
"""Tests for quantize_to_uint8."""
def test_db_min_maps_to_zero(self):
power = np.array([-90.0], dtype=np.float32)
result = quantize_to_uint8(power, db_min=-90, db_max=-20)
assert result[0] == 0
def test_db_max_maps_to_255(self):
power = np.array([-20.0], dtype=np.float32)
result = quantize_to_uint8(power, db_min=-90, db_max=-20)
assert result[0] == 255
def test_below_min_clamped_to_zero(self):
power = np.array([-120.0], dtype=np.float32)
result = quantize_to_uint8(power, db_min=-90, db_max=-20)
assert result[0] == 0
def test_above_max_clamped_to_255(self):
power = np.array([0.0], dtype=np.float32)
result = quantize_to_uint8(power, db_min=-90, db_max=-20)
assert result[0] == 255
def test_midpoint(self):
# Midpoint between -90 and -20 is -55 -> ~127-128
power = np.array([-55.0], dtype=np.float32)
result = quantize_to_uint8(power, db_min=-90, db_max=-20)
assert 125 <= result[0] <= 130
def test_output_length(self):
power = np.random.randn(1024).astype(np.float32) * 30 - 60
result = quantize_to_uint8(power)
assert len(result) == 1024
class TestBuildBinaryFrame:
"""Tests for build_binary_frame."""
def test_header_values(self):
bins = bytes([128] * 1024)
frame = build_binary_frame(100.0, 102.0, bins)
msg_type = frame[0]
start_freq, end_freq = struct.unpack_from('<ff', frame, 1)
bin_count = struct.unpack_from('<H', frame, 9)[0]
assert msg_type == 0x01
assert start_freq == pytest.approx(100.0, abs=0.01)
assert end_freq == pytest.approx(102.0, abs=0.01)
assert bin_count == 1024
def test_total_length(self):
bin_count = 1024
bins = bytes([0] * bin_count)
frame = build_binary_frame(88.0, 108.0, bins)
assert len(frame) == 11 + bin_count
def test_bins_in_payload(self):
bins = bytes(range(256))
frame = build_binary_frame(0.0, 1.0, bins)
payload = frame[11:]
assert payload == bins
def test_round_trip(self):
start = 433.0
end = 435.0
bins = bytes([i % 256 for i in range(2048)])
frame = build_binary_frame(start, end, bins)
# Parse it back
msg_type = frame[0]
parsed_start, parsed_end = struct.unpack_from('<ff', frame, 1)
parsed_count = struct.unpack_from('<H', frame, 9)[0]
parsed_bins = frame[11:]
assert msg_type == 0x01
assert parsed_start == pytest.approx(start, abs=0.01)
assert parsed_end == pytest.approx(end, abs=0.01)
assert parsed_count == 2048
assert parsed_bins == bins
+643
View File
@@ -0,0 +1,643 @@
"""Tests for WeatherSatDecoder class.
Covers WeatherSatDecoder methods, subprocess management, progress callbacks,
and image handling.
"""
from __future__ import annotations
import os
import tempfile
import threading
import time
from datetime import datetime, timezone
from pathlib import Path
from unittest.mock import patch, MagicMock, call, mock_open
import pytest
from utils.weather_sat import (
WeatherSatDecoder,
WeatherSatImage,
CaptureProgress,
WEATHER_SATELLITES,
get_weather_sat_decoder,
is_weather_sat_available,
)
class TestWeatherSatDecoder:
"""Tests for WeatherSatDecoder class."""
def test_decoder_initialization(self):
"""Decoder should initialize with default output directory."""
with patch('shutil.which', return_value='/usr/bin/satdump'):
decoder = WeatherSatDecoder()
assert decoder.is_running is False
assert decoder.decoder_available == 'satdump'
assert decoder.current_satellite == ''
assert decoder.current_frequency == 0.0
def test_decoder_initialization_no_satdump(self):
"""Decoder should detect when SatDump is unavailable."""
with patch('shutil.which', return_value=None):
decoder = WeatherSatDecoder()
assert decoder.decoder_available is None
def test_decoder_custom_output_dir(self):
"""Decoder should accept custom output directory."""
with patch('shutil.which', return_value='/usr/bin/satdump'):
custom_dir = '/tmp/custom_output'
decoder = WeatherSatDecoder(output_dir=custom_dir)
assert decoder._output_dir == Path(custom_dir)
def test_set_callback(self):
"""Decoder should accept progress callback."""
with patch('shutil.which', return_value='/usr/bin/satdump'):
decoder = WeatherSatDecoder()
callback = MagicMock()
decoder.set_callback(callback)
assert decoder._callback == callback
def test_set_on_complete(self):
"""Decoder should accept on_complete callback."""
with patch('shutil.which', return_value='/usr/bin/satdump'):
decoder = WeatherSatDecoder()
callback = MagicMock()
decoder.set_on_complete(callback)
assert decoder._on_complete_callback == callback
def test_start_no_decoder(self):
"""start() should fail when no decoder available."""
with patch('shutil.which', return_value=None):
decoder = WeatherSatDecoder()
callback = MagicMock()
decoder.set_callback(callback)
success = decoder.start(satellite='NOAA-18', device_index=0, gain=40.0)
assert success is False
callback.assert_called()
progress = callback.call_args[0][0]
assert progress.status == 'error'
assert 'SatDump' in progress.message
def test_start_invalid_satellite(self):
"""start() should fail with invalid satellite."""
with patch('shutil.which', return_value='/usr/bin/satdump'):
decoder = WeatherSatDecoder()
callback = MagicMock()
decoder.set_callback(callback)
success = decoder.start(satellite='FAKE-SAT', device_index=0, gain=40.0)
assert success is False
callback.assert_called()
progress = callback.call_args[0][0]
assert progress.status == 'error'
assert 'Unknown satellite' in progress.message
@patch('subprocess.Popen')
@patch('pty.openpty')
@patch('utils.weather_sat.register_process')
def test_start_success(self, mock_register, mock_pty, mock_popen):
"""start() should successfully start SatDump."""
with patch('shutil.which', return_value='/usr/bin/satdump'), \
patch('utils.weather_sat.WeatherSatDecoder._resolve_device_id', return_value='0'):
mock_pty.return_value = (10, 11)
mock_process = MagicMock()
mock_process.poll.return_value = None
mock_popen.return_value = mock_process
decoder = WeatherSatDecoder()
callback = MagicMock()
decoder.set_callback(callback)
success = decoder.start(
satellite='NOAA-18',
device_index=0,
gain=40.0,
bias_t=True,
)
assert success is True
assert decoder.is_running is True
assert decoder.current_satellite == 'NOAA-18'
assert decoder.current_frequency == 137.9125
assert decoder.current_mode == 'APT'
assert decoder.device_index == 0
mock_popen.assert_called_once()
cmd = mock_popen.call_args[0][0]
assert cmd[0] == 'satdump'
assert 'live' in cmd
assert 'noaa_apt' in cmd
assert '--bias' in cmd
@patch('subprocess.Popen')
@patch('pty.openpty')
def test_start_already_running(self, mock_pty, mock_popen):
"""start() should return True when already running."""
with patch('shutil.which', return_value='/usr/bin/satdump'):
decoder = WeatherSatDecoder()
decoder._running = True
success = decoder.start(satellite='NOAA-18', device_index=0, gain=40.0)
assert success is True
mock_popen.assert_not_called()
@patch('subprocess.Popen')
@patch('pty.openpty')
def test_start_exception_handling(self, mock_pty, mock_popen):
"""start() should handle exceptions gracefully."""
with patch('shutil.which', return_value='/usr/bin/satdump'):
mock_pty.return_value = (10, 11)
mock_popen.side_effect = OSError('Device not found')
decoder = WeatherSatDecoder()
callback = MagicMock()
decoder.set_callback(callback)
success = decoder.start(satellite='NOAA-18', device_index=0, gain=40.0)
assert success is False
assert decoder.is_running is False
callback.assert_called()
progress = callback.call_args[0][0]
assert progress.status == 'error'
def test_start_from_file_no_decoder(self):
"""start_from_file() should fail when no decoder available."""
with patch('shutil.which', return_value=None):
decoder = WeatherSatDecoder()
callback = MagicMock()
decoder.set_callback(callback)
success = decoder.start_from_file(
satellite='NOAA-18',
input_file='data/test.wav',
)
assert success is False
callback.assert_called()
@patch('subprocess.Popen')
@patch('pty.openpty')
@patch('pathlib.Path.is_file', return_value=True)
@patch('pathlib.Path.resolve')
def test_start_from_file_success(self, mock_resolve, mock_is_file, mock_pty, mock_popen):
"""start_from_file() should successfully decode from file."""
with patch('shutil.which', return_value='/usr/bin/satdump'), \
patch('utils.weather_sat.register_process'):
# Mock path resolution
mock_path = MagicMock()
mock_path.is_relative_to.return_value = True
mock_path.suffix = '.wav'
mock_resolve.return_value = mock_path
mock_pty.return_value = (10, 11)
mock_process = MagicMock()
mock_popen.return_value = mock_process
decoder = WeatherSatDecoder()
callback = MagicMock()
decoder.set_callback(callback)
success = decoder.start_from_file(
satellite='NOAA-18',
input_file='data/test.wav',
sample_rate=1000000,
)
assert success is True
assert decoder.is_running is True
assert decoder.current_satellite == 'NOAA-18'
mock_popen.assert_called_once()
cmd = mock_popen.call_args[0][0]
assert cmd[0] == 'satdump'
assert 'noaa_apt' in cmd
assert 'audio_wav' in cmd
assert '--samplerate' in cmd
@patch('pathlib.Path.resolve')
def test_start_from_file_path_traversal(self, mock_resolve):
"""start_from_file() should block path traversal."""
with patch('shutil.which', return_value='/usr/bin/satdump'):
# Mock path outside allowed directory
mock_path = MagicMock()
mock_path.is_relative_to.return_value = False
mock_resolve.return_value = mock_path
decoder = WeatherSatDecoder()
callback = MagicMock()
decoder.set_callback(callback)
success = decoder.start_from_file(
satellite='NOAA-18',
input_file='/etc/passwd',
)
assert success is False
callback.assert_called()
progress = callback.call_args[0][0]
assert 'data/ directory' in progress.message
@patch('pathlib.Path.is_file', return_value=False)
@patch('pathlib.Path.resolve')
def test_start_from_file_not_found(self, mock_resolve, mock_is_file):
"""start_from_file() should fail when file not found."""
with patch('shutil.which', return_value='/usr/bin/satdump'):
mock_path = MagicMock()
mock_path.is_relative_to.return_value = True
mock_resolve.return_value = mock_path
decoder = WeatherSatDecoder()
callback = MagicMock()
decoder.set_callback(callback)
success = decoder.start_from_file(
satellite='NOAA-18',
input_file='data/missing.wav',
)
assert success is False
callback.assert_called()
progress = callback.call_args[0][0]
assert 'not found' in progress.message.lower()
def test_stop_not_running(self):
"""stop() should be safe when not running."""
with patch('shutil.which', return_value='/usr/bin/satdump'):
decoder = WeatherSatDecoder()
decoder.stop() # Should not raise
@patch('utils.weather_sat.safe_terminate')
def test_stop_running(self, mock_terminate):
"""stop() should terminate process."""
with patch('shutil.which', return_value='/usr/bin/satdump'):
decoder = WeatherSatDecoder()
mock_process = MagicMock()
decoder._process = mock_process
decoder._running = True
decoder._pty_master_fd = 10
with patch('os.close') as mock_close:
decoder.stop()
assert decoder._running is False
mock_terminate.assert_called_once_with(mock_process)
mock_close.assert_called_once_with(10)
def test_get_images_empty(self):
"""get_images() should return empty list initially."""
with patch('shutil.which', return_value='/usr/bin/satdump'):
decoder = WeatherSatDecoder()
images = decoder.get_images()
assert images == []
@patch('pathlib.Path.glob')
@patch('pathlib.Path.stat')
def test_get_images_scans_directory(self, mock_stat, mock_glob):
"""get_images() should scan output directory."""
with patch('shutil.which', return_value='/usr/bin/satdump'):
decoder = WeatherSatDecoder()
# Mock image files
mock_file = MagicMock()
mock_file.name = 'NOAA-18_test.png'
mock_file.stat.return_value.st_size = 10000
mock_file.stat.return_value.st_mtime = time.time()
mock_glob.return_value = [mock_file]
images = decoder.get_images()
assert len(images) == 1
assert images[0].filename == 'NOAA-18_test.png'
assert images[0].satellite == 'NOAA-18'
def test_delete_image_success(self):
"""delete_image() should delete file."""
with patch('shutil.which', return_value='/usr/bin/satdump'):
decoder = WeatherSatDecoder()
with patch('pathlib.Path.exists', return_value=True), \
patch('pathlib.Path.unlink') as mock_unlink:
result = decoder.delete_image('test.png')
assert result is True
mock_unlink.assert_called_once()
def test_delete_image_not_found(self):
"""delete_image() should return False for non-existent file."""
with patch('shutil.which', return_value='/usr/bin/satdump'):
decoder = WeatherSatDecoder()
with patch('pathlib.Path.exists', return_value=False):
result = decoder.delete_image('missing.png')
assert result is False
def test_delete_all_images(self):
"""delete_all_images() should delete all images."""
with patch('shutil.which', return_value='/usr/bin/satdump'):
decoder = WeatherSatDecoder()
mock_files = [MagicMock() for _ in range(3)]
with patch('pathlib.Path.glob', return_value=mock_files):
count = decoder.delete_all_images()
assert count == 3
for f in mock_files:
f.unlink.assert_called_once()
def test_get_status_idle(self):
"""get_status() should return idle status."""
with patch('shutil.which', return_value='/usr/bin/satdump'):
decoder = WeatherSatDecoder()
status = decoder.get_status()
assert status['available'] is True
assert status['decoder'] == 'satdump'
assert status['running'] is False
assert status['satellite'] == ''
def test_get_status_running(self):
"""get_status() should return running status."""
with patch('shutil.which', return_value='/usr/bin/satdump'):
decoder = WeatherSatDecoder()
decoder._running = True
decoder._current_satellite = 'NOAA-18'
decoder._current_frequency = 137.9125
decoder._current_mode = 'APT'
decoder._capture_start_time = time.time() - 60
status = decoder.get_status()
assert status['running'] is True
assert status['satellite'] == 'NOAA-18'
assert status['frequency'] == 137.9125
assert status['mode'] == 'APT'
assert status['elapsed_seconds'] >= 60
def test_classify_log_type_error(self):
"""_classify_log_type() should detect errors."""
assert WeatherSatDecoder._classify_log_type('(E) Error occurred') == 'error'
assert WeatherSatDecoder._classify_log_type('Failed to open device') == 'error'
def test_classify_log_type_progress(self):
"""_classify_log_type() should detect progress."""
assert WeatherSatDecoder._classify_log_type('Progress: 50%') == 'progress'
def test_classify_log_type_save(self):
"""_classify_log_type() should detect save events."""
assert WeatherSatDecoder._classify_log_type('Saved image: test.png') == 'save'
assert WeatherSatDecoder._classify_log_type('Writing output file') == 'save'
def test_classify_log_type_signal(self):
"""_classify_log_type() should detect signal events."""
assert WeatherSatDecoder._classify_log_type('Signal detected') == 'signal'
assert WeatherSatDecoder._classify_log_type('Lock acquired') == 'signal'
def test_classify_log_type_warning(self):
"""_classify_log_type() should detect warnings."""
assert WeatherSatDecoder._classify_log_type('(W) Low signal quality') == 'warning'
def test_classify_log_type_debug(self):
"""_classify_log_type() should detect debug messages."""
assert WeatherSatDecoder._classify_log_type('(D) Debug info') == 'debug'
@patch('subprocess.run')
def test_resolve_device_id_success(self, mock_run):
"""_resolve_device_id() should extract serial from rtl_test."""
mock_result = MagicMock()
mock_result.stdout = 'Found 1 device(s):\n 0: RTLSDRBlog, SN: 00004000'
mock_result.stderr = ''
mock_run.return_value = mock_result
serial = WeatherSatDecoder._resolve_device_id(0)
assert serial == '00004000'
mock_run.assert_called_once()
@patch('subprocess.run')
def test_resolve_device_id_fallback(self, mock_run):
"""_resolve_device_id() should fall back to index string."""
mock_run.side_effect = FileNotFoundError
serial = WeatherSatDecoder._resolve_device_id(0)
assert serial == '0'
def test_parse_product_name_rgb(self):
"""_parse_product_name() should identify RGB composite."""
with patch('shutil.which', return_value='/usr/bin/satdump'):
decoder = WeatherSatDecoder()
product = decoder._parse_product_name(Path('/tmp/output/rgb_composite.png'))
assert product == 'RGB Composite'
def test_parse_product_name_thermal(self):
"""_parse_product_name() should identify thermal imagery."""
with patch('shutil.which', return_value='/usr/bin/satdump'):
decoder = WeatherSatDecoder()
product = decoder._parse_product_name(Path('/tmp/output/thermal_image.png'))
assert product == 'Thermal'
def test_parse_product_name_channel(self):
"""_parse_product_name() should identify channel images."""
with patch('shutil.which', return_value='/usr/bin/satdump'):
decoder = WeatherSatDecoder()
product = decoder._parse_product_name(Path('/tmp/output/channel_3.png'))
assert product == 'Channel 3'
def test_parse_product_name_unknown(self):
"""_parse_product_name() should return stem for unknown products."""
with patch('shutil.which', return_value='/usr/bin/satdump'):
decoder = WeatherSatDecoder()
product = decoder._parse_product_name(Path('/tmp/output/unknown_image.png'))
assert product == 'unknown_image'
def test_emit_progress(self):
"""_emit_progress() should call callback."""
with patch('shutil.which', return_value='/usr/bin/satdump'):
decoder = WeatherSatDecoder()
callback = MagicMock()
decoder.set_callback(callback)
progress = CaptureProgress(status='capturing', message='Test')
decoder._emit_progress(progress)
callback.assert_called_once_with(progress)
def test_emit_progress_no_callback(self):
"""_emit_progress() should handle missing callback."""
with patch('shutil.which', return_value='/usr/bin/satdump'):
decoder = WeatherSatDecoder()
progress = CaptureProgress(status='capturing', message='Test')
decoder._emit_progress(progress) # Should not raise
def test_emit_progress_callback_exception(self):
"""_emit_progress() should handle callback exceptions."""
with patch('shutil.which', return_value='/usr/bin/satdump'):
decoder = WeatherSatDecoder()
callback = MagicMock(side_effect=Exception('Callback error'))
decoder.set_callback(callback)
progress = CaptureProgress(status='capturing', message='Test')
decoder._emit_progress(progress) # Should not raise
class TestWeatherSatImage:
"""Tests for WeatherSatImage dataclass."""
def test_to_dict(self):
"""WeatherSatImage.to_dict() should serialize correctly."""
image = WeatherSatImage(
filename='test.png',
path=Path('/tmp/test.png'),
satellite='NOAA-18',
mode='APT',
timestamp=datetime(2024, 1, 1, 12, 0, 0, tzinfo=timezone.utc),
frequency=137.9125,
size_bytes=12345,
product='RGB Composite',
)
data = image.to_dict()
assert data['filename'] == 'test.png'
assert data['satellite'] == 'NOAA-18'
assert data['mode'] == 'APT'
assert data['timestamp'] == '2024-01-01T12:00:00+00:00'
assert data['frequency'] == 137.9125
assert data['size_bytes'] == 12345
assert data['product'] == 'RGB Composite'
assert data['url'] == '/weather-sat/images/test.png'
class TestCaptureProgress:
"""Tests for CaptureProgress dataclass."""
def test_to_dict_minimal(self):
"""CaptureProgress.to_dict() with minimal fields."""
progress = CaptureProgress(status='idle')
data = progress.to_dict()
assert data['type'] == 'weather_sat_progress'
assert data['status'] == 'idle'
assert data['satellite'] == ''
assert data['message'] == ''
assert data['progress'] == 0
def test_to_dict_complete(self):
"""CaptureProgress.to_dict() with all fields."""
image = WeatherSatImage(
filename='test.png',
path=Path('/tmp/test.png'),
satellite='NOAA-18',
mode='APT',
timestamp=datetime.now(timezone.utc),
frequency=137.9125,
)
progress = CaptureProgress(
status='complete',
satellite='NOAA-18',
frequency=137.9125,
mode='APT',
message='Capture complete',
progress_percent=100,
elapsed_seconds=600,
image=image,
log_type='info',
capture_phase='complete',
)
data = progress.to_dict()
assert data['status'] == 'complete'
assert data['satellite'] == 'NOAA-18'
assert data['frequency'] == 137.9125
assert data['mode'] == 'APT'
assert data['message'] == 'Capture complete'
assert data['progress'] == 100
assert data['elapsed_seconds'] == 600
assert 'image' in data
assert data['log_type'] == 'info'
assert data['capture_phase'] == 'complete'
class TestGlobalFunctions:
"""Tests for global utility functions."""
def test_get_weather_sat_decoder_singleton(self):
"""get_weather_sat_decoder() should return singleton."""
with patch('shutil.which', return_value='/usr/bin/satdump'):
import utils.weather_sat as mod
old = mod._decoder
mod._decoder = None
try:
decoder1 = get_weather_sat_decoder()
decoder2 = get_weather_sat_decoder()
assert decoder1 is decoder2
finally:
mod._decoder = old
def test_is_weather_sat_available_true(self):
"""is_weather_sat_available() should return True when available."""
with patch('shutil.which', return_value='/usr/bin/satdump'):
import utils.weather_sat as mod
old = mod._decoder
mod._decoder = None
try:
assert is_weather_sat_available() is True
finally:
mod._decoder = old
def test_is_weather_sat_available_false(self):
"""is_weather_sat_available() should return False when unavailable."""
with patch('shutil.which', return_value=None):
import utils.weather_sat as mod
old = mod._decoder
mod._decoder = None
try:
assert is_weather_sat_available() is False
finally:
mod._decoder = old
class TestWeatherSatellitesConstant:
"""Tests for WEATHER_SATELLITES constant."""
def test_weather_satellites_structure(self):
"""WEATHER_SATELLITES should have correct structure."""
assert 'NOAA-18' in WEATHER_SATELLITES
sat = WEATHER_SATELLITES['NOAA-18']
assert 'name' in sat
assert 'frequency' in sat
assert 'mode' in sat
assert 'pipeline' in sat
assert 'tle_key' in sat
assert 'description' in sat
assert 'active' in sat
def test_noaa_satellites(self):
"""NOAA satellites should have correct frequencies."""
assert WEATHER_SATELLITES['NOAA-15']['frequency'] == 137.620
assert WEATHER_SATELLITES['NOAA-18']['frequency'] == 137.9125
assert WEATHER_SATELLITES['NOAA-19']['frequency'] == 137.100
def test_meteor_satellite(self):
"""Meteor satellite should use LRPT mode."""
meteor = WEATHER_SATELLITES['METEOR-M2-3']
assert meteor['mode'] == 'LRPT'
assert meteor['frequency'] == 137.900
assert meteor['pipeline'] == 'meteor_m2-x_lrpt'
+675
View File
@@ -0,0 +1,675 @@
"""Tests for weather satellite pass prediction.
Covers predict_passes() function, TLE handling, trajectory computation,
and ground track generation.
"""
from __future__ import annotations
from datetime import datetime, timezone, timedelta
from unittest.mock import patch, MagicMock
import pytest
from utils.weather_sat_predict import predict_passes
class TestPredictPasses:
"""Tests for predict_passes() function."""
@patch('utils.weather_sat_predict.load')
@patch('utils.weather_sat_predict.TLE_SATELLITES')
def test_predict_passes_no_tle_data(self, mock_tle, mock_load):
"""predict_passes() should handle missing TLE data."""
mock_tle.get.return_value = None
mock_ts = MagicMock()
mock_ts.now.return_value = MagicMock()
mock_ts.utc.return_value = MagicMock()
mock_load.timescale.return_value = mock_ts
passes = predict_passes(lat=51.5, lon=-0.1, hours=24, min_elevation=15)
assert passes == []
@patch('utils.weather_sat_predict.load')
@patch('utils.weather_sat_predict.TLE_SATELLITES')
@patch('utils.weather_sat_predict.wgs84')
@patch('utils.weather_sat_predict.EarthSatellite')
@patch('utils.weather_sat_predict.find_discrete')
def test_predict_passes_basic(self, mock_find, mock_sat, mock_wgs84, mock_tle, mock_load):
"""predict_passes() should predict basic passes."""
# Mock timescale
mock_ts = MagicMock()
now = datetime(2024, 1, 1, 12, 0, 0, tzinfo=timezone.utc)
mock_now = MagicMock()
mock_now.utc_datetime.return_value = now
mock_ts.now.return_value = mock_now
mock_ts.utc.side_effect = lambda dt: self._mock_time(dt)
mock_load.timescale.return_value = mock_ts
# Mock TLE data
mock_tle.get.return_value = (
'NOAA-18',
'1 28654U 05018A 24001.50000000 .00000000 00000-0 00000-0 0 9999',
'2 28654 98.7000 100.0000 0001000 0.0000 0.0000 14.12500000000000'
)
# Mock observer
mock_observer = MagicMock()
mock_wgs84.latlon.return_value = mock_observer
# Mock satellite
mock_satellite_obj = MagicMock()
mock_sat.return_value = mock_satellite_obj
# Mock pass detection - one pass
rise_time = MagicMock()
rise_time.utc_datetime.return_value = now + timedelta(hours=2)
set_time = MagicMock()
set_time.utc_datetime.return_value = now + timedelta(hours=2, minutes=15)
mock_find.return_value = ([rise_time, set_time], [True, False])
# Mock topocentric calculations
def mock_topocentric(t):
topo = MagicMock()
alt = MagicMock()
alt.degrees = 45.0
az = MagicMock()
az.degrees = 180.0
topo.altaz.return_value = (alt, az, MagicMock())
return topo
mock_diff = MagicMock()
mock_diff.at.side_effect = mock_topocentric
mock_satellite_obj.__sub__.return_value = mock_diff
passes = predict_passes(lat=51.5, lon=-0.1, hours=24, min_elevation=15)
assert len(passes) == 1
pass_data = passes[0]
assert pass_data['satellite'] == 'NOAA-18'
assert pass_data['name'] == 'NOAA 18'
assert pass_data['frequency'] == 137.9125
assert pass_data['mode'] == 'APT'
assert 'maxEl' in pass_data
assert 'duration' in pass_data
assert 'quality' in pass_data
@patch('utils.weather_sat_predict.load')
@patch('utils.weather_sat_predict.TLE_SATELLITES')
@patch('utils.weather_sat_predict.wgs84')
@patch('utils.weather_sat_predict.EarthSatellite')
@patch('utils.weather_sat_predict.find_discrete')
def test_predict_passes_below_min_elevation(
self, mock_find, mock_sat, mock_wgs84, mock_tle, mock_load
):
"""predict_passes() should filter passes below min elevation."""
mock_ts = MagicMock()
now = datetime(2024, 1, 1, 12, 0, 0, tzinfo=timezone.utc)
mock_now = MagicMock()
mock_now.utc_datetime.return_value = now
mock_ts.now.return_value = mock_now
mock_ts.utc.side_effect = lambda dt: self._mock_time(dt)
mock_load.timescale.return_value = mock_ts
mock_tle.get.return_value = (
'NOAA-18',
'1 28654U 05018A 24001.50000000 .00000000 00000-0 00000-0 0 9999',
'2 28654 98.7000 100.0000 0001000 0.0000 0.0000 14.12500000000000'
)
mock_observer = MagicMock()
mock_wgs84.latlon.return_value = mock_observer
mock_satellite_obj = MagicMock()
mock_sat.return_value = mock_satellite_obj
rise_time = MagicMock()
rise_time.utc_datetime.return_value = now + timedelta(hours=2)
set_time = MagicMock()
set_time.utc_datetime.return_value = now + timedelta(hours=2, minutes=15)
mock_find.return_value = ([rise_time, set_time], [True, False])
# Mock low elevation pass
def mock_topocentric(t):
topo = MagicMock()
alt = MagicMock()
alt.degrees = 10.0 # Below min_elevation of 15
az = MagicMock()
az.degrees = 180.0
topo.altaz.return_value = (alt, az, MagicMock())
return topo
mock_diff = MagicMock()
mock_diff.at.side_effect = mock_topocentric
mock_satellite_obj.__sub__.return_value = mock_diff
passes = predict_passes(lat=51.5, lon=-0.1, hours=24, min_elevation=15)
assert len(passes) == 0
@patch('utils.weather_sat_predict.load')
@patch('utils.weather_sat_predict.TLE_SATELLITES')
@patch('utils.weather_sat_predict.wgs84')
@patch('utils.weather_sat_predict.EarthSatellite')
@patch('utils.weather_sat_predict.find_discrete')
def test_predict_passes_with_trajectory(
self, mock_find, mock_sat, mock_wgs84, mock_tle, mock_load
):
"""predict_passes() should include trajectory when requested."""
mock_ts = MagicMock()
now = datetime(2024, 1, 1, 12, 0, 0, tzinfo=timezone.utc)
mock_now = MagicMock()
mock_now.utc_datetime.return_value = now
mock_ts.now.return_value = mock_now
mock_ts.utc.side_effect = lambda dt: self._mock_time(dt)
mock_load.timescale.return_value = mock_ts
mock_tle.get.return_value = (
'NOAA-18',
'1 28654U 05018A 24001.50000000 .00000000 00000-0 00000-0 0 9999',
'2 28654 98.7000 100.0000 0001000 0.0000 0.0000 14.12500000000000'
)
mock_observer = MagicMock()
mock_wgs84.latlon.return_value = mock_observer
mock_satellite_obj = MagicMock()
mock_sat.return_value = mock_satellite_obj
rise_time = MagicMock()
rise_time.utc_datetime.return_value = now + timedelta(hours=2)
set_time = MagicMock()
set_time.utc_datetime.return_value = now + timedelta(hours=2, minutes=15)
mock_find.return_value = ([rise_time, set_time], [True, False])
def mock_topocentric(t):
topo = MagicMock()
alt = MagicMock()
alt.degrees = 45.0
az = MagicMock()
az.degrees = 180.0
topo.altaz.return_value = (alt, az, MagicMock())
return topo
mock_diff = MagicMock()
mock_diff.at.side_effect = mock_topocentric
mock_satellite_obj.__sub__.return_value = mock_diff
passes = predict_passes(
lat=51.5, lon=-0.1, hours=24, min_elevation=15, include_trajectory=True
)
assert len(passes) == 1
assert 'trajectory' in passes[0]
assert len(passes[0]['trajectory']) == 30
@patch('utils.weather_sat_predict.load')
@patch('utils.weather_sat_predict.TLE_SATELLITES')
@patch('utils.weather_sat_predict.wgs84')
@patch('utils.weather_sat_predict.EarthSatellite')
@patch('utils.weather_sat_predict.find_discrete')
def test_predict_passes_with_ground_track(
self, mock_find, mock_sat, mock_wgs84, mock_tle, mock_load
):
"""predict_passes() should include ground track when requested."""
mock_ts = MagicMock()
now = datetime(2024, 1, 1, 12, 0, 0, tzinfo=timezone.utc)
mock_now = MagicMock()
mock_now.utc_datetime.return_value = now
mock_ts.now.return_value = mock_now
mock_ts.utc.side_effect = lambda dt: self._mock_time(dt)
mock_load.timescale.return_value = mock_ts
mock_tle.get.return_value = (
'NOAA-18',
'1 28654U 05018A 24001.50000000 .00000000 00000-0 00000-0 0 9999',
'2 28654 98.7000 100.0000 0001000 0.0000 0.0000 14.12500000000000'
)
mock_observer = MagicMock()
mock_wgs84.latlon.return_value = mock_observer
mock_satellite_obj = MagicMock()
mock_sat.return_value = mock_satellite_obj
rise_time = MagicMock()
rise_time.utc_datetime.return_value = now + timedelta(hours=2)
set_time = MagicMock()
set_time.utc_datetime.return_value = now + timedelta(hours=2, minutes=15)
mock_find.return_value = ([rise_time, set_time], [True, False])
def mock_topocentric(t):
topo = MagicMock()
alt = MagicMock()
alt.degrees = 45.0
az = MagicMock()
az.degrees = 180.0
topo.altaz.return_value = (alt, az, MagicMock())
return topo
mock_diff = MagicMock()
mock_diff.at.side_effect = mock_topocentric
mock_satellite_obj.__sub__.return_value = mock_diff
# Mock geocentric position
def mock_at(t):
geocentric = MagicMock()
return geocentric
mock_satellite_obj.at.side_effect = mock_at
# Mock subpoint
mock_subpoint = MagicMock()
mock_lat = MagicMock()
mock_lat.degrees = 51.5
mock_lon = MagicMock()
mock_lon.degrees = -0.1
mock_subpoint.latitude = mock_lat
mock_subpoint.longitude = mock_lon
mock_wgs84.subpoint.return_value = mock_subpoint
passes = predict_passes(
lat=51.5, lon=-0.1, hours=24, min_elevation=15, include_ground_track=True
)
assert len(passes) == 1
assert 'groundTrack' in passes[0]
assert len(passes[0]['groundTrack']) == 60
@patch('utils.weather_sat_predict.load')
@patch('utils.weather_sat_predict.TLE_SATELLITES')
@patch('utils.weather_sat_predict.wgs84')
@patch('utils.weather_sat_predict.EarthSatellite')
@patch('utils.weather_sat_predict.find_discrete')
def test_predict_passes_quality_excellent(
self, mock_find, mock_sat, mock_wgs84, mock_tle, mock_load
):
"""predict_passes() should mark high elevation passes as excellent."""
mock_ts = MagicMock()
now = datetime(2024, 1, 1, 12, 0, 0, tzinfo=timezone.utc)
mock_now = MagicMock()
mock_now.utc_datetime.return_value = now
mock_ts.now.return_value = mock_now
mock_ts.utc.side_effect = lambda dt: self._mock_time(dt)
mock_load.timescale.return_value = mock_ts
mock_tle.get.return_value = (
'NOAA-18',
'1 28654U 05018A 24001.50000000 .00000000 00000-0 00000-0 0 9999',
'2 28654 98.7000 100.0000 0001000 0.0000 0.0000 14.12500000000000'
)
mock_observer = MagicMock()
mock_wgs84.latlon.return_value = mock_observer
mock_satellite_obj = MagicMock()
mock_sat.return_value = mock_satellite_obj
rise_time = MagicMock()
rise_time.utc_datetime.return_value = now + timedelta(hours=2)
set_time = MagicMock()
set_time.utc_datetime.return_value = now + timedelta(hours=2, minutes=15)
mock_find.return_value = ([rise_time, set_time], [True, False])
def mock_topocentric(t):
topo = MagicMock()
alt = MagicMock()
alt.degrees = 75.0 # Excellent pass
az = MagicMock()
az.degrees = 180.0
topo.altaz.return_value = (alt, az, MagicMock())
return topo
mock_diff = MagicMock()
mock_diff.at.side_effect = mock_topocentric
mock_satellite_obj.__sub__.return_value = mock_diff
passes = predict_passes(lat=51.5, lon=-0.1, hours=24, min_elevation=15)
assert len(passes) == 1
assert passes[0]['quality'] == 'excellent'
assert passes[0]['maxEl'] >= 60
@patch('utils.weather_sat_predict.load')
@patch('utils.weather_sat_predict.TLE_SATELLITES')
@patch('utils.weather_sat_predict.wgs84')
@patch('utils.weather_sat_predict.EarthSatellite')
@patch('utils.weather_sat_predict.find_discrete')
def test_predict_passes_quality_good(
self, mock_find, mock_sat, mock_wgs84, mock_tle, mock_load
):
"""predict_passes() should mark medium elevation passes as good."""
mock_ts = MagicMock()
now = datetime(2024, 1, 1, 12, 0, 0, tzinfo=timezone.utc)
mock_now = MagicMock()
mock_now.utc_datetime.return_value = now
mock_ts.now.return_value = mock_now
mock_ts.utc.side_effect = lambda dt: self._mock_time(dt)
mock_load.timescale.return_value = mock_ts
mock_tle.get.return_value = (
'NOAA-18',
'1 28654U 05018A 24001.50000000 .00000000 00000-0 00000-0 0 9999',
'2 28654 98.7000 100.0000 0001000 0.0000 0.0000 14.12500000000000'
)
mock_observer = MagicMock()
mock_wgs84.latlon.return_value = mock_observer
mock_satellite_obj = MagicMock()
mock_sat.return_value = mock_satellite_obj
rise_time = MagicMock()
rise_time.utc_datetime.return_value = now + timedelta(hours=2)
set_time = MagicMock()
set_time.utc_datetime.return_value = now + timedelta(hours=2, minutes=15)
mock_find.return_value = ([rise_time, set_time], [True, False])
def mock_topocentric(t):
topo = MagicMock()
alt = MagicMock()
alt.degrees = 45.0 # Good pass
az = MagicMock()
az.degrees = 180.0
topo.altaz.return_value = (alt, az, MagicMock())
return topo
mock_diff = MagicMock()
mock_diff.at.side_effect = mock_topocentric
mock_satellite_obj.__sub__.return_value = mock_diff
passes = predict_passes(lat=51.5, lon=-0.1, hours=24, min_elevation=15)
assert len(passes) == 1
assert passes[0]['quality'] == 'good'
assert 30 <= passes[0]['maxEl'] < 60
@patch('utils.weather_sat_predict.load')
@patch('utils.weather_sat_predict.TLE_SATELLITES')
@patch('utils.weather_sat_predict.wgs84')
@patch('utils.weather_sat_predict.EarthSatellite')
@patch('utils.weather_sat_predict.find_discrete')
def test_predict_passes_quality_fair(
self, mock_find, mock_sat, mock_wgs84, mock_tle, mock_load
):
"""predict_passes() should mark low elevation passes as fair."""
mock_ts = MagicMock()
now = datetime(2024, 1, 1, 12, 0, 0, tzinfo=timezone.utc)
mock_now = MagicMock()
mock_now.utc_datetime.return_value = now
mock_ts.now.return_value = mock_now
mock_ts.utc.side_effect = lambda dt: self._mock_time(dt)
mock_load.timescale.return_value = mock_ts
mock_tle.get.return_value = (
'NOAA-18',
'1 28654U 05018A 24001.50000000 .00000000 00000-0 00000-0 0 9999',
'2 28654 98.7000 100.0000 0001000 0.0000 0.0000 14.12500000000000'
)
mock_observer = MagicMock()
mock_wgs84.latlon.return_value = mock_observer
mock_satellite_obj = MagicMock()
mock_sat.return_value = mock_satellite_obj
rise_time = MagicMock()
rise_time.utc_datetime.return_value = now + timedelta(hours=2)
set_time = MagicMock()
set_time.utc_datetime.return_value = now + timedelta(hours=2, minutes=15)
mock_find.return_value = ([rise_time, set_time], [True, False])
def mock_topocentric(t):
topo = MagicMock()
alt = MagicMock()
alt.degrees = 20.0 # Fair pass
az = MagicMock()
az.degrees = 180.0
topo.altaz.return_value = (alt, az, MagicMock())
return topo
mock_diff = MagicMock()
mock_diff.at.side_effect = mock_topocentric
mock_satellite_obj.__sub__.return_value = mock_diff
passes = predict_passes(lat=51.5, lon=-0.1, hours=24, min_elevation=15)
assert len(passes) == 1
assert passes[0]['quality'] == 'fair'
assert passes[0]['maxEl'] < 30
@patch('utils.weather_sat_predict.load')
@patch('utils.weather_sat_predict.TLE_SATELLITES')
@patch('utils.weather_sat_predict.wgs84')
@patch('utils.weather_sat_predict.EarthSatellite')
@patch('utils.weather_sat_predict.find_discrete')
def test_predict_passes_inactive_satellite(
self, mock_find, mock_sat, mock_wgs84, mock_tle, mock_load
):
"""predict_passes() should skip inactive satellites."""
mock_ts = MagicMock()
now = datetime(2024, 1, 1, 12, 0, 0, tzinfo=timezone.utc)
mock_now = MagicMock()
mock_now.utc_datetime.return_value = now
mock_ts.now.return_value = mock_now
mock_load.timescale.return_value = mock_ts
# Temporarily mark satellite as inactive
from utils.weather_sat import WEATHER_SATELLITES
original_active = WEATHER_SATELLITES['NOAA-18']['active']
WEATHER_SATELLITES['NOAA-18']['active'] = False
try:
passes = predict_passes(lat=51.5, lon=-0.1, hours=24, min_elevation=15)
# Should not include NOAA-18
noaa_18_passes = [p for p in passes if p['satellite'] == 'NOAA-18']
assert len(noaa_18_passes) == 0
finally:
WEATHER_SATELLITES['NOAA-18']['active'] = original_active
@patch('utils.weather_sat_predict.load')
@patch('utils.weather_sat_predict.TLE_SATELLITES')
@patch('utils.weather_sat_predict.wgs84')
@patch('utils.weather_sat_predict.EarthSatellite')
@patch('utils.weather_sat_predict.find_discrete')
def test_predict_passes_exception_handling(
self, mock_find, mock_sat, mock_wgs84, mock_tle, mock_load
):
"""predict_passes() should handle exceptions gracefully."""
mock_ts = MagicMock()
now = datetime(2024, 1, 1, 12, 0, 0, tzinfo=timezone.utc)
mock_now = MagicMock()
mock_now.utc_datetime.return_value = now
mock_ts.now.return_value = mock_now
mock_ts.utc.side_effect = lambda dt: self._mock_time(dt)
mock_load.timescale.return_value = mock_ts
mock_tle.get.return_value = (
'NOAA-18',
'1 28654U 05018A 24001.50000000 .00000000 00000-0 00000-0 0 9999',
'2 28654 98.7000 100.0000 0001000 0.0000 0.0000 14.12500000000000'
)
mock_observer = MagicMock()
mock_wgs84.latlon.return_value = mock_observer
mock_satellite_obj = MagicMock()
mock_sat.return_value = mock_satellite_obj
# Make find_discrete raise exception
mock_find.side_effect = Exception('Computation error')
# Should not raise, just skip this satellite
passes = predict_passes(lat=51.5, lon=-0.1, hours=24, min_elevation=15)
# May include passes from other satellites or be empty
assert isinstance(passes, list)
@patch('utils.weather_sat_predict.load')
@patch('utils.weather_sat_predict.TLE_SATELLITES')
def test_predict_passes_uses_tle_cache(self, mock_tle, mock_load):
"""predict_passes() should use live TLE cache if available."""
with patch('utils.weather_sat_predict._tle_cache', {'NOAA-18': ('NOAA-18', 'line1', 'line2')}):
mock_ts = MagicMock()
mock_ts.now.return_value = MagicMock()
mock_ts.utc.return_value = MagicMock()
mock_load.timescale.return_value = mock_ts
# Even though TLE_SATELLITES is mocked, should use _tle_cache
with patch('utils.weather_sat_predict.wgs84'), \
patch('utils.weather_sat_predict.EarthSatellite'), \
patch('utils.weather_sat_predict.find_discrete', return_value=([], [])):
passes = predict_passes(lat=51.5, lon=-0.1, hours=24, min_elevation=15)
# Should not raise
@patch('utils.weather_sat_predict.load')
@patch('utils.weather_sat_predict.TLE_SATELLITES')
@patch('utils.weather_sat_predict.wgs84')
@patch('utils.weather_sat_predict.EarthSatellite')
@patch('utils.weather_sat_predict.find_discrete')
def test_predict_passes_sorted_by_time(
self, mock_find, mock_sat, mock_wgs84, mock_tle, mock_load
):
"""predict_passes() should return passes sorted by start time."""
mock_ts = MagicMock()
now = datetime(2024, 1, 1, 12, 0, 0, tzinfo=timezone.utc)
mock_now = MagicMock()
mock_now.utc_datetime.return_value = now
mock_ts.now.return_value = mock_now
mock_ts.utc.side_effect = lambda dt: self._mock_time(dt)
mock_load.timescale.return_value = mock_ts
mock_tle.get.return_value = (
'NOAA-18',
'1 28654U 05018A 24001.50000000 .00000000 00000-0 00000-0 0 9999',
'2 28654 98.7000 100.0000 0001000 0.0000 0.0000 14.12500000000000'
)
mock_observer = MagicMock()
mock_wgs84.latlon.return_value = mock_observer
mock_satellite_obj = MagicMock()
mock_sat.return_value = mock_satellite_obj
# Two passes
rise1 = MagicMock()
rise1.utc_datetime.return_value = now + timedelta(hours=4)
set1 = MagicMock()
set1.utc_datetime.return_value = now + timedelta(hours=4, minutes=15)
rise2 = MagicMock()
rise2.utc_datetime.return_value = now + timedelta(hours=2)
set2 = MagicMock()
set2.utc_datetime.return_value = now + timedelta(hours=2, minutes=15)
# Return in non-chronological order
mock_find.return_value = ([rise1, set1, rise2, set2], [True, False, True, False])
def mock_topocentric(t):
topo = MagicMock()
alt = MagicMock()
alt.degrees = 45.0
az = MagicMock()
az.degrees = 180.0
topo.altaz.return_value = (alt, az, MagicMock())
return topo
mock_diff = MagicMock()
mock_diff.at.side_effect = mock_topocentric
mock_satellite_obj.__sub__.return_value = mock_diff
passes = predict_passes(lat=51.5, lon=-0.1, hours=24, min_elevation=15)
# Should be sorted with earliest pass first
if len(passes) >= 2:
assert passes[0]['startTimeISO'] < passes[1]['startTimeISO']
@staticmethod
def _mock_time(dt):
"""Helper to create mock time object."""
mock_t = MagicMock()
if isinstance(dt, datetime):
mock_t.utc_datetime.return_value = dt
else:
mock_t.utc_datetime.return_value = datetime.now(timezone.utc)
return mock_t
class TestPassDataStructure:
"""Tests for pass data structure."""
@patch('utils.weather_sat_predict.load')
@patch('utils.weather_sat_predict.TLE_SATELLITES')
@patch('utils.weather_sat_predict.wgs84')
@patch('utils.weather_sat_predict.EarthSatellite')
@patch('utils.weather_sat_predict.find_discrete')
def test_pass_data_fields(
self, mock_find, mock_sat, mock_wgs84, mock_tle, mock_load
):
"""Pass data should contain all required fields."""
mock_ts = MagicMock()
now = datetime(2024, 1, 1, 12, 0, 0, tzinfo=timezone.utc)
mock_now = MagicMock()
mock_now.utc_datetime.return_value = now
mock_ts.now.return_value = mock_now
mock_ts.utc.side_effect = lambda dt: TestPredictPasses._mock_time(dt)
mock_load.timescale.return_value = mock_ts
mock_tle.get.return_value = (
'NOAA-18',
'1 28654U 05018A 24001.50000000 .00000000 00000-0 00000-0 0 9999',
'2 28654 98.7000 100.0000 0001000 0.0000 0.0000 14.12500000000000'
)
mock_observer = MagicMock()
mock_wgs84.latlon.return_value = mock_observer
mock_satellite_obj = MagicMock()
mock_sat.return_value = mock_satellite_obj
rise_time = MagicMock()
rise_time.utc_datetime.return_value = now + timedelta(hours=2)
set_time = MagicMock()
set_time.utc_datetime.return_value = now + timedelta(hours=2, minutes=15)
mock_find.return_value = ([rise_time, set_time], [True, False])
def mock_topocentric(t):
topo = MagicMock()
alt = MagicMock()
alt.degrees = 45.0
az = MagicMock()
az.degrees = 180.0
topo.altaz.return_value = (alt, az, MagicMock())
return topo
mock_diff = MagicMock()
mock_diff.at.side_effect = mock_topocentric
mock_satellite_obj.__sub__.return_value = mock_diff
passes = predict_passes(lat=51.5, lon=-0.1, hours=24, min_elevation=15)
assert len(passes) == 1
pass_data = passes[0]
# Check all required fields
required_fields = [
'id', 'satellite', 'name', 'frequency', 'mode',
'startTime', 'startTimeISO', 'endTimeISO',
'maxEl', 'maxElAz', 'riseAz', 'setAz',
'duration', 'quality'
]
for field in required_fields:
assert field in pass_data, f"Missing required field: {field}"
def test_import_error_propagates(self):
"""predict_passes() should raise ImportError if skyfield unavailable."""
with patch.dict('sys.modules', {'skyfield': None, 'skyfield.api': None}):
with pytest.raises((ImportError, AttributeError)):
predict_passes(lat=51.5, lon=-0.1)
+801
View File
@@ -0,0 +1,801 @@
"""Tests for weather satellite routes.
Covers all weather_sat endpoints: /status, /satellites, /start, /test-decode,
/stop, /images, /passes, and scheduler endpoints.
"""
from __future__ import annotations
import json
from pathlib import Path
from unittest.mock import patch, MagicMock, mock_open
import pytest
from utils.weather_sat import WeatherSatImage, WEATHER_SATELLITES
from datetime import datetime, timezone
class TestWeatherSatRoutes:
"""Tests for weather satellite routes."""
def test_get_status(self, client):
"""GET /weather-sat/status returns decoder status."""
with patch('routes.weather_sat.get_weather_sat_decoder') as mock_get:
mock_decoder = MagicMock()
mock_decoder.get_status.return_value = {
'available': True,
'decoder': 'satdump',
'running': False,
'satellite': '',
'frequency': 0.0,
'mode': '',
'elapsed_seconds': 0,
'image_count': 0,
}
mock_get.return_value = mock_decoder
response = client.get('/weather-sat/status')
assert response.status_code == 200
data = response.get_json()
assert data['available'] is True
assert data['decoder'] == 'satdump'
assert data['running'] is False
def test_list_satellites(self, client):
"""GET /weather-sat/satellites returns satellite list."""
response = client.get('/weather-sat/satellites')
assert response.status_code == 200
data = response.get_json()
assert data['status'] == 'ok'
assert 'satellites' in data
assert len(data['satellites']) > 0
# Check structure
sat = data['satellites'][0]
assert 'key' in sat
assert 'name' in sat
assert 'frequency' in sat
assert 'mode' in sat
assert 'description' in sat
assert 'active' in sat
# Verify NOAA-18 is in list
noaa_18 = next((s for s in data['satellites'] if s['key'] == 'NOAA-18'), None)
assert noaa_18 is not None
assert noaa_18['frequency'] == 137.9125
assert noaa_18['mode'] == 'APT'
def test_start_capture_success(self, client):
"""POST /weather-sat/start successfully starts capture."""
with patch('routes.weather_sat.is_weather_sat_available', return_value=True), \
patch('routes.weather_sat.get_weather_sat_decoder') as mock_get, \
patch('routes.weather_sat.queue.Queue') as mock_queue:
mock_decoder = MagicMock()
mock_decoder.is_running = False
mock_decoder.start.return_value = True
mock_get.return_value = mock_decoder
payload = {
'satellite': 'NOAA-18',
'device': 0,
'gain': 40.0,
'bias_t': False,
}
response = client.post(
'/weather-sat/start',
data=json.dumps(payload),
content_type='application/json'
)
assert response.status_code == 200
data = response.get_json()
assert data['status'] == 'started'
assert data['satellite'] == 'NOAA-18'
assert data['frequency'] == 137.9125
assert data['mode'] == 'APT'
assert data['device'] == 0
mock_decoder.start.assert_called_once_with(
satellite='NOAA-18',
device_index=0,
gain=40.0,
bias_t=False,
)
def test_start_capture_no_satdump(self, client):
"""POST /weather-sat/start returns error when SatDump unavailable."""
with patch('routes.weather_sat.is_weather_sat_available', return_value=False):
payload = {'satellite': 'NOAA-18'}
response = client.post(
'/weather-sat/start',
data=json.dumps(payload),
content_type='application/json'
)
assert response.status_code == 400
data = response.get_json()
assert data['status'] == 'error'
assert 'SatDump not installed' in data['message']
def test_start_capture_already_running(self, client):
"""POST /weather-sat/start when already running."""
with patch('routes.weather_sat.is_weather_sat_available', return_value=True), \
patch('routes.weather_sat.get_weather_sat_decoder') as mock_get:
mock_decoder = MagicMock()
mock_decoder.is_running = True
mock_decoder.current_satellite = 'NOAA-19'
mock_decoder.current_frequency = 137.100
mock_get.return_value = mock_decoder
payload = {'satellite': 'NOAA-18'}
response = client.post(
'/weather-sat/start',
data=json.dumps(payload),
content_type='application/json'
)
assert response.status_code == 200
data = response.get_json()
assert data['status'] == 'already_running'
assert data['satellite'] == 'NOAA-19'
def test_start_capture_invalid_satellite(self, client):
"""POST /weather-sat/start with invalid satellite."""
with patch('routes.weather_sat.is_weather_sat_available', return_value=True), \
patch('routes.weather_sat.get_weather_sat_decoder') as mock_get:
mock_decoder = MagicMock()
mock_decoder.is_running = False
mock_get.return_value = mock_decoder
payload = {'satellite': 'FAKE-SAT-99'}
response = client.post(
'/weather-sat/start',
data=json.dumps(payload),
content_type='application/json'
)
assert response.status_code == 400
data = response.get_json()
assert data['status'] == 'error'
assert 'Invalid satellite' in data['message']
def test_start_capture_invalid_device(self, client):
"""POST /weather-sat/start with invalid device index."""
with patch('routes.weather_sat.is_weather_sat_available', return_value=True), \
patch('routes.weather_sat.get_weather_sat_decoder') as mock_get:
mock_decoder = MagicMock()
mock_decoder.is_running = False
mock_get.return_value = mock_decoder
payload = {'satellite': 'NOAA-18', 'device': -1}
response = client.post(
'/weather-sat/start',
data=json.dumps(payload),
content_type='application/json'
)
assert response.status_code == 400
data = response.get_json()
assert data['status'] == 'error'
def test_start_capture_invalid_gain(self, client):
"""POST /weather-sat/start with invalid gain."""
with patch('routes.weather_sat.is_weather_sat_available', return_value=True), \
patch('routes.weather_sat.get_weather_sat_decoder') as mock_get:
mock_decoder = MagicMock()
mock_decoder.is_running = False
mock_get.return_value = mock_decoder
payload = {'satellite': 'NOAA-18', 'gain': 999}
response = client.post(
'/weather-sat/start',
data=json.dumps(payload),
content_type='application/json'
)
assert response.status_code == 400
data = response.get_json()
assert data['status'] == 'error'
def test_start_capture_device_busy(self, client):
"""POST /weather-sat/start when SDR device is busy."""
with patch('routes.weather_sat.is_weather_sat_available', return_value=True), \
patch('routes.weather_sat.get_weather_sat_decoder') as mock_get, \
patch('app.claim_sdr_device', return_value='Device busy with pager') as mock_claim:
mock_decoder = MagicMock()
mock_decoder.is_running = False
mock_get.return_value = mock_decoder
payload = {'satellite': 'NOAA-18'}
response = client.post(
'/weather-sat/start',
data=json.dumps(payload),
content_type='application/json'
)
assert response.status_code == 409
data = response.get_json()
assert data['status'] == 'error'
assert data['error_type'] == 'DEVICE_BUSY'
assert 'Device busy' in data['message']
def test_start_capture_start_failure(self, client):
"""POST /weather-sat/start when decoder.start() fails."""
with patch('routes.weather_sat.is_weather_sat_available', return_value=True), \
patch('routes.weather_sat.get_weather_sat_decoder') as mock_get:
mock_decoder = MagicMock()
mock_decoder.is_running = False
mock_decoder.start.return_value = False
mock_get.return_value = mock_decoder
payload = {'satellite': 'NOAA-18'}
response = client.post(
'/weather-sat/start',
data=json.dumps(payload),
content_type='application/json'
)
assert response.status_code == 500
data = response.get_json()
assert data['status'] == 'error'
assert 'Failed to start capture' in data['message']
def test_test_decode_success(self, client):
"""POST /weather-sat/test-decode successfully starts file decode."""
with patch('routes.weather_sat.is_weather_sat_available', return_value=True), \
patch('routes.weather_sat.get_weather_sat_decoder') as mock_get, \
patch('pathlib.Path.is_file', return_value=True), \
patch('pathlib.Path.resolve') as mock_resolve:
# Mock path resolution to be under data/
mock_path = MagicMock()
mock_path.is_relative_to.return_value = True
mock_resolve.return_value = mock_path
mock_decoder = MagicMock()
mock_decoder.is_running = False
mock_decoder.start_from_file.return_value = True
mock_get.return_value = mock_decoder
payload = {
'satellite': 'NOAA-18',
'input_file': 'data/weather_sat/test.wav',
'sample_rate': 1000000,
}
response = client.post(
'/weather-sat/test-decode',
data=json.dumps(payload),
content_type='application/json'
)
assert response.status_code == 200
data = response.get_json()
assert data['status'] == 'started'
assert data['satellite'] == 'NOAA-18'
assert data['source'] == 'file'
def test_test_decode_invalid_path(self, client):
"""POST /weather-sat/test-decode with path outside data/."""
with patch('routes.weather_sat.is_weather_sat_available', return_value=True), \
patch('routes.weather_sat.get_weather_sat_decoder') as mock_get, \
patch('pathlib.Path.resolve') as mock_resolve:
# Mock path outside allowed directory
mock_path = MagicMock()
mock_path.is_relative_to.return_value = False
mock_resolve.return_value = mock_path
mock_decoder = MagicMock()
mock_decoder.is_running = False
mock_get.return_value = mock_decoder
payload = {
'satellite': 'NOAA-18',
'input_file': '/etc/passwd',
}
response = client.post(
'/weather-sat/test-decode',
data=json.dumps(payload),
content_type='application/json'
)
assert response.status_code == 403
data = response.get_json()
assert data['status'] == 'error'
assert 'data/ directory' in data['message']
def test_test_decode_file_not_found(self, client):
"""POST /weather-sat/test-decode with non-existent file."""
with patch('routes.weather_sat.is_weather_sat_available', return_value=True), \
patch('routes.weather_sat.get_weather_sat_decoder') as mock_get, \
patch('pathlib.Path.is_file', return_value=False), \
patch('pathlib.Path.resolve') as mock_resolve:
mock_path = MagicMock()
mock_path.is_relative_to.return_value = True
mock_resolve.return_value = mock_path
mock_decoder = MagicMock()
mock_decoder.is_running = False
mock_get.return_value = mock_decoder
payload = {
'satellite': 'NOAA-18',
'input_file': 'data/missing.wav',
}
response = client.post(
'/weather-sat/test-decode',
data=json.dumps(payload),
content_type='application/json'
)
assert response.status_code == 404
data = response.get_json()
assert data['status'] == 'error'
assert 'not found' in data['message'].lower()
def test_test_decode_invalid_sample_rate(self, client):
"""POST /weather-sat/test-decode with invalid sample rate."""
with patch('routes.weather_sat.is_weather_sat_available', return_value=True), \
patch('routes.weather_sat.get_weather_sat_decoder') as mock_get:
mock_decoder = MagicMock()
mock_decoder.is_running = False
mock_get.return_value = mock_decoder
payload = {
'satellite': 'NOAA-18',
'input_file': 'data/test.wav',
'sample_rate': 100, # Too low
}
response = client.post(
'/weather-sat/test-decode',
data=json.dumps(payload),
content_type='application/json'
)
assert response.status_code == 400
data = response.get_json()
assert data['status'] == 'error'
assert 'sample_rate' in data['message']
def test_stop_capture(self, client):
"""POST /weather-sat/stop stops capture."""
with patch('routes.weather_sat.get_weather_sat_decoder') as mock_get:
mock_decoder = MagicMock()
mock_decoder.device_index = 0
mock_get.return_value = mock_decoder
response = client.post('/weather-sat/stop')
assert response.status_code == 200
data = response.get_json()
assert data['status'] == 'stopped'
mock_decoder.stop.assert_called_once()
def test_list_images_empty(self, client):
"""GET /weather-sat/images with no images."""
with patch('routes.weather_sat.get_weather_sat_decoder') as mock_get:
mock_decoder = MagicMock()
mock_decoder.get_images.return_value = []
mock_get.return_value = mock_decoder
response = client.get('/weather-sat/images')
assert response.status_code == 200
data = response.get_json()
assert data['status'] == 'ok'
assert data['images'] == []
assert data['count'] == 0
def test_list_images_with_data(self, client):
"""GET /weather-sat/images with images."""
with patch('routes.weather_sat.get_weather_sat_decoder') as mock_get:
mock_decoder = MagicMock()
image = WeatherSatImage(
filename='NOAA-18_test.png',
path=Path('/tmp/test.png'),
satellite='NOAA-18',
mode='APT',
timestamp=datetime(2024, 1, 1, 12, 0, 0, tzinfo=timezone.utc),
frequency=137.9125,
size_bytes=12345,
product='RGB Composite',
)
mock_decoder.get_images.return_value = [image]
mock_get.return_value = mock_decoder
response = client.get('/weather-sat/images')
assert response.status_code == 200
data = response.get_json()
assert data['status'] == 'ok'
assert data['count'] == 1
assert data['images'][0]['filename'] == 'NOAA-18_test.png'
assert data['images'][0]['satellite'] == 'NOAA-18'
def test_list_images_with_filter(self, client):
"""GET /weather-sat/images with satellite filter."""
with patch('routes.weather_sat.get_weather_sat_decoder') as mock_get:
mock_decoder = MagicMock()
image1 = WeatherSatImage(
filename='NOAA-18_test.png',
path=Path('/tmp/test1.png'),
satellite='NOAA-18',
mode='APT',
timestamp=datetime.now(timezone.utc),
frequency=137.9125,
)
image2 = WeatherSatImage(
filename='NOAA-19_test.png',
path=Path('/tmp/test2.png'),
satellite='NOAA-19',
mode='APT',
timestamp=datetime.now(timezone.utc),
frequency=137.100,
)
mock_decoder.get_images.return_value = [image1, image2]
mock_get.return_value = mock_decoder
response = client.get('/weather-sat/images?satellite=NOAA-18')
assert response.status_code == 200
data = response.get_json()
assert data['count'] == 1
assert data['images'][0]['satellite'] == 'NOAA-18'
def test_list_images_with_limit(self, client):
"""GET /weather-sat/images with limit."""
with patch('routes.weather_sat.get_weather_sat_decoder') as mock_get:
mock_decoder = MagicMock()
images = [
WeatherSatImage(
filename=f'test{i}.png',
path=Path(f'/tmp/test{i}.png'),
satellite='NOAA-18',
mode='APT',
timestamp=datetime.now(timezone.utc),
frequency=137.9125,
)
for i in range(10)
]
mock_decoder.get_images.return_value = images
mock_get.return_value = mock_decoder
response = client.get('/weather-sat/images?limit=5')
assert response.status_code == 200
data = response.get_json()
assert data['count'] == 5
def test_get_image_success(self, client):
"""GET /weather-sat/images/<filename> serves image."""
with patch('routes.weather_sat.get_weather_sat_decoder') as mock_get, \
patch('routes.weather_sat.send_file') as mock_send, \
patch('pathlib.Path.exists', return_value=True):
mock_decoder = MagicMock()
mock_decoder._output_dir = Path('/tmp')
mock_get.return_value = mock_decoder
mock_send.return_value = MagicMock()
response = client.get('/weather-sat/images/test_image.png')
mock_send.assert_called_once()
call_args = mock_send.call_args
assert call_args[1]['mimetype'] == 'image/png'
def test_get_image_invalid_filename(self, client):
"""GET /weather-sat/images/<filename> with invalid filename."""
with patch('routes.weather_sat.get_weather_sat_decoder') as mock_get:
mock_decoder = MagicMock()
mock_get.return_value = mock_decoder
response = client.get('/weather-sat/images/../../../etc/passwd')
assert response.status_code == 400
data = response.get_json()
assert data['status'] == 'error'
assert 'Invalid filename' in data['message']
def test_get_image_wrong_extension(self, client):
"""GET /weather-sat/images/<filename> with wrong extension."""
with patch('routes.weather_sat.get_weather_sat_decoder') as mock_get:
mock_decoder = MagicMock()
mock_get.return_value = mock_decoder
response = client.get('/weather-sat/images/test.txt')
assert response.status_code == 400
data = response.get_json()
assert 'PNG/JPG' in data['message']
def test_get_image_not_found(self, client):
"""GET /weather-sat/images/<filename> for non-existent image."""
with patch('routes.weather_sat.get_weather_sat_decoder') as mock_get, \
patch('pathlib.Path.exists', return_value=False):
mock_decoder = MagicMock()
mock_decoder._output_dir = Path('/tmp')
mock_get.return_value = mock_decoder
response = client.get('/weather-sat/images/missing.png')
assert response.status_code == 404
def test_delete_image_success(self, client):
"""DELETE /weather-sat/images/<filename> deletes image."""
with patch('routes.weather_sat.get_weather_sat_decoder') as mock_get:
mock_decoder = MagicMock()
mock_decoder.delete_image.return_value = True
mock_get.return_value = mock_decoder
response = client.delete('/weather-sat/images/test.png')
assert response.status_code == 200
data = response.get_json()
assert data['status'] == 'deleted'
assert data['filename'] == 'test.png'
def test_delete_image_not_found(self, client):
"""DELETE /weather-sat/images/<filename> for non-existent image."""
with patch('routes.weather_sat.get_weather_sat_decoder') as mock_get:
mock_decoder = MagicMock()
mock_decoder.delete_image.return_value = False
mock_get.return_value = mock_decoder
response = client.delete('/weather-sat/images/missing.png')
assert response.status_code == 404
def test_delete_all_images(self, client):
"""DELETE /weather-sat/images deletes all images."""
with patch('routes.weather_sat.get_weather_sat_decoder') as mock_get:
mock_decoder = MagicMock()
mock_decoder.delete_all_images.return_value = 5
mock_get.return_value = mock_decoder
response = client.delete('/weather-sat/images')
assert response.status_code == 200
data = response.get_json()
assert data['status'] == 'ok'
assert data['deleted'] == 5
def test_stream_progress(self, client):
"""GET /weather-sat/stream returns SSE stream."""
response = client.get('/weather-sat/stream')
assert response.status_code == 200
assert response.mimetype == 'text/event-stream'
assert response.headers['Cache-Control'] == 'no-cache'
def test_get_passes_missing_params(self, client):
"""GET /weather-sat/passes without required params."""
response = client.get('/weather-sat/passes')
assert response.status_code == 400
data = response.get_json()
assert data['status'] == 'error'
assert 'latitude and longitude' in data['message']
def test_get_passes_invalid_coords(self, client):
"""GET /weather-sat/passes with invalid coordinates."""
response = client.get('/weather-sat/passes?latitude=999&longitude=0')
assert response.status_code == 400
data = response.get_json()
assert data['status'] == 'error'
def test_get_passes_success(self, client):
"""GET /weather-sat/passes successfully predicts passes."""
with patch('routes.weather_sat.predict_passes') as mock_predict:
mock_predict.return_value = [
{
'id': 'NOAA-18_202401011200',
'satellite': 'NOAA-18',
'name': 'NOAA 18',
'frequency': 137.9125,
'mode': 'APT',
'startTime': '2024-01-01 12:00 UTC',
'startTimeISO': '2024-01-01T12:00:00+00:00',
'endTimeISO': '2024-01-01T12:15:00+00:00',
'maxEl': 45.0,
'maxElAz': 180.0,
'riseAz': 160.0,
'setAz': 200.0,
'duration': 15.0,
'quality': 'good',
}
]
response = client.get('/weather-sat/passes?latitude=51.5&longitude=-0.1')
assert response.status_code == 200
data = response.get_json()
assert data['status'] == 'ok'
assert data['count'] == 1
assert data['passes'][0]['satellite'] == 'NOAA-18'
def test_get_passes_with_options(self, client):
"""GET /weather-sat/passes with trajectory and ground track."""
with patch('routes.weather_sat.predict_passes') as mock_predict:
mock_predict.return_value = []
response = client.get(
'/weather-sat/passes?latitude=51.5&longitude=-0.1&'
'hours=48&min_elevation=20&trajectory=true&ground_track=true'
)
assert response.status_code == 200
mock_predict.assert_called_once()
call_kwargs = mock_predict.call_args[1]
assert call_kwargs['lat'] == 51.5
assert call_kwargs['lon'] == -0.1
assert call_kwargs['hours'] == 48
assert call_kwargs['min_elevation'] == 20.0
assert call_kwargs['include_trajectory'] is True
assert call_kwargs['include_ground_track'] is True
def test_get_passes_import_error(self, client):
"""GET /weather-sat/passes when skyfield not installed."""
with patch('routes.weather_sat.predict_passes', side_effect=ImportError):
response = client.get('/weather-sat/passes?latitude=51.5&longitude=-0.1')
assert response.status_code == 503
data = response.get_json()
assert data['status'] == 'error'
assert 'skyfield' in data['message']
def test_get_passes_prediction_error(self, client):
"""GET /weather-sat/passes when prediction fails."""
with patch('routes.weather_sat.predict_passes', side_effect=Exception('TLE error')):
response = client.get('/weather-sat/passes?latitude=51.5&longitude=-0.1')
assert response.status_code == 500
data = response.get_json()
assert data['status'] == 'error'
class TestWeatherSatScheduler:
"""Tests for weather satellite scheduler endpoints."""
def test_enable_schedule_success(self, client):
"""POST /weather-sat/schedule/enable enables scheduler."""
with patch('routes.weather_sat.get_weather_sat_scheduler') as mock_get:
mock_scheduler = MagicMock()
mock_scheduler.enable.return_value = {
'enabled': True,
'observer': {'latitude': 51.5, 'longitude': -0.1},
'device': 0,
'gain': 40.0,
'bias_t': False,
'min_elevation': 15.0,
'scheduled_count': 3,
'total_passes': 3,
}
mock_get.return_value = mock_scheduler
payload = {
'latitude': 51.5,
'longitude': -0.1,
'min_elevation': 15,
'device': 0,
'gain': 40.0,
'bias_t': False,
}
response = client.post(
'/weather-sat/schedule/enable',
data=json.dumps(payload),
content_type='application/json'
)
assert response.status_code == 200
data = response.get_json()
assert data['status'] == 'ok'
assert data['enabled'] is True
def test_enable_schedule_missing_coords(self, client):
"""POST /weather-sat/schedule/enable without coordinates."""
payload = {'device': 0}
response = client.post(
'/weather-sat/schedule/enable',
data=json.dumps(payload),
content_type='application/json'
)
assert response.status_code == 400
data = response.get_json()
assert data['status'] == 'error'
assert 'latitude and longitude' in data['message']
def test_enable_schedule_invalid_coords(self, client):
"""POST /weather-sat/schedule/enable with invalid coordinates."""
payload = {'latitude': 999, 'longitude': 0}
response = client.post(
'/weather-sat/schedule/enable',
data=json.dumps(payload),
content_type='application/json'
)
assert response.status_code == 400
data = response.get_json()
assert data['status'] == 'error'
def test_disable_schedule(self, client):
"""POST /weather-sat/schedule/disable disables scheduler."""
with patch('routes.weather_sat.get_weather_sat_scheduler') as mock_get:
mock_scheduler = MagicMock()
mock_scheduler.disable.return_value = {'status': 'disabled'}
mock_get.return_value = mock_scheduler
response = client.post('/weather-sat/schedule/disable')
assert response.status_code == 200
data = response.get_json()
assert data['status'] == 'disabled'
def test_schedule_status(self, client):
"""GET /weather-sat/schedule/status returns scheduler status."""
with patch('routes.weather_sat.get_weather_sat_scheduler') as mock_get:
mock_scheduler = MagicMock()
mock_scheduler.get_status.return_value = {
'enabled': False,
'observer': {'latitude': 0, 'longitude': 0},
'device': 0,
'gain': 40.0,
'bias_t': False,
'min_elevation': 15.0,
'scheduled_count': 0,
'total_passes': 0,
}
mock_get.return_value = mock_scheduler
response = client.get('/weather-sat/schedule/status')
assert response.status_code == 200
data = response.get_json()
assert 'enabled' in data
def test_schedule_passes(self, client):
"""GET /weather-sat/schedule/passes lists scheduled passes."""
with patch('routes.weather_sat.get_weather_sat_scheduler') as mock_get:
mock_scheduler = MagicMock()
mock_scheduler.get_passes.return_value = [
{
'id': 'NOAA-18_202401011200',
'satellite': 'NOAA-18',
'status': 'scheduled',
}
]
mock_get.return_value = mock_scheduler
response = client.get('/weather-sat/schedule/passes')
assert response.status_code == 200
data = response.get_json()
assert data['status'] == 'ok'
assert data['count'] == 1
def test_skip_pass_success(self, client):
"""POST /weather-sat/schedule/skip/<id> skips a pass."""
with patch('routes.weather_sat.get_weather_sat_scheduler') as mock_get:
mock_scheduler = MagicMock()
mock_scheduler.skip_pass.return_value = True
mock_get.return_value = mock_scheduler
response = client.post('/weather-sat/schedule/skip/NOAA-18_202401011200')
assert response.status_code == 200
data = response.get_json()
assert data['status'] == 'skipped'
assert data['pass_id'] == 'NOAA-18_202401011200'
def test_skip_pass_not_found(self, client):
"""POST /weather-sat/schedule/skip/<id> for non-existent pass."""
with patch('routes.weather_sat.get_weather_sat_scheduler') as mock_get:
mock_scheduler = MagicMock()
mock_scheduler.skip_pass.return_value = False
mock_get.return_value = mock_scheduler
response = client.post('/weather-sat/schedule/skip/nonexistent')
assert response.status_code == 404
def test_skip_pass_invalid_id(self, client):
"""POST /weather-sat/schedule/skip/<id> with invalid ID."""
response = client.post('/weather-sat/schedule/skip/../../../etc/passwd')
assert response.status_code == 400
data = response.get_json()
assert data['status'] == 'error'
assert 'Invalid pass ID' in data['message']

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