Compare commits

...

124 Commits

Author SHA1 Message Date
Smittix 257de37dfe Update SDR device claims 2026-02-10 18:07:52 +00:00
Smittix b4c47ed28b Add DMR quality meter and fine tune sweep 2026-02-10 15:24:49 +00:00
Smittix cbcb8b02fa Add fine tune offset for digital voice 2026-02-10 12:11:32 +00:00
Smittix 0a02325c0c Add demodulation toggle for digital voice 2026-02-10 12:02:08 +00:00
Smittix 9bfbd6231d Add P25 Phase 2 option for DMR decoder 2026-02-10 11:43:13 +00:00
Smittix 191344f41b Parse dsd-fme JSON output for DMR events 2026-02-10 11:16:17 +00:00
Smittix dcb2488f47 Fix DMR audio mux startup order 2026-02-10 10:08:39 +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
Marc 44b1a74838 Fixes regarding for PR #124, also added vector images for towers and phones 2026-02-08 03:23:23 -06:00
Mitch Ross fd0953bfb5 up 2026-02-07 17:56:45 -05: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
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
Mitch Ross 4ed7969e90 fixes 2026-02-06 15:05:04 -05: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
67 changed files with 11889 additions and 900 deletions
+3
View File
@@ -55,6 +55,9 @@ intercept_agent_*.cfg
/tmp/
*.tmp
# Weather satellite runtime data (decoded images, samples, SatDump output)
data/weather_sat/
# Env files
.env
.env.*
+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
+72 -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 \
@@ -58,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 \
@@ -115,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 \
@@ -137,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 \
@@ -144,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 \
@@ -166,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/)
+102 -43
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
@@ -235,48 +235,77 @@ cleanup_manager.register(deauth_alerts)
# ============================================
# SDR DEVICE REGISTRY
# ============================================
# Tracks which mode is using which SDR device to prevent conflicts
# Key: device_index (int), Value: mode_name (str)
sdr_device_registry: dict[int, str] = {}
sdr_device_registry_lock = threading.Lock()
# Tracks which mode is using which SDR device to prevent conflicts
# Key: device_index (int), Value: {sdr_type: mode_name}
sdr_device_registry: dict[int, dict[str, str]] = {}
sdr_device_registry_lock = threading.Lock()
def claim_sdr_device(device_index: int, mode_name: str, sdr_type: str = 'rtlsdr') -> str | None:
"""Claim an SDR device for a mode.
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')
device_index: The SDR device index to claim
mode_name: Name of the mode claiming the device (e.g., 'sensor', 'rtlamr')
sdr_type: SDR hardware type (e.g., 'rtlsdr', 'hackrf')
Returns:
Error message if device is in use, None if successfully claimed
"""
with sdr_device_registry_lock:
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.'
sdr_device_registry[device_index] = mode_name
return None
def release_sdr_device(device_index: int) -> None:
"""Release an SDR device from the registry.
sdr_type_key = str(sdr_type or 'rtlsdr').lower()
with sdr_device_registry_lock:
device_entry = sdr_device_registry.get(device_index, {})
if sdr_type_key in device_entry:
in_use_by = device_entry[sdr_type_key]
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
# Only relevant for RTL-SDR devices
if sdr_type_key == 'rtlsdr':
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
if device_index not in sdr_device_registry:
sdr_device_registry[device_index] = {}
sdr_device_registry[device_index][sdr_type_key] = mode_name
return None
def release_sdr_device(device_index: int, sdr_type: str = 'rtlsdr') -> None:
"""Release an SDR device from the registry.
Args:
device_index: The SDR device index to release
"""
with sdr_device_registry_lock:
sdr_device_registry.pop(device_index, None)
device_index: The SDR device index to release
sdr_type: SDR hardware type (e.g., 'rtlsdr', 'hackrf')
"""
sdr_type_key = str(sdr_type or 'rtlsdr').lower()
with sdr_device_registry_lock:
entry = sdr_device_registry.get(device_index)
if not entry:
return
entry.pop(sdr_type_key, None)
if not entry:
sdr_device_registry.pop(device_index, None)
def get_sdr_device_status() -> dict[int, str]:
"""Get current SDR device allocations.
def get_sdr_device_status() -> dict[int, dict[str, str]]:
"""Get current SDR device allocations.
Returns:
Dictionary mapping device indices to mode names
"""
with sdr_device_registry_lock:
return dict(sdr_device_registry)
Dictionary mapping device indices to {sdr_type: mode_name}
"""
with sdr_device_registry_lock:
return {idx: dict(modes) for idx, modes in sdr_device_registry.items()}
# ============================================
@@ -292,6 +321,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 +385,8 @@ def index() -> str:
version=VERSION,
changelog=CHANGELOG,
shared_observer_location=SHARED_OBSERVER_LOCATION_ENABLED,
default_latitude=DEFAULT_LATITUDE,
default_longitude=DEFAULT_LONGITUDE,
)
@@ -368,17 +403,20 @@ def get_devices() -> Response:
@app.route('/devices/status')
def get_devices_status() -> Response:
"""Get all SDR devices with usage status."""
devices = SDRFactory.detect_devices()
registry = get_sdr_device_status()
result = []
for device in devices:
d = device.to_dict()
d['in_use'] = device.index in registry
d['used_by'] = registry.get(device.index)
result.append(d)
def get_devices_status() -> Response:
"""Get all SDR devices with usage status."""
devices = SDRFactory.detect_devices()
registry = get_sdr_device_status()
result = []
for device in devices:
d = device.to_dict()
sdr_type_key = device.sdr_type.value if hasattr(device.sdr_type, 'value') else str(device.sdr_type)
sdr_type_key = str(sdr_type_key).lower()
device_registry = registry.get(device.index, {})
d['in_use'] = sdr_type_key in device_registry
d['used_by'] = device_registry.get(sdr_type_key)
result.append(d)
return jsonify(result)
@@ -671,8 +709,8 @@ def kill_all() -> Response:
'rtl_fm', 'multimon-ng', 'rtl_433',
'airodump-ng', 'aireplay-ng', 'airmon-ng',
'dump1090', 'acarsdec', 'direwolf', 'AIS-catcher',
'hcitool', 'bluetoothctl', 'dsd',
'rtl_tcp', 'rtl_power', 'rtlamr', 'ffmpeg',
'hcitool', 'bluetoothctl', 'satdump', 'dsd',
'rtl_tcp', 'rtl_power', 'rtlamr', 'ffmpeg'
]
for proc in processes_to_kill:
@@ -830,6 +868,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()
@@ -837,6 +887,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:
+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 "============================================"
+38 -14
View File
@@ -7,10 +7,23 @@ import os
import sys
# Application version
VERSION = "2.14.0"
VERSION = "2.15.0"
# Changelog - latest release notes (shown on welcome screen)
CHANGELOG = [
{
"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,25 +211,36 @@ 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)
# 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')
# 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)
# 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."""
+28 -10
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"]
+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
+111 -86
View File
@@ -673,13 +673,14 @@ class ModeManager:
def get_status(self) -> dict:
"""Get overall agent status."""
# Build running modes with device info for multi-SDR tracking
running_modes_detail = {}
for mode, info in self.running_modes.items():
params = info.get('params', {})
running_modes_detail[mode] = {
'started_at': info.get('started_at'),
'device': params.get('device', params.get('device_index', 0)),
}
running_modes_detail = {}
for mode, info in self.running_modes.items():
params = info.get('params', {})
running_modes_detail[mode] = {
'started_at': info.get('started_at'),
'device': params.get('device', params.get('device_index', 0)),
'sdr_type': str(params.get('sdr_type', 'rtlsdr')).lower(),
}
status = {
'running_modes': list(self.running_modes.keys()),
@@ -698,22 +699,24 @@ class ModeManager:
# Modes that use RTL-SDR devices
SDR_MODES = {'adsb', 'sensor', 'pager', 'ais', 'acars', 'dsc', 'rtlamr', 'listening_post'}
def get_sdr_in_use(self, device: int = 0) -> str | None:
"""Check if an SDR device is in use by another mode.
Returns the mode name using the device, or None if available.
"""
for mode, info in self.running_modes.items():
if mode in self.SDR_MODES:
mode_device = info.get('params', {}).get('device', 0)
# Normalize to int for comparison
try:
mode_device = int(mode_device)
except (ValueError, TypeError):
mode_device = 0
if mode_device == device:
return mode
return None
def get_sdr_in_use(self, device: int = 0, sdr_type: str = 'rtlsdr') -> str | None:
"""Check if an SDR device is in use by another mode.
Returns the mode name using the device, or None if available.
"""
sdr_type_key = str(sdr_type or 'rtlsdr').lower()
for mode, info in self.running_modes.items():
if mode in self.SDR_MODES:
mode_device = info.get('params', {}).get('device', 0)
mode_sdr_type = str(info.get('params', {}).get('sdr_type', 'rtlsdr')).lower()
# Normalize to int for comparison
try:
mode_device = int(mode_device)
except (ValueError, TypeError):
mode_device = 0
if mode_device == device and mode_sdr_type == sdr_type_key:
return mode
return None
def start_mode(self, mode: str, params: dict) -> dict:
"""Start a mode with given parameters."""
@@ -725,18 +728,19 @@ class ModeManager:
return {'status': 'error', 'message': f'{mode} not available (missing tools)'}
# Check SDR device conflicts for SDR-based modes
if mode in self.SDR_MODES:
device = params.get('device', 0)
try:
device = int(device)
except (ValueError, TypeError):
device = 0
in_use_by = self.get_sdr_in_use(device)
if in_use_by:
return {
'status': 'error',
'message': f'SDR device {device} is in use by {in_use_by}. Stop {in_use_by} first or use a different device.'
}
if mode in self.SDR_MODES:
device = params.get('device', 0)
try:
device = int(device)
except (ValueError, TypeError):
device = 0
sdr_type = str(params.get('sdr_type', 'rtlsdr')).lower()
in_use_by = self.get_sdr_in_use(device, sdr_type)
if in_use_by:
return {
'status': 'error',
'message': f'SDR device {device} is in use by {in_use_by}. Stop {in_use_by} first or use a different device.'
}
# Initialize lock if needed
if mode not in self.locks:
@@ -1097,10 +1101,15 @@ class ModeManager:
if mode in self.data_snapshots:
del self.data_snapshots[mode]
# Mode-specific cleanup
if mode == 'adsb':
self.adsb_aircraft.clear()
elif mode == 'wifi':
# Mode-specific cleanup
if mode == 'adsb':
self.adsb_aircraft.clear()
if 'adsb_mlat' in self.output_threads:
thread = self.output_threads['adsb_mlat']
if thread and thread.is_alive():
thread.join(timeout=1)
del self.output_threads['adsb_mlat']
elif mode == 'wifi':
self.wifi_networks.clear()
self.wifi_clients.clear()
elif mode == 'bluetooth':
@@ -1311,14 +1320,20 @@ class ModeManager:
"""Start dump1090 ADS-B mode using Intercept's utilities."""
gain = params.get('gain', '40')
device = params.get('device', '0')
bias_t = params.get('bias_t', False)
sdr_type_str = params.get('sdr_type', 'rtlsdr')
remote_sbs_host = params.get('remote_sbs_host')
remote_sbs_port = params.get('remote_sbs_port', 30003)
bias_t = params.get('bias_t', False)
sdr_type_str = params.get('sdr_type', 'rtlsdr')
remote_sbs_host = params.get('remote_sbs_host')
remote_sbs_port = params.get('remote_sbs_port', 30003)
mlat_sbs_host = params.get('mlat_sbs_host')
mlat_sbs_port = params.get('mlat_sbs_port', 30105)
# If remote SBS host provided, just connect to it
if remote_sbs_host:
return self._start_adsb_sbs_connection(remote_sbs_host, remote_sbs_port)
# If remote SBS host provided, just connect to it
if remote_sbs_host:
result = self._start_adsb_sbs_connection(remote_sbs_host, remote_sbs_port, source_tag='adsb', thread_name='adsb')
if mlat_sbs_host:
self._start_adsb_sbs_connection(mlat_sbs_host, mlat_sbs_port, source_tag='mlat', thread_name='adsb_mlat')
result['mlat_source'] = f'{mlat_sbs_host}:{mlat_sbs_port}'
return result
# Check if dump1090 already running on port 30003
try:
@@ -1326,9 +1341,13 @@ class ModeManager:
sock.settimeout(1.0)
result = sock.connect_ex(('localhost', 30003))
sock.close()
if result == 0:
logger.info("dump1090 already running, connecting to SBS port")
return self._start_adsb_sbs_connection('localhost', 30003)
if result == 0:
logger.info("dump1090 already running, connecting to SBS port")
result = self._start_adsb_sbs_connection('localhost', 30003, source_tag='adsb', thread_name='adsb')
if mlat_sbs_host:
self._start_adsb_sbs_connection(mlat_sbs_host, mlat_sbs_port, source_tag='mlat', thread_name='adsb_mlat')
result['mlat_source'] = f'{mlat_sbs_host}:{mlat_sbs_port}'
return result
except Exception:
pass
@@ -1380,12 +1399,16 @@ class ModeManager:
# Wait for dump1090 to start
time.sleep(2)
if proc.poll() is not None:
stderr = proc.stderr.read().decode('utf-8', errors='ignore')
return {'status': 'error', 'message': f'dump1090 failed to start: {stderr[:200]}'}
# Connect to SBS port
return self._start_adsb_sbs_connection('localhost', 30003)
if proc.poll() is not None:
stderr = proc.stderr.read().decode('utf-8', errors='ignore')
return {'status': 'error', 'message': f'dump1090 failed to start: {stderr[:200]}'}
# Connect to SBS port
result = self._start_adsb_sbs_connection('localhost', 30003, source_tag='adsb', thread_name='adsb')
if mlat_sbs_host:
self._start_adsb_sbs_connection(mlat_sbs_host, mlat_sbs_port, source_tag='mlat', thread_name='adsb_mlat')
result['mlat_source'] = f'{mlat_sbs_host}:{mlat_sbs_port}'
return result
except FileNotFoundError:
return {'status': 'error', 'message': 'dump1090 not found'}
@@ -1414,27 +1437,27 @@ class ModeManager:
return path
return None
def _start_adsb_sbs_connection(self, host: str, port: int) -> dict:
"""Connect to SBS port and start parsing."""
thread = threading.Thread(
target=self._adsb_sbs_reader,
args=(host, port),
daemon=True
)
thread.start()
self.output_threads['adsb'] = thread
return {
'status': 'started',
'mode': 'adsb',
'sbs_source': f'{host}:{port}',
def _start_adsb_sbs_connection(self, host: str, port: int, *, source_tag: str = 'adsb', thread_name: str = 'adsb') -> dict:
"""Connect to SBS port and start parsing."""
thread = threading.Thread(
target=self._adsb_sbs_reader,
args=(host, port, source_tag),
daemon=True
)
thread.start()
self.output_threads[thread_name] = thread
return {
'status': 'started',
'mode': 'adsb',
'sbs_source': f'{host}:{port}',
'gps_enabled': gps_manager.is_running
}
def _adsb_sbs_reader(self, host: str, port: int):
"""Read and parse SBS data from dump1090."""
mode = 'adsb'
stop_event = self.stop_events.get(mode)
def _adsb_sbs_reader(self, host: str, port: int, source_tag: str = 'adsb'):
"""Read and parse SBS data from dump1090."""
mode = 'adsb'
stop_event = self.stop_events.get(mode)
retry_count = 0
max_retries = 5
@@ -1443,8 +1466,8 @@ class ModeManager:
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
sock.settimeout(5.0)
sock.connect((host, port))
logger.info(f"Connected to SBS at {host}:{port}")
retry_count = 0
logger.info(f"Connected to SBS at {host}:{port} ({source_tag})")
retry_count = 0
buffer = ""
sock.settimeout(1.0)
@@ -1458,7 +1481,7 @@ class ModeManager:
while '\n' in buffer:
line, buffer = buffer.split('\n', 1)
self._parse_sbs_line(line.strip())
self._parse_sbs_line(line.strip(), source_tag)
except socket.timeout:
continue
@@ -1475,10 +1498,10 @@ class ModeManager:
logger.info("ADS-B SBS reader stopped")
def _parse_sbs_line(self, line: str):
"""Parse SBS format line and update aircraft dict."""
if not line:
return
def _parse_sbs_line(self, line: str, source_tag: str = 'adsb'):
"""Parse SBS format line and update aircraft dict."""
if not line:
return
parts = line.split(',')
if len(parts) < 11 or parts[0] != 'MSG':
@@ -1503,12 +1526,14 @@ class ModeManager:
if callsign:
aircraft['callsign'] = callsign
elif msg_type == '3' and len(parts) > 15:
if parts[11]:
aircraft['altitude'] = int(float(parts[11]))
if parts[14] and parts[15]:
aircraft['lat'] = float(parts[14])
aircraft['lon'] = float(parts[15])
elif msg_type == '3' and len(parts) > 15:
if parts[11]:
aircraft['altitude'] = int(float(parts[11]))
if parts[14] and parts[15]:
aircraft['lat'] = float(parts[14])
aircraft['lon'] = float(parts[15])
if source_tag:
aircraft['position_source'] = source_tag
elif msg_type == '4' and len(parts) > 16:
if parts[12]:
+1 -1
View File
@@ -1,6 +1,6 @@
[project]
name = "intercept"
version = "2.14.0"
version = "2.15.0"
description = "Signal Intelligence Platform - Pager/433MHz/ADS-B/Satellite/WiFi/Bluetooth"
readme = "README.md"
requires-python = ">=3.9"
+10 -8
View File
@@ -26,11 +26,12 @@ 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 .dmr import dmr_bp
from .websdr import websdr_bp
from .alerts import alerts_bp
from .recordings import recordings_bp
app.register_blueprint(pager_bp)
app.register_blueprint(sensor_bp)
@@ -56,11 +57,12 @@ 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(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
# Initialize TSCM state with queue and lock from app
import app as app_module
+83 -25
View File
@@ -35,6 +35,9 @@ from config import (
ADSB_DB_USER,
ADSB_AUTO_START,
ADSB_HISTORY_ENABLED,
ADSB_MLAT_ENABLED,
ADSB_MLAT_SBS_HOST,
ADSB_MLAT_SBS_PORT,
SHARED_OBSERVER_LOCATION_ENABLED,
)
from utils.logging import adsb_logger as logger
@@ -71,7 +74,10 @@ adsb_last_message_time = None
adsb_bytes_received = 0
adsb_lines_received = 0
adsb_active_device = None # Track which device index is being used
adsb_active_sdr_type = None
_sbs_error_logged = False # Suppress repeated connection error logs
adsb_connected_sources: set[str] = set()
_adsb_connection_lock = threading.Lock()
# Track ICAOs already looked up in aircraft database (avoid repeated lookups)
_looked_up_icaos: set[str] = set()
@@ -318,7 +324,29 @@ def check_dump1090_service():
return None
def parse_sbs_stream(service_addr):
def _reset_adsb_state() -> None:
global adsb_connected, adsb_messages_received, adsb_last_message_time, adsb_bytes_received, adsb_lines_received, _sbs_error_logged
adsb_connected = False
adsb_messages_received = 0
adsb_last_message_time = None
adsb_bytes_received = 0
adsb_lines_received = 0
_sbs_error_logged = False
with _adsb_connection_lock:
adsb_connected_sources.clear()
def _set_adsb_connected(source_key: str, connected: bool) -> None:
global adsb_connected
with _adsb_connection_lock:
if connected:
adsb_connected_sources.add(source_key)
else:
adsb_connected_sources.discard(source_key)
adsb_connected = bool(adsb_connected_sources)
def parse_sbs_stream(service_addr: str, source_tag: str | None = None):
"""Parse SBS format data from dump1090 SBS port."""
global adsb_using_service, adsb_connected, adsb_messages_received, adsb_last_message_time, adsb_bytes_received, adsb_lines_received, _sbs_error_logged
@@ -327,26 +355,23 @@ def parse_sbs_stream(service_addr):
host, port = service_addr.split(':')
port = int(port)
source_label = source_tag or 'adsb'
logger.info(f"SBS stream parser started, connecting to {host}:{port}")
adsb_connected = False
adsb_messages_received = 0
_sbs_error_logged = False
logger.info(f"SBS stream parser started ({source_label}), connecting to {host}:{port}")
while adsb_using_service:
try:
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
sock.settimeout(SBS_SOCKET_TIMEOUT)
sock.connect((host, port))
adsb_connected = True
_set_adsb_connected(service_addr, True)
_sbs_error_logged = False # Reset so we log next error
logger.info("Connected to SBS stream")
logger.info(f"Connected to SBS stream ({source_label})")
buffer = ""
last_update = time.time()
pending_updates = set()
adsb_bytes_received = 0
adsb_lines_received = 0
local_lines_received = 0
while adsb_using_service:
try:
@@ -364,13 +389,14 @@ def parse_sbs_stream(service_addr):
continue
adsb_lines_received += 1
local_lines_received += 1
# Log first few lines for debugging
if adsb_lines_received <= 3:
logger.info(f"SBS line {adsb_lines_received}: {line[:100]}")
if local_lines_received <= 3:
logger.info(f"SBS line ({source_label}) {local_lines_received}: {line[:100]}")
parts = line.split(',')
if len(parts) < 11 or parts[0] != 'MSG':
if adsb_lines_received <= 5:
if local_lines_received <= 5:
logger.debug(f"Skipping non-MSG line: {line[:50]}")
continue
@@ -421,6 +447,8 @@ def parse_sbs_stream(service_addr):
try:
aircraft['lat'] = float(parts[14])
aircraft['lon'] = float(parts[15])
if source_label:
aircraft['position_source'] = source_label
except (ValueError, TypeError):
pass
@@ -494,18 +522,26 @@ def parse_sbs_stream(service_addr):
continue
sock.close()
adsb_connected = False
_set_adsb_connected(service_addr, False)
except OSError as e:
adsb_connected = False
_set_adsb_connected(service_addr, False)
if not _sbs_error_logged:
logger.warning(f"SBS connection error: {e}, reconnecting...")
_sbs_error_logged = True
time.sleep(SBS_RECONNECT_DELAY)
adsb_connected = False
_set_adsb_connected(service_addr, False)
logger.info("SBS stream parser stopped")
def _start_mlat_stream(host: str, port: int) -> str:
mlat_addr = f"{host}:{port}"
logger.info(f"Connecting to MLAT SBS at {mlat_addr}")
thread = threading.Thread(target=parse_sbs_stream, args=(mlat_addr, 'mlat'), daemon=True)
thread.start()
return mlat_addr
@adsb_bp.route('/tools')
def check_adsb_tools():
"""Check for ADS-B decoding tools and hardware."""
@@ -580,7 +616,7 @@ def adsb_session():
@adsb_bp.route('/start', methods=['POST'])
def start_adsb():
"""Start ADS-B tracking."""
global adsb_using_service, adsb_active_device
global adsb_using_service, adsb_active_device, adsb_active_sdr_type
with app_module.adsb_lock:
if adsb_using_service:
@@ -601,10 +637,22 @@ def start_adsb():
device = validate_device_index(data.get('device', '0'))
except ValueError as e:
return jsonify({'status': 'error', 'message': str(e)}), 400
_reset_adsb_state()
# Check for remote SBS connection (e.g., remote dump1090)
remote_sbs_host = data.get('remote_sbs_host')
remote_sbs_port = data.get('remote_sbs_port', 30003)
mlat_sbs_host = (data.get('mlat_sbs_host') or '').strip()
mlat_sbs_port = data.get('mlat_sbs_port', ADSB_MLAT_SBS_PORT)
if not mlat_sbs_host and ADSB_MLAT_ENABLED and ADSB_MLAT_SBS_HOST:
mlat_sbs_host = ADSB_MLAT_SBS_HOST
mlat_sbs_port = ADSB_MLAT_SBS_PORT
if mlat_sbs_host:
try:
mlat_sbs_host = validate_rtl_tcp_host(mlat_sbs_host)
mlat_sbs_port = validate_rtl_tcp_port(mlat_sbs_port)
except ValueError as e:
return jsonify({'status': 'error', 'message': str(e)}), 400
if remote_sbs_host:
# Validate and connect to remote dump1090 SBS output
@@ -617,8 +665,10 @@ def start_adsb():
remote_addr = f"{remote_sbs_host}:{remote_sbs_port}"
logger.info(f"Connecting to remote dump1090 SBS at {remote_addr}")
adsb_using_service = True
thread = threading.Thread(target=parse_sbs_stream, args=(remote_addr,), daemon=True)
thread = threading.Thread(target=parse_sbs_stream, args=(remote_addr, 'adsb'), daemon=True)
thread.start()
if mlat_sbs_host:
_start_mlat_stream(mlat_sbs_host, mlat_sbs_port)
session = _record_session_start(
device_index=device,
sdr_type='remote',
@@ -638,8 +688,10 @@ def start_adsb():
if existing_service:
logger.info(f"Found existing dump1090 service at {existing_service}")
adsb_using_service = True
thread = threading.Thread(target=parse_sbs_stream, args=(existing_service,), daemon=True)
thread = threading.Thread(target=parse_sbs_stream, args=(existing_service, 'adsb'), daemon=True)
thread.start()
if mlat_sbs_host:
_start_mlat_stream(mlat_sbs_host, mlat_sbs_port)
session = _record_session_start(
device_index=device,
sdr_type='external',
@@ -689,7 +741,7 @@ def start_adsb():
# Check if device is available before starting local dump1090
device_int = int(device)
error = app_module.claim_sdr_device(device_int, 'adsb')
error = app_module.claim_sdr_device(device_int, 'adsb', sdr_type.value)
if error:
return jsonify({
'status': 'error',
@@ -726,7 +778,7 @@ def start_adsb():
if app_module.adsb_process.poll() is not None:
# Process exited - release device and get error message
app_module.release_sdr_device(device_int)
app_module.release_sdr_device(device_int, sdr_type.value)
stderr_output = ''
if app_module.adsb_process.stderr:
try:
@@ -772,9 +824,12 @@ def start_adsb():
})
adsb_using_service = True
adsb_active_device = device # Track which device is being used
thread = threading.Thread(target=parse_sbs_stream, args=(f'localhost:{ADSB_SBS_PORT}',), daemon=True)
adsb_active_device = device # Track which device index is being used
adsb_active_sdr_type = sdr_type.value
thread = threading.Thread(target=parse_sbs_stream, args=(f'localhost:{ADSB_SBS_PORT}', 'adsb'), daemon=True)
thread.start()
if mlat_sbs_host:
_start_mlat_stream(mlat_sbs_host, mlat_sbs_port)
session = _record_session_start(
device_index=device,
@@ -792,14 +847,14 @@ def start_adsb():
})
except Exception as e:
# Release device on failure
app_module.release_sdr_device(device_int)
app_module.release_sdr_device(device_int, sdr_type.value)
return jsonify({'status': 'error', 'message': str(e)})
@adsb_bp.route('/stop', methods=['POST'])
def stop_adsb():
"""Stop ADS-B tracking."""
global adsb_using_service, adsb_active_device
global adsb_using_service, adsb_active_device, adsb_active_sdr_type
data = request.json or {}
stop_source = data.get('source')
stopped_by = request.remote_addr
@@ -823,10 +878,12 @@ def stop_adsb():
# Release device from registry
if adsb_active_device is not None:
app_module.release_sdr_device(adsb_active_device)
app_module.release_sdr_device(adsb_active_device, adsb_active_sdr_type or 'rtlsdr')
adsb_using_service = False
adsb_active_device = None
adsb_active_sdr_type = None
_reset_adsb_state()
app_module.adsb_aircraft.clear()
_looked_up_icaos.clear()
@@ -868,6 +925,7 @@ def adsb_dashboard():
'adsb_dashboard.html',
shared_observer_location=SHARED_OBSERVER_LOCATION_ENABLED,
adsb_auto_start=ADSB_AUTO_START,
adsb_mlat_enabled=ADSB_MLAT_ENABLED,
)
+8 -5
View File
@@ -44,6 +44,7 @@ ais_connected = False
ais_messages_received = 0
ais_last_message_time = None
ais_active_device = None
ais_active_sdr_type = None
_ais_error_logged = True
# Common installation paths for AIS-catcher
@@ -326,7 +327,7 @@ def ais_status():
@ais_bp.route('/start', methods=['POST'])
def start_ais():
"""Start AIS tracking."""
global ais_running, ais_active_device
global ais_running, ais_active_device, ais_active_sdr_type
with app_module.ais_lock:
if ais_running:
@@ -373,7 +374,7 @@ def start_ais():
# Check if device is available
device_int = int(device)
error = app_module.claim_sdr_device(device_int, 'ais')
error = app_module.claim_sdr_device(device_int, 'ais', sdr_type.value)
if error:
return jsonify({
'status': 'error',
@@ -412,7 +413,7 @@ def start_ais():
if app_module.ais_process.poll() is not None:
# Release device on failure
app_module.release_sdr_device(device_int)
app_module.release_sdr_device(device_int, sdr_type.value)
stderr_output = ''
if app_module.ais_process.stderr:
try:
@@ -426,6 +427,7 @@ def start_ais():
ais_running = True
ais_active_device = device
ais_active_sdr_type = sdr_type.value
# Start TCP parser thread
thread = threading.Thread(target=parse_ais_stream, args=(tcp_port,), daemon=True)
@@ -439,7 +441,7 @@ def start_ais():
})
except Exception as e:
# Release device on failure
app_module.release_sdr_device(device_int)
app_module.release_sdr_device(device_int, sdr_type.value)
logger.error(f"Failed to start AIS-catcher: {e}")
return jsonify({'status': 'error', 'message': str(e)}), 500
@@ -466,10 +468,11 @@ def stop_ais():
# Release device from registry
if ais_active_device is not None:
app_module.release_sdr_device(ais_active_device)
app_module.release_sdr_device(ais_active_device, ais_active_sdr_type or 'rtlsdr')
ais_running = False
ais_active_device = None
ais_active_sdr_type = None
app_module.ais_vessels.clear()
return jsonify({'status': 'stopped'})
+17 -9
View File
@@ -21,8 +21,8 @@ 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.event_pipeline import process_event
from utils.sse import format_sse
from utils.event_pipeline import process_event
from utils.constants import (
PROCESS_TERMINATE_TIMEOUT,
SSE_KEEPALIVE_INTERVAL,
@@ -53,6 +53,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
@@ -1371,6 +1372,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)
@@ -1726,13 +1734,13 @@ def stream_aprs() -> Response:
while True:
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)
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()
if now - last_keepalive >= SSE_KEEPALIVE_INTERVAL:
+18 -2
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
@@ -251,4 +252,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")
+375 -33
View File
@@ -3,6 +3,7 @@
from __future__ import annotations
import os
import json
import queue
import re
import select
@@ -20,6 +21,7 @@ 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.constants import (
SSE_QUEUE_TIMEOUT,
SSE_KEEPALIVE_INTERVAL,
@@ -38,11 +40,19 @@ 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
VALID_PROTOCOLS = ['auto', 'dmr', 'p25', 'nxdn', 'dstar', 'provoice']
# Audio mux: the sole reader of dsd-fme stdout. Writes to an ffmpeg
# stdin when a streaming client is connected, discards otherwise.
# This prevents dsd-fme from blocking on stdout (which would also
# freeze stderr / text data output).
_active_ffmpeg_stdin: Optional[object] = None # set by stream endpoint
VALID_PROTOCOLS = ['auto', 'dmr', 'p25', 'p25p2', 'nxdn', 'dstar', 'provoice']
VALID_DEMODS = ['nfm', 'fm']
# Classic dsd flags
_DSD_PROTOCOL_FLAGS = {
@@ -54,14 +64,28 @@ _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': ['-ft'], # XDMA: auto-detect DMR/P25/YSF
'dmr': ['-fs'], # DMR Simplex (-fd is D-STAR in dsd-fme!)
'p25': ['-f1'], # P25 Phase 1 (-fp is ProVoice in dsd-fme!)
'p25p2': ['-f2'], # P25 Phase 2
'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
'p25': ['-mc'], # C4FM (Phase 1)
'p25p2': ['-mq'], # CQPSK (Phase 2)
'nxdn': ['-mc'], # C4FM
}
# ============================================
@@ -89,6 +113,83 @@ def find_rtl_fm() -> str | None:
return shutil.which('rtl_fm')
def find_ffmpeg() -> str | None:
"""Find ffmpeg for audio encoding."""
return shutil.which('ffmpeg')
def _coerce_int(value) -> int | None:
try:
return int(value)
except (TypeError, ValueError):
return None
def _parse_dsd_json(payload: dict, ts: str) -> dict | None:
"""Parse JSON output lines from dsd-fme into events."""
event_type = str(payload.get('type') or payload.get('event') or payload.get('msg') or payload.get('kind') or '').lower()
nested = payload.get('data') if isinstance(payload.get('data'), dict) else {}
def first_of(keys):
for obj in (payload, nested):
for key in keys:
if key in obj and obj[key] is not None:
return obj[key]
return None
talkgroup = _coerce_int(first_of([
'tg', 'tgt', 'talkgroup', 'talk_group', 'tgid',
'group', 'group_id', 'groupId', 'dst', 'dest',
'destination', 'target'
]))
source = _coerce_int(first_of([
'src', 'source', 'src_id', 'source_id', 'sourceId',
'uid', 'unit', 'radio', 'rid', 'radio_id', 'radioId'
]))
slot = _coerce_int(first_of(['slot', 'timeslot', 'time_slot', 'ts']))
nac = first_of(['nac'])
protocol = first_of(['protocol', 'mode', 'system', 'sys', 'network'])
if talkgroup is not None and source is not None:
event = {
'type': 'call',
'talkgroup': talkgroup,
'source_id': source,
'timestamp': ts,
}
if slot is not None:
event['slot'] = slot
if protocol:
event['protocol'] = str(protocol)
return event
if nac is not None:
return {'type': 'nac', 'nac': str(nac), 'timestamp': ts}
if 'sync' in event_type:
return {
'type': 'sync',
'protocol': str(protocol or event_type),
'timestamp': ts,
}
voice_flag = first_of(['voice', 'voice_frame', 'voiceFrame'])
if 'voice' in event_type or voice_flag is True:
event = {
'type': 'voice',
'detail': str(first_of(['detail', 'text']) or event_type or 'voice'),
'timestamp': ts,
}
if slot is not None:
event['slot'] = slot
return event
if protocol:
return {'type': 'sync', 'protocol': str(protocol), 'timestamp': ts}
return None
def parse_dsd_output(line: str) -> dict | None:
"""Parse a line of DSD stderr output into a structured event.
@@ -99,15 +200,56 @@ def parse_dsd_output(line: str) -> dict | None:
if not line:
return None
ts = datetime.now().strftime('%H:%M:%S')
# Frame-level error / OK indicators (useful for quality metrics)
if re.search(r'\bDUID\s+ERR\b', line, re.IGNORECASE):
return {
'type': 'frame_error',
'kind': 'duid',
'detail': line[:200],
'timestamp': ts,
}
if re.search(r'\bR-?S\s+ERR\b', line, re.IGNORECASE):
return {
'type': 'frame_error',
'kind': 'rs',
'detail': line[:200],
'timestamp': ts,
}
if re.search(r'\bP25p2\b.*\b4V\b', line, re.IGNORECASE):
return {
'type': 'frame_ok',
'kind': 'p25p2',
'timestamp': ts,
}
# If dsd-fme is emitting JSON (via -J), parse it first.
if line.startswith('{') and line.endswith('}'):
try:
payload = json.loads(line)
except json.JSONDecodeError:
payload = None
if isinstance(payload, dict):
parsed = _parse_dsd_json(payload, ts)
if parsed:
return parsed
return {
'type': 'raw',
'text': line[:200],
'timestamp': ts,
}
# Skip DSD/dsd-fme startup banner lines (ASCII art, version info, etc.)
# These contain box-drawing characters or are pure decoration.
if re.search(r'[╔╗╚╝║═██▀▄╗╝╩╦╠╣╬│┤├┘└┐┌─┼█▓▒░]', line):
# 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:
@@ -121,8 +263,9 @@ def parse_dsd_output(line: str) -> dict | None:
# 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'(?:TG|Talkgroup)[:\s]+(\d+)[,\s]+(?:Src|Source)[:\s]+(\d+)', line, re.IGNORECASE
r'(?:TGT?|Talkgroup)[:\s]+(\d+)[,|│\s]+(?:Src|Source|SRC)[:\s]+(\d+)', line, re.IGNORECASE
)
if tg_match:
result = {
@@ -181,6 +324,45 @@ def parse_dsd_output(line: str) -> dict | None:
_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 _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 an audio streaming client is connected, forwards audio
to its ffmpeg stdin with silence fill during voice gaps. When no
client is connected, simply discards the data.
"""
try:
while dmr_running:
ready, _, _ = select.select([dsd_stdout], [], [], 0.1)
if ready:
data = os.read(dsd_stdout.fileno(), 4096)
if not data:
break
sink = _active_ffmpeg_stdin
if sink:
try:
sink.write(data)
sink.flush()
except (BrokenPipeError, OSError, ValueError):
pass
else:
# No audio from decoder — feed silence if client connected
sink = _active_ffmpeg_stdin
if sink:
try:
sink.write(_SILENCE_CHUNK)
sink.flush()
except (BrokenPipeError, OSError, ValueError):
pass
except (OSError, ValueError):
pass
def _queue_put(event: dict):
"""Put an event on the DMR queue, dropping oldest if full."""
@@ -228,6 +410,7 @@ def stream_dsd_output(rtl_process: subprocess.Popen, dsd_process: subprocess.Pop
if not text:
continue
logger.debug("DSD raw: %s", text)
parsed = parse_dsd_output(text)
if parsed:
_queue_put(parsed)
@@ -261,7 +444,7 @@ def stream_dsd_output(rtl_process: subprocess.Popen, dsd_process: subprocess.Pop
except Exception:
pass
logger.warning(f"DSD process exited with code {rc}: {detail}")
# Cleanup both processes
# Cleanup decoder + demod processes
for proc in [dsd_process, rtl_process]:
if proc and proc.poll() is None:
try:
@@ -293,9 +476,11 @@ def check_tools() -> Response:
"""Check for required tools."""
dsd_path, _ = find_dsd()
rtl_fm = find_rtl_fm()
ffmpeg = find_ffmpeg()
return jsonify({
'dsd': dsd_path is not None,
'rtl_fm': rtl_fm is not None,
'ffmpeg': ffmpeg is not None,
'available': dsd_path is not None and rtl_fm is not None,
'protocols': VALID_PROTOCOLS,
})
@@ -304,7 +489,8 @@ 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
global dmr_rtl_process, dmr_dsd_process, dmr_thread
global dmr_running, dmr_has_audio, dmr_active_device
with dmr_lock:
if dmr_running:
@@ -321,18 +507,24 @@ def start_dmr() -> Response:
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))
fine_tune = int(data.get('fineTune', 0) or 0)
demod = str(data.get('demod', 'nfm')).lower()
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
if protocol not in VALID_PROTOCOLS:
return jsonify({'status': 'error', 'message': f'Invalid protocol. Use: {", ".join(VALID_PROTOCOLS)}'}), 400
if demod not in VALID_DEMODS:
return jsonify({'status': 'error', 'message': f'Invalid demod. Use: {", ".join(VALID_DEMODS)}'}), 400
if protocol == 'p25p2' and not is_fme:
return jsonify({'status': 'error', 'message': 'P25 Phase 2 requires dsd-fme.'}), 400
if abs(fine_tune) > 20000:
return jsonify({'status': 'error', 'message': 'Fine tune offset too large (max +/- 20000 Hz).'}), 400
# Clear stale queue
try:
@@ -341,32 +533,54 @@ def start_dmr() -> Response:
except queue.Empty:
pass
# Claim SDR device
error = app_module.claim_sdr_device(device, 'dmr')
# 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:
return jsonify({'status': 'error', 'error_type': 'DEVICE_BUSY', 'message': error}), 409
dmr_active_device = device
freq_hz = int(frequency * 1e6)
freq_hz = int((frequency * 1e6) + fine_tune)
# Build rtl_fm command (48kHz sample rate for DSD)
# Build rtl_fm command (48kHz sample rate for DSD).
# Squelch disabled (-l 0): rtl_fm's squelch chops the bitstream
# mid-frame, destroying DSD sync. The decoder handles silence
# internally via its own frame-sync detection.
rtl_cmd = [
rtl_fm_path,
'-M', 'fm',
'-M', demod,
'-f', str(freq_hz),
'-s', '48000',
'-g', str(gain),
'-d', str(device),
'-l', '1', # squelch level
'-l', '0',
]
if ppm != 0:
rtl_cmd.extend(['-p', str(ppm)])
# 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, []))
@@ -378,10 +592,13 @@ def start_dmr() -> Response:
)
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)
@@ -389,6 +606,20 @@ def start_dmr() -> Response:
# Allow rtl_fm to send directly to dsd
dmr_rtl_process.stdout.close()
# Mark running before starting mux so it doesn't exit immediately.
dmr_running = True
# 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()
@@ -402,6 +633,23 @@ 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}")
# Terminate surviving processes and unregister all
dmr_running = False
dmr_has_audio = 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
if proc:
unregister_process(proc)
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
@@ -425,7 +673,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),
@@ -437,10 +684,13 @@ def start_dmr() -> Response:
'status': 'started',
'frequency': frequency,
'protocol': protocol,
'has_audio': dmr_has_audio,
})
except Exception as e:
logger.error(f"Failed to start DMR: {e}")
dmr_running = False
dmr_has_audio = False
if dmr_active_device is not None:
app_module.release_sdr_device(dmr_active_device)
dmr_active_device = None
@@ -450,10 +700,12 @@ def start_dmr() -> Response:
@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
global dmr_rtl_process, dmr_dsd_process
global dmr_running, dmr_has_audio, dmr_active_device
with dmr_lock:
dmr_running = False
dmr_has_audio = False
for proc in [dmr_dsd_process, dmr_rtl_process]:
if proc and proc.poll() is None:
@@ -484,9 +736,99 @@ 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).
"""
global _active_ffmpeg_stdin
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()
# Tell the mux thread to start writing to this ffmpeg
_active_ffmpeg_stdin = audio_proc.stdin
def generate():
global _active_ffmpeg_stdin
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
_active_ffmpeg_stdin = None
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."""
+118 -78
View File
@@ -46,13 +46,15 @@ audio_modulation = 'fm'
# Scanner state
scanner_thread: Optional[threading.Thread] = None
scanner_running = False
scanner_lock = threading.Lock()
scanner_paused = False
scanner_current_freq = 0.0
scanner_active_device: Optional[int] = None
listening_active_device: Optional[int] = None
scanner_power_process: Optional[subprocess.Popen] = None
scanner_running = False
scanner_lock = threading.Lock()
scanner_paused = False
scanner_current_freq = 0.0
scanner_active_device: Optional[int] = None
scanner_active_sdr_type: Optional[str] = None
listening_active_device: Optional[int] = None
listening_active_sdr_type: Optional[str] = None
scanner_power_process: Optional[subprocess.Popen] = None
scanner_config = {
'start_freq': 88.0,
'end_freq': 108.0,
@@ -936,7 +938,8 @@ def check_tools() -> Response:
@listening_post_bp.route('/scanner/start', methods=['POST'])
def start_scanner() -> Response:
"""Start the frequency scanner."""
global scanner_thread, scanner_running, scanner_config, scanner_active_device, listening_active_device
global scanner_thread, scanner_running, scanner_config, scanner_active_device, listening_active_device
global scanner_active_sdr_type, listening_active_sdr_type
with scanner_lock:
if scanner_running:
@@ -1002,21 +1005,23 @@ def start_scanner() -> Response:
'message': 'rtl_power not found. Install rtl-sdr tools.'
}), 503
# Release listening device if active
if listening_active_device is not None:
app_module.release_sdr_device(listening_active_device)
listening_active_device = None
# Claim device for scanner
error = app_module.claim_sdr_device(scanner_config['device'], 'scanner')
if error:
return jsonify({
'status': 'error',
'error_type': 'DEVICE_BUSY',
'message': error
}), 409
scanner_active_device = scanner_config['device']
scanner_running = True
scanner_thread = threading.Thread(target=scanner_loop_power, daemon=True)
scanner_thread.start()
if listening_active_device is not None:
app_module.release_sdr_device(listening_active_device, listening_active_sdr_type or 'rtlsdr')
listening_active_device = None
listening_active_sdr_type = None
# Claim device for scanner
error = app_module.claim_sdr_device(scanner_config['device'], 'scanner', sdr_type)
if error:
return jsonify({
'status': 'error',
'error_type': 'DEVICE_BUSY',
'message': error
}), 409
scanner_active_device = scanner_config['device']
scanner_active_sdr_type = sdr_type
scanner_running = True
scanner_thread = threading.Thread(target=scanner_loop_power, daemon=True)
scanner_thread.start()
else:
if sdr_type == 'rtlsdr':
if not find_rtl_fm():
@@ -1030,17 +1035,19 @@ def start_scanner() -> Response:
'status': 'error',
'message': f'rx_fm not found. Install SoapySDR utilities for {sdr_type}.'
}), 503
if listening_active_device is not None:
app_module.release_sdr_device(listening_active_device)
listening_active_device = None
error = app_module.claim_sdr_device(scanner_config['device'], 'scanner')
if error:
return jsonify({
'status': 'error',
'error_type': 'DEVICE_BUSY',
'message': error
}), 409
scanner_active_device = scanner_config['device']
if listening_active_device is not None:
app_module.release_sdr_device(listening_active_device, listening_active_sdr_type or 'rtlsdr')
listening_active_device = None
listening_active_sdr_type = None
error = app_module.claim_sdr_device(scanner_config['device'], 'scanner', sdr_type)
if error:
return jsonify({
'status': 'error',
'error_type': 'DEVICE_BUSY',
'message': error
}), 409
scanner_active_device = scanner_config['device']
scanner_active_sdr_type = sdr_type
scanner_running = True
scanner_thread = threading.Thread(target=scanner_loop, daemon=True)
@@ -1053,9 +1060,9 @@ def start_scanner() -> Response:
@listening_post_bp.route('/scanner/stop', methods=['POST'])
def stop_scanner() -> Response:
"""Stop the frequency scanner."""
global scanner_running, scanner_active_device, scanner_power_process
def stop_scanner() -> Response:
"""Stop the frequency scanner."""
global scanner_running, scanner_active_device, scanner_power_process, scanner_active_sdr_type
scanner_running = False
_stop_audio_stream()
@@ -1069,9 +1076,10 @@ def stop_scanner() -> Response:
except Exception:
pass
scanner_power_process = None
if scanner_active_device is not None:
app_module.release_sdr_device(scanner_active_device)
scanner_active_device = None
if scanner_active_device is not None:
app_module.release_sdr_device(scanner_active_device, scanner_active_sdr_type or 'rtlsdr')
scanner_active_device = None
scanner_active_sdr_type = None
return jsonify({'status': 'stopped'})
@@ -1242,14 +1250,16 @@ def get_presets() -> Response:
@listening_post_bp.route('/audio/start', methods=['POST'])
def start_audio() -> Response:
"""Start audio at specific frequency (manual mode)."""
global scanner_running, scanner_active_device, listening_active_device, scanner_power_process, scanner_thread
global scanner_running, scanner_active_device, listening_active_device, scanner_power_process, scanner_thread
global scanner_active_sdr_type, listening_active_sdr_type, waterfall_active_sdr_type
# Stop scanner if running
if scanner_running:
scanner_running = False
if scanner_active_device is not None:
app_module.release_sdr_device(scanner_active_device)
scanner_active_device = None
if scanner_active_device is not None:
app_module.release_sdr_device(scanner_active_device, scanner_active_sdr_type or 'rtlsdr')
scanner_active_device = None
scanner_active_sdr_type = None
if scanner_thread and scanner_thread.is_alive():
try:
scanner_thread.join(timeout=2.0)
@@ -1305,23 +1315,49 @@ def start_audio() -> Response:
scanner_config['device'] = device
scanner_config['sdr_type'] = sdr_type
# Stop waterfall if it's using the same SDR
if waterfall_running and waterfall_active_device == device:
_stop_waterfall_internal()
time.sleep(0.2)
# Stop waterfall if it's using the same SDR (SSE path)
if waterfall_running and waterfall_active_device == device and waterfall_active_sdr_type == sdr_type:
_stop_waterfall_internal()
time.sleep(0.2)
# Claim device for listening audio
# 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')
if listening_active_device is not None:
app_module.release_sdr_device(listening_active_device, listening_active_sdr_type or 'rtlsdr')
listening_active_device = None
listening_active_sdr_type = 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, {}).get(sdr_type) == 'waterfall':
app_module.release_sdr_device(device, sdr_type)
error = app_module.claim_sdr_device(device, 'listening', sdr_type)
if not error:
break
if attempt < max_claim_attempts - 1:
logger.debug(
f"Device claim attempt {attempt + 1}/{max_claim_attempts} "
f"failed, retrying in 0.5s: {error}"
)
time.sleep(0.5)
if error:
return jsonify({
'status': 'error',
'error_type': 'DEVICE_BUSY',
'message': error
}), 409
listening_active_device = device
listening_active_device = device
listening_active_sdr_type = sdr_type
_start_audio_stream(frequency, modulation)
@@ -1339,14 +1375,15 @@ def start_audio() -> Response:
@listening_post_bp.route('/audio/stop', methods=['POST'])
def stop_audio() -> Response:
"""Stop audio."""
global listening_active_device
_stop_audio_stream()
if listening_active_device is not None:
app_module.release_sdr_device(listening_active_device)
listening_active_device = None
return jsonify({'status': 'stopped'})
def stop_audio() -> Response:
"""Stop audio."""
global listening_active_device, listening_active_sdr_type
_stop_audio_stream()
if listening_active_device is not None:
app_module.release_sdr_device(listening_active_device, listening_active_sdr_type or 'rtlsdr')
listening_active_device = None
listening_active_sdr_type = None
return jsonify({'status': 'stopped'})
@listening_post_bp.route('/audio/status')
@@ -1523,9 +1560,10 @@ waterfall_process: Optional[subprocess.Popen] = None
waterfall_thread: Optional[threading.Thread] = None
waterfall_running = False
waterfall_lock = threading.Lock()
waterfall_queue: queue.Queue = queue.Queue(maxsize=200)
waterfall_active_device: Optional[int] = None
waterfall_config = {
waterfall_queue: queue.Queue = queue.Queue(maxsize=200)
waterfall_active_device: Optional[int] = None
waterfall_active_sdr_type: Optional[str] = None
waterfall_config = {
'start_freq': 88.0,
'end_freq': 108.0,
'bin_size': 10000,
@@ -1699,9 +1737,9 @@ def _waterfall_loop():
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
def _stop_waterfall_internal() -> None:
"""Stop the waterfall display and release resources."""
global waterfall_running, waterfall_process, waterfall_active_device, waterfall_active_sdr_type
waterfall_running = False
if waterfall_process and waterfall_process.poll() is None:
@@ -1715,15 +1753,16 @@ def _stop_waterfall_internal() -> None:
pass
waterfall_process = None
if waterfall_active_device is not None:
app_module.release_sdr_device(waterfall_active_device)
waterfall_active_device = None
if waterfall_active_device is not None:
app_module.release_sdr_device(waterfall_active_device, waterfall_active_sdr_type or 'rtlsdr')
waterfall_active_device = None
waterfall_active_sdr_type = None
@listening_post_bp.route('/waterfall/start', methods=['POST'])
def start_waterfall() -> Response:
"""Start the waterfall/spectrogram display."""
global waterfall_thread, waterfall_running, waterfall_config, waterfall_active_device
def start_waterfall() -> Response:
"""Start the waterfall/spectrogram display."""
global waterfall_thread, waterfall_running, waterfall_config, waterfall_active_device, waterfall_active_sdr_type
with waterfall_lock:
if waterfall_running:
@@ -1764,11 +1803,12 @@ def start_waterfall() -> Response:
pass
# Claim SDR device
error = app_module.claim_sdr_device(waterfall_config['device'], 'waterfall')
if error:
return jsonify({'status': 'error', 'error_type': 'DEVICE_BUSY', 'message': error}), 409
waterfall_active_device = waterfall_config['device']
error = app_module.claim_sdr_device(waterfall_config['device'], 'waterfall', 'rtlsdr')
if error:
return jsonify({'status': 'error', 'error_type': 'DEVICE_BUSY', 'message': error}), 409
waterfall_active_device = waterfall_config['device']
waterfall_active_sdr_type = 'rtlsdr'
waterfall_running = True
waterfall_thread = threading.Thread(target=_waterfall_loop, daemon=True)
waterfall_thread.start()
+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'
]
}
+68 -61
View File
@@ -32,8 +32,9 @@ from utils.dependencies import get_tool_path
pager_bp = Blueprint('pager', __name__)
# Track which device is being used
pager_active_device: int | None = None
# Track which device is being used
pager_active_device: int | None = None
pager_active_sdr_type: str | None = None
def parse_multimon_output(line: str) -> dict[str, str] | None:
@@ -205,7 +206,7 @@ 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
global pager_active_device, pager_active_sdr_type
try:
os.close(master_fd)
except OSError:
@@ -233,14 +234,15 @@ def stream_decoder(master_fd: int, process: subprocess.Popen[bytes]) -> None:
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
if pager_active_device is not None:
app_module.release_sdr_device(pager_active_device, pager_active_sdr_type or 'rtlsdr')
pager_active_device = None
pager_active_sdr_type = None
@pager_bp.route('/start', methods=['POST'])
def start_decoding() -> Response:
global pager_active_device
def start_decoding() -> Response:
global pager_active_device, pager_active_sdr_type
with app_module.process_lock:
if app_module.current_process:
@@ -262,33 +264,42 @@ def start_decoding() -> Response:
squelch = int(squelch)
if not 0 <= squelch <= 1000:
raise ValueError("Squelch must be between 0 and 1000")
except (ValueError, TypeError):
return jsonify({'status': 'error', 'message': 'Invalid squelch value'}), 400
# Check for rtl_tcp (remote SDR) connection
rtl_tcp_host = data.get('rtl_tcp_host')
rtl_tcp_port = data.get('rtl_tcp_port', 1234)
# Claim local device if not using remote rtl_tcp
if not rtl_tcp_host:
device_int = int(device)
error = app_module.claim_sdr_device(device_int, 'pager')
if error:
return jsonify({
'status': 'error',
'error_type': 'DEVICE_BUSY',
'message': error
}), 409
pager_active_device = device_int
except (ValueError, TypeError):
return jsonify({'status': 'error', 'message': 'Invalid squelch value'}), 400
# Get SDR type and build command via abstraction layer
sdr_type_str = data.get('sdr_type', 'rtlsdr')
try:
sdr_type = SDRType(sdr_type_str)
except ValueError:
sdr_type = SDRType.RTL_SDR
# Check for rtl_tcp (remote SDR) connection
rtl_tcp_host = data.get('rtl_tcp_host')
rtl_tcp_port = data.get('rtl_tcp_port', 1234)
# Claim local device if not using remote rtl_tcp
if not rtl_tcp_host:
device_int = int(device)
error = app_module.claim_sdr_device(device_int, 'pager', sdr_type.value)
if error:
return jsonify({
'status': 'error',
'error_type': 'DEVICE_BUSY',
'message': error
}), 409
pager_active_device = device_int
pager_active_sdr_type = sdr_type.value
# Validate protocols
valid_protocols = ['POCSAG512', 'POCSAG1200', 'POCSAG2400', 'FLEX']
protocols = data.get('protocols', valid_protocols)
if not isinstance(protocols, list):
if pager_active_device is not None:
app_module.release_sdr_device(pager_active_device)
pager_active_device = None
return jsonify({'status': 'error', 'message': 'Protocols must be a list'}), 400
if not isinstance(protocols, list):
if pager_active_device is not None:
app_module.release_sdr_device(pager_active_device, pager_active_sdr_type or 'rtlsdr')
pager_active_device = None
pager_active_sdr_type = None
return jsonify({'status': 'error', 'message': 'Protocols must be a list'}), 400
protocols = [p for p in protocols if p in valid_protocols]
if not protocols:
protocols = valid_protocols
@@ -312,14 +323,7 @@ def start_decoding() -> Response:
elif proto == 'FLEX':
decoders.extend(['-a', 'FLEX'])
# Get SDR type and build command via abstraction layer
sdr_type_str = data.get('sdr_type', 'rtlsdr')
try:
sdr_type = SDRType(sdr_type_str)
except ValueError:
sdr_type = SDRType.RTL_SDR
if rtl_tcp_host:
if rtl_tcp_host:
# Validate and create network device
try:
rtl_tcp_host = validate_rtl_tcp_host(rtl_tcp_host)
@@ -416,22 +420,23 @@ 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 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:
# Release device on failure
if pager_active_device is not None:
app_module.release_sdr_device(pager_active_device, pager_active_sdr_type or 'rtlsdr')
pager_active_device = None
pager_active_sdr_type = 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()
@@ -441,16 +446,17 @@ def start_decoding() -> Response:
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': str(e)})
# Release device on failure
if pager_active_device is not None:
app_module.release_sdr_device(pager_active_device, pager_active_sdr_type or 'rtlsdr')
pager_active_device = None
pager_active_sdr_type = None
return jsonify({'status': 'error', 'message': str(e)})
@pager_bp.route('/stop', methods=['POST'])
def stop_decoding() -> Response:
global pager_active_device
def stop_decoding() -> Response:
global pager_active_device, pager_active_sdr_type
with app_module.process_lock:
if app_module.current_process:
@@ -485,10 +491,11 @@ def stop_decoding() -> Response:
app_module.current_process = None
# Release device from registry
if pager_active_device is not None:
app_module.release_sdr_device(pager_active_device)
pager_active_device = None
# Release device from registry
if pager_active_device is not None:
app_module.release_sdr_device(pager_active_device, pager_active_sdr_type or 'rtlsdr')
pager_active_device = None
pager_active_sdr_type = None
return jsonify({'status': 'stopped'})
+21 -2
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.
@@ -481,7 +498,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 +553,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'})
+60 -45
View File
@@ -25,8 +25,9 @@ from utils.sdr import SDRFactory, SDRType
sensor_bp = Blueprint('sensor', __name__)
# Track which device is being used
sensor_active_device: int | None = None
# Track which device is being used
sensor_active_device: int | None = None
sensor_active_sdr_type: str | None = None
def stream_sensor_output(process: subprocess.Popen[bytes]) -> None:
@@ -75,7 +76,7 @@ def stream_sensor_output(process: subprocess.Popen[bytes]) -> None:
except Exception as e:
app_module.sensor_queue.put({'type': 'error', 'text': str(e)})
finally:
global sensor_active_device
global sensor_active_device, sensor_active_sdr_type
# Ensure process is terminated
try:
process.terminate()
@@ -90,14 +91,23 @@ def stream_sensor_output(process: subprocess.Popen[bytes]) -> None:
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
if sensor_active_device is not None:
app_module.release_sdr_device(sensor_active_device, sensor_active_sdr_type or 'rtlsdr')
sensor_active_device = None
sensor_active_sdr_type = 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'])
def start_sensor() -> Response:
global sensor_active_device
def start_sensor() -> Response:
global sensor_active_device, sensor_active_sdr_type
with app_module.sensor_lock:
if app_module.sensor_process:
@@ -114,21 +124,29 @@ def start_sensor() -> Response:
except ValueError as e:
return jsonify({'status': 'error', 'message': str(e)}), 400
# Check for rtl_tcp (remote SDR) connection
rtl_tcp_host = data.get('rtl_tcp_host')
rtl_tcp_port = data.get('rtl_tcp_port', 1234)
# Get SDR type and build command via abstraction layer
sdr_type_str = data.get('sdr_type', 'rtlsdr')
try:
sdr_type = SDRType(sdr_type_str)
except ValueError:
sdr_type = SDRType.RTL_SDR
# Check for rtl_tcp (remote SDR) connection
rtl_tcp_host = data.get('rtl_tcp_host')
rtl_tcp_port = data.get('rtl_tcp_port', 1234)
# Claim local device if not using remote rtl_tcp
if not rtl_tcp_host:
device_int = int(device)
error = app_module.claim_sdr_device(device_int, 'sensor')
if error:
return jsonify({
'status': 'error',
'error_type': 'DEVICE_BUSY',
'message': error
}), 409
sensor_active_device = device_int
error = app_module.claim_sdr_device(device_int, 'sensor', sdr_type.value)
if error:
return jsonify({
'status': 'error',
'error_type': 'DEVICE_BUSY',
'message': error
}), 409
sensor_active_device = device_int
sensor_active_sdr_type = sdr_type.value
# Clear queue
while not app_module.sensor_queue.empty():
@@ -137,14 +155,7 @@ def start_sensor() -> Response:
except queue.Empty:
break
# Get SDR type and build command via abstraction layer
sdr_type_str = data.get('sdr_type', 'rtlsdr')
try:
sdr_type = SDRType(sdr_type_str)
except ValueError:
sdr_type = SDRType.RTL_SDR
if rtl_tcp_host:
if rtl_tcp_host:
# Validate and create network device
try:
rtl_tcp_host = validate_rtl_tcp_host(rtl_tcp_host)
@@ -174,7 +185,8 @@ def start_sensor() -> Response:
logger.info(f"Running: {full_cmd}")
# Add signal level metadata so the frontend scope can display RSSI/SNR
cmd.extend(['-M', 'level'])
# 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(
@@ -205,23 +217,25 @@ def start_sensor() -> Response:
return jsonify({'status': 'started', 'command': full_cmd})
except FileNotFoundError:
# Release device on failure
if sensor_active_device is not None:
app_module.release_sdr_device(sensor_active_device)
sensor_active_device = None
return jsonify({'status': 'error', 'message': 'rtl_433 not found. Install with: brew install rtl_433'})
except Exception as e:
# Release device on failure
if sensor_active_device is not None:
app_module.release_sdr_device(sensor_active_device)
sensor_active_device = None
return jsonify({'status': 'error', 'message': str(e)})
except FileNotFoundError:
# Release device on failure
if sensor_active_device is not None:
app_module.release_sdr_device(sensor_active_device, sensor_active_sdr_type or 'rtlsdr')
sensor_active_device = None
sensor_active_sdr_type = None
return jsonify({'status': 'error', 'message': 'rtl_433 not found. Install with: brew install rtl_433'})
except Exception as e:
# Release device on failure
if sensor_active_device is not None:
app_module.release_sdr_device(sensor_active_device, sensor_active_sdr_type or 'rtlsdr')
sensor_active_device = None
sensor_active_sdr_type = None
return jsonify({'status': 'error', 'message': str(e)})
@sensor_bp.route('/stop_sensor', methods=['POST'])
def stop_sensor() -> Response:
global sensor_active_device
def stop_sensor() -> Response:
global sensor_active_device, sensor_active_sdr_type
with app_module.sensor_lock:
if app_module.sensor_process:
@@ -233,9 +247,10 @@ def stop_sensor() -> Response:
app_module.sensor_process = None
# Release device from registry
if sensor_active_device is not None:
app_module.release_sdr_device(sensor_active_device)
sensor_active_device = None
if sensor_active_device is not None:
app_module.release_sdr_device(sensor_active_device, sensor_active_sdr_type or 'rtlsdr')
sensor_active_device = None
sensor_active_sdr_type = None
return jsonify({'status': 'stopped'})
+6
View File
@@ -551,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."""
+137 -70
View File
@@ -1,6 +1,8 @@
"""WebSocket-based waterfall streaming with I/Q capture and server-side FFT."""
import json
import queue
import socket
import subprocess
import threading
import time
@@ -81,28 +83,46 @@ def init_waterfall_websocket(app: Flask):
# Import app module for device claiming
import app as app_module
iq_process = None
reader_thread = None
stop_event = threading.Event()
claimed_device = None
iq_process = None
reader_thread = None
stop_event = threading.Event()
claimed_device = None
claimed_sdr_type = 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 TimeoutError:
if stop_event.is_set():
break
continue
except Exception as e:
if "closed" in str(e).lower():
err = str(e).lower()
if "closed" in err:
break
if "timed out" not in str(e).lower():
if "timed out" not in err:
logger.error(f"WebSocket receive error: {e}")
continue
if msg is None:
break
# 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)
@@ -113,17 +133,28 @@ def init_waterfall_websocket(app: Flask):
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
if iq_process:
safe_terminate(iq_process)
unregister_process(iq_process)
iq_process = None
if claimed_device is not None:
app_module.release_sdr_device(claimed_device, claimed_sdr_type or 'rtlsdr')
claimed_device = None
claimed_sdr_type = 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))
@@ -156,15 +187,16 @@ def init_waterfall_websocket(app: Flask):
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',
claim_err = app_module.claim_sdr_device(device_index, 'waterfall', sdr_type.value)
if claim_err:
ws.send(json.dumps({
'status': 'error',
'message': claim_err,
'error_type': 'DEVICE_BUSY',
}))
continue
claimed_device = device_index
claimed_device = device_index
claimed_sdr_type = sdr_type.value
# Build I/Q capture command
try:
@@ -178,44 +210,60 @@ def init_waterfall_websocket(app: Flask):
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',
except NotImplementedError as e:
app_module.release_sdr_device(device_index, sdr_type.value)
claimed_device = None
claimed_sdr_type = None
ws.send(json.dumps({
'status': 'error',
'message': str(e),
}))
continue
# Spawn I/Q capture process
# Spawn I/Q capture process (retry to handle USB release lag)
max_attempts = 3 if was_restarting else 1
try:
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)
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.2)
if iq_process.poll() is not None:
raise RuntimeError("I/Q capture process exited immediately")
# 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',
app_module.release_sdr_device(device_index, sdr_type.value)
claimed_device = None
claimed_sdr_type = None
ws.send(json.dumps({
'status': 'error',
'message': f'Failed to start I/Q capture: {e}',
}))
continue
@@ -229,13 +277,13 @@ def init_waterfall_websocket(app: Flask):
'sample_rate': sample_rate,
}))
# Start reader thread
# Start reader thread — puts frames on queue, never calls ws.send()
def fft_reader(
proc, ws_ref, stop_evt,
proc, _send_q, stop_evt,
_fft_size, _avg_count, _fps,
_start_freq, _end_freq,
):
"""Read I/Q from subprocess, compute FFT, send binary frames."""
"""Read I/Q from subprocess, compute FFT, enqueue binary frames."""
bytes_per_frame = _fft_size * _avg_count * 2
frame_interval = 1.0 / _fps
@@ -272,9 +320,10 @@ def init_waterfall_websocket(app: Flask):
)
try:
ws_ref.send(frame)
except Exception:
break
_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
@@ -288,7 +337,7 @@ def init_waterfall_websocket(app: Flask):
reader_thread = threading.Thread(
target=fft_reader,
args=(
iq_process, ws, stop_event,
iq_process, send_queue, stop_event,
fft_size, avg_count, fps,
start_freq, end_freq,
),
@@ -301,15 +350,16 @@ def init_waterfall_websocket(app: Flask):
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'}))
if iq_process:
safe_terminate(iq_process)
unregister_process(iq_process)
iq_process = None
if claimed_device is not None:
app_module.release_sdr_device(claimed_device, claimed_sdr_type or 'rtlsdr')
claimed_device = None
claimed_sdr_type = None
stop_event.clear()
ws.send(json.dumps({'status': 'stopped'}))
except Exception as e:
logger.info(f"WebSocket waterfall closed: {e}")
@@ -318,9 +368,26 @@ def init_waterfall_websocket(app: Flask):
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)
if iq_process:
safe_terminate(iq_process)
unregister_process(iq_process)
if claimed_device is not None:
app_module.release_sdr_device(claimed_device, claimed_sdr_type or 'rtlsdr')
claimed_sdr_type = None
# 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
+148 -2
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
@@ -204,6 +217,7 @@ check_tools() {
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 "satdump" "Weather satellite decoder (NOAA/Meteor)" satdump
echo
info "GPS:"
check_required "gpsd" "GPS daemon" gpsd
@@ -604,8 +618,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"
@@ -682,6 +781,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
@@ -979,7 +1091,7 @@ install_debian_packages() {
export NEEDRESTART_MODE=a
fi
TOTAL_STEPS=22
TOTAL_STEPS=26
CURRENT_STEP=0
progress "Updating APT package lists"
@@ -1136,6 +1248,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
@@ -1185,6 +1310,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
}
# ----------------------------
@@ -1231,6 +1364,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;
File diff suppressed because it is too large Load Diff
+6
View File
@@ -26,6 +26,9 @@
border-radius: 8px;
max-width: 600px;
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);
}
@@ -115,6 +118,9 @@
.settings-section.active {
display: block;
overflow-y: auto;
flex: 1;
min-height: 0;
}
.settings-group {
+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,
+51
View File
@@ -930,5 +930,56 @@ function switchSettingsTab(tabName) {
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';
}
+408 -12
View File
@@ -10,6 +10,16 @@ let dmrCallCount = 0;
let dmrSyncCount = 0;
let dmrCallHistory = [];
let dmrCurrentProtocol = '--';
let dmrModeLabel = 'dmr'; // Protocol label for device reservation
let dmrHasAudio = false;
let dmrQualitySamples = [];
let dmrQualityScore = null;
let dmrSweepInProgress = false;
// ============== BOOKMARKS ==============
let dmrBookmarks = [];
const DMR_BOOKMARKS_KEY = 'dmrBookmarks';
const DMR_SETTINGS_KEY = 'dmrSettings';
// ============== SYNTHESIZER STATE ==============
let dmrSynthCanvas = null;
@@ -40,6 +50,7 @@ function checkDmrTools() {
const missing = [];
if (!data.dsd) missing.push('dsd (Digital Speech Decoder)');
if (!data.rtl_fm) missing.push('rtl_fm (RTL-SDR)');
if (!data.ffmpeg) missing.push('ffmpeg (audio output — optional)');
if (missing.length > 0) {
warning.style.display = 'block';
@@ -47,6 +58,9 @@ function checkDmrTools() {
} else {
warning.style.display = 'none';
}
// Update audio panel availability
updateDmrAudioStatus(data.ffmpeg ? 'OFF' : 'UNAVAILABLE');
})
.catch(() => {});
}
@@ -57,22 +71,39 @@ 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 fineTune = parseInt(document.getElementById('dmrFineTune')?.value || 0);
const relaxCrc = document.getElementById('dmrRelaxCrc')?.checked || false;
const demod = document.getElementById('dmrDemod')?.value || 'nfm';
const device = typeof getSelectedDevice === 'function' ? getSelectedDevice() : 0;
// 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('dmr')) {
if (typeof checkDeviceAvailability === 'function' && !checkDeviceAvailability(dmrModeLabel)) {
return;
}
fetch('/dmr/start', {
// Save settings to localStorage for persistence
try {
localStorage.setItem(DMR_SETTINGS_KEY, JSON.stringify({
frequency, protocol, gain, ppm, fineTune, relaxCrc, demod
}));
} catch (e) { /* localStorage unavailable */ }
return 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, fineTune, relaxCrc, demod })
})
.then(r => r.json())
.then(data => {
if (data.status === 'started') {
isDmrRunning = true;
dmrQualitySamples = [];
dmrQualityScore = null;
updateDmrQualityUI();
dmrCallCount = 0;
dmrSyncCount = 0;
dmrCallHistory = [];
@@ -86,10 +117,29 @@ function startDmr() {
const statusEl = document.getElementById('dmrStatus');
if (statusEl) statusEl.textContent = 'DECODING';
if (typeof reserveDevice === 'function') {
reserveDevice(parseInt(device), 'dmr');
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') {
@@ -101,19 +151,24 @@ function startDmr() {
}
function stopDmr() {
fetch('/dmr/stop', { method: 'POST' })
stopDmrAudio();
return fetch('/dmr/stop', { method: 'POST' })
.then(r => r.json())
.then(() => {
isDmrRunning = false;
if (dmrEventSource) { dmrEventSource.close(); dmrEventSource = null; }
dmrQualitySamples = [];
dmrQualityScore = null;
updateDmrQualityUI();
updateDmrUI();
dmrEventType = 'stopped';
dmrActivityTarget = 0;
updateDmrSynthStatus();
updateDmrAudioStatus('OFF');
const statusEl = document.getElementById('dmrStatus');
if (statusEl) statusEl.textContent = 'STOPPED';
if (typeof releaseDevice === 'function') {
releaseDevice('dmr');
releaseDevice(dmrModeLabel);
}
})
.catch(err => console.error('[DMR] Stop error:', err));
@@ -150,6 +205,7 @@ function handleDmrMessage(msg) {
const syncCountEl = document.getElementById('dmrSyncCount');
if (syncCountEl) syncCountEl.textContent = dmrSyncCount;
} else if (msg.type === 'call') {
recordDmrQuality(true);
dmrCallCount++;
const countEl = document.getElementById('dmrCallCount');
if (countEl) countEl.textContent = dmrCallCount;
@@ -192,8 +248,14 @@ function handleDmrMessage(msg) {
} else if (msg.type === 'slot') {
// Update slot info in current call
} else if (msg.type === 'frame_ok') {
recordDmrQuality(true);
} else if (msg.type === 'frame_error') {
recordDmrQuality(false);
} else if (msg.type === 'raw') {
// Raw DSD output — triggers synthesizer activity via dmrSynthPulse
} else if (msg.type === 'voice') {
recordDmrQuality(true);
} else if (msg.type === 'heartbeat') {
// Decoder is alive and listening — keep synthesizer in listening state
if (isDmrRunning && dmrSynthInitialized) {
@@ -210,28 +272,137 @@ function handleDmrMessage(msg) {
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('dmr');
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('dmr');
if (typeof releaseDevice === 'function') releaseDevice(dmrModeLabel);
}
}
}
// ============== QUALITY METER ==============
function recordDmrQuality(ok) {
dmrQualitySamples.push(!!ok);
if (dmrQualitySamples.length > 200) dmrQualitySamples.shift();
const total = dmrQualitySamples.length;
if (total < 5) {
dmrQualityScore = null;
updateDmrQualityUI();
return;
}
const errors = dmrQualitySamples.reduce((sum, v) => sum + (v ? 0 : 1), 0);
dmrQualityScore = Math.max(0, Math.min(100, Math.round(100 * (1 - (errors / total)))));
updateDmrQualityUI();
}
function updateDmrQualityUI() {
const textEl = document.getElementById('dmrQualityText');
const barEl = document.getElementById('dmrQualityBar');
if (!textEl || !barEl) return;
if (dmrQualityScore == null) {
textEl.textContent = '--';
barEl.style.width = '0%';
barEl.style.background = 'var(--text-muted)';
return;
}
textEl.textContent = `${dmrQualityScore}%`;
barEl.style.width = `${dmrQualityScore}%`;
if (dmrQualityScore >= 80) {
barEl.style.background = 'var(--accent-green)';
} else if (dmrQualityScore >= 50) {
barEl.style.background = 'var(--accent-amber, #f59e0b)';
} else {
barEl.style.background = 'var(--accent-red)';
}
}
// ============== FINE TUNE SWEEP ==============
async function sweepDmrFineTune() {
if (!isDmrRunning) {
if (typeof showNotification === 'function') {
showNotification('Digital Voice', 'Start the decoder before sweeping fine tune.');
}
return;
}
if (dmrSweepInProgress) return;
dmrSweepInProgress = true;
const freqEl = document.getElementById('dmrFrequency');
const protoEl = document.getElementById('dmrProtocol');
const gainEl = document.getElementById('dmrGain');
const ppmEl = document.getElementById('dmrPPM');
const fineEl = document.getElementById('dmrFineTune');
const crcEl = document.getElementById('dmrRelaxCrc');
const demodEl = document.getElementById('dmrDemod');
const sweepBtn = document.getElementById('dmrFineTuneSweepBtn');
const original = {
frequency: freqEl?.value,
protocol: protoEl?.value,
gain: gainEl?.value,
ppm: ppmEl?.value,
fineTune: fineEl?.value,
relaxCrc: crcEl?.checked,
demod: demodEl?.value,
};
if (sweepBtn) {
sweepBtn.disabled = true;
sweepBtn.textContent = 'Sweeping...';
}
const offsets = [-2000, -1500, -1000, -500, 0, 500, 1000, 1500, 2000];
let best = { offset: parseInt(original.fineTune || 0, 10) || 0, score: -1 };
for (const offset of offsets) {
if (fineEl) fineEl.value = offset;
await stopDmr();
await new Promise(r => setTimeout(r, 300));
await startDmr();
dmrQualitySamples = [];
dmrQualityScore = null;
updateDmrQualityUI();
await new Promise(r => setTimeout(r, 700));
await new Promise(r => setTimeout(r, 2500));
const score = dmrQualityScore == null ? 0 : dmrQualityScore;
if (score > best.score) best = { offset, score };
}
if (fineEl) fineEl.value = best.offset;
await stopDmr();
await new Promise(r => setTimeout(r, 300));
await startDmr();
if (sweepBtn) {
sweepBtn.disabled = false;
sweepBtn.textContent = 'Sweep Fine Tune';
}
dmrSweepInProgress = false;
if (typeof showNotification === 'function') {
showNotification('Digital Voice', `Sweep complete: best offset ${best.offset} Hz (${best.score}%)`);
}
}
// ============== UI ==============
function updateDmrUI() {
@@ -306,10 +477,12 @@ 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.1 && dmrEventType !== 'stopped') {
dmrEventType = 'idle';
@@ -496,9 +669,232 @@ 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 fineTuneEl = document.getElementById('dmrFineTune');
const crcEl = document.getElementById('dmrRelaxCrc');
const demodEl = document.getElementById('dmrDemod');
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 (fineTuneEl && s.fineTune != null) fineTuneEl.value = s.fineTune;
if (crcEl && s.relaxCrc != null) crcEl.checked = s.relaxCrc;
if (demodEl && s.demod) demodEl.value = s.demod;
} catch (e) { /* localStorage unavailable */ }
}
// ============== BOOKMARKS ==============
function loadDmrBookmarks() {
try {
const saved = localStorage.getItem(DMR_BOOKMARKS_KEY);
dmrBookmarks = saved ? JSON.parse(saved) : [];
} catch (e) {
dmrBookmarks = [];
}
renderDmrBookmarks();
}
function saveDmrBookmarks() {
try {
localStorage.setItem(DMR_BOOKMARKS_KEY, JSON.stringify(dmrBookmarks));
} catch (e) { /* localStorage unavailable */ }
}
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 = 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) freqEl.value = freq;
if (protoEl) protoEl.value = protocol;
}
function renderDmrBookmarks() {
const container = document.getElementById('dmrBookmarksList');
if (!container) return;
if (dmrBookmarks.length === 0) {
container.innerHTML = '<div style="color: var(--text-muted); text-align: center; padding: 10px; font-size: 11px;">No bookmarks saved</div>';
return;
}
container.innerHTML = dmrBookmarks.map((b, i) => `
<div style="display: flex; justify-content: space-between; align-items: center; padding: 4px 6px; background: rgba(0,0,0,0.2); border-radius: 3px; margin-bottom: 3px;">
<span style="cursor: pointer; color: var(--accent-cyan); font-size: 11px; flex: 1;" onclick="dmrQuickTune(${b.freq}, '${b.protocol}')" title="${b.freq.toFixed(4)} MHz (${b.protocol.toUpperCase()})">${b.label}</span>
<span style="color: var(--text-muted); font-size: 9px; margin: 0 6px;">${b.protocol.toUpperCase()}</span>
<button onclick="removeDmrBookmark(${i})" style="background: none; border: none; color: var(--accent-red); cursor: pointer; font-size: 12px; padding: 0 4px;">&times;</button>
</div>
`).join('');
}
// ============== 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;
window.sweepDmrFineTune = sweepDmrFineTune;
+168 -8
View File
@@ -1018,8 +1018,16 @@ function addSignalHit(data) {
<td style="padding: 4px; color: var(--accent-green); font-weight: bold;">${data.frequency.toFixed(3)}</td>
<td style="padding: 4px; color: ${snrColor}; font-weight: bold; font-size: 9px;">${snrText}</td>
<td style="padding: 4px; color: var(--text-secondary);">${mod.toUpperCase()}</td>
<td style="padding: 4px; text-align: center;">
<td style="padding: 4px; text-align: center; white-space: nowrap;">
<button class="preset-btn" onclick="tuneToFrequency(${data.frequency}, '${mod}')" style="padding: 2px 6px; font-size: 9px; background: var(--accent-green); border: none; color: #000; cursor: pointer; border-radius: 3px;">Listen</button>
<span style="position:relative;display:inline-block;">
<button class="preset-btn" onclick="this.nextElementSibling.style.display = this.nextElementSibling.style.display === 'block' ? 'none' : 'block'" style="padding:2px 5px; font-size:9px; background:var(--accent-cyan); border:none; color:#000; cursor:pointer; border-radius:3px; margin-left:3px;" title="Send frequency to decoder">&#9654;</button>
<div style="display:none; position:absolute; right:0; top:100%; background:var(--bg-primary); border:1px solid var(--border-color); border-radius:4px; z-index:100; min-width:90px; padding:2px; box-shadow:0 2px 8px rgba(0,0,0,0.4);">
<div onclick="sendFrequencyToMode(${data.frequency}, 'pager'); this.parentElement.style.display='none'" style="padding:3px 8px; cursor:pointer; font-size:9px; color:var(--text-primary); border-radius:3px;" onmouseover="this.style.background='var(--bg-secondary)'" onmouseout="this.style.background='transparent'">Pager</div>
<div onclick="sendFrequencyToMode(${data.frequency}, 'sensor'); this.parentElement.style.display='none'" style="padding:3px 8px; cursor:pointer; font-size:9px; color:var(--text-primary); border-radius:3px;" onmouseover="this.style.background='var(--bg-secondary)'" onmouseout="this.style.background='transparent'">433 Sensor</div>
<div onclick="sendFrequencyToMode(${data.frequency}, 'rtlamr'); this.parentElement.style.display='none'" style="padding:3px 8px; cursor:pointer; font-size:9px; color:var(--text-primary); border-radius:3px;" onmouseover="this.style.background='var(--bg-secondary)'" onmouseout="this.style.background='transparent'">RTLAMR</div>
</div>
</span>
</td>
`;
tbody.insertBefore(row, tbody.firstChild);
@@ -3056,6 +3064,27 @@ function renderSignalGuess(result) {
altsEl.innerHTML = '';
}
}
const sendToEl = document.getElementById('signalGuessSendTo');
if (sendToEl) {
const freqInput = document.getElementById('signalGuessFreqInput');
const freq = freqInput ? parseFloat(freqInput.value) : NaN;
if (!isNaN(freq) && freq > 0) {
const tags = (result.tags || []).map(t => t.toLowerCase());
const modes = [
{ key: 'pager', label: 'Pager', highlight: tags.some(t => t.includes('pager') || t.includes('pocsag') || t.includes('flex')) },
{ key: 'sensor', label: '433 Sensor', highlight: tags.some(t => t.includes('ism') || t.includes('433') || t.includes('sensor') || t.includes('iot')) },
{ key: 'rtlamr', label: 'RTLAMR', highlight: tags.some(t => t.includes('meter') || t.includes('amr') || t.includes('utility')) }
];
sendToEl.style.display = 'block';
sendToEl.innerHTML = '<div style="font-size:9px; color:var(--text-muted); margin-bottom:4px;">Send to:</div><div style="display:flex; gap:4px;">' +
modes.map(m =>
`<button class="preset-btn" onclick="sendFrequencyToMode(${freq}, '${m.key}')" style="padding:2px 8px; font-size:9px; border:none; color:#000; cursor:pointer; border-radius:3px; background:${m.highlight ? 'var(--accent-green)' : 'var(--accent-cyan)'}; ${m.highlight ? 'font-weight:bold;' : ''}">${m.label}</button>`
).join('') + '</div>';
} else {
sendToEl.style.display = 'none';
}
}
}
function manualSignalGuess() {
@@ -3172,8 +3201,12 @@ function setWaterfallControlButtons(running) {
const startBtn = document.getElementById('startWaterfallBtn');
const stopBtn = document.getElementById('stopWaterfallBtn');
if (!startBtn || !stopBtn) return;
startBtn.style.display = running ? 'none' : 'block';
stopBtn.style.display = running ? 'block' : 'none';
startBtn.style.display = running ? 'none' : 'inline-block';
stopBtn.style.display = running ? 'inline-block' : 'none';
const dot = document.getElementById('waterfallStripDot');
if (dot) {
dot.className = running ? 'status-dot sweeping' : 'status-dot inactive';
}
}
function getWaterfallRangeFromInputs() {
@@ -3250,6 +3283,26 @@ async function syncWaterfallToFrequency(freq, options = {}) {
if (isDirectListening || waterfallMode === 'audio') return { started: false };
if (isWaterfallRunning && waterfallMode === 'rf' && restartIfRunning) {
// Reuse existing WebSocket to avoid USB device release race
if (waterfallUseWebSocket && waterfallWebSocket && waterfallWebSocket.readyState === WebSocket.OPEN) {
const sf = parseFloat(document.getElementById('waterfallStartFreq')?.value || 88);
const ef = parseFloat(document.getElementById('waterfallEndFreq')?.value || 108);
const fft = parseInt(document.getElementById('waterfallFftSize')?.value || document.getElementById('waterfallBinSize')?.value || 1024);
const g = parseInt(document.getElementById('waterfallGain')?.value || 40);
const dev = typeof getSelectedDevice === 'function' ? getSelectedDevice() : 0;
waterfallWebSocket.send(JSON.stringify({
cmd: 'start',
center_freq: (sf + ef) / 2,
span_mhz: Math.max(0.1, ef - sf),
gain: g,
device: dev,
sdr_type: (typeof getSelectedSdrType === 'function') ? getSelectedSdrType() : 'rtlsdr',
fft_size: fft,
fps: 25,
avg_count: 4,
}));
return { started: true };
}
await stopWaterfall();
return await startWaterfall({ silent: silent });
}
@@ -3275,8 +3328,28 @@ async function zoomWaterfall(direction) {
setWaterfallRange(center, newSpan);
if (isWaterfallRunning && waterfallMode === 'rf' && !isDirectListening) {
await stopWaterfall();
await startWaterfall({ silent: true });
// Reuse existing WebSocket to avoid USB device release race
if (waterfallUseWebSocket && waterfallWebSocket && waterfallWebSocket.readyState === WebSocket.OPEN) {
const sf = parseFloat(document.getElementById('waterfallStartFreq')?.value || 88);
const ef = parseFloat(document.getElementById('waterfallEndFreq')?.value || 108);
const fft = parseInt(document.getElementById('waterfallFftSize')?.value || document.getElementById('waterfallBinSize')?.value || 1024);
const g = parseInt(document.getElementById('waterfallGain')?.value || 40);
const dev = typeof getSelectedDevice === 'function' ? getSelectedDevice() : 0;
waterfallWebSocket.send(JSON.stringify({
cmd: 'start',
center_freq: (sf + ef) / 2,
span_mhz: Math.max(0.1, ef - sf),
gain: g,
device: dev,
sdr_type: (typeof getSelectedSdrType === 'function') ? getSelectedSdrType() : 'rtlsdr',
fft_size: fft,
fps: 25,
avg_count: 4,
}));
} else {
await stopWaterfall();
await startWaterfall({ silent: true });
}
}
}
@@ -3870,11 +3943,31 @@ async function stopWaterfall() {
// WebSocket path
if (waterfallUseWebSocket && waterfallWebSocket) {
const ws = waterfallWebSocket;
try {
if (waterfallWebSocket.readyState === WebSocket.OPEN) {
waterfallWebSocket.send(JSON.stringify({ cmd: 'stop' }));
if (ws.readyState === WebSocket.OPEN) {
// Wait for server to confirm stop (it terminates the IQ
// process and releases the USB device before responding).
await new Promise((resolve) => {
const timeout = setTimeout(resolve, 4000);
const prevHandler = ws.onmessage;
ws.onmessage = (event) => {
if (typeof event.data === 'string') {
try {
const msg = JSON.parse(event.data);
if (msg.status === 'stopped') {
clearTimeout(timeout);
resolve();
return;
}
} catch (_) {}
}
if (prevHandler) prevHandler(event);
};
ws.send(JSON.stringify({ cmd: 'stop' }));
});
}
waterfallWebSocket.close();
ws.close();
} catch (e) {
console.error('[WATERFALL] WebSocket stop error:', e);
}
@@ -3977,21 +4070,88 @@ function bindWaterfallInteraction() {
tooltip.style.display = 'none';
};
// Right-click context menu for "Send to" decoder
let ctxMenu = document.getElementById('waterfallCtxMenu');
if (!ctxMenu) {
ctxMenu = document.createElement('div');
ctxMenu.id = 'waterfallCtxMenu';
ctxMenu.style.cssText = 'position:fixed;display:none;background:var(--bg-primary);border:1px solid var(--border-color);border-radius:4px;z-index:10000;min-width:120px;padding:4px 0;box-shadow:0 4px 12px rgba(0,0,0,0.5);font-size:11px;';
document.body.appendChild(ctxMenu);
document.addEventListener('click', () => { ctxMenu.style.display = 'none'; });
}
const contextHandler = (event) => {
if (waterfallMode === 'audio') return;
event.preventDefault();
const canvas = event.currentTarget;
const rect = canvas.getBoundingClientRect();
const x = event.clientX - rect.left;
const ratio = Math.max(0, Math.min(1, x / rect.width));
const freq = waterfallStartFreq + ratio * (waterfallEndFreq - waterfallStartFreq);
const modes = [
{ key: 'pager', label: 'Pager' },
{ key: 'sensor', label: '433 Sensor' },
{ key: 'rtlamr', label: 'RTLAMR' }
];
ctxMenu.innerHTML = `<div style="padding:4px 10px; color:var(--text-muted); font-size:9px; border-bottom:1px solid var(--border-color); margin-bottom:2px;">${freq.toFixed(3)} MHz &rarr;</div>` +
modes.map(m =>
`<div onclick="sendFrequencyToMode(${freq}, '${m.key}')" style="padding:4px 10px; cursor:pointer; color:var(--text-primary);" onmouseover="this.style.background='var(--bg-secondary)'" onmouseout="this.style.background='transparent'">Send to ${m.label}</div>`
).join('');
ctxMenu.style.left = event.clientX + 'px';
ctxMenu.style.top = event.clientY + 'px';
ctxMenu.style.display = 'block';
};
if (waterfallCanvas) {
waterfallCanvas.style.cursor = 'crosshair';
waterfallCanvas.addEventListener('click', handler);
waterfallCanvas.addEventListener('mousemove', hoverHandler);
waterfallCanvas.addEventListener('mouseleave', leaveHandler);
waterfallCanvas.addEventListener('contextmenu', contextHandler);
}
if (spectrumCanvas) {
spectrumCanvas.style.cursor = 'crosshair';
spectrumCanvas.addEventListener('click', handler);
spectrumCanvas.addEventListener('mousemove', hoverHandler);
spectrumCanvas.addEventListener('mouseleave', leaveHandler);
spectrumCanvas.addEventListener('contextmenu', contextHandler);
}
}
// ============== CROSS-MODULE FREQUENCY ROUTING ==============
function sendFrequencyToMode(freqMhz, targetMode) {
const inputMap = {
pager: 'frequency',
sensor: 'sensorFrequency',
rtlamr: 'rtlamrFrequency'
};
const inputId = inputMap[targetMode];
if (!inputId) return;
if (typeof switchMode === 'function') {
switchMode(targetMode);
}
setTimeout(() => {
const input = document.getElementById(inputId);
if (input) {
input.value = freqMhz.toFixed(4);
}
}, 300);
if (typeof showNotification === 'function') {
const modeLabels = { pager: 'Pager', sensor: '433 Sensor', rtlamr: 'RTLAMR' };
showNotification('Frequency Sent', `${freqMhz.toFixed(3)} MHz → ${modeLabels[targetMode] || targetMode}`);
}
}
window.sendFrequencyToMode = sendFrequencyToMode;
window.stopDirectListen = stopDirectListen;
window.toggleScanner = toggleScanner;
window.startScanner = startScanner;
File diff suppressed because it is too large Load Diff
+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
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 -->
+51 -26
View File
@@ -449,7 +449,10 @@
devices.forEach((d, i) => {
const opt = document.createElement('option');
opt.value = d.index;
opt.textContent = `SDR ${d.index}: ${d.name}`;
const sdrType = (d.sdr_type || d.type || 'rtlsdr').toLowerCase();
const sdrLabel = sdrType.toUpperCase();
opt.dataset.sdrType = sdrType;
opt.textContent = `SDR ${d.index} (${sdrLabel}): ${d.name}`;
aisSelect.appendChild(opt);
});
}
@@ -457,18 +460,23 @@
// Populate DSC device selector
const dscSelect = document.getElementById('dscDeviceSelect');
dscSelect.innerHTML = '';
if (devices.length === 0) {
dscSelect.innerHTML = '<option value="0">No devices</option>';
const dscDevices = devices.filter(d => {
const sdrType = (d.sdr_type || d.type || 'rtlsdr').toLowerCase();
return sdrType === 'rtlsdr';
});
if (dscDevices.length === 0) {
dscSelect.innerHTML = '<option value="0">No RTL-SDR found</option>';
} else {
devices.forEach((d, i) => {
dscDevices.forEach((d, i) => {
const opt = document.createElement('option');
opt.value = d.index;
opt.textContent = `SDR ${d.index}: ${d.name}`;
opt.dataset.sdrType = 'rtlsdr';
opt.textContent = `SDR ${d.index} (RTLSDR): ${d.name}`;
dscSelect.appendChild(opt);
});
// Default to second device if available
if (devices.length > 1) {
dscSelect.value = devices[1].index;
if (dscDevices.length > 1) {
dscSelect.value = dscDevices[1].index;
}
}
})
@@ -546,7 +554,9 @@
}
function startTracking() {
const device = document.getElementById('aisDeviceSelect').value;
const aisSelect = document.getElementById('aisDeviceSelect');
const device = aisSelect.value;
const sdrType = (aisSelect.selectedOptions[0]?.dataset?.sdrType || 'rtlsdr').toLowerCase();
const gain = document.getElementById('aisGain').value;
// Check if using agent mode
@@ -561,7 +571,7 @@
fetch(`/controller/agents/${aisCurrentAgent}/ais/start`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ device, gain, bias_t: getBiasTEnabled() })
body: JSON.stringify({ device, gain, bias_t: getBiasTEnabled(), sdr_type: sdrType })
})
.then(r => r.json())
.then(result => {
@@ -586,7 +596,7 @@
fetch('/ais/start', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ device, gain, bias_t: getBiasTEnabled() })
body: JSON.stringify({ device, gain, bias_t: getBiasTEnabled(), sdr_type: sdrType })
})
.then(r => r.json())
.then(data => {
@@ -1170,7 +1180,9 @@
}
function startDscTracking() {
const device = document.getElementById('dscDeviceSelect').value;
const dscSelect = document.getElementById('dscDeviceSelect');
const device = dscSelect.value;
const sdrType = (dscSelect.selectedOptions[0]?.dataset?.sdrType || 'rtlsdr').toLowerCase();
const gain = document.getElementById('dscGain').value;
// Check if using agent mode
@@ -1185,7 +1197,7 @@
fetch(endpoint, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ device, gain })
body: JSON.stringify({ device, gain, sdr_type: sdrType })
})
.then(r => r.json())
.then(data => {
@@ -1617,21 +1629,32 @@
const aisSelect = document.getElementById('aisDeviceSelect');
const dscSelect = document.getElementById('dscDeviceSelect');
[aisSelect, dscSelect].forEach(select => {
const aisDevices = devices || [];
const dscDevices = aisDevices.filter(device => {
const sdrType = (device.sdr_type || device.type || 'rtlsdr').toLowerCase();
return sdrType === 'rtlsdr';
});
const fillSelect = (select, list, emptyLabel) => {
if (!select) return;
select.innerHTML = '';
if (devices.length === 0) {
select.innerHTML = '<option value="0">No SDR found</option>';
} else {
devices.forEach(device => {
const opt = document.createElement('option');
opt.value = device.index;
opt.textContent = `Device ${device.index}: ${device.name || device.type || 'SDR'}`;
select.appendChild(opt);
});
if (list.length === 0) {
select.innerHTML = `<option value=\"0\">${emptyLabel}</option>`;
return;
}
});
list.forEach(device => {
const opt = document.createElement('option');
const sdrType = (device.sdr_type || device.type || 'rtlsdr').toLowerCase();
const sdrLabel = sdrType.toUpperCase();
opt.value = device.index;
opt.dataset.sdrType = sdrType;
opt.textContent = `Device ${device.index} (${sdrLabel}): ${device.name || device.type || 'SDR'}`;
select.appendChild(opt);
});
};
fillSelect(aisSelect, aisDevices, 'No SDR found');
fillSelect(dscSelect, dscDevices, 'No RTL-SDR found');
}
// Override startTracking for agent support
@@ -1645,13 +1668,15 @@
return;
}
const device = document.getElementById('aisDeviceSelect').value;
const aisSelect = document.getElementById('aisDeviceSelect');
const device = aisSelect.value;
const sdrType = (aisSelect.selectedOptions[0]?.dataset?.sdrType || 'rtlsdr').toLowerCase();
const gain = document.getElementById('aisGain').value;
fetch(`/controller/agents/${aisCurrentAgent}/ais/start`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ device, gain, bias_t: getBiasTEnabled() })
body: JSON.stringify({ device, gain, bias_t: getBiasTEnabled(), sdr_type: sdrType })
})
.then(r => r.json())
.then(data => {
+280 -68
View File
@@ -21,6 +21,8 @@
</script>
<script>
window.INTERCEPT_SHARED_OBSERVER_LOCATION = {{ shared_observer_location | tojson }};
window.INTERCEPT_DEFAULT_LAT = {{ default_latitude | tojson }};
window.INTERCEPT_DEFAULT_LON = {{ default_longitude | tojson }};
</script>
<script src="{{ url_for('static', filename='js/core/observer-location.js') }}"></script>
<!-- Fonts - Conditional CDN/Local loading -->
@@ -59,8 +61,10 @@
<link rel="stylesheet" href="{{ url_for('static', filename='css/modes/spy-stations.css') }}">
<link rel="stylesheet" href="{{ url_for('static', filename='css/modes/meshtastic.css') }}">
<link rel="stylesheet" href="{{ url_for('static', filename='css/modes/sstv.css') }}">
<link rel="stylesheet" href="{{ url_for('static', filename='css/modes/weather-satellite.css') }}">
<link rel="stylesheet" href="{{ url_for('static', filename='css/modes/sstv-general.css') }}">
<link rel="stylesheet" href="{{ url_for('static', filename='css/settings.css') }}">
<link rel="stylesheet" href="{{ url_for('static', filename='css/components/function-strip.css') }}">
<link rel="stylesheet" href="{{ url_for('static', filename='css/components/toast.css') }}">
</head>
@@ -235,6 +239,10 @@
<span class="mode-icon icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="18" height="18" rx="2"/><circle cx="12" cy="12" r="3"/><path d="M3 9h2"/><path d="M19 9h2"/><path d="M3 15h2"/><path d="M19 15h2"/></svg></span>
<span class="mode-name">ISS SSTV</span>
</button>
<button class="mode-card mode-card-sm" onclick="selectMode('weathersat')">
<span class="mode-icon icon"><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></span>
<span class="mode-name">Weather Sat</span>
</button>
<button class="mode-card mode-card-sm" onclick="selectMode('sstv_general')">
<span class="mode-icon icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="18" height="18" rx="2"/><circle cx="12" cy="12" r="3"/><path d="M16.24 7.76a6 6 0 0 1 0 8.49m-8.48-.01a6 6 0 0 1 0-8.49"/></svg></span>
<span class="mode-name">HF SSTV</span>
@@ -505,42 +513,6 @@
</div>
</div>
<!-- Shared Waterfall Controls -->
<div class="section" id="waterfallControlsSection" style="display: none;">
<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;">Zoom</label>
<div style="display: flex; gap: 6px; align-items: center;">
<button class="tune-btn" type="button" onclick="zoomWaterfall('out')" style="padding: 4px 8px;">-</button>
<button class="tune-btn" type="button" onclick="zoomWaterfall('in')" style="padding: 4px 8px;">+</button>
<span id="waterfallZoomSpan" style="font-size: 10px; color: var(--text-muted);">20.0 MHz</span>
</div>
</div>
<div class="form-group" style="margin-bottom: 6px;">
<label style="font-size: 10px;">FFT Size</label>
<select id="waterfallFftSize" 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="512">512</option>
<option value="1024" selected>1024</option>
<option value="2048">2048</option>
<option value="4096">4096</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>
{% include 'partials/modes/pager.html' %}
{% include 'partials/modes/sensor.html' %}
@@ -557,6 +529,8 @@
{% include 'partials/modes/sstv.html' %}
{% include 'partials/modes/weather-satellite.html' %}
{% include 'partials/modes/sstv-general.html' %}
{% include 'partials/modes/listening-post.html' %}
@@ -607,16 +581,6 @@
</div>
</div>
<!-- WATERFALL / SPECTROGRAM PANEL -->
<div id="waterfallPanel" class="radio-module-box" style="padding: 10px; display: none; margin-bottom: 12px;">
<div class="module-header" style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 8px; font-size: 10px;">
<span>WATERFALL / SPECTROGRAM</span>
<span id="waterfallFreqRange" style="font-size: 9px; color: var(--accent-cyan);"></span>
</div>
<canvas id="spectrumCanvas" width="800" height="120" style="width: 100%; height: 120px; border-radius: 4px; background: rgba(0,0,0,0.8);"></canvas>
<canvas id="waterfallCanvas" width="800" height="400" style="width: 100%; height: 400px; border-radius: 4px; margin-top: 4px; background: rgba(0,0,0,0.9);"></canvas>
</div>
<!-- WiFi Layout Container -->
<div class="wifi-layout-container" id="wifiLayoutContainer" style="display: none;">
<!-- Status Bar -->
@@ -950,7 +914,7 @@
</div>
<!-- APRS Visualizations -->
<div id="aprsVisuals" style="display: none; flex-direction: column; gap: 10px; flex: 1; padding: 10px;">
<div id="aprsVisuals" style="display: none; flex-direction: column; gap: 10px; flex: 1; padding: 10px; overflow: hidden; min-height: 0;">
<!-- APRS Function Bar -->
<div class="aprs-strip">
<div class="aprs-strip-inner">
@@ -1025,7 +989,7 @@
<div style="display: flex; gap: 10px; flex: 1; min-height: 0;">
<!-- Map Panel (larger) -->
<div class="wifi-visual-panel"
style="flex: 2; display: flex; flex-direction: column; min-width: 0;">
style="flex: 2; display: flex; flex-direction: column; min-width: 0; min-height: 0; overflow: hidden;">
<h5
style="color: var(--accent-cyan); text-shadow: 0 0 10px var(--accent-cyan); padding: 0 10px; margin-bottom: 8px;">
APRS STATION MAP</h5>
@@ -1044,12 +1008,12 @@
<!-- Station List Panel -->
<div class="wifi-visual-panel"
style="flex: 1; min-width: 300px; max-width: 400px; display: flex; flex-direction: column;">
style="flex: 1; min-width: 300px; max-width: 400px; display: flex; flex-direction: column; min-height: 0; overflow: hidden;">
<h5
style="color: var(--accent-green); text-shadow: 0 0 10px var(--accent-green); margin-bottom: 8px;">
style="color: var(--accent-green); text-shadow: 0 0 10px var(--accent-green); margin-bottom: 8px; flex-shrink: 0;">
STATION LIST</h5>
<div id="aprsFilterBarContainer"></div>
<div id="aprsStationList" class="signal-cards-container" style="flex: 1; overflow-y: auto; font-size: 11px; gap: 8px;">
<div id="aprsFilterBarContainer" style="flex-shrink: 0;"></div>
<div id="aprsStationList" class="signal-cards-container" style="flex: 1; overflow-y: auto; font-size: 11px; gap: 8px; min-height: 0;">
<div class="signal-cards-placeholder" style="padding: 20px; text-align: center; color: var(--text-muted);">
No stations received yet
</div>
@@ -1058,7 +1022,7 @@
</div>
<!-- Bottom row: Packet Log -->
<div class="wifi-visual-panel" style="display: flex; flex-direction: column; max-height: 200px;">
<div class="wifi-visual-panel" style="display: flex; flex-direction: column; max-height: 200px; flex-shrink: 0;">
<h5
style="color: var(--accent-orange); text-shadow: 0 0 10px var(--accent-orange); margin-bottom: 8px;">
PACKET LOG</h5>
@@ -1072,6 +1036,68 @@
<!-- Listening Post Visualizations - Professional Ham Radio Scanner -->
<div class="wifi-visuals" id="listeningPostVisuals" style="display: none;">
<!-- WATERFALL FUNCTION BAR -->
<div class="function-strip listening-strip" style="grid-column: span 4;">
<div class="function-strip-inner">
<span class="strip-title">WATERFALL</span>
<div class="strip-divider"></div>
<!-- Span display -->
<div class="strip-stat">
<span class="strip-value" id="waterfallZoomSpan">20.0 MHz</span>
<span class="strip-label">SPAN</span>
</div>
<div class="strip-divider"></div>
<!-- Frequency inputs -->
<div class="strip-control">
<span class="strip-input-label">START</span>
<input type="number" id="waterfallStartFreq" class="strip-input wide" value="88" step="0.1">
</div>
<div class="strip-control">
<span class="strip-input-label">END</span>
<input type="number" id="waterfallEndFreq" class="strip-input wide" value="108" step="0.1">
</div>
<div class="strip-divider"></div>
<!-- Zoom buttons -->
<button type="button" class="strip-btn" onclick="zoomWaterfall('out')"></button>
<button type="button" class="strip-btn" onclick="zoomWaterfall('in')">+</button>
<div class="strip-divider"></div>
<!-- FFT Size -->
<div class="strip-control">
<span class="strip-input-label">FFT</span>
<select id="waterfallFftSize" class="strip-select">
<option value="512">512</option>
<option value="1024" selected>1024</option>
<option value="2048">2048</option>
<option value="4096">4096</option>
</select>
</div>
<!-- Gain -->
<div class="strip-control">
<span class="strip-input-label">GAIN</span>
<input type="number" id="waterfallGain" class="strip-input" value="40" min="0" max="50">
</div>
<div class="strip-divider"></div>
<!-- Start / Stop -->
<button type="button" class="strip-btn primary" id="startWaterfallBtn" onclick="startWaterfall()">▶ START</button>
<button type="button" class="strip-btn stop" id="stopWaterfallBtn" onclick="stopWaterfall()" style="display: none;">◼ STOP</button>
<!-- Status -->
<div class="strip-status">
<div class="status-dot inactive" id="waterfallStripDot"></div>
<span id="waterfallFreqRange">STANDBY</span>
</div>
</div>
</div>
<!-- WATERFALL / SPECTROGRAM PANEL -->
<div id="waterfallPanel" class="radio-module-box" style="grid-column: span 4; padding: 10px; display: none;">
<div class="module-header" style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 8px; font-size: 10px;">
<span>WATERFALL / SPECTROGRAM</span>
<span id="waterfallFreqRangeHeader" style="font-size: 9px; color: var(--accent-cyan);"></span>
</div>
<canvas id="spectrumCanvas" width="800" height="120" style="width: 100%; height: 120px; border-radius: 4px; background: rgba(0,0,0,0.8);"></canvas>
<canvas id="waterfallCanvas" width="800" height="400" style="width: 100%; height: 400px; border-radius: 4px; margin-top: 4px; background: rgba(0,0,0,0.9);"></canvas>
</div>
<!-- TOP: FREQUENCY DISPLAY PANEL -->
<div class="radio-module-box scanner-main" style="grid-column: span 4; padding: 12px;">
<div style="display: flex; gap: 15px; align-items: stretch;">
@@ -1673,6 +1699,18 @@
</div>
<canvas id="dmrSynthCanvas" style="width: 100%; height: 70px; background: rgba(0,0,0,0.4); border-radius: 4px; display: block;"></canvas>
</div>
<!-- Audio Output -->
<div class="radio-module-box" style="padding: 8px 12px;">
<audio id="dmrAudioPlayer" style="display: none;"></audio>
<div style="display: flex; align-items: center; gap: 10px;">
<span style="font-size: 10px; color: var(--text-muted); text-transform: uppercase;">AUDIO</span>
<span id="dmrAudioStatus" style="font-size: 9px; font-family: var(--font-mono); color: var(--text-muted);">OFF</span>
<div style="display: flex; align-items: center; gap: 4px; margin-left: auto;">
<span style="font-size: 10px; color: var(--text-muted);">VOL</span>
<input type="range" id="dmrAudioVolume" min="0" max="100" value="80" style="width: 80px;" oninput="setDmrAudioVolume(this.value)">
</div>
</div>
</div>
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 12px;">
<!-- Call History Panel -->
<div class="radio-module-box" style="padding: 10px;">
@@ -2111,6 +2149,172 @@
</div>
</div>
<!-- Weather Satellite visuals (pass predictions + image gallery) -->
<div id="weatherSatVisuals" class="wxsat-visuals-container" style="display: none;">
<!-- Stats strip -->
<div class="wxsat-stats-strip">
<div class="wxsat-strip-group">
<div class="wxsat-strip-status">
<span class="wxsat-strip-dot" id="wxsatStripDot"></span>
<span class="wxsat-strip-status-text" id="wxsatStripStatus">Idle</span>
</div>
<button class="wxsat-strip-btn start" id="wxsatStartBtn" onclick="WeatherSat.start()">Start</button>
<button class="wxsat-strip-btn stop" id="wxsatStopBtn" onclick="WeatherSat.stop()" style="display: none;">Stop</button>
</div>
<div class="wxsat-strip-divider"></div>
<div class="wxsat-strip-group">
<div class="wxsat-strip-stat">
<span class="wxsat-strip-value accent-cyan" id="wxsatStripFreq">--</span>
<span class="wxsat-strip-label">MHZ</span>
</div>
<div class="wxsat-strip-stat">
<span class="wxsat-strip-value" id="wxsatStripMode">--</span>
<span class="wxsat-strip-label">MODE</span>
</div>
<div class="wxsat-strip-stat">
<span class="wxsat-strip-value" id="wxsatStripImageCount">0</span>
<span class="wxsat-strip-label">IMAGES</span>
</div>
</div>
<div class="wxsat-strip-divider"></div>
<div class="wxsat-strip-group">
<div class="wxsat-strip-location">
<span class="wxsat-strip-label" style="margin-right: 6px;">LOC</span>
<input type="number" id="wxsatObsLat" class="wxsat-loc-input" step="0.0001" placeholder="Lat" title="Latitude">
<input type="number" id="wxsatObsLon" class="wxsat-loc-input" step="0.0001" placeholder="Lon" title="Longitude">
<button class="wxsat-strip-btn gps" onclick="WeatherSat.useGPS(this)" title="Use GPS location">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="14" height="14"><circle cx="12" cy="12" r="10"/><circle cx="12" cy="12" r="3"/><path d="M12 2v4m0 12v4M2 12h4m12 0h4"/></svg>
</button>
</div>
</div>
<div class="wxsat-strip-divider"></div>
<div class="wxsat-strip-group">
<label class="wxsat-schedule-toggle" title="Auto-capture passes">
<input type="checkbox" id="wxsatAutoSchedule" onchange="WeatherSat.toggleScheduler()">
<span class="wxsat-toggle-label">AUTO</span>
</label>
</div>
</div>
<!-- Countdown + Timeline -->
<div class="wxsat-countdown-bar">
<div class="wxsat-countdown-next">
<div class="wxsat-countdown-boxes" id="wxsatCountdownBoxes">
<div class="wxsat-countdown-box"><span class="wxsat-cd-value" id="wxsatCdDays">--</span><span class="wxsat-cd-unit">DAYS</span></div>
<div class="wxsat-countdown-box"><span class="wxsat-cd-value" id="wxsatCdHours">--</span><span class="wxsat-cd-unit">HRS</span></div>
<div class="wxsat-countdown-box"><span class="wxsat-cd-value" id="wxsatCdMins">--</span><span class="wxsat-cd-unit">MIN</span></div>
<div class="wxsat-countdown-box"><span class="wxsat-cd-value" id="wxsatCdSecs">--</span><span class="wxsat-cd-unit">SEC</span></div>
</div>
<div class="wxsat-countdown-info" id="wxsatCountdownInfo">
<span class="wxsat-countdown-sat" id="wxsatCountdownSat">--</span>
<span class="wxsat-countdown-detail" id="wxsatCountdownDetail">No passes predicted</span>
</div>
</div>
<div class="wxsat-timeline" id="wxsatTimeline">
<div class="wxsat-timeline-track" id="wxsatTimelineTrack"></div>
<div class="wxsat-timeline-cursor" id="wxsatTimelineCursor"></div>
<div class="wxsat-timeline-labels">
<span>00:00</span><span>06:00</span><span>12:00</span><span>18:00</span><span>24:00</span>
</div>
</div>
</div>
<!-- Capture progress -->
<div class="wxsat-capture-status" id="wxsatCaptureStatus">
<div class="wxsat-capture-info">
<span class="wxsat-capture-message" id="wxsatCaptureMsg">--</span>
<span class="wxsat-capture-elapsed" id="wxsatCaptureElapsed">0:00</span>
</div>
<div class="wxsat-progress-bar">
<div class="progress" id="wxsatProgressFill" style="width: 0%"></div>
</div>
</div>
<!-- Decoder Console -->
<div class="wxsat-signal-console" id="wxsatSignalConsole">
<div class="wxsat-console-header">
<div class="wxsat-console-title-group">
<span class="wxsat-console-title">DECODER CONSOLE</span>
<div class="wxsat-phase-indicator" id="wxsatPhaseIndicator">
<span class="wxsat-phase-step" data-phase="tuning">TUNING</span>
<span class="wxsat-phase-arrow">&#x25B8;</span>
<span class="wxsat-phase-step" data-phase="listening">LISTENING</span>
<span class="wxsat-phase-arrow">&#x25B8;</span>
<span class="wxsat-phase-step" data-phase="signal_detected">SIGNAL</span>
<span class="wxsat-phase-arrow">&#x25B8;</span>
<span class="wxsat-phase-step" data-phase="decoding">DECODING</span>
<span class="wxsat-phase-arrow">&#x25B8;</span>
<span class="wxsat-phase-step" data-phase="complete">COMPLETE</span>
</div>
</div>
<button class="wxsat-strip-btn" id="wxsatConsoleToggle"
onclick="WeatherSat.toggleConsole()" title="Toggle console">&#x25BC;</button>
</div>
<div class="wxsat-console-body" id="wxsatConsoleBody">
<div class="wxsat-console-log" id="wxsatConsoleLog">
<div class="wxsat-console-entry wxsat-log-info">Waiting for capture...</div>
</div>
</div>
</div>
<!-- Main content: 3-column layout -->
<div class="wxsat-content">
<!-- Left: Pass predictions -->
<div class="wxsat-passes-panel">
<div class="wxsat-passes-header">
<span class="wxsat-passes-title">Upcoming Passes</span>
<span class="wxsat-passes-count" id="wxsatPassesCount">0</span>
</div>
<div class="wxsat-passes-list" id="wxsatPassesList">
<div class="wxsat-gallery-empty">
<p>Set location to see pass predictions</p>
</div>
</div>
</div>
<!-- Center: Polar plot + Ground track map -->
<div class="wxsat-center-panel">
<div class="wxsat-polar-container">
<div class="wxsat-panel-header">
<span class="wxsat-panel-title">Polar Plot</span>
<span class="wxsat-panel-subtitle" id="wxsatPolarSat">--</span>
</div>
<canvas id="wxsatPolarCanvas" width="300" height="300"></canvas>
</div>
<div class="wxsat-map-container">
<div class="wxsat-panel-header">
<span class="wxsat-panel-title">Ground Track</span>
</div>
<div id="wxsatGroundMap" class="wxsat-ground-map"></div>
</div>
</div>
<!-- Right: Image gallery -->
<div class="wxsat-gallery-panel">
<div class="wxsat-gallery-header">
<span class="wxsat-gallery-title">Decoded Images</span>
<span class="wxsat-gallery-count" id="wxsatImageCount">0</span>
<button class="wxsat-gallery-clear-btn" onclick="WeatherSat.deleteAllImages()" title="Delete all images">
<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<polyline points="3 6 5 6 21 6"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/>
</svg>
</button>
</div>
<div class="wxsat-gallery-grid" id="wxsatGallery">
<div class="wxsat-gallery-empty">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
<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>
<p>No images decoded yet</p>
<p style="margin-top: 4px; font-size: 11px;">Select a satellite pass and start capturing</p>
</div>
</div>
</div>
</div>
</div>
<!-- SSTV General Decoder Dashboard -->
<div id="sstvGeneralVisuals" class="sstv-general-visuals-container" style="display: none;">
<!-- Status Strip -->
@@ -2330,6 +2534,7 @@
<script src="{{ url_for('static', filename='js/modes/spy-stations.js') }}"></script>
<script src="{{ url_for('static', filename='js/modes/meshtastic.js') }}"></script>
<script src="{{ url_for('static', filename='js/modes/sstv.js') }}"></script>
<script src="{{ url_for('static', filename='js/modes/weather-satellite.js') }}"></script>
<script src="{{ url_for('static', filename='js/modes/sstv-general.js') }}"></script>
<script src="{{ url_for('static', filename='js/modes/dmr.js') }}"></script>
<script src="{{ url_for('static', filename='js/modes/websdr.js') }}"></script>
@@ -2468,7 +2673,7 @@
const validModes = new Set([
'pager', 'sensor', 'rtlamr', 'aprs', 'listening',
'spystations', 'meshtastic', 'wifi', 'bluetooth',
'tscm', 'satellite', 'sstv', 'sstv_general', 'dmr', 'websdr'
'tscm', 'satellite', 'sstv', 'weathersat', 'sstv_general', 'dmr', 'websdr'
]);
function getModeFromQuery() {
@@ -2890,7 +3095,7 @@
'tscm': 'security',
'rtlamr': 'sdr', 'ais': 'sdr', 'spystations': 'sdr',
'meshtastic': 'sdr',
'satellite': 'space', 'sstv': 'space', 'sstv_general': 'space'
'satellite': 'space', 'sstv': 'space', 'weathersat': 'space', 'sstv_general': 'space'
};
// Remove has-active from all dropdowns
@@ -2973,6 +3178,7 @@
document.getElementById('rtlamrMode')?.classList.toggle('active', mode === 'rtlamr');
document.getElementById('satelliteMode')?.classList.toggle('active', mode === 'satellite');
document.getElementById('sstvMode')?.classList.toggle('active', mode === 'sstv');
document.getElementById('weatherSatMode')?.classList.toggle('active', mode === 'weathersat');
document.getElementById('sstvGeneralMode')?.classList.toggle('active', mode === 'sstv_general');
document.getElementById('wifiMode')?.classList.toggle('active', mode === 'wifi');
document.getElementById('bluetoothMode')?.classList.toggle('active', mode === 'bluetooth');
@@ -3010,6 +3216,7 @@
'rtlamr': 'METERS',
'satellite': 'SATELLITE',
'sstv': 'ISS SSTV',
'weathersat': 'WEATHER SAT',
'sstv_general': 'HF SSTV',
'wifi': 'WIFI',
'bluetooth': 'BLUETOOTH',
@@ -3033,6 +3240,7 @@
const spyStationsVisuals = document.getElementById('spyStationsVisuals');
const meshtasticVisuals = document.getElementById('meshtasticVisuals');
const sstvVisuals = document.getElementById('sstvVisuals');
const weatherSatVisuals = document.getElementById('weatherSatVisuals');
const sstvGeneralVisuals = document.getElementById('sstvGeneralVisuals');
const dmrVisuals = document.getElementById('dmrVisuals');
const websdrVisuals = document.getElementById('websdrVisuals');
@@ -3045,6 +3253,7 @@
if (spyStationsVisuals) spyStationsVisuals.style.display = mode === 'spystations' ? 'flex' : 'none';
if (meshtasticVisuals) meshtasticVisuals.style.display = mode === 'meshtastic' ? 'flex' : 'none';
if (sstvVisuals) sstvVisuals.style.display = mode === 'sstv' ? 'flex' : 'none';
if (weatherSatVisuals) weatherSatVisuals.style.display = mode === 'weathersat' ? 'flex' : 'none';
if (sstvGeneralVisuals) sstvGeneralVisuals.style.display = mode === 'sstv_general' ? 'flex' : 'none';
if (dmrVisuals) dmrVisuals.style.display = mode === 'dmr' ? 'flex' : 'none';
if (websdrVisuals) websdrVisuals.style.display = mode === 'websdr' ? 'flex' : 'none';
@@ -3072,6 +3281,7 @@
'rtlamr': 'Utility Meter Monitor',
'satellite': 'Satellite Monitor',
'sstv': 'ISS SSTV Decoder',
'weathersat': 'Weather Satellite Decoder',
'sstv_general': 'HF SSTV Decoder',
'wifi': 'WiFi Scanner',
'bluetooth': 'Bluetooth Scanner',
@@ -3100,7 +3310,7 @@
const reconBtn = document.getElementById('reconBtn');
const intelBtn = document.querySelector('[onclick="exportDeviceDB()"]');
const reconPanel = document.getElementById('reconPanel');
if (mode === 'satellite' || mode === 'sstv' || mode === 'sstv_general' || mode === 'listening' || mode === 'aprs' || mode === 'tscm' || mode === 'spystations' || mode === 'meshtastic' || mode === 'dmr' || mode === 'websdr') {
if (mode === 'satellite' || mode === 'sstv' || mode === 'weathersat' || mode === 'sstv_general' || mode === 'listening' || mode === 'aprs' || mode === 'tscm' || mode === 'spystations' || mode === 'meshtastic' || mode === 'dmr' || mode === 'websdr') {
if (reconPanel) reconPanel.style.display = 'none';
if (reconBtn) reconBtn.style.display = 'none';
if (intelBtn) intelBtn.style.display = 'none';
@@ -3120,17 +3330,13 @@
// Show RTL-SDR device section for modes that use it
const rtlDeviceSection = document.getElementById('rtlDeviceSection');
if (rtlDeviceSection) rtlDeviceSection.style.display = (mode === 'pager' || mode === 'sensor' || mode === 'rtlamr' || mode === 'listening' || mode === 'aprs' || mode === 'sstv' || mode === 'sstv_general' || mode === 'dmr') ? 'block' : 'none';
if (rtlDeviceSection) rtlDeviceSection.style.display = (mode === 'pager' || mode === 'sensor' || mode === 'rtlamr' || mode === 'listening' || mode === 'aprs' || mode === 'sstv' || mode === 'weathersat' || mode === 'sstv_general' || mode === 'dmr') ? 'block' : 'none';
// Show shared waterfall controls for supported modes
const waterfallControlsSection = document.getElementById('waterfallControlsSection');
// Show waterfall panel if running in listening mode
const waterfallPanel = document.getElementById('waterfallPanel');
const waterfallModes = ['listening'];
const waterfallSupported = waterfallModes.includes(mode);
if (waterfallControlsSection) waterfallControlsSection.style.display = waterfallSupported ? 'block' : 'none';
if (waterfallPanel) {
const running = (typeof isWaterfallRunning !== 'undefined' && isWaterfallRunning);
waterfallPanel.style.display = (waterfallSupported && running) ? 'block' : 'none';
waterfallPanel.style.display = (mode === 'listening' && running) ? 'block' : 'none';
}
// Toggle mode-specific tool status displays
@@ -3142,7 +3348,7 @@
// Hide output console for modes with their own visualizations
const outputEl = document.getElementById('output');
const statusBar = document.querySelector('.status-bar');
if (outputEl) outputEl.style.display = (mode === 'satellite' || mode === 'sstv' || mode === 'sstv_general' || mode === 'aprs' || mode === 'wifi' || mode === 'bluetooth' || mode === 'listening' || mode === 'tscm' || mode === 'spystations' || mode === 'meshtastic' || mode === 'dmr' || mode === 'websdr') ? 'none' : 'block';
if (outputEl) outputEl.style.display = (mode === 'satellite' || mode === 'sstv' || mode === 'weathersat' || mode === 'sstv_general' || mode === 'aprs' || mode === 'wifi' || mode === 'bluetooth' || mode === 'listening' || mode === 'tscm' || mode === 'spystations' || mode === 'meshtastic' || mode === 'dmr' || mode === 'websdr') ? 'none' : 'block';
if (statusBar) statusBar.style.display = (mode === 'satellite' || mode === 'websdr' || mode === 'dmr') ? 'none' : 'flex';
// Restore sidebar when leaving Meshtastic mode (user may have collapsed it)
@@ -3190,10 +3396,16 @@
}, 100);
} else if (mode === 'sstv') {
SSTV.init();
} else if (mode === 'weathersat') {
WeatherSat.init();
setTimeout(() => {
WeatherSat.invalidateMap();
}, 100);
} else if (mode === 'sstv_general') {
SSTVGeneral.init();
} else if (mode === 'dmr') {
if (typeof checkDmrTools === 'function') checkDmrTools();
if (typeof checkDmrStatus === 'function') checkDmrStatus();
if (typeof initDmrSynthesizer === 'function') setTimeout(initDmrSynthesizer, 100);
} else if (mode === 'websdr') {
if (typeof initWebSDR === 'function') initWebSDR();
@@ -9340,10 +9552,10 @@
listEl.insertBefore(newCard, listEl.firstChild);
}
// Keep list manageable
const cards = listEl.querySelectorAll('.signal-card');
while (cards.length > 50) {
listEl.removeChild(listEl.lastChild);
// Keep list manageable (use live childElementCount, not static NodeList)
const MAX_APRS_STATION_CARDS = 200;
while (listEl.childElementCount > MAX_APRS_STATION_CARDS && listEl.lastElementChild) {
listEl.removeChild(listEl.lastElementChild);
}
// Update filter counts if filter bar exists
+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>
+85 -16
View File
@@ -21,17 +21,77 @@
<select id="dmrProtocol">
<option value="auto" selected>Auto Detect</option>
<option value="dmr">DMR</option>
<option value="p25">P25</option>
<option value="p25">P25 Phase 1</option>
<option value="p25p2">P25 Phase 2</option>
<option value="nxdn">NXDN</option>
<option value="dstar">D-STAR</option>
<option value="provoice">ProVoice</option>
</select>
</div>
<div class="form-group">
<label>Demodulation</label>
<select id="dmrDemod">
<option value="nfm" selected>NFM (recommended)</option>
<option value="fm">FM (wide)</option>
</select>
<span style="font-size: 0.75em; color: var(--text-muted); display: block; margin-top: 2px;">
Narrow FM often improves digital voice decode on 12.5 kHz channels.
</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">
<label>Fine Tune (Hz)</label>
<input type="number" id="dmrFineTune" value="0" min="-5000" max="5000" step="100" style="width: 100%;"
title="Offset the tuned frequency by a small amount without changing PPM.">
<span style="font-size: 0.75em; color: var(--text-muted); display: block; margin-top: 2px;">
Adjust in 100 Hz steps; small offsets can dramatically improve P25 decode.
</span>
</div>
<button class="preset-btn" id="dmrFineTuneSweepBtn" onclick="sweepDmrFineTune()" style="width: 100%; margin-top: 6px;">
Sweep Fine Tune
</button>
<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>
<!-- 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>
<!-- Actions -->
@@ -51,21 +111,30 @@
</div>
<!-- Status -->
<div class="section" style="margin-top: 12px;">
<h3>Status</h3>
<div style="background: rgba(0,0,0,0.3); border-radius: 6px; padding: 10px;">
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 6px;">
<span style="font-size: 10px; color: var(--text-muted); text-transform: uppercase;">Status</span>
<span id="dmrStatus" style="font-size: 11px; color: var(--accent-cyan);">IDLE</span>
</div>
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 6px;">
<span style="font-size: 10px; color: var(--text-muted); text-transform: uppercase;">Protocol</span>
<span id="dmrActiveProtocol" style="font-size: 11px; color: var(--text-primary);">--</span>
</div>
<div style="display: flex; justify-content: space-between; align-items: center;">
<span style="font-size: 10px; color: var(--text-muted); text-transform: uppercase;">Calls</span>
<span id="dmrCallCount" style="font-size: 14px; font-weight: bold; color: var(--accent-green);">0</span>
<div class="section" style="margin-top: 12px;">
<h3>Status</h3>
<div style="background: rgba(0,0,0,0.3); border-radius: 6px; padding: 10px;">
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 6px;">
<span style="font-size: 10px; color: var(--text-muted); text-transform: uppercase;">Status</span>
<span id="dmrStatus" style="font-size: 11px; color: var(--accent-cyan);">IDLE</span>
</div>
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 6px;">
<span style="font-size: 10px; color: var(--text-muted); text-transform: uppercase;">Protocol</span>
<span id="dmrActiveProtocol" style="font-size: 11px; color: var(--text-primary);">--</span>
</div>
<div style="display: flex; justify-content: space-between; align-items: center;">
<span style="font-size: 10px; color: var(--text-muted); text-transform: uppercase;">Calls</span>
<span id="dmrCallCount" style="font-size: 14px; font-weight: bold; color: var(--accent-green);">0</span>
</div>
<div style="margin-top: 8px;">
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 4px;">
<span style="font-size: 10px; color: var(--text-muted); text-transform: uppercase;">Quality</span>
<span id="dmrQualityText" style="font-size: 11px; color: var(--text-primary);">--</span>
</div>
<div style="height: 6px; background: rgba(255,255,255,0.08); border-radius: 6px; overflow: hidden;">
<div id="dmrQualityBar" style="height: 100%; width: 0%; background: var(--text-muted); transition: width 0.2s ease;"></div>
</div>
</div>
</div>
</div>
</div>
</div>
@@ -61,6 +61,7 @@
<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>
+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>
@@ -0,0 +1,244 @@
<!-- WEATHER SATELLITE MODE -->
<div id="weatherSatMode" class="mode-content">
<div class="section">
<h3>Weather Satellite Decoder</h3>
<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="NOAA-15">NOAA-15 (137.620 MHz APT)</option>
<option value="NOAA-18" selected>NOAA-18 (137.9125 MHz APT)</option>
<option value="NOAA-19">NOAA-19 (137.100 MHz APT)</option>
<option value="METEOR-M2-3">Meteor-M2-3 (137.900 MHz LRPT)</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="NOAA-15">NOAA-15 (APT)</option>
<option value="NOAA-18" selected>NOAA-18 (APT)</option>
<option value="NOAA-19">NOAA-19 (APT)</option>
<option value="METEOR-M2-3">Meteor-M2-3 (LRPT)</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>
+2
View File
@@ -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,6 +186,7 @@
{{ 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>') }}
+87 -1
View File
@@ -2,7 +2,7 @@
from unittest.mock import patch, MagicMock
import pytest
from routes.dmr import parse_dsd_output
from routes.dmr import parse_dsd_output, _DSD_PROTOCOL_FLAGS, _DSD_FME_PROTOCOL_FLAGS, _DSD_FME_MODULATION
# ============================================
@@ -66,6 +66,16 @@ def test_parse_talkgroup_dsd_fme_format():
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')
@@ -98,6 +108,60 @@ def test_parse_unrecognized():
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'] == ['-ft'] # XDMA
assert _DSD_FME_PROTOCOL_FLAGS['dmr'] == ['-fs'] # Simplex (-fd is D-STAR!)
assert _DSD_FME_PROTOCOL_FLAGS['p25'] == ['-f1'] # NOT -fp (ProVoice in fme)
assert _DSD_FME_PROTOCOL_FLAGS['p25p2'] == ['-f2'] # Phase 2
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['p25'] == ['-mc']
assert _DSD_FME_MODULATION['p25p2'] == ['-mq']
assert _DSD_FME_MODULATION['nxdn'] == ['-mc']
# D-Star and ProVoice should not have forced modulation
assert 'dstar' not in _DSD_FME_MODULATION
assert 'provoice' not in _DSD_FME_MODULATION
# ============================================
# Endpoint tests
# ============================================
@@ -173,3 +237,25 @@ 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_parse_frame_error_duid():
"""Should parse DUID errors as frame_error."""
result = parse_dsd_output('P25p2 LCH 0 DUID ERR 11')
assert result is not None
assert result['type'] == 'frame_error'
assert result['kind'] == 'duid'
def test_parse_frame_error_rs():
"""Should parse Reed-Solomon errors as frame_error."""
result = parse_dsd_output('P25p2 SACCH R-S ERR Sc')
assert result is not None
assert result['type'] == 'frame_error'
assert result['kind'] == 'rs'
def test_parse_frame_ok_p25p2():
"""Should parse P25p2 4V frames as OK."""
result = parse_dsd_output('P25p2 LCH 1 4V 1')
assert result is not None
assert result['type'] == 'frame_ok'
assert result['kind'] == 'p25p2'
+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']
+779
View File
@@ -0,0 +1,779 @@
"""Tests for weather satellite auto-scheduler.
Covers WeatherSatScheduler class, pass scheduling, timer management,
and automatic capture execution.
"""
from __future__ import annotations
import threading
import time
from datetime import datetime, timezone, timedelta
from unittest.mock import patch, MagicMock, call
import pytest
from utils.weather_sat_scheduler import (
WeatherSatScheduler,
ScheduledPass,
get_weather_sat_scheduler,
)
class TestScheduledPass:
"""Tests for ScheduledPass class."""
def test_scheduled_pass_initialization(self):
"""ScheduledPass should initialize from pass data."""
pass_data = {
'id': 'NOAA-18_202401011200',
'satellite': 'NOAA-18',
'name': 'NOAA 18',
'frequency': 137.9125,
'mode': 'APT',
'startTimeISO': '2024-01-01T12:00:00+00:00',
'endTimeISO': '2024-01-01T12:15:00+00:00',
'maxEl': 45.0,
'duration': 15.0,
'quality': 'good',
}
sp = ScheduledPass(pass_data)
assert sp.id == 'NOAA-18_202401011200'
assert sp.satellite == 'NOAA-18'
assert sp.name == 'NOAA 18'
assert sp.frequency == 137.9125
assert sp.mode == 'APT'
assert sp.max_el == 45.0
assert sp.duration == 15.0
assert sp.quality == 'good'
assert sp.status == 'scheduled'
assert sp.skipped is False
def test_scheduled_pass_start_dt(self):
"""ScheduledPass.start_dt should parse ISO datetime."""
pass_data = {
'satellite': 'NOAA-18',
'name': 'NOAA 18',
'frequency': 137.9125,
'mode': 'APT',
'startTimeISO': '2024-01-01T12:00:00+00:00',
'endTimeISO': '2024-01-01T12:15:00+00:00',
'maxEl': 45.0,
'duration': 15.0,
'quality': 'good',
}
sp = ScheduledPass(pass_data)
assert sp.start_dt.year == 2024
assert sp.start_dt.month == 1
assert sp.start_dt.day == 1
assert sp.start_dt.hour == 12
assert sp.start_dt.tzinfo == timezone.utc
def test_scheduled_pass_end_dt(self):
"""ScheduledPass.end_dt should parse ISO datetime."""
pass_data = {
'satellite': 'NOAA-18',
'name': 'NOAA 18',
'frequency': 137.9125,
'mode': 'APT',
'startTimeISO': '2024-01-01T12:00:00+00:00',
'endTimeISO': '2024-01-01T12:15:00+00:00',
'maxEl': 45.0,
'duration': 15.0,
'quality': 'good',
}
sp = ScheduledPass(pass_data)
assert sp.end_dt.year == 2024
assert sp.end_dt.minute == 15
def test_scheduled_pass_to_dict(self):
"""ScheduledPass.to_dict() should serialize correctly."""
pass_data = {
'id': 'NOAA-18_202401011200',
'satellite': 'NOAA-18',
'name': 'NOAA 18',
'frequency': 137.9125,
'mode': 'APT',
'startTimeISO': '2024-01-01T12:00:00+00:00',
'endTimeISO': '2024-01-01T12:15:00+00:00',
'maxEl': 45.0,
'duration': 15.0,
'quality': 'good',
}
sp = ScheduledPass(pass_data)
sp.status = 'complete'
data = sp.to_dict()
assert data['id'] == 'NOAA-18_202401011200'
assert data['satellite'] == 'NOAA-18'
assert data['status'] == 'complete'
assert data['skipped'] is False
class TestWeatherSatScheduler:
"""Tests for WeatherSatScheduler class."""
def test_scheduler_initialization(self):
"""Scheduler should initialize with defaults."""
scheduler = WeatherSatScheduler()
assert scheduler.enabled is False
assert scheduler._lat == 0.0
assert scheduler._lon == 0.0
assert scheduler._min_elevation == 15.0
assert scheduler._device == 0
assert scheduler._gain == 40.0
assert scheduler._bias_t is False
assert scheduler._passes == []
def test_set_callbacks(self):
"""Scheduler should accept callbacks."""
scheduler = WeatherSatScheduler()
progress_cb = MagicMock()
event_cb = MagicMock()
scheduler.set_callbacks(progress_cb, event_cb)
assert scheduler._progress_callback == progress_cb
assert scheduler._event_callback == event_cb
@patch('utils.weather_sat_scheduler.WeatherSatScheduler._refresh_passes')
def test_enable(self, mock_refresh):
"""enable() should start scheduler."""
scheduler = WeatherSatScheduler()
result = scheduler.enable(
lat=51.5,
lon=-0.1,
min_elevation=20.0,
device=1,
gain=35.0,
bias_t=True,
)
assert scheduler._enabled is True
assert scheduler._lat == 51.5
assert scheduler._lon == -0.1
assert scheduler._min_elevation == 20.0
assert scheduler._device == 1
assert scheduler._gain == 35.0
assert scheduler._bias_t is True
mock_refresh.assert_called_once()
assert 'enabled' in result
def test_disable(self):
"""disable() should stop scheduler and cancel timers."""
scheduler = WeatherSatScheduler()
scheduler._enabled = True
# Add mock timer
mock_timer = MagicMock()
scheduler._refresh_timer = mock_timer
# Add pass with timer
pass_data = {
'satellite': 'NOAA-18',
'name': 'NOAA 18',
'frequency': 137.9125,
'mode': 'APT',
'startTimeISO': '2024-01-01T12:00:00+00:00',
'endTimeISO': '2024-01-01T12:15:00+00:00',
'maxEl': 45.0,
'duration': 15.0,
'quality': 'good',
}
sp = ScheduledPass(pass_data)
sp._timer = MagicMock()
sp._stop_timer = MagicMock()
scheduler._passes = [sp]
result = scheduler.disable()
assert scheduler._enabled is False
assert scheduler._passes == []
mock_timer.cancel.assert_called_once()
sp._timer.cancel.assert_called_once()
sp._stop_timer.cancel.assert_called_once()
assert result['status'] == 'disabled'
def test_skip_pass_success(self):
"""skip_pass() should skip a scheduled pass."""
scheduler = WeatherSatScheduler()
event_cb = MagicMock()
scheduler.set_callbacks(MagicMock(), event_cb)
pass_data = {
'id': 'NOAA-18_202401011200',
'satellite': 'NOAA-18',
'name': 'NOAA 18',
'frequency': 137.9125,
'mode': 'APT',
'startTimeISO': '2024-01-01T12:00:00+00:00',
'endTimeISO': '2024-01-01T12:15:00+00:00',
'maxEl': 45.0,
'duration': 15.0,
'quality': 'good',
}
sp = ScheduledPass(pass_data)
sp._timer = MagicMock()
scheduler._passes = [sp]
result = scheduler.skip_pass('NOAA-18_202401011200')
assert result is True
assert sp.status == 'skipped'
assert sp.skipped is True
sp._timer.cancel.assert_called_once()
event_cb.assert_called_once()
def test_skip_pass_not_found(self):
"""skip_pass() should return False for non-existent pass."""
scheduler = WeatherSatScheduler()
result = scheduler.skip_pass('NONEXISTENT')
assert result is False
def test_skip_pass_already_complete(self):
"""skip_pass() should not skip already complete passes."""
scheduler = WeatherSatScheduler()
pass_data = {
'id': 'NOAA-18_202401011200',
'satellite': 'NOAA-18',
'name': 'NOAA 18',
'frequency': 137.9125,
'mode': 'APT',
'startTimeISO': '2024-01-01T12:00:00+00:00',
'endTimeISO': '2024-01-01T12:15:00+00:00',
'maxEl': 45.0,
'duration': 15.0,
'quality': 'good',
}
sp = ScheduledPass(pass_data)
sp.status = 'complete'
scheduler._passes = [sp]
result = scheduler.skip_pass('NOAA-18_202401011200')
assert result is False
assert sp.status == 'complete'
def test_get_status(self):
"""get_status() should return scheduler state."""
scheduler = WeatherSatScheduler()
scheduler._enabled = True
scheduler._lat = 51.5
scheduler._lon = -0.1
scheduler._device = 0
scheduler._gain = 40.0
scheduler._bias_t = False
scheduler._min_elevation = 15.0
pass_data = {
'satellite': 'NOAA-18',
'name': 'NOAA 18',
'frequency': 137.9125,
'mode': 'APT',
'startTimeISO': '2024-01-01T12:00:00+00:00',
'endTimeISO': '2024-01-01T12:15:00+00:00',
'maxEl': 45.0,
'duration': 15.0,
'quality': 'good',
}
sp = ScheduledPass(pass_data)
scheduler._passes = [sp]
status = scheduler.get_status()
assert status['enabled'] is True
assert status['observer']['latitude'] == 51.5
assert status['observer']['longitude'] == -0.1
assert status['device'] == 0
assert status['gain'] == 40.0
assert status['bias_t'] is False
assert status['min_elevation'] == 15.0
assert status['scheduled_count'] == 1
assert status['total_passes'] == 1
def test_get_passes(self):
"""get_passes() should return list of scheduled passes."""
scheduler = WeatherSatScheduler()
pass_data = {
'id': 'NOAA-18_202401011200',
'satellite': 'NOAA-18',
'name': 'NOAA 18',
'frequency': 137.9125,
'mode': 'APT',
'startTimeISO': '2024-01-01T12:00:00+00:00',
'endTimeISO': '2024-01-01T12:15:00+00:00',
'maxEl': 45.0,
'duration': 15.0,
'quality': 'good',
}
sp = ScheduledPass(pass_data)
scheduler._passes = [sp]
passes = scheduler.get_passes()
assert len(passes) == 1
assert passes[0]['id'] == 'NOAA-18_202401011200'
@patch('utils.weather_sat_scheduler.predict_passes')
@patch('threading.Timer')
def test_refresh_passes(self, mock_timer, mock_predict):
"""_refresh_passes() should schedule future passes."""
now = datetime.now(timezone.utc)
future_pass = {
'id': 'NOAA-18_202401011200',
'satellite': 'NOAA-18',
'name': 'NOAA 18',
'frequency': 137.9125,
'mode': 'APT',
'startTimeISO': (now + timedelta(hours=2)).isoformat(),
'endTimeISO': (now + timedelta(hours=2, minutes=15)).isoformat(),
'maxEl': 45.0,
'duration': 15.0,
'quality': 'good',
}
mock_predict.return_value = [future_pass]
mock_timer_instance = MagicMock()
mock_timer.return_value = mock_timer_instance
scheduler = WeatherSatScheduler()
scheduler._enabled = True
scheduler._lat = 51.5
scheduler._lon = -0.1
scheduler._refresh_passes()
mock_predict.assert_called_once()
assert len(scheduler._passes) == 1
assert scheduler._passes[0].satellite == 'NOAA-18'
mock_timer_instance.start.assert_called()
@patch('utils.weather_sat_scheduler.predict_passes')
def test_refresh_passes_skip_past(self, mock_predict):
"""_refresh_passes() should skip passes that already started."""
now = datetime.now(timezone.utc)
past_pass = {
'id': 'NOAA-18_202401011200',
'satellite': 'NOAA-18',
'name': 'NOAA 18',
'frequency': 137.9125,
'mode': 'APT',
'startTimeISO': (now - timedelta(hours=1)).isoformat(),
'endTimeISO': (now - timedelta(hours=1) + timedelta(minutes=15)).isoformat(),
'maxEl': 45.0,
'duration': 15.0,
'quality': 'good',
}
mock_predict.return_value = [past_pass]
scheduler = WeatherSatScheduler()
scheduler._enabled = True
scheduler._lat = 51.5
scheduler._lon = -0.1
scheduler._refresh_passes()
# Should not schedule past passes
assert len(scheduler._passes) == 0
@patch('utils.weather_sat_scheduler.predict_passes')
def test_refresh_passes_disabled(self, mock_predict):
"""_refresh_passes() should do nothing when disabled."""
scheduler = WeatherSatScheduler()
scheduler._enabled = False
scheduler._refresh_passes()
mock_predict.assert_not_called()
@patch('utils.weather_sat_scheduler.predict_passes')
def test_refresh_passes_error_handling(self, mock_predict):
"""_refresh_passes() should handle prediction errors."""
mock_predict.side_effect = Exception('TLE error')
scheduler = WeatherSatScheduler()
scheduler._enabled = True
scheduler._lat = 51.5
scheduler._lon = -0.1
# Should not raise
scheduler._refresh_passes()
assert len(scheduler._passes) == 0
@patch('utils.weather_sat_scheduler.get_weather_sat_decoder')
def test_execute_capture_disabled(self, mock_get):
"""_execute_capture() should do nothing when disabled."""
scheduler = WeatherSatScheduler()
scheduler._enabled = False
pass_data = {
'satellite': 'NOAA-18',
'name': 'NOAA 18',
'frequency': 137.9125,
'mode': 'APT',
'startTimeISO': '2024-01-01T12:00:00+00:00',
'endTimeISO': '2024-01-01T12:15:00+00:00',
'maxEl': 45.0,
'duration': 15.0,
'quality': 'good',
}
sp = ScheduledPass(pass_data)
scheduler._execute_capture(sp)
mock_get.assert_not_called()
@patch('utils.weather_sat_scheduler.get_weather_sat_decoder')
def test_execute_capture_skipped(self, mock_get):
"""_execute_capture() should do nothing for skipped passes."""
scheduler = WeatherSatScheduler()
scheduler._enabled = True
pass_data = {
'satellite': 'NOAA-18',
'name': 'NOAA 18',
'frequency': 137.9125,
'mode': 'APT',
'startTimeISO': '2024-01-01T12:00:00+00:00',
'endTimeISO': '2024-01-01T12:15:00+00:00',
'maxEl': 45.0,
'duration': 15.0,
'quality': 'good',
}
sp = ScheduledPass(pass_data)
sp.skipped = True
scheduler._execute_capture(sp)
mock_get.assert_not_called()
@patch('utils.weather_sat_scheduler.get_weather_sat_decoder')
def test_execute_capture_decoder_busy(self, mock_get):
"""_execute_capture() should skip when decoder is busy."""
scheduler = WeatherSatScheduler()
scheduler._enabled = True
event_cb = MagicMock()
scheduler.set_callbacks(MagicMock(), event_cb)
mock_decoder = MagicMock()
mock_decoder.is_running = True
mock_get.return_value = mock_decoder
pass_data = {
'satellite': 'NOAA-18',
'name': 'NOAA 18',
'frequency': 137.9125,
'mode': 'APT',
'startTimeISO': '2024-01-01T12:00:00+00:00',
'endTimeISO': '2024-01-01T12:15:00+00:00',
'maxEl': 45.0,
'duration': 15.0,
'quality': 'good',
}
sp = ScheduledPass(pass_data)
scheduler._execute_capture(sp)
assert sp.status == 'skipped'
assert sp.skipped is True
event_cb.assert_called_once()
event_data = event_cb.call_args[0][0]
assert event_data['type'] == 'schedule_capture_skipped'
assert event_data['reason'] == 'sdr_busy'
@patch('utils.weather_sat_scheduler.get_weather_sat_decoder')
@patch('threading.Timer')
def test_execute_capture_success(self, mock_timer, mock_get):
"""_execute_capture() should start capture."""
scheduler = WeatherSatScheduler()
scheduler._enabled = True
scheduler._device = 0
scheduler._gain = 40.0
scheduler._bias_t = False
progress_cb = MagicMock()
event_cb = MagicMock()
scheduler.set_callbacks(progress_cb, event_cb)
mock_decoder = MagicMock()
mock_decoder.is_running = False
mock_decoder.start.return_value = True
mock_get.return_value = mock_decoder
mock_timer_instance = MagicMock()
mock_timer.return_value = mock_timer_instance
now = datetime.now(timezone.utc)
pass_data = {
'satellite': 'NOAA-18',
'name': 'NOAA 18',
'frequency': 137.9125,
'mode': 'APT',
'startTimeISO': (now + timedelta(seconds=10)).isoformat(),
'endTimeISO': (now + timedelta(minutes=15)).isoformat(),
'maxEl': 45.0,
'duration': 15.0,
'quality': 'good',
}
sp = ScheduledPass(pass_data)
scheduler._execute_capture(sp)
assert sp.status == 'capturing'
mock_decoder.set_callback.assert_called_once_with(progress_cb)
mock_decoder.start.assert_called_once_with(
satellite='NOAA-18',
device_index=0,
gain=40.0,
bias_t=False,
)
event_cb.assert_called_once()
event_data = event_cb.call_args[0][0]
assert event_data['type'] == 'schedule_capture_start'
@patch('utils.weather_sat_scheduler.get_weather_sat_decoder')
def test_execute_capture_start_failed(self, mock_get):
"""_execute_capture() should handle start failure."""
scheduler = WeatherSatScheduler()
scheduler._enabled = True
event_cb = MagicMock()
scheduler.set_callbacks(MagicMock(), event_cb)
mock_decoder = MagicMock()
mock_decoder.is_running = False
mock_decoder.start.return_value = False
mock_get.return_value = mock_decoder
pass_data = {
'satellite': 'NOAA-18',
'name': 'NOAA 18',
'frequency': 137.9125,
'mode': 'APT',
'startTimeISO': '2024-01-01T12:00:00+00:00',
'endTimeISO': '2024-01-01T12:15:00+00:00',
'maxEl': 45.0,
'duration': 15.0,
'quality': 'good',
}
sp = ScheduledPass(pass_data)
scheduler._execute_capture(sp)
assert sp.status == 'skipped'
event_cb.assert_called_once()
event_data = event_cb.call_args[0][0]
assert event_data['reason'] == 'start_failed'
@patch('utils.weather_sat_scheduler.get_weather_sat_decoder')
def test_stop_capture(self, mock_get):
"""_stop_capture() should stop decoder."""
scheduler = WeatherSatScheduler()
mock_decoder = MagicMock()
mock_decoder.is_running = True
mock_get.return_value = mock_decoder
pass_data = {
'satellite': 'NOAA-18',
'name': 'NOAA 18',
'frequency': 137.9125,
'mode': 'APT',
'startTimeISO': '2024-01-01T12:00:00+00:00',
'endTimeISO': '2024-01-01T12:15:00+00:00',
'maxEl': 45.0,
'duration': 15.0,
'quality': 'good',
}
sp = ScheduledPass(pass_data)
scheduler._stop_capture(sp)
mock_decoder.stop.assert_called_once()
def test_on_capture_complete(self):
"""_on_capture_complete() should mark pass complete and emit event."""
scheduler = WeatherSatScheduler()
event_cb = MagicMock()
scheduler.set_callbacks(MagicMock(), event_cb)
release_fn = MagicMock()
pass_data = {
'satellite': 'NOAA-18',
'name': 'NOAA 18',
'frequency': 137.9125,
'mode': 'APT',
'startTimeISO': '2024-01-01T12:00:00+00:00',
'endTimeISO': '2024-01-01T12:15:00+00:00',
'maxEl': 45.0,
'duration': 15.0,
'quality': 'good',
}
sp = ScheduledPass(pass_data)
scheduler._on_capture_complete(sp, release_fn)
assert sp.status == 'complete'
release_fn.assert_called_once()
event_cb.assert_called_once()
event_data = event_cb.call_args[0][0]
assert event_data['type'] == 'schedule_capture_complete'
def test_emit_event(self):
"""_emit_event() should call event callback."""
scheduler = WeatherSatScheduler()
event_cb = MagicMock()
scheduler.set_callbacks(MagicMock(), event_cb)
event = {'type': 'test_event', 'data': 'test'}
scheduler._emit_event(event)
event_cb.assert_called_once_with(event)
def test_emit_event_no_callback(self):
"""_emit_event() should handle missing callback."""
scheduler = WeatherSatScheduler()
event = {'type': 'test_event'}
scheduler._emit_event(event) # Should not raise
def test_emit_event_callback_exception(self):
"""_emit_event() should handle callback exceptions."""
scheduler = WeatherSatScheduler()
event_cb = MagicMock(side_effect=Exception('Callback error'))
scheduler.set_callbacks(MagicMock(), event_cb)
event = {'type': 'test_event'}
scheduler._emit_event(event) # Should not raise
class TestGlobalScheduler:
"""Tests for global scheduler singleton."""
def test_get_weather_sat_scheduler_singleton(self):
"""get_weather_sat_scheduler() should return singleton."""
import utils.weather_sat_scheduler as mod
old = mod._scheduler
mod._scheduler = None
try:
scheduler1 = get_weather_sat_scheduler()
scheduler2 = get_weather_sat_scheduler()
assert scheduler1 is scheduler2
finally:
mod._scheduler = old
def test_get_weather_sat_scheduler_thread_safe(self):
"""get_weather_sat_scheduler() should be thread-safe."""
import utils.weather_sat_scheduler as mod
old = mod._scheduler
mod._scheduler = None
schedulers = []
def create_scheduler():
schedulers.append(get_weather_sat_scheduler())
try:
threads = [threading.Thread(target=create_scheduler) for _ in range(10)]
for t in threads:
t.start()
for t in threads:
t.join()
# All should be the same instance
assert all(s is schedulers[0] for s in schedulers)
finally:
mod._scheduler = old
class TestSchedulerConfiguration:
"""Tests for scheduler configuration constants."""
def test_config_constants(self):
"""Scheduler should have configuration constants."""
from utils.weather_sat_scheduler import (
WEATHER_SAT_SCHEDULE_REFRESH_MINUTES,
WEATHER_SAT_CAPTURE_BUFFER_SECONDS,
)
assert isinstance(WEATHER_SAT_SCHEDULE_REFRESH_MINUTES, int)
assert isinstance(WEATHER_SAT_CAPTURE_BUFFER_SECONDS, int)
assert WEATHER_SAT_SCHEDULE_REFRESH_MINUTES > 0
assert WEATHER_SAT_CAPTURE_BUFFER_SECONDS >= 0
class TestSchedulerIntegration:
"""Integration tests for scheduler."""
@patch('utils.weather_sat_scheduler.predict_passes')
@patch('utils.weather_sat_scheduler.get_weather_sat_decoder')
@patch('threading.Timer')
def test_full_scheduling_cycle(self, mock_timer, mock_get_decoder, mock_predict):
"""Test complete scheduling cycle from enable to execute."""
now = datetime.now(timezone.utc)
future_pass = {
'id': 'NOAA-18_202401011200',
'satellite': 'NOAA-18',
'name': 'NOAA 18',
'frequency': 137.9125,
'mode': 'APT',
'startTimeISO': (now + timedelta(hours=2)).isoformat(),
'endTimeISO': (now + timedelta(hours=2, minutes=15)).isoformat(),
'maxEl': 45.0,
'duration': 15.0,
'quality': 'good',
}
mock_predict.return_value = [future_pass]
mock_timer_instance = MagicMock()
mock_timer.return_value = mock_timer_instance
mock_decoder = MagicMock()
mock_decoder.is_running = False
mock_decoder.start.return_value = True
mock_get_decoder.return_value = mock_decoder
scheduler = WeatherSatScheduler()
progress_cb = MagicMock()
event_cb = MagicMock()
scheduler.set_callbacks(progress_cb, event_cb)
# Enable scheduler
result = scheduler.enable(lat=51.5, lon=-0.1)
assert result['enabled'] is True
assert len(scheduler._passes) == 1
assert scheduler._passes[0].satellite == 'NOAA-18'
# Simulate timer firing (capture start)
scheduler._execute_capture(scheduler._passes[0])
assert scheduler._passes[0].status == 'capturing'
mock_decoder.start.assert_called_once()
# Simulate completion
release_fn = MagicMock()
scheduler._on_capture_complete(scheduler._passes[0], release_fn)
assert scheduler._passes[0].status == 'complete'
release_fn.assert_called_once()
# Disable scheduler
scheduler.disable()
assert scheduler.enabled is False
assert len(scheduler._passes) == 0
+30 -2
View File
@@ -142,7 +142,7 @@ class DataStore:
class CleanupManager:
"""Manages periodic cleanup of multiple data stores."""
"""Manages periodic cleanup of multiple data stores and database tables."""
def __init__(self, interval: float = 60.0):
"""
@@ -152,9 +152,11 @@ class CleanupManager:
interval: Cleanup interval in seconds
"""
self.stores: list[DataStore] = []
self.db_cleanup_funcs: list[tuple[callable, int]] = [] # (func, interval_multiplier)
self.interval = interval
self._timer: threading.Timer | None = None
self._running = False
self._cleanup_count = 0
self._lock = threading.Lock()
def register(self, store: DataStore) -> None:
@@ -169,6 +171,17 @@ class CleanupManager:
if store in self.stores:
self.stores.remove(store)
def register_db_cleanup(self, func: callable, interval_multiplier: int = 60) -> None:
"""
Register a database cleanup function.
Args:
func: Cleanup function to call (should return number of deleted rows)
interval_multiplier: How many cleanup cycles to wait between calls (default: 60 = 1 hour if interval is 60s)
"""
with self._lock:
self.db_cleanup_funcs.append((func, interval_multiplier))
def start(self) -> None:
"""Start the cleanup timer."""
with self._lock:
@@ -194,11 +207,15 @@ class CleanupManager:
self._timer.start()
def _run_cleanup(self) -> None:
"""Run cleanup on all registered stores."""
"""Run cleanup on all registered stores and database tables."""
total_cleaned = 0
# Cleanup in-memory data stores
with self._lock:
stores = list(self.stores)
db_funcs = list(self.db_cleanup_funcs)
self._cleanup_count += 1
current_count = self._cleanup_count
for store in stores:
try:
@@ -206,6 +223,17 @@ class CleanupManager:
except Exception as e:
logger.error(f"Error cleaning up {store.name}: {e}")
# Cleanup database tables (less frequently)
for func, interval_multiplier in db_funcs:
if current_count % interval_multiplier == 0:
try:
deleted = func()
if deleted > 0:
logger.info(f"Database cleanup: {func.__name__} removed {deleted} rows")
total_cleaned += deleted
except Exception as e:
logger.error(f"Error in database cleanup {func.__name__}: {e}")
if total_cleaned > 0:
logger.info(f"Cleanup complete: removed {total_cleaned} stale entries")
+1
View File
@@ -274,3 +274,4 @@ MAX_DEAUTH_ALERTS_AGE_SECONDS = 300 # 5 minutes
# Deauth detector sniff timeout (seconds)
DEAUTH_SNIFF_TIMEOUT = 0.5
+300 -253
View File
@@ -88,65 +88,111 @@ def init_db() -> None:
ON signal_history(mode, device_id, timestamp)
''')
# Device correlation table
conn.execute('''
CREATE TABLE IF NOT EXISTS device_correlations (
id INTEGER PRIMARY KEY AUTOINCREMENT,
wifi_mac TEXT,
bt_mac TEXT,
confidence REAL,
first_seen TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
last_seen TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
metadata TEXT,
UNIQUE(wifi_mac, bt_mac)
)
''')
# Alert rules
conn.execute('''
CREATE TABLE IF NOT EXISTS alert_rules (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
mode TEXT,
event_type TEXT,
match TEXT,
severity TEXT DEFAULT 'medium',
enabled BOOLEAN DEFAULT 1,
notify TEXT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)
''')
# Alert events
conn.execute('''
CREATE TABLE IF NOT EXISTS alert_events (
id INTEGER PRIMARY KEY AUTOINCREMENT,
rule_id INTEGER,
mode TEXT,
event_type TEXT,
severity TEXT DEFAULT 'medium',
title TEXT,
message TEXT,
payload TEXT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (rule_id) REFERENCES alert_rules(id) ON DELETE SET NULL
)
''')
# Session recordings
conn.execute('''
CREATE TABLE IF NOT EXISTS recording_sessions (
id TEXT PRIMARY KEY,
mode TEXT NOT NULL,
label TEXT,
started_at TIMESTAMP NOT NULL,
stopped_at TIMESTAMP,
file_path TEXT NOT NULL,
event_count INTEGER DEFAULT 0,
size_bytes INTEGER DEFAULT 0,
metadata TEXT
)
''')
# Device correlation table
conn.execute('''
CREATE TABLE IF NOT EXISTS device_correlations (
id INTEGER PRIMARY KEY AUTOINCREMENT,
wifi_mac TEXT,
bt_mac TEXT,
confidence REAL,
first_seen TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
last_seen TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
metadata TEXT,
UNIQUE(wifi_mac, bt_mac)
)
''')
# Alert rules
conn.execute('''
CREATE TABLE IF NOT EXISTS alert_rules (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
mode TEXT,
event_type TEXT,
match TEXT,
severity TEXT DEFAULT 'medium',
enabled BOOLEAN DEFAULT 1,
notify TEXT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)
''')
# Alert events
conn.execute('''
CREATE TABLE IF NOT EXISTS alert_events (
id INTEGER PRIMARY KEY AUTOINCREMENT,
rule_id INTEGER,
mode TEXT,
event_type TEXT,
severity TEXT DEFAULT 'medium',
title TEXT,
message TEXT,
payload TEXT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (rule_id) REFERENCES alert_rules(id) ON DELETE SET NULL
)
''')
# Session recordings
conn.execute('''
CREATE TABLE IF NOT EXISTS recording_sessions (
id TEXT PRIMARY KEY,
mode TEXT NOT NULL,
label TEXT,
started_at TIMESTAMP NOT NULL,
stopped_at TIMESTAMP,
file_path TEXT NOT NULL,
event_count INTEGER DEFAULT 0,
size_bytes INTEGER DEFAULT 0,
metadata TEXT
)
''')
# Alert rules
conn.execute('''
CREATE TABLE IF NOT EXISTS alert_rules (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
mode TEXT,
event_type TEXT,
match TEXT,
severity TEXT DEFAULT 'medium',
enabled BOOLEAN DEFAULT 1,
notify TEXT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)
''')
# Alert events
conn.execute('''
CREATE TABLE IF NOT EXISTS alert_events (
id INTEGER PRIMARY KEY AUTOINCREMENT,
rule_id INTEGER,
mode TEXT,
event_type TEXT,
severity TEXT DEFAULT 'medium',
title TEXT,
message TEXT,
payload TEXT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (rule_id) REFERENCES alert_rules(id) ON DELETE SET NULL
)
''')
# Session recordings
conn.execute('''
CREATE TABLE IF NOT EXISTS recording_sessions (
id TEXT PRIMARY KEY,
mode TEXT NOT NULL,
label TEXT,
started_at TIMESTAMP NOT NULL,
stopped_at TIMESTAMP,
file_path TEXT NOT NULL,
event_count INTEGER DEFAULT 0,
size_bytes INTEGER DEFAULT 0,
metadata TEXT
)
''')
# Users table for authentication
conn.execute('''
@@ -177,29 +223,29 @@ def init_db() -> None:
# =====================================================================
# TSCM Baselines - Environment snapshots for comparison
conn.execute('''
CREATE TABLE IF NOT EXISTS tscm_baselines (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
location TEXT,
description TEXT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
wifi_networks TEXT,
wifi_clients TEXT,
bt_devices TEXT,
rf_frequencies TEXT,
gps_coords TEXT,
is_active BOOLEAN DEFAULT 0
)
''')
# Ensure new columns exist for older databases
try:
columns = {row['name'] for row in conn.execute("PRAGMA table_info(tscm_baselines)")}
if 'wifi_clients' not in columns:
conn.execute('ALTER TABLE tscm_baselines ADD COLUMN wifi_clients TEXT')
except Exception as e:
logger.debug(f"Schema update skipped for tscm_baselines: {e}")
conn.execute('''
CREATE TABLE IF NOT EXISTS tscm_baselines (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
location TEXT,
description TEXT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
wifi_networks TEXT,
wifi_clients TEXT,
bt_devices TEXT,
rf_frequencies TEXT,
gps_coords TEXT,
is_active BOOLEAN DEFAULT 0
)
''')
# Ensure new columns exist for older databases
try:
columns = {row['name'] for row in conn.execute("PRAGMA table_info(tscm_baselines)")}
if 'wifi_clients' not in columns:
conn.execute('ALTER TABLE tscm_baselines ADD COLUMN wifi_clients TEXT')
except Exception as e:
logger.debug(f"Schema update skipped for tscm_baselines: {e}")
# TSCM Sweeps - Individual sweep sessions
conn.execute('''
@@ -740,16 +786,16 @@ def get_correlations(min_confidence: float = 0.5) -> list[dict]:
# TSCM Functions
# =============================================================================
def create_tscm_baseline(
name: str,
location: str | None = None,
description: str | None = None,
wifi_networks: list | None = None,
wifi_clients: list | None = None,
bt_devices: list | None = None,
rf_frequencies: list | None = None,
gps_coords: dict | None = None
) -> int:
def create_tscm_baseline(
name: str,
location: str | None = None,
description: str | None = None,
wifi_networks: list | None = None,
wifi_clients: list | None = None,
bt_devices: list | None = None,
rf_frequencies: list | None = None,
gps_coords: dict | None = None
) -> int:
"""
Create a new TSCM baseline.
@@ -757,20 +803,20 @@ def create_tscm_baseline(
The ID of the created baseline
"""
with get_db() as conn:
cursor = conn.execute('''
INSERT INTO tscm_baselines
(name, location, description, wifi_networks, wifi_clients, bt_devices, rf_frequencies, gps_coords)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
''', (
name,
location,
description,
json.dumps(wifi_networks) if wifi_networks else None,
json.dumps(wifi_clients) if wifi_clients else None,
json.dumps(bt_devices) if bt_devices else None,
json.dumps(rf_frequencies) if rf_frequencies else None,
json.dumps(gps_coords) if gps_coords else None
))
cursor = conn.execute('''
INSERT INTO tscm_baselines
(name, location, description, wifi_networks, wifi_clients, bt_devices, rf_frequencies, gps_coords)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
''', (
name,
location,
description,
json.dumps(wifi_networks) if wifi_networks else None,
json.dumps(wifi_clients) if wifi_clients else None,
json.dumps(bt_devices) if bt_devices else None,
json.dumps(rf_frequencies) if rf_frequencies else None,
json.dumps(gps_coords) if gps_coords else None
))
return cursor.lastrowid
@@ -785,19 +831,19 @@ def get_tscm_baseline(baseline_id: int) -> dict | None:
if row is None:
return None
return {
'id': row['id'],
'name': row['name'],
'location': row['location'],
'description': row['description'],
'created_at': row['created_at'],
'wifi_networks': json.loads(row['wifi_networks']) if row['wifi_networks'] else [],
'wifi_clients': json.loads(row['wifi_clients']) if row['wifi_clients'] else [],
'bt_devices': json.loads(row['bt_devices']) if row['bt_devices'] else [],
'rf_frequencies': json.loads(row['rf_frequencies']) if row['rf_frequencies'] else [],
'gps_coords': json.loads(row['gps_coords']) if row['gps_coords'] else None,
'is_active': bool(row['is_active'])
}
return {
'id': row['id'],
'name': row['name'],
'location': row['location'],
'description': row['description'],
'created_at': row['created_at'],
'wifi_networks': json.loads(row['wifi_networks']) if row['wifi_networks'] else [],
'wifi_clients': json.loads(row['wifi_clients']) if row['wifi_clients'] else [],
'bt_devices': json.loads(row['bt_devices']) if row['bt_devices'] else [],
'rf_frequencies': json.loads(row['rf_frequencies']) if row['rf_frequencies'] else [],
'gps_coords': json.loads(row['gps_coords']) if row['gps_coords'] else None,
'is_active': bool(row['is_active'])
}
def get_all_tscm_baselines() -> list[dict]:
@@ -839,23 +885,23 @@ def set_active_tscm_baseline(baseline_id: int) -> bool:
return cursor.rowcount > 0
def update_tscm_baseline(
baseline_id: int,
wifi_networks: list | None = None,
wifi_clients: list | None = None,
bt_devices: list | None = None,
rf_frequencies: list | None = None
) -> bool:
def update_tscm_baseline(
baseline_id: int,
wifi_networks: list | None = None,
wifi_clients: list | None = None,
bt_devices: list | None = None,
rf_frequencies: list | None = None
) -> bool:
"""Update baseline device lists."""
updates = []
params = []
if wifi_networks is not None:
updates.append('wifi_networks = ?')
params.append(json.dumps(wifi_networks))
if wifi_clients is not None:
updates.append('wifi_clients = ?')
params.append(json.dumps(wifi_clients))
if wifi_networks is not None:
updates.append('wifi_networks = ?')
params.append(json.dumps(wifi_networks))
if wifi_clients is not None:
updates.append('wifi_clients = ?')
params.append(json.dumps(wifi_clients))
if bt_devices is not None:
updates.append('bt_devices = ?')
params.append(json.dumps(bt_devices))
@@ -1267,127 +1313,127 @@ def get_all_known_devices(
]
def delete_known_device(identifier: str) -> bool:
"""Remove a device from the known-good registry."""
with get_db() as conn:
cursor = conn.execute(
'DELETE FROM tscm_known_devices WHERE identifier = ?',
(identifier.upper(),)
)
return cursor.rowcount > 0
# =============================================================================
# TSCM Schedule Functions
# =============================================================================
def create_tscm_schedule(
name: str,
cron_expression: str,
sweep_type: str = 'standard',
baseline_id: int | None = None,
zone_name: str | None = None,
enabled: bool = True,
notify_on_threat: bool = True,
notify_email: str | None = None,
last_run: str | None = None,
next_run: str | None = None,
) -> int:
"""Create a new TSCM sweep schedule."""
with get_db() as conn:
cursor = conn.execute('''
INSERT INTO tscm_schedules
(name, baseline_id, zone_name, cron_expression, sweep_type,
enabled, last_run, next_run, notify_on_threat, notify_email)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
''', (
name,
baseline_id,
zone_name,
cron_expression,
sweep_type,
1 if enabled else 0,
last_run,
next_run,
1 if notify_on_threat else 0,
notify_email,
))
return cursor.lastrowid
def get_tscm_schedule(schedule_id: int) -> dict | None:
"""Get a TSCM schedule by ID."""
with get_db() as conn:
cursor = conn.execute(
'SELECT * FROM tscm_schedules WHERE id = ?',
(schedule_id,)
)
row = cursor.fetchone()
return dict(row) if row else None
def get_all_tscm_schedules(
enabled: bool | None = None,
limit: int = 200
) -> list[dict]:
"""Get all TSCM schedules."""
conditions = []
params = []
if enabled is not None:
conditions.append('enabled = ?')
params.append(1 if enabled else 0)
where_clause = f'WHERE {" AND ".join(conditions)}' if conditions else ''
params.append(limit)
with get_db() as conn:
cursor = conn.execute(f'''
SELECT * FROM tscm_schedules
{where_clause}
ORDER BY id DESC
LIMIT ?
''', params)
return [dict(row) for row in cursor]
def update_tscm_schedule(schedule_id: int, **fields) -> bool:
"""Update a TSCM schedule."""
if not fields:
return False
updates = []
params = []
for key, value in fields.items():
updates.append(f'{key} = ?')
params.append(value)
params.append(schedule_id)
with get_db() as conn:
cursor = conn.execute(
f'UPDATE tscm_schedules SET {", ".join(updates)} WHERE id = ?',
params
)
return cursor.rowcount > 0
def delete_tscm_schedule(schedule_id: int) -> bool:
"""Delete a TSCM schedule."""
with get_db() as conn:
cursor = conn.execute(
'DELETE FROM tscm_schedules WHERE id = ?',
(schedule_id,)
)
return cursor.rowcount > 0
def is_known_good_device(identifier: str, location: str | None = None) -> dict | None:
"""Check if a device is in the known-good registry for a location."""
with get_db() as conn:
if location:
cursor = conn.execute('''
def delete_known_device(identifier: str) -> bool:
"""Remove a device from the known-good registry."""
with get_db() as conn:
cursor = conn.execute(
'DELETE FROM tscm_known_devices WHERE identifier = ?',
(identifier.upper(),)
)
return cursor.rowcount > 0
# =============================================================================
# TSCM Schedule Functions
# =============================================================================
def create_tscm_schedule(
name: str,
cron_expression: str,
sweep_type: str = 'standard',
baseline_id: int | None = None,
zone_name: str | None = None,
enabled: bool = True,
notify_on_threat: bool = True,
notify_email: str | None = None,
last_run: str | None = None,
next_run: str | None = None,
) -> int:
"""Create a new TSCM sweep schedule."""
with get_db() as conn:
cursor = conn.execute('''
INSERT INTO tscm_schedules
(name, baseline_id, zone_name, cron_expression, sweep_type,
enabled, last_run, next_run, notify_on_threat, notify_email)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
''', (
name,
baseline_id,
zone_name,
cron_expression,
sweep_type,
1 if enabled else 0,
last_run,
next_run,
1 if notify_on_threat else 0,
notify_email,
))
return cursor.lastrowid
def get_tscm_schedule(schedule_id: int) -> dict | None:
"""Get a TSCM schedule by ID."""
with get_db() as conn:
cursor = conn.execute(
'SELECT * FROM tscm_schedules WHERE id = ?',
(schedule_id,)
)
row = cursor.fetchone()
return dict(row) if row else None
def get_all_tscm_schedules(
enabled: bool | None = None,
limit: int = 200
) -> list[dict]:
"""Get all TSCM schedules."""
conditions = []
params = []
if enabled is not None:
conditions.append('enabled = ?')
params.append(1 if enabled else 0)
where_clause = f'WHERE {" AND ".join(conditions)}' if conditions else ''
params.append(limit)
with get_db() as conn:
cursor = conn.execute(f'''
SELECT * FROM tscm_schedules
{where_clause}
ORDER BY id DESC
LIMIT ?
''', params)
return [dict(row) for row in cursor]
def update_tscm_schedule(schedule_id: int, **fields) -> bool:
"""Update a TSCM schedule."""
if not fields:
return False
updates = []
params = []
for key, value in fields.items():
updates.append(f'{key} = ?')
params.append(value)
params.append(schedule_id)
with get_db() as conn:
cursor = conn.execute(
f'UPDATE tscm_schedules SET {", ".join(updates)} WHERE id = ?',
params
)
return cursor.rowcount > 0
def delete_tscm_schedule(schedule_id: int) -> bool:
"""Delete a TSCM schedule."""
with get_db() as conn:
cursor = conn.execute(
'DELETE FROM tscm_schedules WHERE id = ?',
(schedule_id,)
)
return cursor.rowcount > 0
def is_known_good_device(identifier: str, location: str | None = None) -> dict | None:
"""Check if a device is in the known-good registry for a location."""
with get_db() as conn:
if location:
cursor = conn.execute('''
SELECT * FROM tscm_known_devices
WHERE identifier = ? AND (location = ? OR scope = 'global')
''', (identifier.upper(), location))
@@ -2123,3 +2169,4 @@ def cleanup_old_payloads(max_age_hours: int = 24) -> int:
WHERE received_at < datetime('now', ?)
''', (f'-{max_age_hours} hours',))
return cursor.rowcount
+1 -1
View File
@@ -443,7 +443,7 @@ TOOL_DEPENDENCIES = {
}
}
}
}
},
}
+6 -4
View File
@@ -85,11 +85,13 @@ atexit.register(cleanup_all_processes)
# Handle signals for graceful shutdown
def _signal_handler(signum, frame):
"""Handle termination signals."""
"""Handle termination signals.
Keep this minimal logging and lock acquisition in signal handlers
can deadlock when another thread holds the logging or process lock.
Process cleanup is handled by the atexit handler registered above.
"""
import sys
logger.info(f"Received signal {signum}, cleaning up...")
cleanup_all_processes()
# Re-raise KeyboardInterrupt for SIGINT so Flask can handle shutdown
if signum == signal.SIGINT:
raise KeyboardInterrupt()
sys.exit(0)
+3 -1
View File
@@ -26,7 +26,7 @@ from __future__ import annotations
from typing import Optional
from .base import CommandBuilder, SDRCapabilities, SDRDevice, SDRType
from .detection import detect_all_devices
from .detection import detect_all_devices, probe_rtlsdr_device
from .rtlsdr import RTLSDRCommandBuilder
from .limesdr import LimeSDRCommandBuilder
from .hackrf import HackRFCommandBuilder
@@ -229,4 +229,6 @@ __all__ = [
'validate_device_index',
'validate_squelch',
'get_capabilities_for_type',
# Device probing
'probe_rtlsdr_device',
]
+62
View File
@@ -348,6 +348,68 @@ def detect_hackrf_devices() -> list[SDRDevice]:
return devices
def probe_rtlsdr_device(device_index: int) -> str | None:
"""Probe whether an RTL-SDR device is available at the USB level.
Runs a quick ``rtl_test`` invocation targeting a single device to
check for USB claim errors that indicate the device is held by an
external process (or a stale handle from a previous crash).
Args:
device_index: The RTL-SDR device index to probe.
Returns:
An error message string if the device cannot be opened,
or ``None`` if the device is available.
"""
if not _check_tool('rtl_test'):
# Can't probe without rtl_test — let the caller proceed and
# surface errors from the actual decoder process instead.
return None
try:
import os
import platform
env = os.environ.copy()
if platform.system() == 'Darwin':
lib_paths = ['/usr/local/lib', '/opt/homebrew/lib']
current_ld = env.get('DYLD_LIBRARY_PATH', '')
env['DYLD_LIBRARY_PATH'] = ':'.join(
lib_paths + [current_ld] if current_ld else lib_paths
)
result = subprocess.run(
['rtl_test', '-d', str(device_index), '-t'],
capture_output=True,
text=True,
timeout=3,
env=env,
)
output = result.stderr + result.stdout
if 'usb_claim_interface' in output or 'Failed to open' in output:
logger.warning(
f"RTL-SDR device {device_index} USB probe failed: "
f"device busy or unavailable"
)
return (
f'SDR device {device_index} is busy at the USB level — '
f'another process outside INTERCEPT may be using it. '
f'Check for stale rtl_fm/rtl_433/dump1090 processes, '
f'or try a different device.'
)
except subprocess.TimeoutExpired:
# rtl_test opened the device successfully and is running the
# test — that means the device *is* available.
pass
except Exception as e:
logger.debug(f"RTL-SDR probe error for device {device_index}: {e}")
return None
def detect_all_devices() -> list[SDRDevice]:
"""
Detect all connected SDR devices across all supported hardware types.
+18 -4
View File
@@ -72,19 +72,33 @@ def compute_power_spectrum(
def quantize_to_uint8(
power_db: np.ndarray,
db_min: float = -90.0,
db_max: float = -20.0,
db_min: float | None = None,
db_max: float | None = None,
) -> bytes:
"""Clamp and scale dB values to 0-255.
When *db_min* / *db_max* are ``None`` (the default) the range is
derived from the data so the full colour palette is always used.
Args:
power_db: Float32 array of power values in dB.
db_min: Value mapped to 0.
db_max: Value mapped to 255.
db_min: Value mapped to 0 (auto if None).
db_max: Value mapped to 255 (auto if None).
Returns:
Bytes of length len(power_db), each in [0, 255].
"""
if db_min is None or db_max is None:
actual_min = float(np.min(power_db))
actual_max = float(np.max(power_db))
# Guarantee at least 1 dB of dynamic range
if actual_max - actual_min < 1.0:
actual_max = actual_min + 1.0
if db_min is None:
db_min = actual_min
if db_max is None:
db_max = actual_max
db_range = db_max - db_min
if db_range <= 0:
db_range = 1.0
+1041
View File
File diff suppressed because it is too large Load Diff
+188
View File
@@ -0,0 +1,188 @@
"""Weather satellite pass prediction utility.
Shared prediction logic used by both the API endpoint and the auto-scheduler.
"""
from __future__ import annotations
import datetime
from typing import Any
from utils.logging import get_logger
from utils.weather_sat import WEATHER_SATELLITES
logger = get_logger('intercept.weather_sat_predict')
def predict_passes(
lat: float,
lon: float,
hours: int = 24,
min_elevation: float = 15.0,
include_trajectory: bool = False,
include_ground_track: bool = False,
) -> list[dict[str, Any]]:
"""Predict upcoming weather satellite passes for an observer location.
Args:
lat: Observer latitude (-90 to 90)
lon: Observer longitude (-180 to 180)
hours: Hours ahead to predict (1-72)
min_elevation: Minimum max elevation in degrees (0-90)
include_trajectory: Include az/el trajectory points (30 points)
include_ground_track: Include lat/lon ground track points (60 points)
Returns:
List of pass dicts sorted by start time.
Raises:
ImportError: If skyfield is not installed.
"""
from skyfield.api import load, wgs84, EarthSatellite
from skyfield.almanac import find_discrete
from data.satellites import TLE_SATELLITES
# Use live TLE cache from satellite module if available (refreshed from CelesTrak)
tle_source = TLE_SATELLITES
try:
from routes.satellite import _tle_cache
if _tle_cache:
tle_source = _tle_cache
except ImportError:
pass
ts = load.timescale()
observer = wgs84.latlon(lat, lon)
t0 = ts.now()
t1 = ts.utc(t0.utc_datetime() + datetime.timedelta(hours=hours))
all_passes: list[dict[str, Any]] = []
for sat_key, sat_info in WEATHER_SATELLITES.items():
if not sat_info['active']:
continue
tle_data = tle_source.get(sat_info['tle_key'])
if not tle_data:
continue
satellite = EarthSatellite(tle_data[1], tle_data[2], tle_data[0], ts)
def above_horizon(t, _sat=satellite):
diff = _sat - observer
topocentric = diff.at(t)
alt, _, _ = topocentric.altaz()
return alt.degrees > 0
above_horizon.step_days = 1 / 720
try:
times, events = find_discrete(t0, t1, above_horizon)
except Exception:
continue
i = 0
while i < len(times):
if i < len(events) and events[i]: # Rising
rise_time = times[i]
set_time = None
for j in range(i + 1, len(times)):
if not events[j]: # Setting
set_time = times[j]
i = j
break
else:
i += 1
continue
if set_time is None:
i += 1
continue
duration_seconds = (
set_time.utc_datetime() - rise_time.utc_datetime()
).total_seconds()
duration_minutes = round(duration_seconds / 60, 1)
# Calculate max elevation and trajectory
max_el = 0.0
max_el_az = 0.0
trajectory: list[dict[str, float]] = []
num_traj_points = 30
for k in range(num_traj_points):
frac = k / (num_traj_points - 1)
t_point = ts.utc(
rise_time.utc_datetime()
+ datetime.timedelta(seconds=duration_seconds * frac)
)
diff = satellite - observer
topocentric = diff.at(t_point)
alt, az, _ = topocentric.altaz()
if alt.degrees > max_el:
max_el = alt.degrees
max_el_az = az.degrees
if include_trajectory:
trajectory.append({
'el': float(max(0, alt.degrees)),
'az': float(az.degrees),
})
if max_el < min_elevation:
i += 1
continue
# Rise/set azimuths
rise_topo = (satellite - observer).at(rise_time)
_, rise_az, _ = rise_topo.altaz()
set_topo = (satellite - observer).at(set_time)
_, set_az, _ = set_topo.altaz()
pass_data: dict[str, Any] = {
'id': f"{sat_key}_{rise_time.utc_datetime().strftime('%Y%m%d%H%M')}",
'satellite': sat_key,
'name': sat_info['name'],
'frequency': sat_info['frequency'],
'mode': sat_info['mode'],
'startTime': rise_time.utc_datetime().strftime('%Y-%m-%d %H:%M UTC'),
'startTimeISO': rise_time.utc_datetime().isoformat(),
'endTimeISO': set_time.utc_datetime().isoformat(),
'maxEl': round(max_el, 1),
'maxElAz': round(max_el_az, 1),
'riseAz': round(rise_az.degrees, 1),
'setAz': round(set_az.degrees, 1),
'duration': duration_minutes,
'quality': (
'excellent' if max_el >= 60
else 'good' if max_el >= 30
else 'fair'
),
}
if include_trajectory:
pass_data['trajectory'] = trajectory
if include_ground_track:
ground_track: list[dict[str, float]] = []
for k in range(60):
frac = k / 59
t_point = ts.utc(
rise_time.utc_datetime()
+ datetime.timedelta(seconds=duration_seconds * frac)
)
geocentric = satellite.at(t_point)
subpoint = wgs84.subpoint(geocentric)
ground_track.append({
'lat': float(subpoint.latitude.degrees),
'lon': float(subpoint.longitude.degrees),
})
pass_data['groundTrack'] = ground_track
all_passes.append(pass_data)
i += 1
all_passes.sort(key=lambda p: p['startTimeISO'])
return all_passes
+396
View File
@@ -0,0 +1,396 @@
"""Weather satellite auto-scheduler.
Automatically captures satellite passes based on predicted pass times.
Uses threading.Timer for scheduling no external dependencies required.
"""
from __future__ import annotations
import threading
import time
import uuid
from datetime import datetime, timezone, timedelta
from typing import Any, Callable
from utils.logging import get_logger
from utils.weather_sat import get_weather_sat_decoder, WEATHER_SATELLITES, CaptureProgress
logger = get_logger('intercept.weather_sat_scheduler')
# Import config defaults
try:
from config import (
WEATHER_SAT_SCHEDULE_REFRESH_MINUTES,
WEATHER_SAT_CAPTURE_BUFFER_SECONDS,
)
except ImportError:
WEATHER_SAT_SCHEDULE_REFRESH_MINUTES = 30
WEATHER_SAT_CAPTURE_BUFFER_SECONDS = 30
class ScheduledPass:
"""A pass scheduled for automatic capture."""
def __init__(self, pass_data: dict[str, Any]):
self.id: str = pass_data.get('id', str(uuid.uuid4())[:8])
self.satellite: str = pass_data['satellite']
self.name: str = pass_data['name']
self.frequency: float = pass_data['frequency']
self.mode: str = pass_data['mode']
self.start_time: str = pass_data['startTimeISO']
self.end_time: str = pass_data['endTimeISO']
self.max_el: float = pass_data['maxEl']
self.duration: float = pass_data['duration']
self.quality: str = pass_data['quality']
self.status: str = 'scheduled' # scheduled, capturing, complete, skipped
self.skipped: bool = False
self._timer: threading.Timer | None = None
self._stop_timer: threading.Timer | None = None
@property
def start_dt(self) -> datetime:
dt = datetime.fromisoformat(self.start_time)
if dt.tzinfo is None:
# Naive datetime - assume UTC
dt = dt.replace(tzinfo=timezone.utc)
return dt.astimezone(timezone.utc)
@property
def end_dt(self) -> datetime:
dt = datetime.fromisoformat(self.end_time)
if dt.tzinfo is None:
# Naive datetime - assume UTC
dt = dt.replace(tzinfo=timezone.utc)
return dt.astimezone(timezone.utc)
def to_dict(self) -> dict[str, Any]:
return {
'id': self.id,
'satellite': self.satellite,
'name': self.name,
'frequency': self.frequency,
'mode': self.mode,
'startTimeISO': self.start_time,
'endTimeISO': self.end_time,
'maxEl': self.max_el,
'duration': self.duration,
'quality': self.quality,
'status': self.status,
'skipped': self.skipped,
}
class WeatherSatScheduler:
"""Auto-scheduler for weather satellite captures."""
def __init__(self):
self._enabled = False
self._lock = threading.Lock()
self._passes: list[ScheduledPass] = []
self._refresh_timer: threading.Timer | None = None
self._lat: float = 0.0
self._lon: float = 0.0
self._min_elevation: float = 15.0
self._device: int = 0
self._gain: float = 40.0
self._bias_t: bool = False
self._progress_callback: Callable[[CaptureProgress], None] | None = None
self._event_callback: Callable[[dict[str, Any]], None] | None = None
@property
def enabled(self) -> bool:
return self._enabled
def set_callbacks(
self,
progress_callback: Callable[[CaptureProgress], None],
event_callback: Callable[[dict[str, Any]], None],
) -> None:
"""Set callbacks for progress and scheduler events."""
self._progress_callback = progress_callback
self._event_callback = event_callback
def enable(
self,
lat: float,
lon: float,
min_elevation: float = 15.0,
device: int = 0,
gain: float = 40.0,
bias_t: bool = False,
) -> dict[str, Any]:
"""Enable auto-scheduling.
Args:
lat: Observer latitude
lon: Observer longitude
min_elevation: Minimum pass elevation to capture
device: RTL-SDR device index
gain: SDR gain in dB
bias_t: Enable bias-T
Returns:
Status dict with scheduled passes.
"""
with self._lock:
self._lat = lat
self._lon = lon
self._min_elevation = min_elevation
self._device = device
self._gain = gain
self._bias_t = bias_t
self._enabled = True
self._refresh_passes()
return self.get_status()
def disable(self) -> dict[str, Any]:
"""Disable auto-scheduling and cancel all timers."""
with self._lock:
self._enabled = False
# Cancel refresh timer
if self._refresh_timer:
self._refresh_timer.cancel()
self._refresh_timer = None
# Cancel all pass timers
for p in self._passes:
if p._timer:
p._timer.cancel()
p._timer = None
if p._stop_timer:
p._stop_timer.cancel()
p._stop_timer = None
self._passes.clear()
logger.info("Weather satellite auto-scheduler disabled")
return {'status': 'disabled'}
def skip_pass(self, pass_id: str) -> bool:
"""Manually skip a scheduled pass."""
with self._lock:
for p in self._passes:
if p.id == pass_id and p.status == 'scheduled':
p.skipped = True
p.status = 'skipped'
if p._timer:
p._timer.cancel()
p._timer = None
logger.info(f"Skipped pass: {p.satellite} at {p.start_time}")
self._emit_event({
'type': 'schedule_capture_skipped',
'pass': p.to_dict(),
'reason': 'manual',
})
return True
return False
def get_status(self) -> dict[str, Any]:
"""Get current scheduler status."""
with self._lock:
return {
'enabled': self._enabled,
'observer': {'latitude': self._lat, 'longitude': self._lon},
'device': self._device,
'gain': self._gain,
'bias_t': self._bias_t,
'min_elevation': self._min_elevation,
'scheduled_count': sum(
1 for p in self._passes if p.status == 'scheduled'
),
'total_passes': len(self._passes),
}
def get_passes(self) -> list[dict[str, Any]]:
"""Get list of scheduled passes."""
with self._lock:
return [p.to_dict() for p in self._passes]
def _refresh_passes(self) -> None:
"""Recompute passes and schedule timers."""
if not self._enabled:
return
try:
from utils.weather_sat_predict import predict_passes
passes = predict_passes(
lat=self._lat,
lon=self._lon,
hours=24,
min_elevation=self._min_elevation,
)
except Exception as e:
logger.error(f"Failed to predict passes for scheduler: {e}")
passes = []
with self._lock:
# Cancel existing timers
for p in self._passes:
if p._timer:
p._timer.cancel()
if p._stop_timer:
p._stop_timer.cancel()
# Keep completed/skipped for history, replace scheduled
history = [p for p in self._passes if p.status in ('complete', 'skipped', 'capturing')]
self._passes = history
now = datetime.now(timezone.utc)
buffer = WEATHER_SAT_CAPTURE_BUFFER_SECONDS
for pass_data in passes:
sp = ScheduledPass(pass_data)
# Skip passes that already started
if sp.start_dt - timedelta(seconds=buffer) <= now:
continue
# Check if already in history
if any(h.id == sp.id for h in history):
continue
# Schedule capture timer
delay = (sp.start_dt - timedelta(seconds=buffer) - now).total_seconds()
if delay > 0:
sp._timer = threading.Timer(delay, self._execute_capture, args=[sp])
sp._timer.daemon = True
sp._timer.start()
self._passes.append(sp)
logger.info(
f"Scheduler refreshed: {sum(1 for p in self._passes if p.status == 'scheduled')} "
f"passes scheduled"
)
# Schedule next refresh
if self._refresh_timer:
self._refresh_timer.cancel()
self._refresh_timer = threading.Timer(
WEATHER_SAT_SCHEDULE_REFRESH_MINUTES * 60,
self._refresh_passes,
)
self._refresh_timer.daemon = True
self._refresh_timer.start()
def _execute_capture(self, sp: ScheduledPass) -> None:
"""Execute capture for a scheduled pass."""
if not self._enabled or sp.skipped:
return
decoder = get_weather_sat_decoder()
if decoder.is_running:
logger.info(f"SDR busy, skipping scheduled pass: {sp.satellite}")
sp.status = 'skipped'
sp.skipped = True
self._emit_event({
'type': 'schedule_capture_skipped',
'pass': sp.to_dict(),
'reason': 'sdr_busy',
})
return
# Claim SDR device
try:
import app as app_module
error = app_module.claim_sdr_device(self._device, 'weather_sat')
if error:
logger.info(f"SDR device busy, skipping: {sp.satellite} - {error}")
sp.status = 'skipped'
sp.skipped = True
self._emit_event({
'type': 'schedule_capture_skipped',
'pass': sp.to_dict(),
'reason': 'device_busy',
})
return
except ImportError:
pass
sp.status = 'capturing'
# Set up callbacks
if self._progress_callback:
decoder.set_callback(self._progress_callback)
def _release_device():
try:
import app as app_module
app_module.release_sdr_device(self._device)
except ImportError:
pass
decoder.set_on_complete(lambda: self._on_capture_complete(sp, _release_device))
success = decoder.start(
satellite=sp.satellite,
device_index=self._device,
gain=self._gain,
bias_t=self._bias_t,
)
if success:
logger.info(f"Auto-scheduler started capture: {sp.satellite}")
self._emit_event({
'type': 'schedule_capture_start',
'pass': sp.to_dict(),
})
# Schedule stop timer at pass end + buffer
now = datetime.now(timezone.utc)
stop_delay = (sp.end_dt + timedelta(seconds=WEATHER_SAT_CAPTURE_BUFFER_SECONDS) - now).total_seconds()
if stop_delay > 0:
sp._stop_timer = threading.Timer(stop_delay, self._stop_capture, args=[sp])
sp._stop_timer.daemon = True
sp._stop_timer.start()
else:
sp.status = 'skipped'
_release_device()
self._emit_event({
'type': 'schedule_capture_skipped',
'pass': sp.to_dict(),
'reason': 'start_failed',
})
def _stop_capture(self, sp: ScheduledPass) -> None:
"""Stop capture at pass end."""
decoder = get_weather_sat_decoder()
if decoder.is_running:
decoder.stop()
logger.info(f"Auto-scheduler stopped capture: {sp.satellite}")
def _on_capture_complete(self, sp: ScheduledPass, release_fn: Callable) -> None:
"""Handle capture completion."""
sp.status = 'complete'
release_fn()
self._emit_event({
'type': 'schedule_capture_complete',
'pass': sp.to_dict(),
})
def _emit_event(self, event: dict[str, Any]) -> None:
"""Emit scheduler event to callback."""
if self._event_callback:
try:
self._event_callback(event)
except Exception as e:
logger.error(f"Error in scheduler event callback: {e}")
# Singleton
_scheduler: WeatherSatScheduler | None = None
_scheduler_lock = threading.Lock()
def get_weather_sat_scheduler() -> WeatherSatScheduler:
"""Get or create the global weather satellite scheduler instance."""
global _scheduler
if _scheduler is None:
with _scheduler_lock:
if _scheduler is None:
_scheduler = WeatherSatScheduler()
return _scheduler