Compare commits

..

93 Commits

Author SHA1 Message Date
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
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
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
Smittix f18ed26005 Fix grgsm_livemon Qt crash in headless Docker container
Set QT_QPA_PLATFORM=offscreen for both grgsm_livemon and
grgsm_scanner to prevent SIGABRT when no X11 display is available.
grgsm_livemon uses GNU Radio which loads Qt plugins — without a
display, Qt aborts with "could not connect to display".

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Applied to both scanner and livemon subprocess spawns.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-07 15:35:32 +00:00
Smittix adb472956e Merge pull request #126 from suidroot/add_airspy_docker
Add Soapy Airspy package and airspy pages to Dockerfile
2026-02-07 15:31:08 +00:00
Marc 297f971bd5 adding vector images for the towers and phones 2026-02-07 01:22:50 -06:00
Ben Mason 28e19b8898 Add Soapy Airspy package and airspy pages to Dockerfile 2026-02-06 17:00:52 -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
79 changed files with 7076 additions and 1032 deletions
+33
View File
@@ -2,6 +2,39 @@
All notable changes to iNTERCEPT will be documented in this file. 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 ## [2.14.0] - 2026-02-06
### Added ### Added
+5
View File
@@ -9,6 +9,9 @@ LABEL description="Signal Intelligence Platform for SDR monitoring"
# Set working directory # Set working directory
WORKDIR /app 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 # Install system dependencies for SDR tools
RUN apt-get update && apt-get install -y --no-install-recommends \ RUN apt-get update && apt-get install -y --no-install-recommends \
# RTL-SDR tools # RTL-SDR tools
@@ -41,6 +44,8 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
soapysdr-module-rtlsdr \ soapysdr-module-rtlsdr \
soapysdr-module-hackrf \ soapysdr-module-hackrf \
soapysdr-module-lms7 \ soapysdr-module-lms7 \
soapysdr-module-airspy \
airspy \
limesuite \ limesuite \
hackrf \ hackrf \
# Utilities # Utilities
+40 -2
View File
@@ -244,6 +244,10 @@ sdr_device_registry_lock = threading.Lock()
def claim_sdr_device(device_index: int, mode_name: str) -> str | None: def claim_sdr_device(device_index: int, mode_name: str) -> str | None:
"""Claim an SDR device for a mode. """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: Args:
device_index: The SDR device index to claim device_index: The SDR device index to claim
mode_name: Name of the mode claiming the device (e.g., 'sensor', 'rtlamr') mode_name: Name of the mode claiming the device (e.g., 'sensor', 'rtlamr')
@@ -255,6 +259,16 @@ def claim_sdr_device(device_index: int, mode_name: str) -> str | None:
if device_index in sdr_device_registry: if device_index in sdr_device_registry:
in_use_by = sdr_device_registry[device_index] 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.' return f'SDR device {device_index} is in use by {in_use_by}. Stop {in_use_by} first or use a different device.'
# Probe the USB device to catch external processes holding the handle
try:
from utils.sdr.detection import probe_rtlsdr_device
usb_error = probe_rtlsdr_device(device_index)
if usb_error:
return usb_error
except Exception:
pass # If probe fails, let the caller proceed normally
sdr_device_registry[device_index] = mode_name sdr_device_registry[device_index] = mode_name
return None return None
@@ -292,6 +306,10 @@ def require_login():
if request.path.startswith('/listening/audio/'): if request.path.startswith('/listening/audio/'):
return None 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 # Controller API endpoints use API key auth, not session auth
# Allow agent push/pull endpoints without session login # Allow agent push/pull endpoints without session login
if request.path.startswith('/controller/'): if request.path.startswith('/controller/'):
@@ -672,7 +690,7 @@ def kill_all() -> Response:
'airodump-ng', 'aireplay-ng', 'airmon-ng', 'airodump-ng', 'aireplay-ng', 'airmon-ng',
'dump1090', 'acarsdec', 'direwolf', 'AIS-catcher', 'dump1090', 'acarsdec', 'direwolf', 'AIS-catcher',
'hcitool', 'bluetoothctl', 'dsd', 'hcitool', 'bluetoothctl', 'dsd',
'rtl_tcp', 'rtl_power', 'rtlamr', 'ffmpeg', 'rtl_tcp', 'rtl_power', 'rtlamr', 'ffmpeg'
] ]
for proc in processes_to_kill: for proc in processes_to_kill:
@@ -737,7 +755,7 @@ def kill_all() -> Response:
# Reset Bluetooth v2 scanner # Reset Bluetooth v2 scanner
try: try:
reset_bluetooth_scanner() reset_bluetooth_scanner()
killed.append('bluetooth_scanner') killed.append('bluetooth')
except Exception: except Exception:
pass pass
@@ -830,6 +848,18 @@ def main() -> None:
from utils.database import init_db from utils.database import init_db
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 # Start automatic cleanup of stale data entries
cleanup_manager.start() cleanup_manager.start()
@@ -869,6 +899,14 @@ def main() -> None:
except ImportError as e: except ImportError as e:
print(f"KiwiSDR audio proxy disabled: {e}") print(f"KiwiSDR audio proxy disabled: {e}")
# Initialize WebSocket for waterfall streaming
try:
from routes.waterfall_websocket import init_waterfall_websocket
init_waterfall_websocket(app)
print("WebSocket waterfall streaming enabled")
except ImportError as e:
print(f"WebSocket waterfall disabled: {e}")
print(f"Open http://localhost:{args.port} in your browser") print(f"Open http://localhost:{args.port} in your browser")
print() print()
print("Press Ctrl+C to stop") print("Press Ctrl+C to stop")
+20 -1
View File
@@ -7,10 +7,23 @@ import os
import sys import sys
# Application version # Application version
VERSION = "2.14.0" VERSION = "2.15.0"
# Changelog - latest release notes (shown on welcome screen) # Changelog - latest release notes (shown on welcome screen)
CHANGELOG = [ 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", "version": "2.14.0",
"date": "February 2026", "date": "February 2026",
@@ -209,10 +222,16 @@ GITHUB_REPO = _get_env('GITHUB_REPO', 'smittix/intercept')
UPDATE_CHECK_ENABLED = _get_env_bool('UPDATE_CHECK_ENABLED', True) UPDATE_CHECK_ENABLED = _get_env_bool('UPDATE_CHECK_ENABLED', True)
UPDATE_CHECK_INTERVAL_HOURS = _get_env_int('UPDATE_CHECK_INTERVAL_HOURS', 6) 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 credentials
ADMIN_USERNAME = _get_env('ADMIN_USERNAME', 'admin') ADMIN_USERNAME = _get_env('ADMIN_USERNAME', 'admin')
ADMIN_PASSWORD = _get_env('ADMIN_PASSWORD', 'admin') ADMIN_PASSWORD = _get_env('ADMIN_PASSWORD', 'admin')
def configure_logging() -> None: def configure_logging() -> None:
"""Configure application logging.""" """Configure application logging."""
logging.basicConfig( logging.basicConfig(
+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
+87 -5
View File
@@ -843,6 +843,7 @@ class ModeManager:
'anomalies': getattr(self, 'tscm_anomalies', []), 'anomalies': getattr(self, 'tscm_anomalies', []),
'baseline': getattr(self, 'tscm_baseline', {}), 'baseline': getattr(self, 'tscm_baseline', {}),
'wifi_devices': list(self.wifi_networks.values()), 'wifi_devices': list(self.wifi_networks.values()),
'wifi_clients': list(getattr(self, 'tscm_wifi_clients', {}).values()),
'bt_devices': list(self.bluetooth_devices.values()), 'bt_devices': list(self.bluetooth_devices.values()),
'rf_signals': getattr(self, 'tscm_rf_signals', []), 'rf_signals': getattr(self, 'tscm_rf_signals', []),
} }
@@ -1116,6 +1117,7 @@ class ModeManager:
self.tscm_anomalies = [] self.tscm_anomalies = []
self.tscm_baseline = {} self.tscm_baseline = {}
self.tscm_rf_signals = [] self.tscm_rf_signals = []
self.tscm_wifi_clients = {}
# Clear reported threat tracking sets # Clear reported threat tracking sets
if hasattr(self, '_tscm_reported_wifi'): if hasattr(self, '_tscm_reported_wifi'):
self._tscm_reported_wifi.clear() self._tscm_reported_wifi.clear()
@@ -1541,6 +1543,7 @@ class ModeManager:
"""Start WiFi scanning using Intercept's UnifiedWiFiScanner.""" """Start WiFi scanning using Intercept's UnifiedWiFiScanner."""
interface = params.get('interface') interface = params.get('interface')
channel = params.get('channel') channel = params.get('channel')
channels = params.get('channels')
band = params.get('band', 'abg') band = params.get('band', 'abg')
scan_type = params.get('scan_type', 'deep') scan_type = params.get('scan_type', 'deep')
@@ -1571,8 +1574,21 @@ class ModeManager:
else: else:
scan_band = 'all' scan_band = 'all'
channel_list = None
if channels:
if isinstance(channels, str):
channel_list = [c.strip() for c in channels.split(',') if c.strip()]
elif isinstance(channels, (list, tuple, set)):
channel_list = list(channels)
else:
channel_list = [channels]
try:
channel_list = [int(c) for c in channel_list]
except (TypeError, ValueError):
return {'status': 'error', 'message': 'Invalid channels'}
# Start deep scan # Start deep scan
if scanner.start_deep_scan(interface=interface, band=scan_band, channel=channel): if scanner.start_deep_scan(interface=interface, band=scan_band, channel=channel, channels=channel_list):
# Start thread to sync data to agent's dictionaries # Start thread to sync data to agent's dictionaries
thread = threading.Thread( thread = threading.Thread(
target=self._wifi_data_sync, target=self._wifi_data_sync,
@@ -1593,7 +1609,7 @@ class ModeManager:
except ImportError: except ImportError:
# Fallback to direct airodump-ng # Fallback to direct airodump-ng
return self._start_wifi_fallback(interface, channel, band) return self._start_wifi_fallback(interface, channel, band, channels)
except Exception as e: except Exception as e:
logger.error(f"WiFi scanner error: {e}") logger.error(f"WiFi scanner error: {e}")
return {'status': 'error', 'message': str(e)} return {'status': 'error', 'message': str(e)}
@@ -1630,7 +1646,13 @@ class ModeManager:
if hasattr(self, '_wifi_scanner_instance') and self._wifi_scanner_instance: if hasattr(self, '_wifi_scanner_instance') and self._wifi_scanner_instance:
self._wifi_scanner_instance.stop_deep_scan() self._wifi_scanner_instance.stop_deep_scan()
def _start_wifi_fallback(self, interface: str | None, channel: int | None, band: str) -> dict: def _start_wifi_fallback(
self,
interface: str | None,
channel: int | None,
band: str,
channels: list[int] | str | None = None,
) -> dict:
"""Fallback WiFi deep scan using airodump-ng directly.""" """Fallback WiFi deep scan using airodump-ng directly."""
if not interface: if not interface:
return {'status': 'error', 'message': 'WiFi interface required'} return {'status': 'error', 'message': 'WiFi interface required'}
@@ -1658,7 +1680,22 @@ class ModeManager:
cmd = [airodump_path, '-w', csv_path, '--output-format', output_formats, '--band', band] cmd = [airodump_path, '-w', csv_path, '--output-format', output_formats, '--band', band]
if gps_manager.is_running: if gps_manager.is_running:
cmd.append('--gpsd') cmd.append('--gpsd')
if channel: channel_list = None
if channels:
if isinstance(channels, str):
channel_list = [c.strip() for c in channels.split(',') if c.strip()]
elif isinstance(channels, (list, tuple, set)):
channel_list = list(channels)
else:
channel_list = [channels]
try:
channel_list = [int(c) for c in channel_list]
except (TypeError, ValueError):
return {'status': 'error', 'message': 'Invalid channels'}
if channel_list:
cmd.extend(['-c', ','.join(str(c) for c in channel_list)])
elif channel:
cmd.extend(['-c', str(channel)]) cmd.extend(['-c', str(channel)])
cmd.append(interface) cmd.append(interface)
@@ -3113,7 +3150,10 @@ class ModeManager:
self.tscm_anomalies = [] self.tscm_anomalies = []
if not hasattr(self, 'tscm_rf_signals'): if not hasattr(self, 'tscm_rf_signals'):
self.tscm_rf_signals = [] self.tscm_rf_signals = []
if not hasattr(self, 'tscm_wifi_clients'):
self.tscm_wifi_clients = {}
self.tscm_anomalies.clear() self.tscm_anomalies.clear()
self.tscm_wifi_clients.clear()
# Get params for what to scan # Get params for what to scan
scan_wifi = params.get('wifi', True) scan_wifi = params.get('wifi', True)
@@ -3168,7 +3208,7 @@ class ModeManager:
stop_event = self.stop_events.get(mode) stop_event = self.stop_events.get(mode)
# Import existing Intercept TSCM functions # Import existing Intercept TSCM functions
from routes.tscm import _scan_wifi_networks, _scan_bluetooth_devices, _scan_rf_signals from routes.tscm import _scan_wifi_networks, _scan_wifi_clients, _scan_bluetooth_devices, _scan_rf_signals
logger.info("TSCM imports successful") logger.info("TSCM imports successful")
sweep_ranges = None sweep_ranges = None
@@ -3203,6 +3243,7 @@ class ModeManager:
# Track devices seen during this sweep (like local mode's all_wifi/all_bt dicts) # Track devices seen during this sweep (like local mode's all_wifi/all_bt dicts)
seen_wifi = {} seen_wifi = {}
seen_wifi_clients = {}
seen_bt = {} seen_bt = {}
last_rf_scan = 0 last_rf_scan = 0
@@ -3263,6 +3304,47 @@ class ModeManager:
enriched['recommended_action'] = profile.recommended_action enriched['recommended_action'] = profile.recommended_action
self.wifi_networks[bssid] = enriched self.wifi_networks[bssid] = enriched
# WiFi clients (monitor mode only)
try:
wifi_clients = _scan_wifi_clients(wifi_interface or '')
for client in wifi_clients:
mac = (client.get('mac') or '').upper()
if not mac or mac in seen_wifi_clients:
continue
seen_wifi_clients[mac] = client
rssi_val = client.get('rssi_current')
if rssi_val is None:
rssi_val = client.get('rssi_median') or client.get('rssi_ema')
client_device = {
'mac': mac,
'vendor': client.get('vendor'),
'name': client.get('vendor') or 'WiFi Client',
'rssi': rssi_val,
'associated_bssid': client.get('associated_bssid'),
'probed_ssids': client.get('probed_ssids', []),
'probe_count': client.get('probe_count', len(client.get('probed_ssids', []))),
'is_client': True,
}
if self._tscm_correlation:
profile = self._tscm_correlation.analyze_wifi_device(client_device)
client_device['classification'] = profile.risk_level.value
client_device['score'] = profile.total_score
client_device['score_modifier'] = profile.score_modifier
client_device['known_device'] = profile.known_device
client_device['known_device_name'] = profile.known_device_name
client_device['indicators'] = [
{'type': i.type.value, 'desc': i.description}
for i in profile.indicators
]
client_device['recommended_action'] = profile.recommended_action
self.tscm_wifi_clients[mac] = client_device
except Exception as e:
logger.debug(f"WiFi client scan error: {e}")
except Exception as e: except Exception as e:
logger.debug(f"WiFi scan error: {e}") logger.debug(f"WiFi scan error: {e}")
+1 -1
View File
@@ -1,6 +1,6 @@
[project] [project]
name = "intercept" name = "intercept"
version = "2.14.0" version = "2.15.0"
description = "Signal Intelligence Platform - Pager/433MHz/ADS-B/Satellite/WiFi/Bluetooth" description = "Signal Intelligence Platform - Pager/433MHz/ADS-B/Satellite/WiFi/Bluetooth"
readme = "README.md" readme = "README.md"
requires-python = ">=3.9" requires-python = ">=3.9"
+4
View File
@@ -29,6 +29,8 @@ def register_blueprints(app):
from .sstv_general import sstv_general_bp from .sstv_general import sstv_general_bp
from .dmr import dmr_bp from .dmr import dmr_bp
from .websdr import websdr_bp from .websdr import websdr_bp
from .alerts import alerts_bp
from .recordings import recordings_bp
app.register_blueprint(pager_bp) app.register_blueprint(pager_bp)
app.register_blueprint(sensor_bp) app.register_blueprint(sensor_bp)
@@ -57,6 +59,8 @@ def register_blueprints(app):
app.register_blueprint(sstv_general_bp) # General terrestrial SSTV app.register_blueprint(sstv_general_bp) # General terrestrial SSTV
app.register_blueprint(dmr_bp) # DMR / P25 / Digital Voice app.register_blueprint(dmr_bp) # DMR / P25 / Digital Voice
app.register_blueprint(websdr_bp) # HF/Shortwave WebSDR 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 # Initialize TSCM state with queue and lock from app
import app as app_module import app as app_module
+5
View File
@@ -21,6 +21,7 @@ import app as app_module
from utils.logging import sensor_logger as logger from utils.logging import sensor_logger as logger
from utils.validation import validate_device_index, validate_gain, validate_ppm from utils.validation import validate_device_index, validate_gain, validate_ppm
from utils.sse import format_sse from utils.sse import format_sse
from utils.event_pipeline import process_event
from utils.constants import ( from utils.constants import (
PROCESS_TERMINATE_TIMEOUT, PROCESS_TERMINATE_TIMEOUT,
SSE_KEEPALIVE_INTERVAL, SSE_KEEPALIVE_INTERVAL,
@@ -393,6 +394,10 @@ def stream_acars() -> Response:
try: try:
msg = app_module.acars_queue.get(timeout=SSE_QUEUE_TIMEOUT) msg = app_module.acars_queue.get(timeout=SSE_QUEUE_TIMEOUT)
last_keepalive = time.time() last_keepalive = time.time()
try:
process_event('acars', msg, msg.get('type'))
except Exception:
pass
yield format_sse(msg) yield format_sse(msg)
except queue.Empty: except queue.Empty:
now = time.time() now = time.time()
+5
View File
@@ -43,6 +43,7 @@ from utils.validation import (
validate_rtl_tcp_host, validate_rtl_tcp_port validate_rtl_tcp_host, validate_rtl_tcp_port
) )
from utils.sse import format_sse from utils.sse import format_sse
from utils.event_pipeline import process_event
from utils.sdr import SDRFactory, SDRType from utils.sdr import SDRFactory, SDRType
from utils.constants import ( from utils.constants import (
ADSB_SBS_PORT, ADSB_SBS_PORT,
@@ -843,6 +844,10 @@ def stream_adsb():
try: try:
msg = app_module.adsb_queue.get(timeout=SSE_QUEUE_TIMEOUT) msg = app_module.adsb_queue.get(timeout=SSE_QUEUE_TIMEOUT)
last_keepalive = time.time() last_keepalive = time.time()
try:
process_event('adsb', msg, msg.get('type'))
except Exception:
pass
yield format_sse(msg) yield format_sse(msg)
except queue.Empty: except queue.Empty:
now = time.time() now = time.time()
+5
View File
@@ -19,6 +19,7 @@ from config import SHARED_OBSERVER_LOCATION_ENABLED
from utils.logging import get_logger from utils.logging import get_logger
from utils.validation import validate_device_index, validate_gain from utils.validation import validate_device_index, validate_gain
from utils.sse import format_sse from utils.sse import format_sse
from utils.event_pipeline import process_event
from utils.sdr import SDRFactory, SDRType from utils.sdr import SDRFactory, SDRType
from utils.constants import ( from utils.constants import (
AIS_TCP_PORT, AIS_TCP_PORT,
@@ -484,6 +485,10 @@ def stream_ais():
try: try:
msg = app_module.ais_queue.get(timeout=SSE_QUEUE_TIMEOUT) msg = app_module.ais_queue.get(timeout=SSE_QUEUE_TIMEOUT)
last_keepalive = time.time() last_keepalive = time.time()
try:
process_event('ais', msg, msg.get('type'))
except Exception:
pass
yield format_sse(msg) yield format_sse(msg)
except queue.Empty: except queue.Empty:
now = time.time() now = time.time()
+76
View File
@@ -0,0 +1,76 @@
"""Alerting API endpoints."""
from __future__ import annotations
import queue
import time
from typing import Generator
from flask import Blueprint, Response, jsonify, request
from utils.alerts import get_alert_manager
from utils.sse import format_sse
alerts_bp = Blueprint('alerts', __name__, url_prefix='/alerts')
@alerts_bp.route('/rules', methods=['GET'])
def list_rules():
manager = get_alert_manager()
include_disabled = request.args.get('all') in ('1', 'true', 'yes')
return jsonify({'status': 'success', 'rules': manager.list_rules(include_disabled=include_disabled)})
@alerts_bp.route('/rules', methods=['POST'])
def create_rule():
data = request.get_json() or {}
if not isinstance(data.get('match', {}), dict):
return jsonify({'status': 'error', 'message': 'match must be a JSON object'}), 400
manager = get_alert_manager()
rule_id = manager.add_rule(data)
return jsonify({'status': 'success', 'rule_id': rule_id})
@alerts_bp.route('/rules/<int:rule_id>', methods=['PUT', 'PATCH'])
def update_rule(rule_id: int):
data = request.get_json() or {}
manager = get_alert_manager()
ok = manager.update_rule(rule_id, data)
if not ok:
return jsonify({'status': 'error', 'message': 'Rule not found or no changes'}), 404
return jsonify({'status': 'success'})
@alerts_bp.route('/rules/<int:rule_id>', methods=['DELETE'])
def delete_rule(rule_id: int):
manager = get_alert_manager()
ok = manager.delete_rule(rule_id)
if not ok:
return jsonify({'status': 'error', 'message': 'Rule not found'}), 404
return jsonify({'status': 'success'})
@alerts_bp.route('/events', methods=['GET'])
def list_events():
manager = get_alert_manager()
limit = request.args.get('limit', default=100, type=int)
mode = request.args.get('mode')
severity = request.args.get('severity')
events = manager.list_events(limit=limit, mode=mode, severity=severity)
return jsonify({'status': 'success', 'events': events})
@alerts_bp.route('/stream', methods=['GET'])
def stream_alerts() -> Response:
manager = get_alert_manager()
def generate() -> Generator[str, None, None]:
for event in manager.stream_events(timeout=1.0):
yield format_sse(event)
response = Response(generate(), mimetype='text/event-stream')
response.headers['Cache-Control'] = 'no-cache'
response.headers['X-Accel-Buffering'] = 'no'
response.headers['Connection'] = 'keep-alive'
return response
+13
View File
@@ -22,6 +22,7 @@ import app as app_module
from utils.logging import sensor_logger as logger from utils.logging import sensor_logger as logger
from utils.validation import validate_device_index, validate_gain, validate_ppm from utils.validation import validate_device_index, validate_gain, validate_ppm
from utils.sse import format_sse from utils.sse import format_sse
from utils.event_pipeline import process_event
from utils.constants import ( from utils.constants import (
PROCESS_TERMINATE_TIMEOUT, PROCESS_TERMINATE_TIMEOUT,
SSE_KEEPALIVE_INTERVAL, SSE_KEEPALIVE_INTERVAL,
@@ -52,6 +53,7 @@ aprs_packet_count = 0
aprs_station_count = 0 aprs_station_count = 0
aprs_last_packet_time = None aprs_last_packet_time = None
aprs_stations = {} # callsign -> station data aprs_stations = {} # callsign -> station data
APRS_MAX_STATIONS = 500 # Limit tracked stations to prevent memory growth
# Meter rate limiting # Meter rate limiting
_last_meter_time = 0.0 _last_meter_time = 0.0
@@ -1370,6 +1372,13 @@ def stream_aprs_output(rtl_process: subprocess.Popen, decoder_process: subproces
'last_seen': packet.get('timestamp'), 'last_seen': packet.get('timestamp'),
'packet_type': packet.get('packet_type'), '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) app_module.aprs_queue.put(packet)
@@ -1727,6 +1736,10 @@ def stream_aprs() -> Response:
try: try:
msg = app_module.aprs_queue.get(timeout=SSE_QUEUE_TIMEOUT) msg = app_module.aprs_queue.get(timeout=SSE_QUEUE_TIMEOUT)
last_keepalive = time.time() last_keepalive = time.time()
try:
process_event('aprs', msg, msg.get('type'))
except Exception:
pass
yield format_sse(msg) yield format_sse(msg)
except queue.Empty: except queue.Empty:
now = time.time() now = time.time()
+18 -2
View File
@@ -1,10 +1,11 @@
"""WebSocket-based audio streaming for SDR.""" """WebSocket-based audio streaming for SDR."""
import json
import shutil
import socket
import subprocess import subprocess
import threading import threading
import time import time
import shutil
import json
from flask import Flask from flask import Flask
# Try to import flask-sock # Try to import flask-sock
@@ -251,4 +252,19 @@ def init_audio_websocket(app: Flask):
finally: finally:
with process_lock: with process_lock:
kill_audio_processes() 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") logger.info("WebSocket audio client disconnected")
+5
View File
@@ -21,6 +21,7 @@ import app as app_module
from utils.dependencies import check_tool from utils.dependencies import check_tool
from utils.logging import bluetooth_logger as logger from utils.logging import bluetooth_logger as logger
from utils.sse import format_sse from utils.sse import format_sse
from utils.event_pipeline import process_event
from utils.validation import validate_bluetooth_interface from utils.validation import validate_bluetooth_interface
from data.oui import OUI_DATABASE, load_oui_database, get_manufacturer from data.oui import OUI_DATABASE, load_oui_database, get_manufacturer
from data.patterns import AIRTAG_PREFIXES, TILE_PREFIXES, SAMSUNG_TRACKER from data.patterns import AIRTAG_PREFIXES, TILE_PREFIXES, SAMSUNG_TRACKER
@@ -563,6 +564,10 @@ def stream_bt():
try: try:
msg = app_module.bt_queue.get(timeout=1) msg = app_module.bt_queue.get(timeout=1)
last_keepalive = time.time() last_keepalive = time.time()
try:
process_event('bluetooth', msg, msg.get('type'))
except Exception:
pass
yield format_sse(msg) yield format_sse(msg)
except queue.Empty: except queue.Empty:
now = time.time() now = time.time()
+105 -3
View File
@@ -11,6 +11,8 @@ import csv
import io import io
import json import json
import logging import logging
import threading
import time
from datetime import datetime from datetime import datetime
from typing import Generator from typing import Generator
@@ -28,12 +30,18 @@ from utils.bluetooth import (
) )
from utils.database import get_db from utils.database import get_db
from utils.sse import format_sse from utils.sse import format_sse
from utils.event_pipeline import process_event
logger = logging.getLogger('intercept.bluetooth_v2') logger = logging.getLogger('intercept.bluetooth_v2')
# Blueprint # Blueprint
bluetooth_v2_bp = Blueprint('bluetooth_v2', __name__, url_prefix='/api/bluetooth') bluetooth_v2_bp = Blueprint('bluetooth_v2', __name__, url_prefix='/api/bluetooth')
# Seen-before tracking
_bt_seen_cache: set[str] = set()
_bt_session_seen: set[str] = set()
_bt_seen_lock = threading.Lock()
# ============================================================================= # =============================================================================
# DATABASE FUNCTIONS # DATABASE FUNCTIONS
# ============================================================================= # =============================================================================
@@ -173,6 +181,13 @@ def save_observation_history(device: BTDeviceAggregate) -> None:
''', (device.device_id, device.rssi_current, device.seen_count)) ''', (device.device_id, device.rssi_current, device.seen_count))
def load_seen_device_ids() -> set[str]:
"""Load distinct device IDs from history for seen-before tracking."""
with get_db() as conn:
cursor = conn.execute('SELECT DISTINCT device_id FROM bt_observation_history')
return {row['device_id'] for row in cursor}
# ============================================================================= # =============================================================================
# API ENDPOINTS # API ENDPOINTS
# ============================================================================= # =============================================================================
@@ -221,6 +236,28 @@ def start_scan():
# Get scanner instance # Get scanner instance
scanner = get_bluetooth_scanner(adapter_id) scanner = get_bluetooth_scanner(adapter_id)
# Initialize database tables if needed
init_bt_tables()
def _handle_seen_before(device: BTDeviceAggregate) -> None:
try:
with _bt_seen_lock:
device.seen_before = device.device_id in _bt_seen_cache
if device.device_id not in _bt_session_seen:
save_observation_history(device)
_bt_session_seen.add(device.device_id)
except Exception as e:
logger.debug(f"BT seen-before update failed: {e}")
# Setup seen-before callback
if scanner._on_device_updated is None:
scanner._on_device_updated = _handle_seen_before
# Ensure cache is initialized
with _bt_seen_lock:
if not _bt_seen_cache:
_bt_seen_cache.update(load_seen_device_ids())
# Check if already scanning # Check if already scanning
if scanner.is_scanning: if scanner.is_scanning:
return jsonify({ return jsonify({
@@ -228,8 +265,11 @@ def start_scan():
'scan_status': scanner.get_status().to_dict() 'scan_status': scanner.get_status().to_dict()
}) })
# Initialize database tables if needed # Refresh seen-before cache and reset session set for a new scan
init_bt_tables() with _bt_seen_lock:
_bt_seen_cache.clear()
_bt_seen_cache.update(load_seen_device_ids())
_bt_session_seen.clear()
# Load active baseline if exists # Load active baseline if exists
baseline_id = get_active_baseline_id() baseline_id = get_active_baseline_id()
@@ -860,6 +900,10 @@ def stream_events():
"""Generate SSE events from scanner.""" """Generate SSE events from scanner."""
for event in scanner.stream_events(timeout=1.0): for event in scanner.stream_events(timeout=1.0):
event_name, event_data = map_event_type(event) event_name, event_data = map_event_type(event)
try:
process_event('bluetooth', event_data, event_name)
except Exception:
pass
yield format_sse(event_data, event=event_name) yield format_sse(event_data, event=event_name)
return Response( return Response(
@@ -947,6 +991,17 @@ def get_tscm_bluetooth_snapshot(duration: int = 8) -> list[dict]:
# Convert to TSCM format with tracker detection data # Convert to TSCM format with tracker detection data
tscm_devices = [] tscm_devices = []
for device in devices: for device in devices:
manufacturer_name = device.manufacturer_name
if (not manufacturer_name) or str(manufacturer_name).lower().startswith('unknown'):
if device.address and not device.is_randomized_mac:
try:
from data.oui import get_manufacturer
oui_vendor = get_manufacturer(device.address)
if oui_vendor and oui_vendor != 'Unknown':
manufacturer_name = oui_vendor
except Exception:
pass
device_data = { device_data = {
'mac': device.address, 'mac': device.address,
'address_type': device.address_type, 'address_type': device.address_type,
@@ -956,7 +1011,7 @@ def get_tscm_bluetooth_snapshot(duration: int = 8) -> list[dict]:
'rssi_median': device.rssi_median, 'rssi_median': device.rssi_median,
'rssi_ema': round(device.rssi_ema, 1) if device.rssi_ema else None, 'rssi_ema': round(device.rssi_ema, 1) if device.rssi_ema else None,
'type': _classify_device_type(device), 'type': _classify_device_type(device),
'manufacturer': device.manufacturer_name, 'manufacturer': manufacturer_name,
'manufacturer_id': device.manufacturer_id, 'manufacturer_id': device.manufacturer_id,
'manufacturer_data': device.manufacturer_bytes.hex() if device.manufacturer_bytes else None, 'manufacturer_data': device.manufacturer_bytes.hex() if device.manufacturer_bytes else None,
'protocol': device.protocol, 'protocol': device.protocol,
@@ -1178,6 +1233,30 @@ def _classify_device_type(device: BTDeviceAggregate) -> str:
"""Classify device type from available data.""" """Classify device type from available data."""
name_lower = (device.name or '').lower() name_lower = (device.name or '').lower()
manufacturer_lower = (device.manufacturer_name or '').lower() manufacturer_lower = (device.manufacturer_name or '').lower()
service_uuids = device.service_uuids or []
if (not manufacturer_lower) or manufacturer_lower.startswith('unknown'):
if device.address and not device.is_randomized_mac:
try:
from data.oui import get_manufacturer
oui_vendor = get_manufacturer(device.address)
if oui_vendor and oui_vendor != 'Unknown':
manufacturer_lower = oui_vendor.lower()
except Exception:
pass
def normalize_uuid(uuid: str) -> str:
if not uuid:
return ''
value = str(uuid).lower().strip()
if value.startswith('0x'):
value = value[2:]
# Bluetooth Base UUID normalization (16-bit UUIDs)
if value.endswith('-0000-1000-8000-00805f9b34fb') and len(value) >= 8:
return value[4:8]
if len(value) == 4:
return value
return value
# Check by name patterns # Check by name patterns
if any(x in name_lower for x in ['airpods', 'headphone', 'earbuds', 'buds', 'beats']): if any(x in name_lower for x in ['airpods', 'headphone', 'earbuds', 'buds', 'beats']):
@@ -1197,6 +1276,29 @@ def _classify_device_type(device: BTDeviceAggregate) -> str:
if any(x in name_lower for x in ['tv', 'chromecast', 'roku', 'firestick']): if any(x in name_lower for x in ['tv', 'chromecast', 'roku', 'firestick']):
return 'media' return 'media'
# Tracker signals (metadata or Find My service)
if getattr(device, 'is_tracker', False) or getattr(device, 'tracker_type', None):
return 'tracker'
normalized_uuids = {normalize_uuid(u) for u in service_uuids if u}
if 'fd6f' in normalized_uuids:
return 'tracker'
# Service UUIDs (GATT / classic)
audio_uuids = {'110b', '110a', '111e', '111f', '1108', '1203'}
wearable_uuids = {'180d', '1814', '1816'}
hid_uuids = {'1812'}
beacon_uuids = {'feaa', 'feab', 'feb1', 'febe'}
if normalized_uuids & audio_uuids:
return 'audio'
if normalized_uuids & hid_uuids:
return 'peripheral'
if normalized_uuids & wearable_uuids:
return 'wearable'
if normalized_uuids & beacon_uuids:
return 'beacon'
# Check by manufacturer # Check by manufacturer
if 'apple' in manufacturer_lower: if 'apple' in manufacturer_lower:
return 'apple_device' return 'apple_device'
+58 -17
View File
@@ -18,7 +18,9 @@ from flask import Blueprint, jsonify, request, Response
import app as app_module import app as app_module
from utils.logging import get_logger from utils.logging import get_logger
from utils.sse import format_sse from utils.sse import format_sse
from utils.event_pipeline import process_event
from utils.process import register_process, unregister_process from utils.process import register_process, unregister_process
from utils.validation import validate_frequency, validate_gain, validate_device_index, validate_ppm
from utils.constants import ( from utils.constants import (
SSE_QUEUE_TIMEOUT, SSE_QUEUE_TIMEOUT,
SSE_KEEPALIVE_INTERVAL, SSE_KEEPALIVE_INTERVAL,
@@ -53,14 +55,24 @@ _DSD_PROTOCOL_FLAGS = {
'provoice': ['-fv'], 'provoice': ['-fv'],
} }
# dsd-fme uses different flag names # dsd-fme remapped several flags from classic DSD:
# -fp = ProVoice (NOT P25), -fi = NXDN48 (NOT D-Star),
# -f1 = P25 Phase 1, -ft = XDMA multi-protocol decoder
_DSD_FME_PROTOCOL_FLAGS = { _DSD_FME_PROTOCOL_FLAGS = {
'auto': ['-ft'], 'auto': ['-ft'], # XDMA: auto-detect DMR/P25/YSF
'dmr': ['-fs'], 'dmr': ['-fd'], # DMR (classic flag, works in dsd-fme)
'p25': ['-f1'], 'p25': ['-f1'], # P25 Phase 1 (-fp is ProVoice in dsd-fme!)
'nxdn': ['-fi'], 'nxdn': ['-fn'], # NXDN96
'dstar': [], 'dstar': [], # No dedicated flag in dsd-fme; auto-detect
'provoice': ['-fp'], '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; Phase 2 would use -mq)
'nxdn': ['-mc'], # C4FM
} }
# ============================================ # ============================================
@@ -320,16 +332,14 @@ def start_dmr() -> Response:
data = request.json or {} data = request.json or {}
try: try:
frequency = float(data.get('frequency', 462.5625)) frequency = validate_frequency(data.get('frequency', 462.5625))
gain = int(data.get('gain', 40)) gain = int(validate_gain(data.get('gain', 40)))
device = int(data.get('device', 0)) device = validate_device_index(data.get('device', 0))
protocol = str(data.get('protocol', 'auto')).lower() protocol = str(data.get('protocol', 'auto')).lower()
ppm = validate_ppm(data.get('ppm', 0))
except (ValueError, TypeError) as e: except (ValueError, TypeError) as e:
return jsonify({'status': 'error', 'message': f'Invalid parameter: {e}'}), 400 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: if protocol not in VALID_PROTOCOLS:
return jsonify({'status': 'error', 'message': f'Invalid protocol. Use: {", ".join(VALID_PROTOCOLS)}'}), 400 return jsonify({'status': 'error', 'message': f'Invalid protocol. Use: {", ".join(VALID_PROTOCOLS)}'}), 400
@@ -340,8 +350,10 @@ def start_dmr() -> Response:
except queue.Empty: except queue.Empty:
pass pass
# Claim SDR device # Claim SDR device — use protocol name so the device panel shows
error = app_module.claim_sdr_device(device, 'dmr') # "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: if error:
return jsonify({'status': 'error', 'error_type': 'DEVICE_BUSY', 'message': error}), 409 return jsonify({'status': 'error', 'error_type': 'DEVICE_BUSY', 'message': error}), 409
@@ -349,7 +361,10 @@ def start_dmr() -> Response:
freq_hz = int(frequency * 1e6) freq_hz = int(frequency * 1e6)
# 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_cmd = [
rtl_fm_path, rtl_fm_path,
'-M', 'fm', '-M', 'fm',
@@ -357,8 +372,10 @@ def start_dmr() -> Response:
'-s', '48000', '-s', '48000',
'-g', str(gain), '-g', str(gain),
'-d', str(device), '-d', str(device),
'-l', '1', # squelch level '-l', '0',
] ]
if ppm != 0:
rtl_cmd.extend(['-p', str(ppm)])
# Build DSD command # Build DSD command
# Use -o - to send decoded audio to stdout (piped to DEVNULL) # Use -o - to send decoded audio to stdout (piped to DEVNULL)
@@ -366,6 +383,11 @@ def start_dmr() -> Response:
dsd_cmd = [dsd_path, '-i', '-', '-o', '-'] dsd_cmd = [dsd_path, '-i', '-', '-o', '-']
if is_fme: if is_fme:
dsd_cmd.extend(_DSD_FME_PROTOCOL_FLAGS.get(protocol, [])) dsd_cmd.extend(_DSD_FME_PROTOCOL_FLAGS.get(protocol, []))
dsd_cmd.extend(_DSD_FME_MODULATION.get(protocol, []))
# 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: else:
dsd_cmd.extend(_DSD_PROTOCOL_FLAGS.get(protocol, [])) dsd_cmd.extend(_DSD_PROTOCOL_FLAGS.get(protocol, []))
@@ -401,6 +423,21 @@ def start_dmr() -> Response:
if dmr_dsd_process.stderr: if dmr_dsd_process.stderr:
dsd_err = dmr_dsd_process.stderr.read().decode('utf-8', errors='replace')[:500] 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}") 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 process and unregister both
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: if dmr_active_device is not None:
app_module.release_sdr_device(dmr_active_device) app_module.release_sdr_device(dmr_active_device)
dmr_active_device = None dmr_active_device = None
@@ -495,6 +532,10 @@ def stream_dmr() -> Response:
try: try:
msg = dmr_queue.get(timeout=SSE_QUEUE_TIMEOUT) msg = dmr_queue.get(timeout=SSE_QUEUE_TIMEOUT)
last_keepalive = time.time() last_keepalive = time.time()
try:
process_event('dmr', msg, msg.get('type'))
except Exception:
pass
yield format_sse(msg) yield format_sse(msg)
except queue.Empty: except queue.Empty:
now = time.time() now = time.time()
+5
View File
@@ -36,6 +36,7 @@ from utils.database import (
) )
from utils.dsc.parser import parse_dsc_message from utils.dsc.parser import parse_dsc_message
from utils.sse import format_sse from utils.sse import format_sse
from utils.event_pipeline import process_event
from utils.validation import validate_device_index, validate_gain from utils.validation import validate_device_index, validate_gain
from utils.sdr import SDRFactory, SDRType from utils.sdr import SDRFactory, SDRType
from utils.dependencies import get_tool_path from utils.dependencies import get_tool_path
@@ -525,6 +526,10 @@ def stream() -> Response:
try: try:
msg = app_module.dsc_queue.get(timeout=1) msg = app_module.dsc_queue.get(timeout=1)
last_keepalive = time.time() last_keepalive = time.time()
try:
process_event('dsc', msg, msg.get('type'))
except Exception:
pass
yield format_sse(msg) yield format_sse(msg)
except queue.Empty: except queue.Empty:
now = time.time() now = time.time()
+200 -91
View File
@@ -20,6 +20,7 @@ from flask import Blueprint, jsonify, request, Response
import app as app_module import app as app_module
from utils.logging import get_logger from utils.logging import get_logger
from utils.sse import format_sse from utils.sse import format_sse
from utils.event_pipeline import process_event
from utils.constants import ( from utils.constants import (
SSE_QUEUE_TIMEOUT, SSE_QUEUE_TIMEOUT,
SSE_KEEPALIVE_INTERVAL, SSE_KEEPALIVE_INTERVAL,
@@ -839,9 +840,13 @@ def _start_audio_stream(frequency: float, modulation: str):
try: try:
ready, _, _ = select.select([audio_process.stdout], [], [], 4.0) ready, _, _ = select.select([audio_process.stdout], [], [], 4.0)
if not ready: if not ready:
logger.warning("Audio pipeline produced no data in startup window") logger.warning("Audio pipeline produced no data in startup window — killing stalled pipeline")
_stop_audio_stream_internal()
return
except Exception as e: except Exception as e:
logger.warning(f"Audio startup check failed: {e}") logger.warning(f"Audio startup check failed: {e}")
_stop_audio_stream_internal()
return
audio_running = True audio_running = True
audio_frequency = frequency audio_frequency = frequency
@@ -866,6 +871,8 @@ def _stop_audio_stream_internal():
audio_running = False audio_running = False
audio_frequency = 0.0 audio_frequency = 0.0
had_processes = audio_process is not None or audio_rtl_process is not None
# Kill the pipeline processes and their groups # Kill the pipeline processes and their groups
if audio_process: if audio_process:
try: try:
@@ -892,7 +899,8 @@ def _stop_audio_stream_internal():
audio_rtl_process = None audio_rtl_process = None
# Pause for SDR device USB interface to be released by kernel # Pause for SDR device USB interface to be released by kernel
time.sleep(1.0) if had_processes:
time.sleep(1.0)
# ============================================ # ============================================
@@ -1175,6 +1183,10 @@ def stream_scanner_events() -> Response:
try: try:
msg = scanner_queue.get(timeout=SSE_QUEUE_TIMEOUT) msg = scanner_queue.get(timeout=SSE_QUEUE_TIMEOUT)
last_keepalive = time.time() last_keepalive = time.time()
try:
process_event('listening_scanner', msg, msg.get('type'))
except Exception:
pass
yield format_sse(msg) yield format_sse(msg)
except queue.Empty: except queue.Empty:
now = time.time() now = time.time()
@@ -1293,6 +1305,20 @@ def start_audio() -> Response:
scanner_config['device'] = device scanner_config['device'] = device
scanner_config['sdr_type'] = sdr_type scanner_config['sdr_type'] = sdr_type
# Stop waterfall if it's using the same SDR (SSE path)
if waterfall_running and waterfall_active_device == device:
_stop_waterfall_internal()
time.sleep(0.2)
# Release waterfall device claim if the WebSocket waterfall is still
# holding it. The JS client sends a stop command and closes the
# WebSocket before requesting audio, but the backend handler may not
# have finished its cleanup yet.
device_status = app_module.get_sdr_device_status()
if device_status.get(device) == 'waterfall':
app_module.release_sdr_device(device)
time.sleep(0.3)
# Claim device for listening audio # Claim device for listening audio
if listening_active_device is None or listening_active_device != device: if listening_active_device is None or listening_active_device != device:
if listening_active_device is not None: if listening_active_device is not None:
@@ -1400,13 +1426,6 @@ def audio_probe() -> Response:
@listening_post_bp.route('/audio/stream') @listening_post_bp.route('/audio/stream')
def stream_audio() -> Response: def stream_audio() -> Response:
"""Stream WAV audio.""" """Stream WAV audio."""
# Optionally restart pipeline so the stream starts with a fresh header
if request.args.get('fresh') == '1' and audio_running:
try:
_start_audio_stream(audio_frequency or 0.0, audio_modulation or 'fm')
except Exception as e:
logger.error(f"Audio stream restart failed: {e}")
# Wait for audio to be ready (up to 2 seconds for modulation/squelch changes) # Wait for audio to be ready (up to 2 seconds for modulation/squelch changes)
for _ in range(40): for _ in range(40):
if audio_running and audio_process: if audio_running and audio_process:
@@ -1521,9 +1540,51 @@ waterfall_config = {
'bin_size': 10000, 'bin_size': 10000,
'gain': 40, 'gain': 40,
'device': 0, 'device': 0,
'max_bins': 1024,
'interval': 0.4,
} }
def _parse_rtl_power_line(line: str) -> tuple[str | None, float | None, float | None, list[float]]:
"""Parse a single rtl_power CSV line into bins."""
if not line or line.startswith('#'):
return None, None, None, []
parts = [p.strip() for p in line.split(',')]
if len(parts) < 6:
return None, None, None, []
# Timestamp in first two fields (YYYY-MM-DD, HH:MM:SS)
timestamp = f"{parts[0]} {parts[1]}" if len(parts) >= 2 else parts[0]
start_idx = None
for i, tok in enumerate(parts):
try:
val = float(tok)
except ValueError:
continue
if val > 1e5:
start_idx = i
break
if start_idx is None or len(parts) < start_idx + 4:
return timestamp, None, None, []
try:
seg_start = float(parts[start_idx])
seg_end = float(parts[start_idx + 1])
raw_values = []
for v in parts[start_idx + 3:]:
try:
raw_values.append(float(v))
except ValueError:
continue
if raw_values and raw_values[0] >= 0 and any(val < 0 for val in raw_values[1:]):
raw_values = raw_values[1:]
return timestamp, seg_start, seg_end, raw_values
except ValueError:
return timestamp, None, None, []
def _waterfall_loop(): def _waterfall_loop():
"""Continuous rtl_power sweep loop emitting waterfall data.""" """Continuous rtl_power sweep loop emitting waterfall data."""
global waterfall_running, waterfall_process global waterfall_running, waterfall_process
@@ -1534,84 +1595,59 @@ def _waterfall_loop():
waterfall_running = False waterfall_running = False
return return
start_hz = int(waterfall_config['start_freq'] * 1e6)
end_hz = int(waterfall_config['end_freq'] * 1e6)
bin_hz = int(waterfall_config['bin_size'])
gain = waterfall_config['gain']
device = waterfall_config['device']
interval = float(waterfall_config.get('interval', 0.4))
cmd = [
rtl_power_path,
'-f', f'{start_hz}:{end_hz}:{bin_hz}',
'-i', str(interval),
'-g', str(gain),
'-d', str(device),
]
try: try:
while waterfall_running: waterfall_process = subprocess.Popen(
start_hz = int(waterfall_config['start_freq'] * 1e6) cmd,
end_hz = int(waterfall_config['end_freq'] * 1e6) stdout=subprocess.PIPE,
bin_hz = int(waterfall_config['bin_size']) stderr=subprocess.DEVNULL,
gain = waterfall_config['gain'] bufsize=1,
device = waterfall_config['device'] text=True,
)
cmd = [ current_ts = None
rtl_power_path, all_bins: list[float] = []
'-f', f'{start_hz}:{end_hz}:{bin_hz}', sweep_start_hz = start_hz
'-i', '0.5', sweep_end_hz = end_hz
'-1',
'-g', str(gain),
'-d', str(device),
]
try: if not waterfall_process.stdout:
proc = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.DEVNULL) return
waterfall_process = proc
stdout, _ = proc.communicate(timeout=15)
except subprocess.TimeoutExpired:
proc.kill()
stdout = b''
finally:
waterfall_process = None
for line in waterfall_process.stdout:
if not waterfall_running: if not waterfall_running:
break break
if not stdout: ts, seg_start, seg_end, bins = _parse_rtl_power_line(line)
time.sleep(0.2) if ts is None or not bins:
continue continue
# Parse rtl_power CSV output if current_ts is None:
all_bins = [] current_ts = ts
sweep_start_hz = start_hz
sweep_end_hz = end_hz
for line in stdout.decode(errors='ignore').splitlines(): if ts != current_ts and all_bins:
if not line or line.startswith('#'): max_bins = int(waterfall_config.get('max_bins') or 0)
continue bins_to_send = all_bins
parts = [p.strip() for p in line.split(',')] if max_bins > 0 and len(bins_to_send) > max_bins:
start_idx = None bins_to_send = _downsample_bins(bins_to_send, max_bins)
for i, tok in enumerate(parts):
try:
val = float(tok)
except ValueError:
continue
if val > 1e5:
start_idx = i
break
if start_idx is None or len(parts) < start_idx + 4:
continue
try:
seg_start = float(parts[start_idx])
seg_end = float(parts[start_idx + 1])
seg_bin = float(parts[start_idx + 2])
raw_values = []
for v in parts[start_idx + 3:]:
try:
raw_values.append(float(v))
except ValueError:
continue
if raw_values and raw_values[0] >= 0 and any(val < 0 for val in raw_values[1:]):
raw_values = raw_values[1:]
all_bins.extend(raw_values)
sweep_start_hz = min(sweep_start_hz, seg_start)
sweep_end_hz = max(sweep_end_hz, seg_end)
except ValueError:
continue
if all_bins:
msg = { msg = {
'type': 'waterfall_sweep', 'type': 'waterfall_sweep',
'start_freq': sweep_start_hz / 1e6, 'start_freq': sweep_start_hz / 1e6,
'end_freq': sweep_end_hz / 1e6, 'end_freq': sweep_end_hz / 1e6,
'bins': all_bins, 'bins': bins_to_send,
'timestamp': datetime.now().isoformat(), 'timestamp': datetime.now().isoformat(),
} }
try: try:
@@ -1626,15 +1662,73 @@ def _waterfall_loop():
except queue.Full: except queue.Full:
pass pass
time.sleep(0.1) all_bins = []
sweep_start_hz = start_hz
sweep_end_hz = end_hz
current_ts = ts
all_bins.extend(bins)
if seg_start is not None:
sweep_start_hz = min(sweep_start_hz, seg_start)
if seg_end is not None:
sweep_end_hz = max(sweep_end_hz, seg_end)
# Flush any remaining bins
if all_bins and waterfall_running:
max_bins = int(waterfall_config.get('max_bins') or 0)
bins_to_send = all_bins
if max_bins > 0 and len(bins_to_send) > max_bins:
bins_to_send = _downsample_bins(bins_to_send, max_bins)
msg = {
'type': 'waterfall_sweep',
'start_freq': sweep_start_hz / 1e6,
'end_freq': sweep_end_hz / 1e6,
'bins': bins_to_send,
'timestamp': datetime.now().isoformat(),
}
try:
waterfall_queue.put_nowait(msg)
except queue.Full:
pass
except Exception as e: except Exception as e:
logger.error(f"Waterfall loop error: {e}") logger.error(f"Waterfall loop error: {e}")
finally: finally:
waterfall_running = False waterfall_running = False
if waterfall_process and waterfall_process.poll() is None:
try:
waterfall_process.terminate()
waterfall_process.wait(timeout=1)
except Exception:
try:
waterfall_process.kill()
except Exception:
pass
waterfall_process = None
logger.info("Waterfall loop stopped") logger.info("Waterfall loop stopped")
def _stop_waterfall_internal() -> None:
"""Stop the waterfall display and release resources."""
global waterfall_running, waterfall_process, waterfall_active_device
waterfall_running = False
if waterfall_process and waterfall_process.poll() is None:
try:
waterfall_process.terminate()
waterfall_process.wait(timeout=1)
except Exception:
try:
waterfall_process.kill()
except Exception:
pass
waterfall_process = None
if waterfall_active_device is not None:
app_module.release_sdr_device(waterfall_active_device)
waterfall_active_device = None
@listening_post_bp.route('/waterfall/start', methods=['POST']) @listening_post_bp.route('/waterfall/start', methods=['POST'])
def start_waterfall() -> Response: def start_waterfall() -> Response:
"""Start the waterfall/spectrogram display.""" """Start the waterfall/spectrogram display."""
@@ -1655,6 +1749,16 @@ def start_waterfall() -> Response:
waterfall_config['bin_size'] = int(data.get('bin_size', 10000)) waterfall_config['bin_size'] = int(data.get('bin_size', 10000))
waterfall_config['gain'] = int(data.get('gain', 40)) waterfall_config['gain'] = int(data.get('gain', 40))
waterfall_config['device'] = int(data.get('device', 0)) waterfall_config['device'] = int(data.get('device', 0))
if data.get('interval') is not None:
interval = float(data.get('interval', waterfall_config['interval']))
if interval < 0.1 or interval > 5:
return jsonify({'status': 'error', 'message': 'interval must be between 0.1 and 5 seconds'}), 400
waterfall_config['interval'] = interval
if data.get('max_bins') is not None:
max_bins = int(data.get('max_bins', waterfall_config['max_bins']))
if max_bins < 64 or max_bins > 4096:
return jsonify({'status': 'error', 'message': 'max_bins must be between 64 and 4096'}), 400
waterfall_config['max_bins'] = max_bins
except (ValueError, TypeError) as e: except (ValueError, TypeError) as e:
return jsonify({'status': 'error', 'message': f'Invalid parameter: {e}'}), 400 return jsonify({'status': 'error', 'message': f'Invalid parameter: {e}'}), 400
@@ -1684,23 +1788,7 @@ def start_waterfall() -> Response:
@listening_post_bp.route('/waterfall/stop', methods=['POST']) @listening_post_bp.route('/waterfall/stop', methods=['POST'])
def stop_waterfall() -> Response: def stop_waterfall() -> Response:
"""Stop the waterfall display.""" """Stop the waterfall display."""
global waterfall_running, waterfall_process, waterfall_active_device _stop_waterfall_internal()
waterfall_running = False
if waterfall_process and waterfall_process.poll() is None:
try:
waterfall_process.terminate()
waterfall_process.wait(timeout=1)
except Exception:
try:
waterfall_process.kill()
except Exception:
pass
waterfall_process = None
if waterfall_active_device is not None:
app_module.release_sdr_device(waterfall_active_device)
waterfall_active_device = None
return jsonify({'status': 'stopped'}) return jsonify({'status': 'stopped'})
@@ -1714,6 +1802,10 @@ def stream_waterfall() -> Response:
try: try:
msg = waterfall_queue.get(timeout=SSE_QUEUE_TIMEOUT) msg = waterfall_queue.get(timeout=SSE_QUEUE_TIMEOUT)
last_keepalive = time.time() last_keepalive = time.time()
try:
process_event('waterfall', msg, msg.get('type'))
except Exception:
pass
yield format_sse(msg) yield format_sse(msg)
except queue.Empty: except queue.Empty:
now = time.time() now = time.time()
@@ -1725,3 +1817,20 @@ def stream_waterfall() -> Response:
response.headers['Cache-Control'] = 'no-cache' response.headers['Cache-Control'] = 'no-cache'
response.headers['X-Accel-Buffering'] = 'no' response.headers['X-Accel-Buffering'] = 'no'
return response return response
def _downsample_bins(values: list[float], target: int) -> list[float]:
"""Downsample bins to a target length using simple averaging."""
if target <= 0 or len(values) <= target:
return values
out: list[float] = []
step = len(values) / target
for i in range(target):
start = int(i * step)
end = int((i + 1) * step)
if end <= start:
end = min(start + 1, len(values))
chunk = values[start:end]
if not chunk:
continue
out.append(sum(chunk) / len(chunk))
return out
+3
View File
@@ -44,6 +44,9 @@ ASSET_PATHS = {
'static/vendor/leaflet/images/marker-shadow.png', 'static/vendor/leaflet/images/marker-shadow.png',
'static/vendor/leaflet/images/layers.png', 'static/vendor/leaflet/images/layers.png',
'static/vendor/leaflet/images/layers-2x.png' 'static/vendor/leaflet/images/layers-2x.png'
],
'leaflet_heat': [
'static/vendor/leaflet-heat/leaflet-heat.js'
] ]
} }
+85 -2
View File
@@ -2,12 +2,14 @@
from __future__ import annotations from __future__ import annotations
import math
import os import os
import pathlib import pathlib
import re import re
import pty import pty
import queue import queue
import select import select
import struct
import subprocess import subprocess
import threading import threading
import time import time
@@ -23,6 +25,7 @@ from utils.validation import (
validate_rtl_tcp_host, validate_rtl_tcp_port validate_rtl_tcp_host, validate_rtl_tcp_port
) )
from utils.sse import format_sse from utils.sse import format_sse
from utils.event_pipeline import process_event
from utils.process import safe_terminate, register_process, unregister_process from utils.process import safe_terminate, register_process, unregister_process
from utils.sdr import SDRFactory, SDRType, SDRValidationError from utils.sdr import SDRFactory, SDRType, SDRValidationError
from utils.dependencies import get_tool_path from utils.dependencies import get_tool_path
@@ -105,6 +108,62 @@ def log_message(msg: dict[str, Any]) -> None:
logger.error(f"Failed to log message: {e}") logger.error(f"Failed to log message: {e}")
def audio_relay_thread(
rtl_stdout,
multimon_stdin,
output_queue: queue.Queue,
stop_event: threading.Event,
) -> None:
"""Relay audio from rtl_fm to multimon-ng while computing signal levels.
Reads raw 16-bit LE PCM from *rtl_stdout*, writes every chunk straight
through to *multimon_stdin*, and every ~100 ms pushes an RMS / peak scope
event onto *output_queue*.
"""
CHUNK = 4096 # bytes 2048 samples at 16-bit mono
INTERVAL = 0.1 # seconds between scope updates
last_scope = time.monotonic()
try:
while not stop_event.is_set():
data = rtl_stdout.read(CHUNK)
if not data:
break
# Forward audio untouched
try:
multimon_stdin.write(data)
multimon_stdin.flush()
except (BrokenPipeError, OSError):
break
# Compute scope levels every ~100 ms
now = time.monotonic()
if now - last_scope >= INTERVAL:
last_scope = now
try:
n_samples = len(data) // 2
if n_samples == 0:
continue
samples = struct.unpack(f'<{n_samples}h', data[:n_samples * 2])
peak = max(abs(s) for s in samples)
rms = int(math.sqrt(sum(s * s for s in samples) / n_samples))
output_queue.put_nowait({
'type': 'scope',
'rms': rms,
'peak': peak,
})
except (struct.error, ValueError, queue.Full):
pass
except Exception as e:
logger.debug(f"Audio relay error: {e}")
finally:
try:
multimon_stdin.close()
except OSError:
pass
def stream_decoder(master_fd: int, process: subprocess.Popen[bytes]) -> None: def stream_decoder(master_fd: int, process: subprocess.Popen[bytes]) -> None:
"""Stream decoder output to queue using PTY for unbuffered output.""" """Stream decoder output to queue using PTY for unbuffered output."""
try: try:
@@ -151,6 +210,11 @@ def stream_decoder(master_fd: int, process: subprocess.Popen[bytes]) -> None:
os.close(master_fd) os.close(master_fd)
except OSError: except OSError:
pass pass
# Signal relay thread to stop
with app_module.process_lock:
stop_relay = getattr(app_module.current_process, '_stop_relay', None)
if stop_relay:
stop_relay.set()
# Cleanup companion rtl_fm process and decoder # Cleanup companion rtl_fm process and decoder
with app_module.process_lock: with app_module.process_lock:
rtl_proc = getattr(app_module.current_process, '_rtl_process', None) rtl_proc = getattr(app_module.current_process, '_rtl_process', None)
@@ -318,7 +382,7 @@ def start_decoding() -> Response:
multimon_process = subprocess.Popen( multimon_process = subprocess.Popen(
multimon_cmd, multimon_cmd,
stdin=rtl_process.stdout, stdin=subprocess.PIPE,
stdout=slave_fd, stdout=slave_fd,
stderr=slave_fd, stderr=slave_fd,
close_fds=True close_fds=True
@@ -326,11 +390,22 @@ def start_decoding() -> Response:
register_process(multimon_process) register_process(multimon_process)
os.close(slave_fd) os.close(slave_fd)
rtl_process.stdout.close()
# Spawn audio relay thread between rtl_fm and multimon-ng
stop_relay = threading.Event()
relay = threading.Thread(
target=audio_relay_thread,
args=(rtl_process.stdout, multimon_process.stdin,
app_module.output_queue, stop_relay),
)
relay.daemon = True
relay.start()
app_module.current_process = multimon_process app_module.current_process = multimon_process
app_module.current_process._rtl_process = rtl_process app_module.current_process._rtl_process = rtl_process
app_module.current_process._master_fd = master_fd app_module.current_process._master_fd = master_fd
app_module.current_process._stop_relay = stop_relay
app_module.current_process._relay_thread = relay
# Start output thread with PTY master fd # Start output thread with PTY master fd
thread = threading.Thread(target=stream_decoder, args=(master_fd, multimon_process)) thread = threading.Thread(target=stream_decoder, args=(master_fd, multimon_process))
@@ -379,6 +454,10 @@ def stop_decoding() -> Response:
with app_module.process_lock: with app_module.process_lock:
if app_module.current_process: if app_module.current_process:
# Signal audio relay thread to stop
if hasattr(app_module.current_process, '_stop_relay'):
app_module.current_process._stop_relay.set()
# Kill rtl_fm process first # Kill rtl_fm process first
if hasattr(app_module.current_process, '_rtl_process'): if hasattr(app_module.current_process, '_rtl_process'):
try: try:
@@ -471,6 +550,10 @@ def stream() -> Response:
try: try:
msg = app_module.output_queue.get(timeout=1) msg = app_module.output_queue.get(timeout=1)
last_keepalive = time.time() last_keepalive = time.time()
try:
process_event('pager', msg, msg.get('type'))
except Exception:
pass
yield format_sse(msg) yield format_sse(msg)
except queue.Empty: except queue.Empty:
now = time.time() now = time.time()
+109
View File
@@ -0,0 +1,109 @@
"""Session recording API endpoints."""
from __future__ import annotations
from pathlib import Path
from flask import Blueprint, jsonify, request, send_file
from utils.recording import get_recording_manager, RECORDING_ROOT
recordings_bp = Blueprint('recordings', __name__, url_prefix='/recordings')
@recordings_bp.route('/start', methods=['POST'])
def start_recording():
data = request.get_json() or {}
mode = (data.get('mode') or '').strip()
if not mode:
return jsonify({'status': 'error', 'message': 'mode is required'}), 400
label = data.get('label')
metadata = data.get('metadata') if isinstance(data.get('metadata'), dict) else {}
manager = get_recording_manager()
session = manager.start_recording(mode=mode, label=label, metadata=metadata)
return jsonify({
'status': 'success',
'session': {
'id': session.id,
'mode': session.mode,
'label': session.label,
'started_at': session.started_at.isoformat(),
'file_path': str(session.file_path),
}
})
@recordings_bp.route('/stop', methods=['POST'])
def stop_recording():
data = request.get_json() or {}
mode = data.get('mode')
session_id = data.get('id')
manager = get_recording_manager()
session = manager.stop_recording(mode=mode, session_id=session_id)
if not session:
return jsonify({'status': 'error', 'message': 'No active recording found'}), 404
return jsonify({
'status': 'success',
'session': {
'id': session.id,
'mode': session.mode,
'label': session.label,
'started_at': session.started_at.isoformat(),
'stopped_at': session.stopped_at.isoformat() if session.stopped_at else None,
'event_count': session.event_count,
'size_bytes': session.size_bytes,
'file_path': str(session.file_path),
}
})
@recordings_bp.route('', methods=['GET'])
def list_recordings():
manager = get_recording_manager()
limit = request.args.get('limit', default=50, type=int)
return jsonify({
'status': 'success',
'recordings': manager.list_recordings(limit=limit),
'active': manager.get_active(),
})
@recordings_bp.route('/<session_id>', methods=['GET'])
def get_recording(session_id: str):
manager = get_recording_manager()
rec = manager.get_recording(session_id)
if not rec:
return jsonify({'status': 'error', 'message': 'Recording not found'}), 404
return jsonify({'status': 'success', 'recording': rec})
@recordings_bp.route('/<session_id>/download', methods=['GET'])
def download_recording(session_id: str):
manager = get_recording_manager()
rec = manager.get_recording(session_id)
if not rec:
return jsonify({'status': 'error', 'message': 'Recording not found'}), 404
file_path = Path(rec['file_path'])
try:
resolved_root = RECORDING_ROOT.resolve()
resolved_file = file_path.resolve()
if resolved_root not in resolved_file.parents:
return jsonify({'status': 'error', 'message': 'Invalid recording path'}), 400
except Exception:
return jsonify({'status': 'error', 'message': 'Invalid recording path'}), 400
if not file_path.exists():
return jsonify({'status': 'error', 'message': 'Recording file missing'}), 404
return send_file(
file_path,
mimetype='application/x-ndjson',
as_attachment=True,
download_name=file_path.name,
)
+5
View File
@@ -18,6 +18,7 @@ from utils.validation import (
validate_frequency, validate_device_index, validate_gain, validate_ppm validate_frequency, validate_device_index, validate_gain, validate_ppm
) )
from utils.sse import format_sse from utils.sse import format_sse
from utils.event_pipeline import process_event
from utils.process import safe_terminate, register_process, unregister_process from utils.process import safe_terminate, register_process, unregister_process
rtlamr_bp = Blueprint('rtlamr', __name__) rtlamr_bp = Blueprint('rtlamr', __name__)
@@ -295,6 +296,10 @@ def stream_rtlamr() -> Response:
try: try:
msg = app_module.rtlamr_queue.get(timeout=1) msg = app_module.rtlamr_queue.get(timeout=1)
last_keepalive = time.time() last_keepalive = time.time()
try:
process_event('rtlamr', msg, msg.get('type'))
except Exception:
pass
yield format_sse(msg) yield format_sse(msg)
except queue.Empty: except queue.Empty:
now = time.time() now = time.time()
+32
View File
@@ -19,6 +19,7 @@ from utils.validation import (
validate_rtl_tcp_host, validate_rtl_tcp_port validate_rtl_tcp_host, validate_rtl_tcp_port
) )
from utils.sse import format_sse from utils.sse import format_sse
from utils.event_pipeline import process_event
from utils.process import safe_terminate, register_process, unregister_process from utils.process import safe_terminate, register_process, unregister_process
from utils.sdr import SDRFactory, SDRType from utils.sdr import SDRFactory, SDRType
@@ -44,6 +45,21 @@ def stream_sensor_output(process: subprocess.Popen[bytes]) -> None:
data['type'] = 'sensor' data['type'] = 'sensor'
app_module.sensor_queue.put(data) app_module.sensor_queue.put(data)
# Push scope event when signal level data is present
rssi = data.get('rssi')
snr = data.get('snr')
noise = data.get('noise')
if rssi is not None or snr is not None:
try:
app_module.sensor_queue.put_nowait({
'type': 'scope',
'rssi': rssi if rssi is not None else 0,
'snr': snr if snr is not None else 0,
'noise': noise if noise is not None else 0,
})
except queue.Full:
pass
# Log if enabled # Log if enabled
if app_module.logging_enabled: if app_module.logging_enabled:
try: try:
@@ -79,6 +95,14 @@ def stream_sensor_output(process: subprocess.Popen[bytes]) -> None:
sensor_active_device = None sensor_active_device = None
@sensor_bp.route('/sensor/status')
def sensor_status() -> Response:
"""Check if sensor decoder is currently running."""
with app_module.sensor_lock:
running = app_module.sensor_process is not None and app_module.sensor_process.poll() is None
return jsonify({'running': running})
@sensor_bp.route('/start_sensor', methods=['POST']) @sensor_bp.route('/start_sensor', methods=['POST'])
def start_sensor() -> Response: def start_sensor() -> Response:
global sensor_active_device global sensor_active_device
@@ -157,6 +181,10 @@ def start_sensor() -> Response:
full_cmd = ' '.join(cmd) full_cmd = ' '.join(cmd)
logger.info(f"Running: {full_cmd}") logger.info(f"Running: {full_cmd}")
# Add signal level metadata so the frontend scope can display RSSI/SNR
# Disable stats reporting to suppress "row count limit 50 reached" warnings
cmd.extend(['-M', 'level', '-M', 'stats:0'])
try: try:
app_module.sensor_process = subprocess.Popen( app_module.sensor_process = subprocess.Popen(
cmd, cmd,
@@ -233,6 +261,10 @@ def stream_sensor() -> Response:
try: try:
msg = app_module.sensor_queue.get(timeout=1) msg = app_module.sensor_queue.get(timeout=1)
last_keepalive = time.time() last_keepalive = time.time()
try:
process_event('sensor', msg, msg.get('type'))
except Exception:
pass
yield format_sse(msg) yield format_sse(msg)
except queue.Empty: except queue.Empty:
now = time.time() now = time.time()
+9 -6
View File
@@ -16,12 +16,11 @@ from flask import Blueprint, jsonify, request, Response, send_file
import app as app_module import app as app_module
from utils.logging import get_logger from utils.logging import get_logger
from utils.sse import format_sse from utils.sse import format_sse
from utils.event_pipeline import process_event
from utils.sstv import ( from utils.sstv import (
get_sstv_decoder, get_sstv_decoder,
is_sstv_available, is_sstv_available,
ISS_SSTV_FREQ, ISS_SSTV_FREQ,
DecodeProgress,
DopplerInfo,
) )
logger = get_logger('intercept.sstv') logger = get_logger('intercept.sstv')
@@ -35,14 +34,14 @@ _sstv_queue: queue.Queue = queue.Queue(maxsize=100)
sstv_active_device: int | None = None sstv_active_device: int | None = None
def _progress_callback(progress: DecodeProgress) -> None: def _progress_callback(data: dict) -> None:
"""Callback to queue progress updates for SSE stream.""" """Callback to queue progress/scope updates for SSE stream."""
try: try:
_sstv_queue.put_nowait(progress.to_dict()) _sstv_queue.put_nowait(data)
except queue.Full: except queue.Full:
try: try:
_sstv_queue.get_nowait() _sstv_queue.get_nowait()
_sstv_queue.put_nowait(progress.to_dict()) _sstv_queue.put_nowait(data)
except queue.Empty: except queue.Empty:
pass pass
@@ -401,6 +400,10 @@ def stream_progress():
try: try:
progress = _sstv_queue.get(timeout=1) progress = _sstv_queue.get(timeout=1)
last_keepalive = time.time() last_keepalive = time.time()
try:
process_event('sstv', progress, progress.get('type'))
except Exception:
pass
yield format_sse(progress) yield format_sse(progress)
except queue.Empty: except queue.Empty:
now = time.time() now = time.time()
+9 -5
View File
@@ -15,8 +15,8 @@ from flask import Blueprint, Response, jsonify, request, send_file
from utils.logging import get_logger from utils.logging import get_logger
from utils.sse import format_sse from utils.sse import format_sse
from utils.event_pipeline import process_event
from utils.sstv import ( from utils.sstv import (
DecodeProgress,
get_general_sstv_decoder, get_general_sstv_decoder,
) )
@@ -48,14 +48,14 @@ SSTV_FREQUENCIES = [
_FREQ_MODULATION_MAP = {entry['frequency']: entry['modulation'] for entry in SSTV_FREQUENCIES} _FREQ_MODULATION_MAP = {entry['frequency']: entry['modulation'] for entry in SSTV_FREQUENCIES}
def _progress_callback(progress: DecodeProgress) -> None: def _progress_callback(data: dict) -> None:
"""Callback to queue progress updates for SSE stream.""" """Callback to queue progress/scope updates for SSE stream."""
try: try:
_sstv_general_queue.put_nowait(progress.to_dict()) _sstv_general_queue.put_nowait(data)
except queue.Full: except queue.Full:
try: try:
_sstv_general_queue.get_nowait() _sstv_general_queue.get_nowait()
_sstv_general_queue.put_nowait(progress.to_dict()) _sstv_general_queue.put_nowait(data)
except queue.Empty: except queue.Empty:
pass pass
@@ -274,6 +274,10 @@ def stream_progress():
try: try:
progress = _sstv_general_queue.get(timeout=1) progress = _sstv_general_queue.get(timeout=1)
last_keepalive = time.time() last_keepalive = time.time()
try:
process_event('sstv_general', progress, progress.get('type'))
except Exception:
pass
yield format_sse(progress) yield format_sse(progress)
except queue.Empty: except queue.Empty:
now = time.time() now = time.time()
+150 -1
View File
@@ -60,6 +60,7 @@ from utils.tscm.device_identity import (
ingest_ble_dict, ingest_ble_dict,
ingest_wifi_dict, ingest_wifi_dict,
) )
from utils.event_pipeline import process_event
# Import unified Bluetooth scanner helper for TSCM integration # Import unified Bluetooth scanner helper for TSCM integration
try: try:
@@ -550,6 +551,12 @@ def _start_sweep_internal(
} }
@tscm_bp.route('/status')
def tscm_status():
"""Check if any TSCM operation is currently running."""
return jsonify({'running': _sweep_running})
@tscm_bp.route('/sweep/start', methods=['POST']) @tscm_bp.route('/sweep/start', methods=['POST'])
def start_sweep(): def start_sweep():
"""Start a TSCM sweep.""" """Start a TSCM sweep."""
@@ -627,6 +634,10 @@ def sweep_stream():
try: try:
if tscm_queue: if tscm_queue:
msg = tscm_queue.get(timeout=1) msg = tscm_queue.get(timeout=1)
try:
process_event('tscm', msg, msg.get('type'))
except Exception:
pass
yield f"data: {json.dumps(msg)}\n\n" yield f"data: {json.dumps(msg)}\n\n"
else: else:
time.sleep(1) time.sleep(1)
@@ -1072,6 +1083,32 @@ def _scan_wifi_networks(interface: str) -> list[dict]:
return [] return []
def _scan_wifi_clients(interface: str) -> list[dict]:
"""
Get WiFi client observations from the unified WiFi scanner.
Clients are only available when monitor-mode scanning is active.
"""
try:
from utils.wifi import get_wifi_scanner
scanner = get_wifi_scanner()
if interface:
try:
if not scanner._is_monitor_mode_interface(interface):
return []
except Exception:
return []
return [client.to_dict() for client in scanner.clients]
except ImportError as e:
logger.error(f"Failed to import wifi scanner: {e}")
return []
except Exception as e:
logger.exception(f"WiFi client scan failed: {e}")
return []
def _scan_bluetooth_devices(interface: str, duration: int = 10) -> list[dict]: def _scan_bluetooth_devices(interface: str, duration: int = 10) -> list[dict]:
""" """
Scan for Bluetooth devices with manufacturer data detection. Scan for Bluetooth devices with manufacturer data detection.
@@ -1606,6 +1643,7 @@ def _run_sweep(
threats_found = 0 threats_found = 0
severity_counts = {'critical': 0, 'high': 0, 'medium': 0, 'low': 0} severity_counts = {'critical': 0, 'high': 0, 'medium': 0, 'low': 0}
all_wifi = {} # Use dict for deduplication by BSSID all_wifi = {} # Use dict for deduplication by BSSID
all_wifi_clients = {} # Use dict for deduplication by client MAC
all_bt = {} # Use dict for deduplication by MAC all_bt = {} # Use dict for deduplication by MAC
all_rf = [] all_rf = []
@@ -1702,6 +1740,7 @@ def _run_sweep(
'channel': network.get('channel', ''), 'channel': network.get('channel', ''),
'signal': network.get('power', ''), 'signal': network.get('power', ''),
'security': network.get('privacy', ''), 'security': network.get('privacy', ''),
'vendor': network.get('vendor'),
'is_threat': is_threat, 'is_threat': is_threat,
'is_new': not classification.get('in_baseline', False), 'is_new': not classification.get('in_baseline', False),
'classification': profile.risk_level.value, 'classification': profile.risk_level.value,
@@ -1715,6 +1754,77 @@ def _run_sweep(
}) })
except Exception as e: except Exception as e:
logger.error(f"WiFi device processing error for {network.get('bssid', '?')}: {e}") logger.error(f"WiFi device processing error for {network.get('bssid', '?')}: {e}")
# WiFi clients (monitor mode only)
try:
wifi_clients = _scan_wifi_clients(wifi_interface)
for client in wifi_clients:
mac = (client.get('mac') or '').upper()
if not mac or mac in all_wifi_clients:
continue
all_wifi_clients[mac] = client
rssi_val = client.get('rssi_current')
if rssi_val is None:
rssi_val = client.get('rssi_median') or client.get('rssi_ema')
client_device = {
'mac': mac,
'vendor': client.get('vendor'),
'name': client.get('vendor') or 'WiFi Client',
'rssi': rssi_val,
'associated_bssid': client.get('associated_bssid'),
'probed_ssids': client.get('probed_ssids', []),
'probe_count': client.get('probe_count', len(client.get('probed_ssids', []))),
'is_client': True,
}
try:
timeline_manager.add_observation(
identifier=mac,
protocol='wifi',
rssi=rssi_val,
name=client_device.get('vendor') or f'WiFi Client {mac[-5:]}',
attributes={'client': True, 'associated_bssid': client_device.get('associated_bssid')}
)
except Exception as e:
logger.debug(f"WiFi client timeline observation error: {e}")
_maybe_store_timeline(
identifier=mac,
protocol='wifi',
rssi=rssi_val,
attributes={'client': True, 'associated_bssid': client_device.get('associated_bssid')}
)
profile = correlation.analyze_wifi_device(client_device)
client_device['classification'] = profile.risk_level.value
client_device['score'] = profile.total_score
client_device['score_modifier'] = profile.score_modifier
client_device['known_device'] = profile.known_device
client_device['known_device_name'] = profile.known_device_name
client_device['indicators'] = [
{'type': i.type.value, 'desc': i.description}
for i in profile.indicators
]
client_device['recommended_action'] = profile.recommended_action
# Feed to identity engine for MAC-randomization resistant clustering
try:
wifi_obs = {
'timestamp': datetime.now().isoformat(),
'src_mac': mac,
'bssid': client_device.get('associated_bssid'),
'rssi': rssi_val,
'frame_type': 'probe_request',
'probed_ssids': client_device.get('probed_ssids', []),
}
ingest_wifi_dict(wifi_obs)
except Exception as e:
logger.debug(f"Identity engine WiFi client ingest error: {e}")
_emit_event('wifi_client', client_device)
except Exception as e:
logger.debug(f"WiFi client scan error: {e}")
except Exception as e: except Exception as e:
last_wifi_scan = current_time last_wifi_scan = current_time
logger.error(f"WiFi scan error: {e}") logger.error(f"WiFi scan error: {e}")
@@ -1793,6 +1903,9 @@ def _run_sweep(
'name': device.get('name', 'Unknown'), 'name': device.get('name', 'Unknown'),
'device_type': device.get('type', ''), 'device_type': device.get('type', ''),
'rssi': device.get('rssi', ''), 'rssi': device.get('rssi', ''),
'manufacturer': device.get('manufacturer'),
'tracker': device.get('tracker'),
'tracker_type': device.get('tracker_type'),
'is_threat': is_threat, 'is_threat': is_threat,
'is_new': not classification.get('in_baseline', False), 'is_new': not classification.get('in_baseline', False),
'classification': profile.risk_level.value, 'classification': profile.risk_level.value,
@@ -1921,6 +2034,7 @@ def _run_sweep(
comparator = BaselineComparator(baseline) comparator = BaselineComparator(baseline)
baseline_comparison = comparator.compare_all( baseline_comparison = comparator.compare_all(
wifi_devices=list(all_wifi.values()), wifi_devices=list(all_wifi.values()),
wifi_clients=list(all_wifi_clients.values()),
bt_devices=list(all_bt.values()), bt_devices=list(all_bt.values()),
rf_signals=all_rf rf_signals=all_rf
) )
@@ -1936,6 +2050,7 @@ def _run_sweep(
if verbose_results: if verbose_results:
wifi_payload = list(all_wifi.values()) wifi_payload = list(all_wifi.values())
wifi_client_payload = list(all_wifi_clients.values())
bt_payload = list(all_bt.values()) bt_payload = list(all_bt.values())
rf_payload = list(all_rf) rf_payload = list(all_rf)
else: else:
@@ -1951,6 +2066,28 @@ def _run_sweep(
} }
for d in all_wifi.values() for d in all_wifi.values()
] ]
wifi_client_payload = []
for client in all_wifi_clients.values():
mac = client.get('mac') or client.get('address')
if isinstance(mac, str):
mac = mac.upper()
probed_ssids = client.get('probed_ssids') or []
rssi = client.get('rssi')
if rssi is None:
rssi = client.get('rssi_current')
if rssi is None:
rssi = client.get('rssi_median')
if rssi is None:
rssi = client.get('rssi_ema')
wifi_client_payload.append({
'mac': mac,
'vendor': client.get('vendor'),
'rssi': rssi,
'associated_bssid': client.get('associated_bssid'),
'is_associated': client.get('is_associated'),
'probed_ssids': probed_ssids,
'probe_count': client.get('probe_count', len(probed_ssids)),
})
bt_payload = [ bt_payload = [
{ {
'mac': d.get('mac') or d.get('address'), 'mac': d.get('mac') or d.get('address'),
@@ -1975,9 +2112,11 @@ def _run_sweep(
status='completed', status='completed',
results={ results={
'wifi_devices': wifi_payload, 'wifi_devices': wifi_payload,
'wifi_clients': wifi_client_payload,
'bt_devices': bt_payload, 'bt_devices': bt_payload,
'rf_signals': rf_payload, 'rf_signals': rf_payload,
'wifi_count': len(all_wifi), 'wifi_count': len(all_wifi),
'wifi_client_count': len(all_wifi_clients),
'bt_count': len(all_bt), 'bt_count': len(all_bt),
'rf_count': len(all_rf), 'rf_count': len(all_rf),
'severity_counts': severity_counts, 'severity_counts': severity_counts,
@@ -2005,6 +2144,7 @@ def _run_sweep(
'total_new': baseline_comparison['total_new'], 'total_new': baseline_comparison['total_new'],
'total_missing': baseline_comparison['total_missing'], 'total_missing': baseline_comparison['total_missing'],
'wifi': baseline_comparison.get('wifi'), 'wifi': baseline_comparison.get('wifi'),
'wifi_clients': baseline_comparison.get('wifi_clients'),
'bluetooth': baseline_comparison.get('bluetooth'), 'bluetooth': baseline_comparison.get('bluetooth'),
'rf': baseline_comparison.get('rf'), 'rf': baseline_comparison.get('rf'),
}) })
@@ -2022,6 +2162,7 @@ def _run_sweep(
'sweep_id': _current_sweep_id, 'sweep_id': _current_sweep_id,
'threats_found': threats_found, 'threats_found': threats_found,
'wifi_count': len(all_wifi), 'wifi_count': len(all_wifi),
'wifi_client_count': len(all_wifi_clients),
'bt_count': len(all_bt), 'bt_count': len(all_bt),
'rf_count': len(all_rf), 'rf_count': len(all_rf),
'severity_counts': severity_counts, 'severity_counts': severity_counts,
@@ -2169,6 +2310,7 @@ def compare_against_baseline():
Expects JSON body with: Expects JSON body with:
- wifi_devices: list of WiFi devices (optional) - wifi_devices: list of WiFi devices (optional)
- wifi_clients: list of WiFi clients (optional)
- bt_devices: list of Bluetooth devices (optional) - bt_devices: list of Bluetooth devices (optional)
- rf_signals: list of RF signals (optional) - rf_signals: list of RF signals (optional)
@@ -2177,12 +2319,14 @@ def compare_against_baseline():
data = request.get_json() or {} data = request.get_json() or {}
wifi_devices = data.get('wifi_devices') wifi_devices = data.get('wifi_devices')
wifi_clients = data.get('wifi_clients')
bt_devices = data.get('bt_devices') bt_devices = data.get('bt_devices')
rf_signals = data.get('rf_signals') rf_signals = data.get('rf_signals')
# Use the convenience function that gets active baseline # Use the convenience function that gets active baseline
comparison = get_comparison_for_active_baseline( comparison = get_comparison_for_active_baseline(
wifi_devices=wifi_devices, wifi_devices=wifi_devices,
wifi_clients=wifi_clients,
bt_devices=bt_devices, bt_devices=bt_devices,
rf_signals=rf_signals rf_signals=rf_signals
) )
@@ -2276,7 +2420,10 @@ def feed_wifi():
"""Feed WiFi device data for baseline recording.""" """Feed WiFi device data for baseline recording."""
data = request.get_json() data = request.get_json()
if data: if data:
_baseline_recorder.add_wifi_device(data) if data.get('is_client'):
_baseline_recorder.add_wifi_client(data)
else:
_baseline_recorder.add_wifi_device(data)
return jsonify({'status': 'success'}) return jsonify({'status': 'success'})
@@ -2928,12 +3075,14 @@ def get_baseline_diff(baseline_id: int, sweep_id: int):
results = json.loads(results) results = json.loads(results)
current_wifi = results.get('wifi_devices', []) current_wifi = results.get('wifi_devices', [])
current_wifi_clients = results.get('wifi_clients', [])
current_bt = results.get('bt_devices', []) current_bt = results.get('bt_devices', [])
current_rf = results.get('rf_signals', []) current_rf = results.get('rf_signals', [])
diff = calculate_baseline_diff( diff = calculate_baseline_diff(
baseline=baseline, baseline=baseline,
current_wifi=current_wifi, current_wifi=current_wifi,
current_wifi_clients=current_wifi_clients,
current_bt=current_bt, current_bt=current_bt,
current_rf=current_rf, current_rf=current_rf,
sweep_id=sweep_id sweep_id=sweep_id
+386
View File
@@ -0,0 +1,386 @@
"""WebSocket-based waterfall streaming with I/Q capture and server-side FFT."""
import json
import queue
import socket
import subprocess
import threading
import time
from flask import Flask
try:
from flask_sock import Sock
WEBSOCKET_AVAILABLE = True
except ImportError:
WEBSOCKET_AVAILABLE = False
Sock = None
from utils.logging import get_logger
from utils.process import safe_terminate, register_process, unregister_process
from utils.waterfall_fft import (
build_binary_frame,
compute_power_spectrum,
cu8_to_complex,
quantize_to_uint8,
)
from utils.sdr import SDRFactory, SDRType
from utils.sdr.base import SDRCapabilities, SDRDevice
logger = get_logger('intercept.waterfall_ws')
# Maximum bandwidth per SDR type (Hz)
MAX_BANDWIDTH = {
SDRType.RTL_SDR: 2400000,
SDRType.HACKRF: 20000000,
SDRType.LIME_SDR: 20000000,
SDRType.AIRSPY: 10000000,
SDRType.SDRPLAY: 2000000,
}
def _resolve_sdr_type(sdr_type_str: str) -> SDRType:
"""Convert client sdr_type string to SDRType enum."""
mapping = {
'rtlsdr': SDRType.RTL_SDR,
'rtl_sdr': SDRType.RTL_SDR,
'hackrf': SDRType.HACKRF,
'limesdr': SDRType.LIME_SDR,
'lime_sdr': SDRType.LIME_SDR,
'airspy': SDRType.AIRSPY,
'sdrplay': SDRType.SDRPLAY,
}
return mapping.get(sdr_type_str.lower(), SDRType.RTL_SDR)
def _build_dummy_device(device_index: int, sdr_type: SDRType) -> SDRDevice:
"""Build a minimal SDRDevice for command building."""
builder = SDRFactory.get_builder(sdr_type)
caps = builder.get_capabilities()
return SDRDevice(
sdr_type=sdr_type,
index=device_index,
name=f'{sdr_type.value}-{device_index}',
serial='N/A',
driver=sdr_type.value,
capabilities=caps,
)
def init_waterfall_websocket(app: Flask):
"""Initialize WebSocket waterfall streaming."""
if not WEBSOCKET_AVAILABLE:
logger.warning("flask-sock not installed, WebSocket waterfall disabled")
return
sock = Sock(app)
@sock.route('/ws/waterfall')
def waterfall_stream(ws):
"""WebSocket endpoint for real-time waterfall streaming."""
logger.info("WebSocket waterfall client connected")
# Import app module for device claiming
import app as app_module
iq_process = None
reader_thread = None
stop_event = threading.Event()
claimed_device = None
# Queue for outgoing messages — only the main loop touches ws.send()
send_queue = queue.Queue(maxsize=120)
try:
while True:
# Drain send queue first (non-blocking)
while True:
try:
outgoing = send_queue.get_nowait()
except queue.Empty:
break
try:
ws.send(outgoing)
except Exception:
stop_event.set()
break
try:
msg = ws.receive(timeout=0.1)
except Exception as e:
err = str(e).lower()
if "closed" in err:
break
if "timed out" not in err:
logger.error(f"WebSocket receive error: {e}")
continue
if msg is None:
# simple-websocket returns None on timeout AND on
# close; check ws.connected to tell them apart.
if not ws.connected:
break
if stop_event.is_set():
break
continue
try:
data = json.loads(msg)
except (json.JSONDecodeError, TypeError):
continue
cmd = data.get('cmd')
if cmd == 'start':
# Stop any existing capture
was_restarting = iq_process is not None
stop_event.set()
if reader_thread and reader_thread.is_alive():
reader_thread.join(timeout=2)
if iq_process:
safe_terminate(iq_process)
unregister_process(iq_process)
iq_process = None
if claimed_device is not None:
app_module.release_sdr_device(claimed_device)
claimed_device = None
stop_event.clear()
# Flush stale frames from previous capture
while not send_queue.empty():
try:
send_queue.get_nowait()
except queue.Empty:
break
# Allow USB device to be released by the kernel
if was_restarting:
time.sleep(0.5)
# Parse config
center_freq = float(data.get('center_freq', 100.0))
span_mhz = float(data.get('span_mhz', 2.0))
gain = data.get('gain')
if gain is not None:
gain = float(gain)
device_index = int(data.get('device', 0))
sdr_type_str = data.get('sdr_type', 'rtlsdr')
fft_size = int(data.get('fft_size', 1024))
fps = int(data.get('fps', 25))
avg_count = int(data.get('avg_count', 4))
ppm = data.get('ppm')
if ppm is not None:
ppm = int(ppm)
bias_t = bool(data.get('bias_t', False))
# Clamp FFT size to valid powers of 2
fft_size = max(256, min(8192, fft_size))
# Resolve SDR type and bandwidth
sdr_type = _resolve_sdr_type(sdr_type_str)
max_bw = MAX_BANDWIDTH.get(sdr_type, 2400000)
span_hz = int(span_mhz * 1e6)
sample_rate = min(span_hz, max_bw)
# Compute effective frequency range
effective_span_mhz = sample_rate / 1e6
start_freq = center_freq - effective_span_mhz / 2
end_freq = center_freq + effective_span_mhz / 2
# Claim the device
claim_err = app_module.claim_sdr_device(device_index, 'waterfall')
if claim_err:
ws.send(json.dumps({
'status': 'error',
'message': claim_err,
'error_type': 'DEVICE_BUSY',
}))
continue
claimed_device = device_index
# Build I/Q capture command
try:
builder = SDRFactory.get_builder(sdr_type)
device = _build_dummy_device(device_index, sdr_type)
iq_cmd = builder.build_iq_capture_command(
device=device,
frequency_mhz=center_freq,
sample_rate=sample_rate,
gain=gain,
ppm=ppm,
bias_t=bias_t,
)
except NotImplementedError as e:
app_module.release_sdr_device(device_index)
claimed_device = None
ws.send(json.dumps({
'status': 'error',
'message': str(e),
}))
continue
# Spawn I/Q capture process (retry to handle USB release lag)
max_attempts = 3 if was_restarting else 1
try:
for attempt in range(max_attempts):
logger.info(
f"Starting I/Q capture: {center_freq} MHz, "
f"span={effective_span_mhz:.1f} MHz, "
f"sr={sample_rate}, fft={fft_size}"
)
iq_process = subprocess.Popen(
iq_cmd,
stdout=subprocess.PIPE,
stderr=subprocess.DEVNULL,
bufsize=0,
)
register_process(iq_process)
# Brief check that process started
time.sleep(0.3)
if iq_process.poll() is not None:
unregister_process(iq_process)
iq_process = None
if attempt < max_attempts - 1:
logger.info(
f"I/Q process exited immediately, "
f"retrying ({attempt + 1}/{max_attempts})..."
)
time.sleep(0.5)
continue
raise RuntimeError(
"I/Q capture process exited immediately"
)
break # Process started successfully
except Exception as e:
logger.error(f"Failed to start I/Q capture: {e}")
if iq_process:
safe_terminate(iq_process)
unregister_process(iq_process)
iq_process = None
app_module.release_sdr_device(device_index)
claimed_device = None
ws.send(json.dumps({
'status': 'error',
'message': f'Failed to start I/Q capture: {e}',
}))
continue
# Send started confirmation
ws.send(json.dumps({
'status': 'started',
'start_freq': start_freq,
'end_freq': end_freq,
'fft_size': fft_size,
'sample_rate': sample_rate,
}))
# Start reader thread — puts frames on queue, never calls ws.send()
def fft_reader(
proc, _send_q, stop_evt,
_fft_size, _avg_count, _fps,
_start_freq, _end_freq,
):
"""Read I/Q from subprocess, compute FFT, enqueue binary frames."""
bytes_per_frame = _fft_size * _avg_count * 2
frame_interval = 1.0 / _fps
try:
while not stop_evt.is_set():
if proc.poll() is not None:
break
frame_start = time.monotonic()
# Read raw I/Q bytes
raw = b''
remaining = bytes_per_frame
while remaining > 0 and not stop_evt.is_set():
chunk = proc.stdout.read(min(remaining, 65536))
if not chunk:
break
raw += chunk
remaining -= len(chunk)
if len(raw) < _fft_size * 2:
break
# Process FFT pipeline
samples = cu8_to_complex(raw)
power_db = compute_power_spectrum(
samples,
fft_size=_fft_size,
avg_count=_avg_count,
)
quantized = quantize_to_uint8(power_db)
frame = build_binary_frame(
_start_freq, _end_freq, quantized,
)
try:
_send_q.put_nowait(frame)
except queue.Full:
# Drop frame if main loop can't keep up
pass
# Pace to target FPS
elapsed = time.monotonic() - frame_start
sleep_time = frame_interval - elapsed
if sleep_time > 0:
stop_evt.wait(sleep_time)
except Exception as e:
logger.debug(f"FFT reader stopped: {e}")
reader_thread = threading.Thread(
target=fft_reader,
args=(
iq_process, send_queue, stop_event,
fft_size, avg_count, fps,
start_freq, end_freq,
),
daemon=True,
)
reader_thread.start()
elif cmd == 'stop':
stop_event.set()
if reader_thread and reader_thread.is_alive():
reader_thread.join(timeout=2)
reader_thread = None
if iq_process:
safe_terminate(iq_process)
unregister_process(iq_process)
iq_process = None
if claimed_device is not None:
app_module.release_sdr_device(claimed_device)
claimed_device = None
stop_event.clear()
ws.send(json.dumps({'status': 'stopped'}))
except Exception as e:
logger.info(f"WebSocket waterfall closed: {e}")
finally:
# Cleanup
stop_event.set()
if reader_thread and reader_thread.is_alive():
reader_thread.join(timeout=2)
if iq_process:
safe_terminate(iq_process)
unregister_process(iq_process)
if claimed_device is not None:
app_module.release_sdr_device(claimed_device)
# Complete WebSocket close handshake, then shut down the
# raw socket so Werkzeug cannot write its HTTP 200 response
# on top of the WebSocket stream (which browsers see as
# "Invalid frame header").
try:
ws.close()
except Exception:
pass
try:
ws.sock.shutdown(socket.SHUT_RDWR)
except Exception:
pass
try:
ws.sock.close()
except Exception:
pass
logger.info("WebSocket waterfall client disconnected")
+66 -5
View File
@@ -21,6 +21,7 @@ from utils.logging import wifi_logger as logger
from utils.process import is_valid_mac, is_valid_channel from utils.process import is_valid_mac, is_valid_channel
from utils.validation import validate_wifi_channel, validate_mac_address, validate_network_interface from utils.validation import validate_wifi_channel, validate_mac_address, validate_network_interface
from utils.sse import format_sse from utils.sse import format_sse
from utils.event_pipeline import process_event
from data.oui import get_manufacturer from data.oui import get_manufacturer
from utils.constants import ( from utils.constants import (
WIFI_TERMINATE_TIMEOUT, WIFI_TERMINATE_TIMEOUT,
@@ -50,6 +51,31 @@ pmkid_process = None
pmkid_lock = threading.Lock() pmkid_lock = threading.Lock()
def _parse_channel_list(raw_channels: Any) -> list[int] | None:
"""Parse a channel list from string/list input."""
if raw_channels in (None, '', []):
return None
if isinstance(raw_channels, str):
parts = [p.strip() for p in re.split(r'[\s,]+', raw_channels) if p.strip()]
elif isinstance(raw_channels, (list, tuple, set)):
parts = list(raw_channels)
else:
parts = [raw_channels]
channels: list[int] = []
seen = set()
for part in parts:
if part in (None, ''):
continue
ch = validate_wifi_channel(part)
if ch not in seen:
channels.append(ch)
seen.add(ch)
return channels or None
def detect_wifi_interfaces(): def detect_wifi_interfaces():
"""Detect available WiFi interfaces.""" """Detect available WiFi interfaces."""
interfaces = [] interfaces = []
@@ -608,6 +634,7 @@ def start_wifi_scan():
data = request.json data = request.json
channel = data.get('channel') channel = data.get('channel')
channels = data.get('channels')
band = data.get('band', 'abg') band = data.get('band', 'abg')
# Use provided interface or fall back to stored monitor interface # Use provided interface or fall back to stored monitor interface
@@ -658,7 +685,16 @@ def start_wifi_scan():
interface interface
] ]
if channel: channel_list = None
if channels:
try:
channel_list = _parse_channel_list(channels)
except ValueError as e:
return jsonify({'status': 'error', 'message': str(e)}), 400
if channel_list:
cmd.extend(['-c', ','.join(str(c) for c in channel_list)])
elif channel:
cmd.extend(['-c', str(channel)]) cmd.extend(['-c', str(channel)])
logger.info(f"Running: {' '.join(cmd)}") logger.info(f"Running: {' '.join(cmd)}")
@@ -852,6 +888,9 @@ def check_handshake_status():
file_size = os.path.getsize(capture_file) file_size = os.path.getsize(capture_file)
handshake_found = False handshake_found = False
handshake_valid: bool | None = None
handshake_checked = False
handshake_reason: str | None = None
try: try:
if target_bssid and is_valid_mac(target_bssid): if target_bssid and is_valid_mac(target_bssid):
@@ -862,20 +901,38 @@ def check_handshake_status():
capture_output=True, text=True, timeout=10 capture_output=True, text=True, timeout=10
) )
output = result.stdout + result.stderr output = result.stdout + result.stderr
if '1 handshake' in output or ('handshake' in output.lower() and 'wpa' in output.lower()): output_lower = output.lower()
if '0 handshake' not in output: handshake_checked = True
handshake_found = True
if 'no valid wpa handshakes found' in output_lower:
handshake_valid = False
handshake_reason = 'No valid WPA handshake found'
elif '0 handshake' in output_lower:
handshake_valid = False
elif '1 handshake' in output_lower or ('handshake' in output_lower and 'wpa' in output_lower):
handshake_valid = True
else:
handshake_valid = False
except subprocess.TimeoutExpired: except subprocess.TimeoutExpired:
pass pass
except Exception as e: except Exception as e:
logger.error(f"Error checking handshake: {e}") logger.error(f"Error checking handshake: {e}")
if handshake_valid:
handshake_found = True
normalized_bssid = target_bssid.upper() if target_bssid else None
if normalized_bssid and normalized_bssid not in app_module.wifi_handshakes:
app_module.wifi_handshakes.append(normalized_bssid)
return jsonify({ return jsonify({
'status': 'running' if app_module.wifi_process and app_module.wifi_process.poll() is None else 'stopped', 'status': 'running' if app_module.wifi_process and app_module.wifi_process.poll() is None else 'stopped',
'file_exists': True, 'file_exists': True,
'file_size': file_size, 'file_size': file_size,
'file': capture_file, 'file': capture_file,
'handshake_found': handshake_found 'handshake_found': handshake_found,
'handshake_valid': handshake_valid,
'handshake_checked': handshake_checked,
'handshake_reason': handshake_reason
}) })
@@ -1086,6 +1143,10 @@ def stream_wifi():
try: try:
msg = app_module.wifi_queue.get(timeout=1) msg = app_module.wifi_queue.get(timeout=1)
last_keepalive = time.time() last_keepalive = time.time()
try:
process_event('wifi', msg, msg.get('type'))
except Exception:
pass
yield format_sse(msg) yield format_sse(msg)
except queue.Empty: except queue.Empty:
now = time.time() now = time.time()
+23 -1
View File
@@ -24,6 +24,8 @@ from utils.wifi import (
SCAN_MODE_DEEP, SCAN_MODE_DEEP,
) )
from utils.sse import format_sse from utils.sse import format_sse
from utils.validation import validate_wifi_channel
from utils.event_pipeline import process_event
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -89,15 +91,30 @@ def start_deep_scan():
interface: Monitor mode interface (e.g., 'wlan0mon') interface: Monitor mode interface (e.g., 'wlan0mon')
band: Band to scan ('2.4', '5', 'all') band: Band to scan ('2.4', '5', 'all')
channel: Optional specific channel to monitor channel: Optional specific channel to monitor
channels: Optional list or comma-separated channels to monitor
""" """
data = request.get_json() or {} data = request.get_json() or {}
interface = data.get('interface') interface = data.get('interface')
band = data.get('band', 'all') band = data.get('band', 'all')
channel = data.get('channel') channel = data.get('channel')
channels = data.get('channels')
channel_list = None
if channels:
if isinstance(channels, str):
channel_list = [c.strip() for c in channels.split(',') if c.strip()]
elif isinstance(channels, (list, tuple, set)):
channel_list = list(channels)
else:
channel_list = [channels]
try:
channel_list = [validate_wifi_channel(c) for c in channel_list]
except (TypeError, ValueError):
return jsonify({'error': 'Invalid channels'}), 400
if channel: if channel:
try: try:
channel = int(channel) channel = validate_wifi_channel(channel)
except ValueError: except ValueError:
return jsonify({'error': 'Invalid channel'}), 400 return jsonify({'error': 'Invalid channel'}), 400
@@ -106,6 +123,7 @@ def start_deep_scan():
interface=interface, interface=interface,
band=band, band=band,
channel=channel, channel=channel,
channels=channel_list,
) )
if success: if success:
@@ -391,6 +409,10 @@ def event_stream():
scanner = get_wifi_scanner() scanner = get_wifi_scanner()
for event in scanner.get_event_stream(): for event in scanner.get_event_stream():
try:
process_event('wifi', event, event.get('type'))
except Exception:
pass
yield format_sse(event) yield format_sse(event)
response = Response(generate(), mimetype='text/event-stream') response = Response(generate(), mimetype='text/event-stream')
+36 -2
View File
@@ -165,6 +165,7 @@ detect_dragonos() {
# Required tool checks (with alternates) # Required tool checks (with alternates)
# ---------------------------- # ----------------------------
missing_required=() missing_required=()
missing_recommended=()
check_required() { check_required() {
local label="$1"; shift local label="$1"; shift
@@ -178,6 +179,18 @@ check_required() {
fi 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() { check_optional() {
local label="$1"; shift local label="$1"; shift
local desc="$1"; shift local desc="$1"; shift
@@ -605,7 +618,7 @@ install_aiscatcher_from_source_macos() {
} }
install_macos_packages() { install_macos_packages() {
TOTAL_STEPS=17 TOTAL_STEPS=18
CURRENT_STEP=0 CURRENT_STEP=0
progress "Checking Homebrew" progress "Checking Homebrew"
@@ -979,7 +992,7 @@ install_debian_packages() {
export NEEDRESTART_MODE=a export NEEDRESTART_MODE=a
fi fi
TOTAL_STEPS=22 TOTAL_STEPS=25
CURRENT_STEP=0 CURRENT_STEP=0
progress "Updating APT package lists" progress "Updating APT package lists"
@@ -1185,6 +1198,14 @@ final_summary_and_hard_fail() {
exit 1 exit 1
fi fi
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 +1252,19 @@ main() {
fi fi
install_python_deps 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 final_summary_and_hard_fail
} }
+11
View File
@@ -19,6 +19,17 @@
min-width: max-content; 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 */ /* Stats */
.function-strip .strip-stat { .function-strip .strip-stat {
display: flex; display: flex;
+17
View File
@@ -4201,6 +4201,12 @@ header h1 .tagline {
color: #000; color: #000;
} }
.bt-detail-btn.active {
background: rgba(34, 197, 94, 0.2);
border-color: rgba(34, 197, 94, 0.6);
color: #9fffd1;
}
/* Selected device highlight */ /* Selected device highlight */
.bt-device-row.selected { .bt-device-row.selected {
background: rgba(0, 212, 255, 0.1); background: rgba(0, 212, 255, 0.1);
@@ -4392,6 +4398,17 @@ header h1 .tagline {
border: 1px solid rgba(139, 92, 246, 0.3); border: 1px solid rgba(139, 92, 246, 0.3);
} }
.bt-history-badge {
display: inline-block;
padding: 1px 4px;
border-radius: 3px;
font-size: 8px;
font-weight: 600;
letter-spacing: 0.2px;
background: rgba(34, 197, 94, 0.15);
color: #22c55e;
}
.bt-device-name { .bt-device-name {
font-size: 13px; font-size: 13px;
font-weight: 600; font-weight: 600;
+22
View File
@@ -196,6 +196,28 @@
margin-left: 6px; margin-left: 6px;
font-size: 10px; font-size: 10px;
} }
.tracker-badge {
margin-left: 6px;
font-size: 9px;
padding: 1px 4px;
border-radius: 3px;
background: rgba(255, 51, 102, 0.2);
color: #ff3366;
border: 1px solid rgba(255, 51, 102, 0.4);
text-transform: uppercase;
letter-spacing: 0.4px;
}
.client-badge {
margin-left: 6px;
font-size: 9px;
padding: 1px 4px;
border-radius: 3px;
background: rgba(74, 158, 255, 0.2);
color: #4a9eff;
border: 1px solid rgba(74, 158, 255, 0.4);
text-transform: uppercase;
letter-spacing: 0.4px;
}
.known-badge { .known-badge {
margin-left: 6px; margin-left: 6px;
font-size: 9px; font-size: 9px;
+47
View File
@@ -26,6 +26,9 @@
border-radius: 8px; border-radius: 8px;
max-width: 600px; max-width: 600px;
width: 100%; width: 100%;
max-height: calc(100vh - 80px);
display: flex;
flex-direction: column;
position: relative; position: relative;
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.5); box-shadow: 0 20px 60px rgba(0, 0, 0, 0.5);
} }
@@ -115,6 +118,9 @@
.settings-section.active { .settings-section.active {
display: block; display: block;
overflow-y: auto;
flex: 1;
min-height: 0;
} }
.settings-group { .settings-group {
@@ -163,6 +169,47 @@
color: var(--text-muted, #666); color: var(--text-muted, #666);
} }
/* Settings Feed Lists */
.settings-feed {
background: var(--bg-tertiary, #12121f);
border: 1px solid var(--border-color, #1a1a2e);
border-radius: 6px;
padding: 8px;
max-height: 240px;
overflow-y: auto;
}
.settings-feed-item {
padding: 8px;
border-bottom: 1px solid rgba(255, 255, 255, 0.05);
font-size: 11px;
}
.settings-feed-item:last-child {
border-bottom: none;
}
.settings-feed-title {
display: flex;
justify-content: space-between;
align-items: center;
font-weight: 600;
color: var(--text-primary, #e0e0e0);
margin-bottom: 4px;
}
.settings-feed-meta {
color: var(--text-muted, #666);
font-size: 10px;
}
.settings-feed-empty {
color: var(--text-dim, #666);
text-align: center;
padding: 20px 10px;
font-size: 11px;
}
/* Toggle Switch */ /* Toggle Switch */
.toggle-switch { .toggle-switch {
position: relative; position: relative;
+194
View File
@@ -0,0 +1,194 @@
const AlertCenter = (function() {
'use strict';
let alerts = [];
let rules = [];
let eventSource = null;
const TRACKER_RULE_NAME = 'Tracker Detected';
function init() {
loadRules();
loadFeed();
connect();
}
function connect() {
if (eventSource) {
eventSource.close();
}
eventSource = new EventSource('/alerts/stream');
eventSource.onmessage = function(e) {
try {
const data = JSON.parse(e.data);
if (data.type === 'keepalive') return;
handleAlert(data);
} catch (err) {
console.error('[Alerts] SSE parse error', err);
}
};
eventSource.onerror = function() {
console.warn('[Alerts] SSE connection error');
};
}
function handleAlert(alert) {
alerts.unshift(alert);
alerts = alerts.slice(0, 50);
updateFeedUI();
if (typeof showNotification === 'function') {
const severity = (alert.severity || '').toLowerCase();
if (['high', 'critical'].includes(severity)) {
showNotification(alert.title || 'Alert', alert.message || 'Alert triggered');
}
}
}
function updateFeedUI() {
const list = document.getElementById('alertsFeedList');
const countEl = document.getElementById('alertsFeedCount');
if (countEl) countEl.textContent = `(${alerts.length})`;
if (!list) return;
if (alerts.length === 0) {
list.innerHTML = '<div class="settings-feed-empty">No alerts yet</div>';
return;
}
list.innerHTML = alerts.map(alert => {
const title = escapeHtml(alert.title || 'Alert');
const message = escapeHtml(alert.message || '');
const severity = escapeHtml(alert.severity || 'medium');
const createdAt = alert.created_at ? new Date(alert.created_at).toLocaleString() : '';
return `
<div class="settings-feed-item">
<div class="settings-feed-title">
<span>${title}</span>
<span style="color: var(--text-dim);">${severity.toUpperCase()}</span>
</div>
<div class="settings-feed-meta">${message}</div>
<div class="settings-feed-meta" style="margin-top: 4px;">${createdAt}</div>
</div>
`;
}).join('');
}
function loadFeed() {
fetch('/alerts/events?limit=20')
.then(r => r.json())
.then(data => {
if (data.status === 'success') {
alerts = data.events || [];
updateFeedUI();
}
})
.catch(err => console.error('[Alerts] Load feed failed', err));
}
function loadRules() {
fetch('/alerts/rules?all=1')
.then(r => r.json())
.then(data => {
if (data.status === 'success') {
rules = data.rules || [];
}
})
.catch(err => console.error('[Alerts] Load rules failed', err));
}
function enableTrackerAlerts() {
ensureTrackerRule(true);
}
function disableTrackerAlerts() {
ensureTrackerRule(false);
}
function ensureTrackerRule(enabled) {
loadRules();
setTimeout(() => {
const existing = rules.find(r => r.name === TRACKER_RULE_NAME);
if (existing) {
fetch(`/alerts/rules/${existing.id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ enabled })
}).then(() => loadRules());
} else if (enabled) {
fetch('/alerts/rules', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
name: TRACKER_RULE_NAME,
mode: 'bluetooth',
event_type: 'device_update',
match: { is_tracker: true },
severity: 'high',
enabled: true,
notify: { webhook: true }
})
}).then(() => loadRules());
}
}, 150);
}
function addBluetoothWatchlist(address, name) {
if (!address) return;
const existing = rules.find(r => r.mode === 'bluetooth' && r.match && r.match.address === address);
if (existing) {
return;
}
fetch('/alerts/rules', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
name: name ? `Watchlist ${name}` : `Watchlist ${address}`,
mode: 'bluetooth',
event_type: 'device_update',
match: { address: address },
severity: 'medium',
enabled: true,
notify: { webhook: true }
})
}).then(() => loadRules());
}
function removeBluetoothWatchlist(address) {
if (!address) return;
const existing = rules.find(r => r.mode === 'bluetooth' && r.match && r.match.address === address);
if (!existing) return;
fetch(`/alerts/rules/${existing.id}`, { method: 'DELETE' })
.then(() => loadRules());
}
function isWatchlisted(address) {
return rules.some(r => r.mode === 'bluetooth' && r.match && r.match.address === address && r.enabled);
}
function escapeHtml(str) {
if (!str) return '';
return String(str)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#039;');
}
return {
init,
loadFeed,
enableTrackerAlerts,
disableTrackerAlerts,
addBluetoothWatchlist,
removeBluetoothWatchlist,
isWatchlisted,
};
})();
document.addEventListener('DOMContentLoaded', () => {
if (typeof AlertCenter !== 'undefined') {
AlertCenter.init();
}
});
+136
View File
@@ -0,0 +1,136 @@
const RecordingUI = (function() {
'use strict';
let recordings = [];
let active = [];
function init() {
refresh();
}
function refresh() {
fetch('/recordings')
.then(r => r.json())
.then(data => {
if (data.status !== 'success') return;
recordings = data.recordings || [];
active = data.active || [];
renderActive();
renderRecordings();
})
.catch(err => console.error('[Recording] Load failed', err));
}
function start() {
const modeSelect = document.getElementById('recordingModeSelect');
const labelInput = document.getElementById('recordingLabelInput');
const mode = modeSelect ? modeSelect.value : '';
const label = labelInput ? labelInput.value : '';
if (!mode) return;
fetch('/recordings/start', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ mode, label })
})
.then(r => r.json())
.then(() => {
refresh();
})
.catch(err => console.error('[Recording] Start failed', err));
}
function stop() {
const modeSelect = document.getElementById('recordingModeSelect');
const mode = modeSelect ? modeSelect.value : '';
if (!mode) return;
fetch('/recordings/stop', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ mode })
})
.then(r => r.json())
.then(() => refresh())
.catch(err => console.error('[Recording] Stop failed', err));
}
function stopById(sessionId) {
fetch('/recordings/stop', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ id: sessionId })
}).then(() => refresh());
}
function renderActive() {
const container = document.getElementById('recordingActiveList');
if (!container) return;
if (!active.length) {
container.innerHTML = '<div class="settings-feed-empty">No active recordings</div>';
return;
}
container.innerHTML = active.map(session => {
return `
<div class="settings-feed-item">
<div class="settings-feed-title">
<span>${escapeHtml(session.mode)}</span>
<button class="preset-btn" style="font-size: 9px; padding: 2px 6px;" onclick="RecordingUI.stopById('${session.id}')">Stop</button>
</div>
<div class="settings-feed-meta">Started: ${new Date(session.started_at).toLocaleString()}</div>
<div class="settings-feed-meta">Events: ${session.event_count || 0}</div>
</div>
`;
}).join('');
}
function renderRecordings() {
const container = document.getElementById('recordingList');
if (!container) return;
if (!recordings.length) {
container.innerHTML = '<div class="settings-feed-empty">No recordings yet</div>';
return;
}
container.innerHTML = recordings.map(rec => {
return `
<div class="settings-feed-item">
<div class="settings-feed-title">
<span>${escapeHtml(rec.mode)}${rec.label ? `${escapeHtml(rec.label)}` : ''}</span>
<button class="preset-btn" style="font-size: 9px; padding: 2px 6px;" onclick="RecordingUI.download('${rec.id}')">Download</button>
</div>
<div class="settings-feed-meta">${new Date(rec.started_at).toLocaleString()}${rec.stopped_at ? `${new Date(rec.stopped_at).toLocaleString()}` : ''}</div>
<div class="settings-feed-meta">Events: ${rec.event_count || 0} ${(rec.size_bytes || 0) / 1024.0 > 0 ? (rec.size_bytes / 1024).toFixed(1) + ' KB' : '0 KB'}</div>
</div>
`;
}).join('');
}
function download(sessionId) {
window.open(`/recordings/${sessionId}/download`, '_blank');
}
function escapeHtml(str) {
if (!str) return '';
return String(str)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#039;');
}
return {
init,
refresh,
start,
stop,
stopById,
download,
};
})();
document.addEventListener('DOMContentLoaded', () => {
if (typeof RecordingUI !== 'undefined') {
RecordingUI.init();
}
});
+59
View File
@@ -922,5 +922,64 @@ function switchSettingsTab(tabName) {
loadUpdateStatus(); loadUpdateStatus();
} else if (tabName === 'location') { } else if (tabName === 'location') {
loadObserverLocation(); loadObserverLocation();
} else if (tabName === 'alerts') {
if (typeof AlertCenter !== 'undefined') {
AlertCenter.loadFeed();
}
} else if (tabName === 'recording') {
if (typeof RecordingUI !== 'undefined') {
RecordingUI.refresh();
}
} else if (tabName === 'apikeys') {
loadApiKeyStatus();
} }
} }
/**
* Load API key status into the API Keys settings tab
*/
function loadApiKeyStatus() {
const badge = document.getElementById('apiKeyStatusBadge');
const desc = document.getElementById('apiKeyStatusDesc');
const usage = document.getElementById('apiKeyUsageCount');
const bar = document.getElementById('apiKeyUsageBar');
if (!badge) return;
badge.textContent = 'Not available';
badge.className = 'asset-badge missing';
desc.textContent = 'GSM feature removed';
}
/**
* Save API key from the settings input
*/
function saveApiKey() {
const input = document.getElementById('apiKeyInput');
const result = document.getElementById('apiKeySaveResult');
if (!input || !result) return;
const key = input.value.trim();
if (!key) {
result.style.display = 'block';
result.style.color = 'var(--accent-red)';
result.textContent = 'Please enter an API key.';
return;
}
result.style.display = 'block';
result.style.color = 'var(--text-dim)';
result.textContent = 'Saving...';
result.style.color = 'var(--accent-red)';
result.textContent = 'GSM feature has been removed.';
}
/**
* Toggle API key input visibility
*/
function toggleApiKeyVisibility() {
const input = document.getElementById('apiKeyInput');
if (!input) return;
input.type = input.type === 'password' ? 'text' : 'password';
}
+44 -1
View File
@@ -367,6 +367,9 @@ const BluetoothMode = (function() {
const badgesEl = document.getElementById('btDetailBadges'); const badgesEl = document.getElementById('btDetailBadges');
let badgesHtml = `<span class="bt-detail-badge ${protocol}">${protocol.toUpperCase()}</span>`; let badgesHtml = `<span class="bt-detail-badge ${protocol}">${protocol.toUpperCase()}</span>`;
badgesHtml += `<span class="bt-detail-badge ${device.in_baseline ? 'baseline' : 'new'}">${device.in_baseline ? '✓ KNOWN' : '● NEW'}</span>`; badgesHtml += `<span class="bt-detail-badge ${device.in_baseline ? 'baseline' : 'new'}">${device.in_baseline ? '✓ KNOWN' : '● NEW'}</span>`;
if (device.seen_before) {
badgesHtml += `<span class="bt-detail-badge flag">SEEN BEFORE</span>`;
}
// Tracker badge // Tracker badge
if (device.is_tracker) { if (device.is_tracker) {
@@ -455,6 +458,8 @@ const BluetoothMode = (function() {
? new Date(device.last_seen).toLocaleTimeString() ? new Date(device.last_seen).toLocaleTimeString()
: '--'; : '--';
updateWatchlistButton(device);
// Services // Services
const servicesContainer = document.getElementById('btDetailServices'); const servicesContainer = document.getElementById('btDetailServices');
const servicesList = document.getElementById('btDetailServicesList'); const servicesList = document.getElementById('btDetailServicesList');
@@ -473,6 +478,22 @@ const BluetoothMode = (function() {
highlightSelectedDevice(deviceId); highlightSelectedDevice(deviceId);
} }
/**
* Update watchlist button state
*/
function updateWatchlistButton(device) {
const btn = document.getElementById('btDetailWatchBtn');
if (!btn) return;
if (typeof AlertCenter === 'undefined') {
btn.style.display = 'none';
return;
}
btn.style.display = '';
const watchlisted = AlertCenter.isWatchlisted(device.address);
btn.textContent = watchlisted ? 'Watching' : 'Watchlist';
btn.classList.toggle('active', watchlisted);
}
/** /**
* Clear device selection * Clear device selection
*/ */
@@ -531,7 +552,7 @@ const BluetoothMode = (function() {
if (!device) return; if (!device) return;
navigator.clipboard.writeText(device.address).then(() => { navigator.clipboard.writeText(device.address).then(() => {
const btn = document.querySelector('.bt-detail-btn'); const btn = document.getElementById('btDetailCopyBtn');
if (btn) { if (btn) {
const originalText = btn.textContent; const originalText = btn.textContent;
btn.textContent = 'Copied!'; btn.textContent = 'Copied!';
@@ -544,6 +565,25 @@ const BluetoothMode = (function() {
}); });
} }
/**
* Toggle Bluetooth watchlist for selected device
*/
function toggleWatchlist() {
if (!selectedDeviceId) return;
const device = devices.get(selectedDeviceId);
if (!device || typeof AlertCenter === 'undefined') return;
if (AlertCenter.isWatchlisted(device.address)) {
AlertCenter.removeBluetoothWatchlist(device.address);
showInfo('Removed from watchlist');
} else {
AlertCenter.addBluetoothWatchlist(device.address, device.name || device.address);
showInfo('Added to watchlist');
}
setTimeout(() => updateWatchlistButton(device), 200);
}
/** /**
* Select a device - opens modal with details * Select a device - opens modal with details
*/ */
@@ -1094,6 +1134,7 @@ const BluetoothMode = (function() {
const trackerConfidence = device.tracker_confidence; const trackerConfidence = device.tracker_confidence;
const riskScore = device.risk_score || 0; const riskScore = device.risk_score || 0;
const agentName = device._agent || 'Local'; const agentName = device._agent || 'Local';
const seenBefore = device.seen_before === true;
// Calculate RSSI bar width (0-100%) // Calculate RSSI bar width (0-100%)
// RSSI typically ranges from -100 (weak) to -30 (very strong) // RSSI typically ranges from -100 (weak) to -30 (very strong)
@@ -1147,6 +1188,7 @@ const BluetoothMode = (function() {
let secondaryParts = [addr]; let secondaryParts = [addr];
if (mfr) secondaryParts.push(mfr); if (mfr) secondaryParts.push(mfr);
secondaryParts.push('Seen ' + seenCount + '×'); secondaryParts.push('Seen ' + seenCount + '×');
if (seenBefore) secondaryParts.push('<span class="bt-history-badge">SEEN BEFORE</span>');
// Add agent name if not Local // Add agent name if not Local
if (agentName !== 'Local') { if (agentName !== 'Local') {
secondaryParts.push('<span class="agent-badge agent-remote" style="font-size:8px;padding:1px 4px;">' + escapeHtml(agentName) + '</span>'); secondaryParts.push('<span class="agent-badge agent-remote" style="font-size:8px;padding:1px 4px;">' + escapeHtml(agentName) + '</span>');
@@ -1361,6 +1403,7 @@ const BluetoothMode = (function() {
selectDevice, selectDevice,
clearSelection, clearSelection,
copyAddress, copyAddress,
toggleWatchlist,
// Agent handling // Agent handling
handleAgentChange, handleAgentChange,
+67 -10
View File
@@ -10,6 +10,7 @@ let dmrCallCount = 0;
let dmrSyncCount = 0; let dmrSyncCount = 0;
let dmrCallHistory = []; let dmrCallHistory = [];
let dmrCurrentProtocol = '--'; let dmrCurrentProtocol = '--';
let dmrModeLabel = 'dmr'; // Protocol label for device reservation
// ============== SYNTHESIZER STATE ============== // ============== SYNTHESIZER STATE ==============
let dmrSynthCanvas = null; let dmrSynthCanvas = null;
@@ -57,17 +58,22 @@ function startDmr() {
const frequency = parseFloat(document.getElementById('dmrFrequency')?.value || 462.5625); const frequency = parseFloat(document.getElementById('dmrFrequency')?.value || 462.5625);
const protocol = document.getElementById('dmrProtocol')?.value || 'auto'; const protocol = document.getElementById('dmrProtocol')?.value || 'auto';
const gain = parseInt(document.getElementById('dmrGain')?.value || 40); const gain = parseInt(document.getElementById('dmrGain')?.value || 40);
const ppm = parseInt(document.getElementById('dmrPPM')?.value || 0);
const relaxCrc = document.getElementById('dmrRelaxCrc')?.checked || false;
const device = typeof getSelectedDevice === 'function' ? getSelectedDevice() : 0; const 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 // Check device availability before starting
if (typeof checkDeviceAvailability === 'function' && !checkDeviceAvailability('dmr')) { if (typeof checkDeviceAvailability === 'function' && !checkDeviceAvailability(dmrModeLabel)) {
return; return;
} }
fetch('/dmr/start', { fetch('/dmr/start', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ frequency, protocol, gain, device }) body: JSON.stringify({ frequency, protocol, gain, device, ppm, relaxCrc })
}) })
.then(r => r.json()) .then(r => r.json())
.then(data => { .then(data => {
@@ -86,10 +92,25 @@ function startDmr() {
const statusEl = document.getElementById('dmrStatus'); const statusEl = document.getElementById('dmrStatus');
if (statusEl) statusEl.textContent = 'DECODING'; if (statusEl) statusEl.textContent = 'DECODING';
if (typeof reserveDevice === 'function') { if (typeof reserveDevice === 'function') {
reserveDevice(parseInt(device), 'dmr'); reserveDevice(parseInt(device), dmrModeLabel);
} }
if (typeof showNotification === 'function') { 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 { } else {
if (typeof showNotification === 'function') { if (typeof showNotification === 'function') {
@@ -113,7 +134,7 @@ function stopDmr() {
const statusEl = document.getElementById('dmrStatus'); const statusEl = document.getElementById('dmrStatus');
if (statusEl) statusEl.textContent = 'STOPPED'; if (statusEl) statusEl.textContent = 'STOPPED';
if (typeof releaseDevice === 'function') { if (typeof releaseDevice === 'function') {
releaseDevice('dmr'); releaseDevice(dmrModeLabel);
} }
}) })
.catch(err => console.error('[DMR] Stop error:', err)); .catch(err => console.error('[DMR] Stop error:', err));
@@ -215,7 +236,7 @@ function handleDmrMessage(msg) {
dmrActivityTarget = 0; dmrActivityTarget = 0;
updateDmrSynthStatus(); updateDmrSynthStatus();
if (statusEl) statusEl.textContent = 'CRASHED'; 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})`; const detail = msg.detail || `Decoder exited (code ${msg.exit_code})`;
if (typeof showNotification === 'function') { if (typeof showNotification === 'function') {
showNotification('DMR Error', detail); showNotification('DMR Error', detail);
@@ -227,7 +248,7 @@ function handleDmrMessage(msg) {
dmrActivityTarget = 0; dmrActivityTarget = 0;
updateDmrSynthStatus(); updateDmrSynthStatus();
if (statusEl) statusEl.textContent = 'STOPPED'; if (statusEl) statusEl.textContent = 'STOPPED';
if (typeof releaseDevice === 'function') releaseDevice('dmr'); if (typeof releaseDevice === 'function') releaseDevice(dmrModeLabel);
} }
} }
} }
@@ -306,10 +327,12 @@ function drawDmrSynthesizer() {
dmrSynthCtx.fillStyle = 'rgba(0, 0, 0, 0.3)'; dmrSynthCtx.fillStyle = 'rgba(0, 0, 0, 0.3)';
dmrSynthCtx.fillRect(0, 0, width, height); 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; const timeSinceEvent = now - dmrLastEventTime;
if (timeSinceEvent > 2000) { if (timeSinceEvent > 5000) {
// No events for 2s — decay target toward idle // No events for 5s — decay target toward idle
dmrActivityTarget = Math.max(0, dmrActivityTarget - DMR_DECAY_RATE); dmrActivityTarget = Math.max(0, dmrActivityTarget - DMR_DECAY_RATE);
if (dmrActivityTarget < 0.1 && dmrEventType !== 'stopped') { if (dmrActivityTarget < 0.1 && dmrEventType !== 'stopped') {
dmrEventType = 'idle'; dmrEventType = 'idle';
@@ -496,9 +519,43 @@ function stopDmrSynthesizer() {
window.addEventListener('resize', resizeDmrSynthesizer); window.addEventListener('resize', resizeDmrSynthesizer);
// ============== 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(() => {});
}
// ============== EXPORTS ============== // ============== EXPORTS ==============
window.startDmr = startDmr; window.startDmr = startDmr;
window.stopDmr = stopDmr; window.stopDmr = stopDmr;
window.checkDmrTools = checkDmrTools; window.checkDmrTools = checkDmrTools;
window.checkDmrStatus = checkDmrStatus;
window.initDmrSynthesizer = initDmrSynthesizer; window.initDmrSynthesizer = initDmrSynthesizer;
File diff suppressed because it is too large Load Diff
+155
View File
@@ -11,6 +11,18 @@ const SSTVGeneral = (function() {
let currentMode = null; let currentMode = null;
let progress = 0; let progress = 0;
// Signal scope state
let sstvGeneralScopeCtx = null;
let sstvGeneralScopeAnim = null;
let sstvGeneralScopeHistory = [];
const SSTV_GENERAL_SCOPE_LEN = 200;
let sstvGeneralScopeRms = 0;
let sstvGeneralScopePeak = 0;
let sstvGeneralScopeTargetRms = 0;
let sstvGeneralScopeTargetPeak = 0;
let sstvGeneralScopeMsgBurst = 0;
let sstvGeneralScopeTone = null;
/** /**
* Initialize the SSTV General mode * Initialize the SSTV General mode
*/ */
@@ -190,6 +202,136 @@ const SSTVGeneral = (function() {
`; `;
} }
/**
* Initialize signal scope canvas
*/
function initSstvGeneralScope() {
const canvas = document.getElementById('sstvGeneralScopeCanvas');
if (!canvas) return;
const rect = canvas.getBoundingClientRect();
canvas.width = rect.width * (window.devicePixelRatio || 1);
canvas.height = rect.height * (window.devicePixelRatio || 1);
sstvGeneralScopeCtx = canvas.getContext('2d');
sstvGeneralScopeHistory = new Array(SSTV_GENERAL_SCOPE_LEN).fill(0);
sstvGeneralScopeRms = 0;
sstvGeneralScopePeak = 0;
sstvGeneralScopeTargetRms = 0;
sstvGeneralScopeTargetPeak = 0;
sstvGeneralScopeMsgBurst = 0;
sstvGeneralScopeTone = null;
drawSstvGeneralScope();
}
/**
* Draw signal scope animation frame
*/
function drawSstvGeneralScope() {
const ctx = sstvGeneralScopeCtx;
if (!ctx) return;
const W = ctx.canvas.width;
const H = ctx.canvas.height;
const midY = H / 2;
// Phosphor persistence
ctx.fillStyle = 'rgba(5, 5, 16, 0.3)';
ctx.fillRect(0, 0, W, H);
// Smooth towards target
sstvGeneralScopeRms += (sstvGeneralScopeTargetRms - sstvGeneralScopeRms) * 0.25;
sstvGeneralScopePeak += (sstvGeneralScopeTargetPeak - sstvGeneralScopePeak) * 0.15;
// Push to history
sstvGeneralScopeHistory.push(Math.min(sstvGeneralScopeRms / 32768, 1.0));
if (sstvGeneralScopeHistory.length > SSTV_GENERAL_SCOPE_LEN) sstvGeneralScopeHistory.shift();
// Grid lines
ctx.strokeStyle = 'rgba(60, 40, 80, 0.4)';
ctx.lineWidth = 0.5;
for (let i = 1; i < 4; i++) {
const y = (H / 4) * i;
ctx.beginPath(); ctx.moveTo(0, y); ctx.lineTo(W, y); ctx.stroke();
}
for (let i = 1; i < 8; i++) {
const x = (W / 8) * i;
ctx.beginPath(); ctx.moveTo(x, 0); ctx.lineTo(x, H); ctx.stroke();
}
// Waveform
const stepX = W / (SSTV_GENERAL_SCOPE_LEN - 1);
ctx.strokeStyle = '#c080ff';
ctx.lineWidth = 1.5;
ctx.shadowColor = '#c080ff';
ctx.shadowBlur = 4;
// Upper half
ctx.beginPath();
for (let i = 0; i < sstvGeneralScopeHistory.length; i++) {
const x = i * stepX;
const amp = sstvGeneralScopeHistory[i] * midY * 0.9;
const y = midY - amp;
if (i === 0) ctx.moveTo(x, y); else ctx.lineTo(x, y);
}
ctx.stroke();
// Lower half (mirror)
ctx.beginPath();
for (let i = 0; i < sstvGeneralScopeHistory.length; i++) {
const x = i * stepX;
const amp = sstvGeneralScopeHistory[i] * midY * 0.9;
const y = midY + amp;
if (i === 0) ctx.moveTo(x, y); else ctx.lineTo(x, y);
}
ctx.stroke();
ctx.shadowBlur = 0;
// Peak indicator
const peakNorm = Math.min(sstvGeneralScopePeak / 32768, 1.0);
if (peakNorm > 0.01) {
const peakY = midY - peakNorm * midY * 0.9;
ctx.strokeStyle = 'rgba(255, 68, 68, 0.6)';
ctx.lineWidth = 1;
ctx.setLineDash([4, 4]);
ctx.beginPath(); ctx.moveTo(0, peakY); ctx.lineTo(W, peakY); ctx.stroke();
ctx.setLineDash([]);
}
// Image decode flash
if (sstvGeneralScopeMsgBurst > 0.01) {
ctx.fillStyle = `rgba(0, 255, 100, ${sstvGeneralScopeMsgBurst * 0.15})`;
ctx.fillRect(0, 0, W, H);
sstvGeneralScopeMsgBurst *= 0.88;
}
// Update labels
const rmsLabel = document.getElementById('sstvGeneralScopeRmsLabel');
const peakLabel = document.getElementById('sstvGeneralScopePeakLabel');
const toneLabel = document.getElementById('sstvGeneralScopeToneLabel');
const statusLabel = document.getElementById('sstvGeneralScopeStatusLabel');
if (rmsLabel) rmsLabel.textContent = Math.round(sstvGeneralScopeRms);
if (peakLabel) peakLabel.textContent = Math.round(sstvGeneralScopePeak);
if (toneLabel) {
if (sstvGeneralScopeTone === 'leader') { toneLabel.textContent = 'LEADER'; toneLabel.style.color = '#0f0'; }
else if (sstvGeneralScopeTone === 'sync') { toneLabel.textContent = 'SYNC'; toneLabel.style.color = '#0ff'; }
else if (sstvGeneralScopeTone === 'decoding') { toneLabel.textContent = 'DECODING'; toneLabel.style.color = '#fa0'; }
else if (sstvGeneralScopeTone === 'noise') { toneLabel.textContent = 'NOISE'; toneLabel.style.color = '#555'; }
else { toneLabel.textContent = 'QUIET'; toneLabel.style.color = '#444'; }
}
if (statusLabel) {
if (sstvGeneralScopeRms > 500) { statusLabel.textContent = 'SIGNAL'; statusLabel.style.color = '#0f0'; }
else { statusLabel.textContent = 'MONITORING'; statusLabel.style.color = '#555'; }
}
sstvGeneralScopeAnim = requestAnimationFrame(drawSstvGeneralScope);
}
/**
* Stop signal scope
*/
function stopSstvGeneralScope() {
if (sstvGeneralScopeAnim) { cancelAnimationFrame(sstvGeneralScopeAnim); sstvGeneralScopeAnim = null; }
sstvGeneralScopeCtx = null;
}
/** /**
* Start SSE stream * Start SSE stream
*/ */
@@ -198,6 +340,11 @@ const SSTVGeneral = (function() {
eventSource.close(); eventSource.close();
} }
// Show and init scope
const scopePanel = document.getElementById('sstvGeneralScopePanel');
if (scopePanel) scopePanel.style.display = 'block';
initSstvGeneralScope();
eventSource = new EventSource('/sstv-general/stream'); eventSource = new EventSource('/sstv-general/stream');
eventSource.onmessage = (e) => { eventSource.onmessage = (e) => {
@@ -205,6 +352,10 @@ const SSTVGeneral = (function() {
const data = JSON.parse(e.data); const data = JSON.parse(e.data);
if (data.type === 'sstv_progress') { if (data.type === 'sstv_progress') {
handleProgress(data); handleProgress(data);
} else if (data.type === 'sstv_scope') {
sstvGeneralScopeTargetRms = data.rms;
sstvGeneralScopeTargetPeak = data.peak;
if (data.tone !== undefined) sstvGeneralScopeTone = data.tone;
} }
} catch (err) { } catch (err) {
console.error('Failed to parse SSE message:', err); console.error('Failed to parse SSE message:', err);
@@ -227,6 +378,9 @@ const SSTVGeneral = (function() {
eventSource.close(); eventSource.close();
eventSource = null; eventSource = null;
} }
stopSstvGeneralScope();
const scopePanel = document.getElementById('sstvGeneralScopePanel');
if (scopePanel) scopePanel.style.display = 'none';
} }
/** /**
@@ -245,6 +399,7 @@ const SSTVGeneral = (function() {
renderGallery(); renderGallery();
showNotification('SSTV', 'New image decoded!'); showNotification('SSTV', 'New image decoded!');
updateStatusUI('listening', 'Listening...'); updateStatusUI('listening', 'Listening...');
sstvGeneralScopeMsgBurst = 1.0;
// Clear decode progress so signal monitor can take over // Clear decode progress so signal monitor can take over
const liveContent = document.getElementById('sstvGeneralLiveContent'); const liveContent = document.getElementById('sstvGeneralLiveContent');
if (liveContent) liveContent.innerHTML = ''; if (liveContent) liveContent.innerHTML = '';
+155
View File
@@ -21,6 +21,18 @@ const SSTV = (function() {
// ISS frequency // ISS frequency
const ISS_FREQ = 145.800; const ISS_FREQ = 145.800;
// Signal scope state
let sstvScopeCtx = null;
let sstvScopeAnim = null;
let sstvScopeHistory = [];
const SSTV_SCOPE_LEN = 200;
let sstvScopeRms = 0;
let sstvScopePeak = 0;
let sstvScopeTargetRms = 0;
let sstvScopeTargetPeak = 0;
let sstvScopeMsgBurst = 0;
let sstvScopeTone = null;
/** /**
* Initialize the SSTV mode * Initialize the SSTV mode
*/ */
@@ -634,6 +646,136 @@ const SSTV = (function() {
`; `;
} }
/**
* Initialize signal scope canvas
*/
function initSstvScope() {
const canvas = document.getElementById('sstvScopeCanvas');
if (!canvas) return;
const rect = canvas.getBoundingClientRect();
canvas.width = rect.width * (window.devicePixelRatio || 1);
canvas.height = rect.height * (window.devicePixelRatio || 1);
sstvScopeCtx = canvas.getContext('2d');
sstvScopeHistory = new Array(SSTV_SCOPE_LEN).fill(0);
sstvScopeRms = 0;
sstvScopePeak = 0;
sstvScopeTargetRms = 0;
sstvScopeTargetPeak = 0;
sstvScopeMsgBurst = 0;
sstvScopeTone = null;
drawSstvScope();
}
/**
* Draw signal scope animation frame
*/
function drawSstvScope() {
const ctx = sstvScopeCtx;
if (!ctx) return;
const W = ctx.canvas.width;
const H = ctx.canvas.height;
const midY = H / 2;
// Phosphor persistence
ctx.fillStyle = 'rgba(5, 5, 16, 0.3)';
ctx.fillRect(0, 0, W, H);
// Smooth towards target
sstvScopeRms += (sstvScopeTargetRms - sstvScopeRms) * 0.25;
sstvScopePeak += (sstvScopeTargetPeak - sstvScopePeak) * 0.15;
// Push to history
sstvScopeHistory.push(Math.min(sstvScopeRms / 32768, 1.0));
if (sstvScopeHistory.length > SSTV_SCOPE_LEN) sstvScopeHistory.shift();
// Grid lines
ctx.strokeStyle = 'rgba(60, 40, 80, 0.4)';
ctx.lineWidth = 0.5;
for (let i = 1; i < 4; i++) {
const y = (H / 4) * i;
ctx.beginPath(); ctx.moveTo(0, y); ctx.lineTo(W, y); ctx.stroke();
}
for (let i = 1; i < 8; i++) {
const x = (W / 8) * i;
ctx.beginPath(); ctx.moveTo(x, 0); ctx.lineTo(x, H); ctx.stroke();
}
// Waveform
const stepX = W / (SSTV_SCOPE_LEN - 1);
ctx.strokeStyle = '#c080ff';
ctx.lineWidth = 1.5;
ctx.shadowColor = '#c080ff';
ctx.shadowBlur = 4;
// Upper half
ctx.beginPath();
for (let i = 0; i < sstvScopeHistory.length; i++) {
const x = i * stepX;
const amp = sstvScopeHistory[i] * midY * 0.9;
const y = midY - amp;
if (i === 0) ctx.moveTo(x, y); else ctx.lineTo(x, y);
}
ctx.stroke();
// Lower half (mirror)
ctx.beginPath();
for (let i = 0; i < sstvScopeHistory.length; i++) {
const x = i * stepX;
const amp = sstvScopeHistory[i] * midY * 0.9;
const y = midY + amp;
if (i === 0) ctx.moveTo(x, y); else ctx.lineTo(x, y);
}
ctx.stroke();
ctx.shadowBlur = 0;
// Peak indicator
const peakNorm = Math.min(sstvScopePeak / 32768, 1.0);
if (peakNorm > 0.01) {
const peakY = midY - peakNorm * midY * 0.9;
ctx.strokeStyle = 'rgba(255, 68, 68, 0.6)';
ctx.lineWidth = 1;
ctx.setLineDash([4, 4]);
ctx.beginPath(); ctx.moveTo(0, peakY); ctx.lineTo(W, peakY); ctx.stroke();
ctx.setLineDash([]);
}
// Image decode flash
if (sstvScopeMsgBurst > 0.01) {
ctx.fillStyle = `rgba(0, 255, 100, ${sstvScopeMsgBurst * 0.15})`;
ctx.fillRect(0, 0, W, H);
sstvScopeMsgBurst *= 0.88;
}
// Update labels
const rmsLabel = document.getElementById('sstvScopeRmsLabel');
const peakLabel = document.getElementById('sstvScopePeakLabel');
const toneLabel = document.getElementById('sstvScopeToneLabel');
const statusLabel = document.getElementById('sstvScopeStatusLabel');
if (rmsLabel) rmsLabel.textContent = Math.round(sstvScopeRms);
if (peakLabel) peakLabel.textContent = Math.round(sstvScopePeak);
if (toneLabel) {
if (sstvScopeTone === 'leader') { toneLabel.textContent = 'LEADER'; toneLabel.style.color = '#0f0'; }
else if (sstvScopeTone === 'sync') { toneLabel.textContent = 'SYNC'; toneLabel.style.color = '#0ff'; }
else if (sstvScopeTone === 'decoding') { toneLabel.textContent = 'DECODING'; toneLabel.style.color = '#fa0'; }
else if (sstvScopeTone === 'noise') { toneLabel.textContent = 'NOISE'; toneLabel.style.color = '#555'; }
else { toneLabel.textContent = 'QUIET'; toneLabel.style.color = '#444'; }
}
if (statusLabel) {
if (sstvScopeRms > 500) { statusLabel.textContent = 'SIGNAL'; statusLabel.style.color = '#0f0'; }
else { statusLabel.textContent = 'MONITORING'; statusLabel.style.color = '#555'; }
}
sstvScopeAnim = requestAnimationFrame(drawSstvScope);
}
/**
* Stop signal scope
*/
function stopSstvScope() {
if (sstvScopeAnim) { cancelAnimationFrame(sstvScopeAnim); sstvScopeAnim = null; }
sstvScopeCtx = null;
}
/** /**
* Start SSE stream * Start SSE stream
*/ */
@@ -642,6 +784,11 @@ const SSTV = (function() {
eventSource.close(); eventSource.close();
} }
// Show and init scope
const scopePanel = document.getElementById('sstvScopePanel');
if (scopePanel) scopePanel.style.display = 'block';
initSstvScope();
eventSource = new EventSource('/sstv/stream'); eventSource = new EventSource('/sstv/stream');
eventSource.onmessage = (e) => { eventSource.onmessage = (e) => {
@@ -649,6 +796,10 @@ const SSTV = (function() {
const data = JSON.parse(e.data); const data = JSON.parse(e.data);
if (data.type === 'sstv_progress') { if (data.type === 'sstv_progress') {
handleProgress(data); handleProgress(data);
} else if (data.type === 'sstv_scope') {
sstvScopeTargetRms = data.rms;
sstvScopeTargetPeak = data.peak;
if (data.tone !== undefined) sstvScopeTone = data.tone;
} }
} catch (err) { } catch (err) {
console.error('Failed to parse SSE message:', err); console.error('Failed to parse SSE message:', err);
@@ -671,6 +822,9 @@ const SSTV = (function() {
eventSource.close(); eventSource.close();
eventSource = null; eventSource = null;
} }
stopSstvScope();
const scopePanel = document.getElementById('sstvScopePanel');
if (scopePanel) scopePanel.style.display = 'none';
} }
/** /**
@@ -691,6 +845,7 @@ const SSTV = (function() {
renderGallery(); renderGallery();
showNotification('SSTV', 'New image decoded!'); showNotification('SSTV', 'New image decoded!');
updateStatusUI('listening', 'Listening...'); updateStatusUI('listening', 'Listening...');
sstvScopeMsgBurst = 1.0;
// Clear decode progress so signal monitor can take over // Clear decode progress so signal monitor can take over
const liveContent = document.getElementById('sstvLiveContent'); const liveContent = document.getElementById('sstvLiveContent');
if (liveContent) liveContent.innerHTML = ''; if (liveContent) liveContent.innerHTML = '';
+39 -3
View File
@@ -69,6 +69,40 @@ const WiFiMode = (function() {
return true; return true;
} }
function getChannelPresetList(preset) {
switch (preset) {
case '2.4-common':
return '1,6,11';
case '2.4-all':
return '1,2,3,4,5,6,7,8,9,10,11,12,13';
case '5-low':
return '36,40,44,48';
case '5-mid':
return '52,56,60,64';
case '5-high':
return '149,153,157,161,165';
default:
return '';
}
}
function buildChannelConfig() {
const preset = document.getElementById('wifiChannelPreset')?.value || '';
const listInput = document.getElementById('wifiChannelList')?.value || '';
const singleInput = document.getElementById('wifiChannel')?.value || '';
const listValue = listInput.trim();
const presetValue = getChannelPresetList(preset);
const channels = listValue || presetValue || '';
const channel = channels ? null : (singleInput.trim() ? parseInt(singleInput.trim()) : null);
return {
channels: channels || null,
channel: Number.isFinite(channel) ? channel : null,
};
}
// ========================================================================== // ==========================================================================
// State // State
// ========================================================================== // ==========================================================================
@@ -463,7 +497,7 @@ const WiFiMode = (function() {
try { try {
const iface = elements.interfaceSelect?.value || null; const iface = elements.interfaceSelect?.value || null;
const band = document.getElementById('wifiBand')?.value || 'all'; const band = document.getElementById('wifiBand')?.value || 'all';
const channel = document.getElementById('wifiChannel')?.value || null; const channelConfig = buildChannelConfig();
const isAgentMode = typeof currentAgent !== 'undefined' && currentAgent !== 'local'; const isAgentMode = typeof currentAgent !== 'undefined' && currentAgent !== 'local';
let response; let response;
@@ -476,7 +510,8 @@ const WiFiMode = (function() {
interface: iface, interface: iface,
scan_type: 'deep', scan_type: 'deep',
band: band === 'abg' ? 'all' : band === 'bg' ? '2.4' : '5', band: band === 'abg' ? 'all' : band === 'bg' ? '2.4' : '5',
channel: channel ? parseInt(channel) : null, channel: channelConfig.channel,
channels: channelConfig.channels,
}), }),
}); });
} else { } else {
@@ -486,7 +521,8 @@ const WiFiMode = (function() {
body: JSON.stringify({ body: JSON.stringify({
interface: iface, interface: iface,
band: band === 'abg' ? 'all' : band === 'bg' ? '2.4' : '5', band: band === 'abg' ? 'all' : band === 'bg' ? '2.4' : '5',
channel: channel ? parseInt(channel) : null, channel: channelConfig.channel,
channels: channelConfig.channels,
}), }),
}); });
} }
+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)};
+682 -44
View File
File diff suppressed because it is too large Load Diff
+16
View File
@@ -32,6 +32,22 @@
<label>Gain</label> <label>Gain</label>
<input type="number" id="dmrGain" value="40" min="0" max="50" style="width: 100%;"> <input type="number" id="dmrGain" value="40" min="0" max="50" style="width: 100%;">
</div> </div>
<div class="form-group">
<label>PPM Correction</label>
<input type="number" id="dmrPPM" value="0" min="-200" max="200" step="1" style="width: 100%;"
title="Frequency error correction for your RTL-SDR dongle. Digital voice is very sensitive to frequency offset.">
</div>
<div class="form-group" style="margin-top: 4px;">
<label style="display: flex; align-items: center; gap: 8px; cursor: pointer;">
<input type="checkbox" id="dmrRelaxCrc" style="width: auto; accent-color: var(--accent-cyan);">
<span>Relax CRC (weak signals)</span>
</label>
<span style="font-size: 0.75em; color: var(--text-muted); display: block; margin-top: 2px;">
Allows more frames through on marginal signals at the cost of occasional errors
</span>
</div>
</div> </div>
<!-- Actions --> <!-- Actions -->
+1 -28
View File
@@ -61,35 +61,8 @@
<div id="signalGuessExplanation" style="color: var(--text-muted); font-size: 10px; margin-bottom: 6px;"></div> <div id="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="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="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>
</div> </div>
<!-- Waterfall Controls -->
<div class="section">
<h3>Waterfall</h3>
<div class="form-group" style="margin-bottom: 6px;">
<label style="font-size: 10px;">Start (MHz)</label>
<input type="number" id="waterfallStartFreq" value="88" step="0.1" style="width: 100%; padding: 5px; background: var(--bg-secondary); border: 1px solid var(--border-color); color: var(--text-primary); border-radius: 4px; font-size: 11px;">
</div>
<div class="form-group" style="margin-bottom: 6px;">
<label style="font-size: 10px;">End (MHz)</label>
<input type="number" id="waterfallEndFreq" value="108" step="0.1" style="width: 100%; padding: 5px; background: var(--bg-secondary); border: 1px solid var(--border-color); color: var(--text-primary); border-radius: 4px; font-size: 11px;">
</div>
<div class="form-group" style="margin-bottom: 6px;">
<label style="font-size: 10px;">Bin Size</label>
<select id="waterfallBinSize" style="width: 100%; padding: 5px; background: var(--bg-secondary); border: 1px solid var(--border-color); color: var(--text-primary); border-radius: 4px; font-size: 11px;">
<option value="5000">5 kHz</option>
<option value="10000" selected>10 kHz</option>
<option value="25000">25 kHz</option>
<option value="100000">100 kHz</option>
</select>
</div>
<div class="form-group" style="margin-bottom: 8px;">
<label style="font-size: 10px;">Gain</label>
<input type="number" id="waterfallGain" value="40" min="0" max="50" style="width: 100%; padding: 5px; background: var(--bg-secondary); border: 1px solid var(--border-color); color: var(--text-primary); border-radius: 4px; font-size: 11px;">
</div>
<button class="run-btn" id="startWaterfallBtn" onclick="startWaterfall()" style="width: 100%; padding: 8px;">Start Waterfall</button>
<button class="stop-btn" id="stopWaterfallBtn" onclick="stopWaterfall()" style="display: none; width: 100%; padding: 8px; margin-top: 4px;">Stop Waterfall</button>
</div>
</div> </div>
+16 -1
View File
@@ -69,7 +69,22 @@
</select> </select>
</div> </div>
<div class="form-group"> <div class="form-group">
<label>Channel (empty = hop)</label> <label>Channel Preset</label>
<select id="wifiChannelPreset">
<option value="">Auto hop (all)</option>
<option value="2.4-common">2.4 GHz Common (1,6,11)</option>
<option value="2.4-all">2.4 GHz All (1-13)</option>
<option value="5-low">5 GHz Low (36-48)</option>
<option value="5-mid">5 GHz Mid/DFS (52-64)</option>
<option value="5-high">5 GHz High (149-165)</option>
</select>
</div>
<div class="form-group">
<label>Channel List (overrides preset)</label>
<input type="text" id="wifiChannelList" placeholder="e.g., 1,6,11 or 36,40,44,48">
</div>
<div class="form-group">
<label>Channel (single)</label>
<input type="text" id="wifiChannel" placeholder="e.g., 6 or 36"> <input type="text" id="wifiChannel" placeholder="e.g., 6 or 36">
</div> </div>
</div> </div>
+79
View File
@@ -15,6 +15,8 @@
<button class="settings-tab" data-tab="display" onclick="switchSettingsTab('display')">Display</button> <button class="settings-tab" data-tab="display" onclick="switchSettingsTab('display')">Display</button>
<button class="settings-tab" data-tab="updates" onclick="switchSettingsTab('updates')">Updates</button> <button class="settings-tab" data-tab="updates" onclick="switchSettingsTab('updates')">Updates</button>
<button class="settings-tab" data-tab="tools" onclick="switchSettingsTab('tools')">Tools</button> <button class="settings-tab" data-tab="tools" onclick="switchSettingsTab('tools')">Tools</button>
<button class="settings-tab" data-tab="alerts" onclick="switchSettingsTab('alerts')">Alerts</button>
<button class="settings-tab" data-tab="recording" onclick="switchSettingsTab('recording')">Recording</button>
<button class="settings-tab" data-tab="about" onclick="switchSettingsTab('about')">About</button> <button class="settings-tab" data-tab="about" onclick="switchSettingsTab('about')">About</button>
</div> </div>
@@ -280,6 +282,83 @@
</div> </div>
</div> </div>
<!-- Alerts Section -->
<div id="settings-alerts" class="settings-section">
<div class="settings-group">
<div class="settings-group-title">Alert Feed <span id="alertsFeedCount" style="color: var(--text-dim); font-weight: 500;"></span></div>
<div id="alertsFeedList" class="settings-feed">
<div class="settings-feed-empty">No alerts yet</div>
</div>
</div>
<div class="settings-group">
<div class="settings-group-title">Quick Rules</div>
<div style="display: flex; gap: 10px; flex-wrap: wrap;">
<button class="check-assets-btn" onclick="AlertCenter.enableTrackerAlerts()">Enable Tracker Alerts</button>
<button class="check-assets-btn" onclick="AlertCenter.disableTrackerAlerts()">Disable Tracker Alerts</button>
</div>
<div class="settings-info" style="margin-top: 10px;">
Use Bluetooth device details to add specific device watchlist alerts.
</div>
</div>
</div>
<!-- Recording Section -->
<div id="settings-recording" class="settings-section">
<div class="settings-group">
<div class="settings-group-title">Start Recording</div>
<div class="settings-row" style="border-bottom: none; padding-top: 0;">
<div class="settings-label">
<span class="settings-label-text">Mode</span>
<span class="settings-label-desc">Record live events for a mode</span>
</div>
<select id="recordingModeSelect" class="settings-select" style="width: 200px;">
<option value="pager">Pager</option>
<option value="sensor">433 Sensors</option>
<option value="wifi">WiFi</option>
<option value="bluetooth">Bluetooth</option>
<option value="adsb">ADS-B</option>
<option value="ais">AIS</option>
<option value="dsc">DSC</option>
<option value="acars">ACARS</option>
<option value="aprs">APRS</option>
<option value="rtlamr">RTLAMR</option>
<option value="dmr">DMR</option>
<option value="tscm">TSCM</option>
<option value="sstv">SSTV</option>
<option value="sstv_general">SSTV General</option>
<option value="listening_scanner">Listening Post</option>
<option value="waterfall">Waterfall</option>
</select>
</div>
<div class="settings-row" style="border-bottom: none;">
<div class="settings-label">
<span class="settings-label-text">Label</span>
<span class="settings-label-desc">Optional note for the session</span>
</div>
<input type="text" id="recordingLabelInput" class="settings-input" placeholder="Morning sweep" style="width: 200px;">
</div>
<div style="display: flex; gap: 10px; margin-top: 10px;">
<button class="check-assets-btn" onclick="RecordingUI.start()">Start</button>
<button class="check-assets-btn" onclick="RecordingUI.stop()">Stop</button>
</div>
</div>
<div class="settings-group">
<div class="settings-group-title">Active Sessions</div>
<div id="recordingActiveList" class="settings-feed">
<div class="settings-feed-empty">No active recordings</div>
</div>
</div>
<div class="settings-group">
<div class="settings-group-title">Recent Recordings</div>
<div id="recordingList" class="settings-feed">
<div class="settings-feed-empty">No recordings yet</div>
</div>
</div>
</div>
<!-- About Section --> <!-- About Section -->
<div id="settings-about" class="settings-section"> <div id="settings-about" class="settings-section">
<div class="settings-group"> <div class="settings-group">
+36 -1
View File
@@ -2,7 +2,7 @@
from unittest.mock import patch, MagicMock from unittest.mock import patch, MagicMock
import pytest 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
# ============================================ # ============================================
@@ -98,6 +98,41 @@ def test_parse_unrecognized():
assert result['text'] == 'some random text' assert result['text'] == 'some random text'
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'] == ['-fd']
assert _DSD_FME_PROTOCOL_FLAGS['p25'] == ['-f1'] # NOT -fp (ProVoice in fme)
assert _DSD_FME_PROTOCOL_FLAGS['nxdn'] == ['-fn']
assert _DSD_FME_PROTOCOL_FLAGS['dstar'] == [] # No dedicated flag
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['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 # Endpoint tests
# ============================================ # ============================================
+168
View File
@@ -0,0 +1,168 @@
"""Tests for the waterfall FFT pipeline."""
import struct
import numpy as np
import pytest
from utils.waterfall_fft import (
build_binary_frame,
compute_power_spectrum,
cu8_to_complex,
quantize_to_uint8,
)
class TestCu8ToComplex:
"""Tests for cu8_to_complex conversion."""
def test_zero_maps_to_negative_one(self):
# I=0, Q=0 -> approximately -1 - 1j
result = cu8_to_complex(bytes([0, 0]))
assert result[0].real == pytest.approx(-1.0, abs=0.01)
assert result[0].imag == pytest.approx(-1.0, abs=0.01)
def test_255_maps_to_positive_one(self):
# I=255, Q=255 -> approximately +1 + 1j
result = cu8_to_complex(bytes([255, 255]))
assert result[0].real == pytest.approx(1.0, abs=0.01)
assert result[0].imag == pytest.approx(1.0, abs=0.01)
def test_128_maps_to_near_zero(self):
# I=128, Q=128 -> approximately 0 + 0j
result = cu8_to_complex(bytes([128, 128]))
assert abs(result[0].real) < 0.01
assert abs(result[0].imag) < 0.01
def test_output_length(self):
raw = bytes(range(256)) * 4 # 1024 bytes -> 512 complex samples
result = cu8_to_complex(raw)
assert len(result) == 512
def test_output_dtype(self):
result = cu8_to_complex(bytes([100, 200, 50, 150]))
assert result.dtype == np.complex64 or np.issubdtype(result.dtype, np.complexfloating)
class TestComputePowerSpectrum:
"""Tests for compute_power_spectrum."""
def test_output_length_matches_fft_size(self):
samples = np.zeros(4096, dtype=np.complex64)
result = compute_power_spectrum(samples, fft_size=1024, avg_count=4)
assert len(result) == 1024
def test_output_dtype(self):
samples = np.zeros(4096, dtype=np.complex64)
result = compute_power_spectrum(samples, fft_size=1024, avg_count=4)
assert result.dtype == np.float32
def test_pure_tone_peak_at_correct_bin(self):
fft_size = 1024
avg_count = 4
n = fft_size * avg_count
# Generate a pure tone at bin 256 (1/4 of sample rate)
t = np.arange(n, dtype=np.float32)
freq_bin = 256
tone = np.exp(2j * np.pi * freq_bin / fft_size * t).astype(np.complex64)
result = compute_power_spectrum(tone, fft_size=fft_size, avg_count=avg_count)
# After fftshift, bin 256 maps to index 256 + 512 = 768
peak_idx = np.argmax(result)
expected_idx = fft_size // 2 + freq_bin
assert peak_idx == expected_idx
def test_insufficient_samples_returns_default(self):
# Not enough samples for even one segment
samples = np.zeros(100, dtype=np.complex64)
result = compute_power_spectrum(samples, fft_size=1024, avg_count=4)
assert len(result) == 1024
assert np.all(result == -100.0)
def test_partial_avg_count(self):
# Only enough for 2 of 4 requested averages
fft_size = 1024
samples = np.random.randn(2048).astype(np.float32).view(np.complex64)
result = compute_power_spectrum(samples, fft_size=fft_size, avg_count=4)
assert len(result) == fft_size
# Should still return valid dB values (not -100 default)
assert np.any(result != -100.0)
class TestQuantizeToUint8:
"""Tests for quantize_to_uint8."""
def test_db_min_maps_to_zero(self):
power = np.array([-90.0], dtype=np.float32)
result = quantize_to_uint8(power, db_min=-90, db_max=-20)
assert result[0] == 0
def test_db_max_maps_to_255(self):
power = np.array([-20.0], dtype=np.float32)
result = quantize_to_uint8(power, db_min=-90, db_max=-20)
assert result[0] == 255
def test_below_min_clamped_to_zero(self):
power = np.array([-120.0], dtype=np.float32)
result = quantize_to_uint8(power, db_min=-90, db_max=-20)
assert result[0] == 0
def test_above_max_clamped_to_255(self):
power = np.array([0.0], dtype=np.float32)
result = quantize_to_uint8(power, db_min=-90, db_max=-20)
assert result[0] == 255
def test_midpoint(self):
# Midpoint between -90 and -20 is -55 -> ~127-128
power = np.array([-55.0], dtype=np.float32)
result = quantize_to_uint8(power, db_min=-90, db_max=-20)
assert 125 <= result[0] <= 130
def test_output_length(self):
power = np.random.randn(1024).astype(np.float32) * 30 - 60
result = quantize_to_uint8(power)
assert len(result) == 1024
class TestBuildBinaryFrame:
"""Tests for build_binary_frame."""
def test_header_values(self):
bins = bytes([128] * 1024)
frame = build_binary_frame(100.0, 102.0, bins)
msg_type = frame[0]
start_freq, end_freq = struct.unpack_from('<ff', frame, 1)
bin_count = struct.unpack_from('<H', frame, 9)[0]
assert msg_type == 0x01
assert start_freq == pytest.approx(100.0, abs=0.01)
assert end_freq == pytest.approx(102.0, abs=0.01)
assert bin_count == 1024
def test_total_length(self):
bin_count = 1024
bins = bytes([0] * bin_count)
frame = build_binary_frame(88.0, 108.0, bins)
assert len(frame) == 11 + bin_count
def test_bins_in_payload(self):
bins = bytes(range(256))
frame = build_binary_frame(0.0, 1.0, bins)
payload = frame[11:]
assert payload == bins
def test_round_trip(self):
start = 433.0
end = 435.0
bins = bytes([i % 256 for i in range(2048)])
frame = build_binary_frame(start, end, bins)
# Parse it back
msg_type = frame[0]
parsed_start, parsed_end = struct.unpack_from('<ff', frame, 1)
parsed_count = struct.unpack_from('<H', frame, 9)[0]
parsed_bins = frame[11:]
assert msg_type == 0x01
assert parsed_start == pytest.approx(start, abs=0.01)
assert parsed_end == pytest.approx(end, abs=0.01)
assert parsed_count == 2048
assert parsed_bins == bins
+443
View File
@@ -0,0 +1,443 @@
"""Alerting engine for cross-mode events."""
from __future__ import annotations
import json
import logging
import queue
import re
import threading
import time
from dataclasses import dataclass
from datetime import datetime, timezone
from typing import Any, Generator
from config import ALERT_WEBHOOK_URL, ALERT_WEBHOOK_TIMEOUT, ALERT_WEBHOOK_SECRET
from utils.database import get_db
logger = logging.getLogger('intercept.alerts')
@dataclass
class AlertRule:
id: int
name: str
mode: str | None
event_type: str | None
match: dict
severity: str
enabled: bool
notify: dict
created_at: str | None = None
class AlertManager:
def __init__(self) -> None:
self._queue: queue.Queue = queue.Queue(maxsize=1000)
self._rules_cache: list[AlertRule] = []
self._rules_loaded_at = 0.0
self._cache_lock = threading.Lock()
# ------------------------------------------------------------------
# Rule management
# ------------------------------------------------------------------
def invalidate_cache(self) -> None:
with self._cache_lock:
self._rules_loaded_at = 0.0
def _load_rules(self) -> None:
with get_db() as conn:
cursor = conn.execute('''
SELECT id, name, mode, event_type, match, severity, enabled, notify, created_at
FROM alert_rules
WHERE enabled = 1
ORDER BY id ASC
''')
rules: list[AlertRule] = []
for row in cursor:
match = {}
notify = {}
try:
match = json.loads(row['match']) if row['match'] else {}
except json.JSONDecodeError:
match = {}
try:
notify = json.loads(row['notify']) if row['notify'] else {}
except json.JSONDecodeError:
notify = {}
rules.append(AlertRule(
id=row['id'],
name=row['name'],
mode=row['mode'],
event_type=row['event_type'],
match=match,
severity=row['severity'] or 'medium',
enabled=bool(row['enabled']),
notify=notify,
created_at=row['created_at'],
))
with self._cache_lock:
self._rules_cache = rules
self._rules_loaded_at = time.time()
def _get_rules(self) -> list[AlertRule]:
with self._cache_lock:
stale = (time.time() - self._rules_loaded_at) > 10
if stale:
self._load_rules()
with self._cache_lock:
return list(self._rules_cache)
def list_rules(self, include_disabled: bool = False) -> list[dict]:
with get_db() as conn:
if include_disabled:
cursor = conn.execute('''
SELECT id, name, mode, event_type, match, severity, enabled, notify, created_at
FROM alert_rules
ORDER BY id DESC
''')
else:
cursor = conn.execute('''
SELECT id, name, mode, event_type, match, severity, enabled, notify, created_at
FROM alert_rules
WHERE enabled = 1
ORDER BY id DESC
''')
return [
{
'id': row['id'],
'name': row['name'],
'mode': row['mode'],
'event_type': row['event_type'],
'match': json.loads(row['match']) if row['match'] else {},
'severity': row['severity'],
'enabled': bool(row['enabled']),
'notify': json.loads(row['notify']) if row['notify'] else {},
'created_at': row['created_at'],
}
for row in cursor
]
def add_rule(self, rule: dict) -> int:
with get_db() as conn:
cursor = conn.execute('''
INSERT INTO alert_rules (name, mode, event_type, match, severity, enabled, notify)
VALUES (?, ?, ?, ?, ?, ?, ?)
''', (
rule.get('name') or 'Alert Rule',
rule.get('mode'),
rule.get('event_type'),
json.dumps(rule.get('match') or {}),
rule.get('severity') or 'medium',
1 if rule.get('enabled', True) else 0,
json.dumps(rule.get('notify') or {}),
))
rule_id = cursor.lastrowid
self.invalidate_cache()
return int(rule_id)
def update_rule(self, rule_id: int, updates: dict) -> bool:
fields = []
params = []
for key in ('name', 'mode', 'event_type', 'severity'):
if key in updates:
fields.append(f"{key} = ?")
params.append(updates[key])
if 'enabled' in updates:
fields.append('enabled = ?')
params.append(1 if updates['enabled'] else 0)
if 'match' in updates:
fields.append('match = ?')
params.append(json.dumps(updates['match'] or {}))
if 'notify' in updates:
fields.append('notify = ?')
params.append(json.dumps(updates['notify'] or {}))
if not fields:
return False
params.append(rule_id)
with get_db() as conn:
cursor = conn.execute(
f"UPDATE alert_rules SET {', '.join(fields)} WHERE id = ?",
params
)
updated = cursor.rowcount > 0
if updated:
self.invalidate_cache()
return updated
def delete_rule(self, rule_id: int) -> bool:
with get_db() as conn:
cursor = conn.execute('DELETE FROM alert_rules WHERE id = ?', (rule_id,))
deleted = cursor.rowcount > 0
if deleted:
self.invalidate_cache()
return deleted
def list_events(self, limit: int = 100, mode: str | None = None, severity: str | None = None) -> list[dict]:
query = 'SELECT id, rule_id, mode, event_type, severity, title, message, payload, created_at FROM alert_events'
clauses = []
params: list[Any] = []
if mode:
clauses.append('mode = ?')
params.append(mode)
if severity:
clauses.append('severity = ?')
params.append(severity)
if clauses:
query += ' WHERE ' + ' AND '.join(clauses)
query += ' ORDER BY id DESC LIMIT ?'
params.append(limit)
with get_db() as conn:
cursor = conn.execute(query, params)
events = []
for row in cursor:
events.append({
'id': row['id'],
'rule_id': row['rule_id'],
'mode': row['mode'],
'event_type': row['event_type'],
'severity': row['severity'],
'title': row['title'],
'message': row['message'],
'payload': json.loads(row['payload']) if row['payload'] else {},
'created_at': row['created_at'],
})
return events
# ------------------------------------------------------------------
# Event processing
# ------------------------------------------------------------------
def process_event(self, mode: str, event: dict, event_type: str | None = None) -> None:
if not isinstance(event, dict):
return
if event_type in ('keepalive', 'ping', 'status'):
return
rules = self._get_rules()
if not rules:
return
for rule in rules:
if rule.mode and rule.mode != mode:
continue
if rule.event_type and event_type and rule.event_type != event_type:
continue
if rule.event_type and not event_type:
continue
if not self._match_rule(rule.match, event):
continue
title = rule.name or 'Alert'
message = self._build_message(rule, event, event_type)
payload = {
'mode': mode,
'event_type': event_type,
'event': event,
'rule': {
'id': rule.id,
'name': rule.name,
},
}
event_id = self._store_event(rule.id, mode, event_type, rule.severity, title, message, payload)
alert_payload = {
'id': event_id,
'rule_id': rule.id,
'mode': mode,
'event_type': event_type,
'severity': rule.severity,
'title': title,
'message': message,
'payload': payload,
'created_at': datetime.now(timezone.utc).isoformat(),
}
self._queue_event(alert_payload)
self._maybe_send_webhook(alert_payload, rule.notify)
def _build_message(self, rule: AlertRule, event: dict, event_type: str | None) -> str:
if isinstance(rule.notify, dict) and rule.notify.get('message'):
return str(rule.notify.get('message'))
summary_bits = []
if event_type:
summary_bits.append(event_type)
if 'name' in event:
summary_bits.append(str(event.get('name')))
if 'ssid' in event:
summary_bits.append(str(event.get('ssid')))
if 'bssid' in event:
summary_bits.append(str(event.get('bssid')))
if 'address' in event:
summary_bits.append(str(event.get('address')))
if 'mac' in event:
summary_bits.append(str(event.get('mac')))
summary = ' | '.join(summary_bits) if summary_bits else 'Alert triggered'
return summary
def _store_event(
self,
rule_id: int,
mode: str,
event_type: str | None,
severity: str,
title: str,
message: str,
payload: dict,
) -> int:
with get_db() as conn:
cursor = conn.execute('''
INSERT INTO alert_events (rule_id, mode, event_type, severity, title, message, payload)
VALUES (?, ?, ?, ?, ?, ?, ?)
''', (
rule_id,
mode,
event_type,
severity,
title,
message,
json.dumps(payload),
))
return int(cursor.lastrowid)
def _queue_event(self, alert_payload: dict) -> None:
try:
self._queue.put_nowait(alert_payload)
except queue.Full:
try:
self._queue.get_nowait()
self._queue.put_nowait(alert_payload)
except queue.Empty:
pass
def _maybe_send_webhook(self, payload: dict, notify: dict) -> None:
if not ALERT_WEBHOOK_URL:
return
if isinstance(notify, dict) and notify.get('webhook') is False:
return
try:
import urllib.request
req = urllib.request.Request(
ALERT_WEBHOOK_URL,
data=json.dumps(payload).encode('utf-8'),
headers={
'Content-Type': 'application/json',
'User-Agent': 'Intercept-Alert',
'X-Alert-Token': ALERT_WEBHOOK_SECRET or '',
},
method='POST'
)
with urllib.request.urlopen(req, timeout=ALERT_WEBHOOK_TIMEOUT) as _:
pass
except Exception as e:
logger.debug(f"Alert webhook failed: {e}")
# ------------------------------------------------------------------
# Matching
# ------------------------------------------------------------------
def _match_rule(self, rule_match: dict, event: dict) -> bool:
if not rule_match:
return True
for key, expected in rule_match.items():
actual = self._extract_value(event, key)
if not self._match_value(actual, expected):
return False
return True
def _extract_value(self, event: dict, key: str) -> Any:
if '.' not in key:
return event.get(key)
current: Any = event
for part in key.split('.'):
if isinstance(current, dict):
current = current.get(part)
else:
return None
return current
def _match_value(self, actual: Any, expected: Any) -> bool:
if isinstance(expected, dict) and 'op' in expected:
op = expected.get('op')
value = expected.get('value')
return self._apply_op(op, actual, value)
if isinstance(expected, list):
return actual in expected
if isinstance(expected, str):
if actual is None:
return False
return str(actual).lower() == expected.lower()
return actual == expected
def _apply_op(self, op: str, actual: Any, value: Any) -> bool:
if op == 'exists':
return actual is not None
if op == 'eq':
return actual == value
if op == 'neq':
return actual != value
if op == 'gt':
return _safe_number(actual) is not None and _safe_number(actual) > _safe_number(value)
if op == 'gte':
return _safe_number(actual) is not None and _safe_number(actual) >= _safe_number(value)
if op == 'lt':
return _safe_number(actual) is not None and _safe_number(actual) < _safe_number(value)
if op == 'lte':
return _safe_number(actual) is not None and _safe_number(actual) <= _safe_number(value)
if op == 'in':
return actual in (value or [])
if op == 'contains':
if actual is None:
return False
if isinstance(actual, list):
return any(str(value).lower() in str(item).lower() for item in actual)
return str(value).lower() in str(actual).lower()
if op == 'regex':
if actual is None or value is None:
return False
try:
return re.search(str(value), str(actual)) is not None
except re.error:
return False
return False
# ------------------------------------------------------------------
# Streaming
# ------------------------------------------------------------------
def stream_events(self, timeout: float = 1.0) -> Generator[dict, None, None]:
while True:
try:
event = self._queue.get(timeout=timeout)
yield event
except queue.Empty:
yield {'type': 'keepalive'}
_alert_manager: AlertManager | None = None
_alert_lock = threading.Lock()
def get_alert_manager() -> AlertManager:
global _alert_manager
with _alert_lock:
if _alert_manager is None:
_alert_manager = AlertManager()
return _alert_manager
def _safe_number(value: Any) -> float | None:
try:
return float(value)
except (TypeError, ValueError):
return None
+3
View File
@@ -151,6 +151,7 @@ class BTDeviceAggregate:
# Baseline tracking # Baseline tracking
in_baseline: bool = False in_baseline: bool = False
baseline_id: Optional[int] = None baseline_id: Optional[int] = None
seen_before: bool = False
# Tracker detection fields # Tracker detection fields
is_tracker: bool = False is_tracker: bool = False
@@ -277,6 +278,7 @@ class BTDeviceAggregate:
# Baseline # Baseline
'in_baseline': self.in_baseline, 'in_baseline': self.in_baseline,
'baseline_id': self.baseline_id, 'baseline_id': self.baseline_id,
'seen_before': self.seen_before,
# Tracker detection # Tracker detection
'tracker': { 'tracker': {
@@ -327,6 +329,7 @@ class BTDeviceAggregate:
'seen_count': self.seen_count, 'seen_count': self.seen_count,
'heuristic_flags': self.heuristic_flags, 'heuristic_flags': self.heuristic_flags,
'in_baseline': self.in_baseline, 'in_baseline': self.in_baseline,
'seen_before': self.seen_before,
# Tracker info for list view # Tracker info for list view
'is_tracker': self.is_tracker, 'is_tracker': self.is_tracker,
'tracker_type': self.tracker_type, 'tracker_type': self.tracker_type,
+30 -2
View File
@@ -142,7 +142,7 @@ class DataStore:
class CleanupManager: 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): def __init__(self, interval: float = 60.0):
""" """
@@ -152,9 +152,11 @@ class CleanupManager:
interval: Cleanup interval in seconds interval: Cleanup interval in seconds
""" """
self.stores: list[DataStore] = [] self.stores: list[DataStore] = []
self.db_cleanup_funcs: list[tuple[callable, int]] = [] # (func, interval_multiplier)
self.interval = interval self.interval = interval
self._timer: threading.Timer | None = None self._timer: threading.Timer | None = None
self._running = False self._running = False
self._cleanup_count = 0
self._lock = threading.Lock() self._lock = threading.Lock()
def register(self, store: DataStore) -> None: def register(self, store: DataStore) -> None:
@@ -169,6 +171,17 @@ class CleanupManager:
if store in self.stores: if store in self.stores:
self.stores.remove(store) 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: def start(self) -> None:
"""Start the cleanup timer.""" """Start the cleanup timer."""
with self._lock: with self._lock:
@@ -194,11 +207,15 @@ class CleanupManager:
self._timer.start() self._timer.start()
def _run_cleanup(self) -> None: def _run_cleanup(self) -> None:
"""Run cleanup on all registered stores.""" """Run cleanup on all registered stores and database tables."""
total_cleaned = 0 total_cleaned = 0
# Cleanup in-memory data stores
with self._lock: with self._lock:
stores = list(self.stores) stores = list(self.stores)
db_funcs = list(self.db_cleanup_funcs)
self._cleanup_count += 1
current_count = self._cleanup_count
for store in stores: for store in stores:
try: try:
@@ -206,6 +223,17 @@ class CleanupManager:
except Exception as e: except Exception as e:
logger.error(f"Error cleaning up {store.name}: {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: if total_cleaned > 0:
logger.info(f"Cleanup complete: removed {total_cleaned} stale entries") 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 detector sniff timeout (seconds)
DEAUTH_SNIFF_TIMEOUT = 0.5 DEAUTH_SNIFF_TIMEOUT = 0.5
+111 -2
View File
@@ -102,6 +102,98 @@ def init_db() -> None:
) )
''') ''')
# 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 # Users table for authentication
conn.execute(''' conn.execute('''
CREATE TABLE IF NOT EXISTS users ( CREATE TABLE IF NOT EXISTS users (
@@ -139,6 +231,7 @@ def init_db() -> None:
description TEXT, description TEXT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
wifi_networks TEXT, wifi_networks TEXT,
wifi_clients TEXT,
bt_devices TEXT, bt_devices TEXT,
rf_frequencies TEXT, rf_frequencies TEXT,
gps_coords TEXT, gps_coords TEXT,
@@ -146,6 +239,14 @@ def init_db() -> None:
) )
''') ''')
# 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 # TSCM Sweeps - Individual sweep sessions
conn.execute(''' conn.execute('''
CREATE TABLE IF NOT EXISTS tscm_sweeps ( CREATE TABLE IF NOT EXISTS tscm_sweeps (
@@ -690,6 +791,7 @@ def create_tscm_baseline(
location: str | None = None, location: str | None = None,
description: str | None = None, description: str | None = None,
wifi_networks: list | None = None, wifi_networks: list | None = None,
wifi_clients: list | None = None,
bt_devices: list | None = None, bt_devices: list | None = None,
rf_frequencies: list | None = None, rf_frequencies: list | None = None,
gps_coords: dict | None = None gps_coords: dict | None = None
@@ -703,13 +805,14 @@ def create_tscm_baseline(
with get_db() as conn: with get_db() as conn:
cursor = conn.execute(''' cursor = conn.execute('''
INSERT INTO tscm_baselines INSERT INTO tscm_baselines
(name, location, description, wifi_networks, bt_devices, rf_frequencies, gps_coords) (name, location, description, wifi_networks, wifi_clients, bt_devices, rf_frequencies, gps_coords)
VALUES (?, ?, ?, ?, ?, ?, ?) VALUES (?, ?, ?, ?, ?, ?, ?, ?)
''', ( ''', (
name, name,
location, location,
description, description,
json.dumps(wifi_networks) if wifi_networks else None, 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(bt_devices) if bt_devices else None,
json.dumps(rf_frequencies) if rf_frequencies else None, json.dumps(rf_frequencies) if rf_frequencies else None,
json.dumps(gps_coords) if gps_coords else None json.dumps(gps_coords) if gps_coords else None
@@ -735,6 +838,7 @@ def get_tscm_baseline(baseline_id: int) -> dict | None:
'description': row['description'], 'description': row['description'],
'created_at': row['created_at'], 'created_at': row['created_at'],
'wifi_networks': json.loads(row['wifi_networks']) if row['wifi_networks'] else [], '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 [], 'bt_devices': json.loads(row['bt_devices']) if row['bt_devices'] else [],
'rf_frequencies': json.loads(row['rf_frequencies']) if row['rf_frequencies'] 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, 'gps_coords': json.loads(row['gps_coords']) if row['gps_coords'] else None,
@@ -784,6 +888,7 @@ def set_active_tscm_baseline(baseline_id: int) -> bool:
def update_tscm_baseline( def update_tscm_baseline(
baseline_id: int, baseline_id: int,
wifi_networks: list | None = None, wifi_networks: list | None = None,
wifi_clients: list | None = None,
bt_devices: list | None = None, bt_devices: list | None = None,
rf_frequencies: list | None = None rf_frequencies: list | None = None
) -> bool: ) -> bool:
@@ -794,6 +899,9 @@ def update_tscm_baseline(
if wifi_networks is not None: if wifi_networks is not None:
updates.append('wifi_networks = ?') updates.append('wifi_networks = ?')
params.append(json.dumps(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: if bt_devices is not None:
updates.append('bt_devices = ?') updates.append('bt_devices = ?')
params.append(json.dumps(bt_devices)) params.append(json.dumps(bt_devices))
@@ -2061,3 +2169,4 @@ def cleanup_old_payloads(max_age_hours: int = 24) -> int:
WHERE received_at < datetime('now', ?) WHERE received_at < datetime('now', ?)
''', (f'-{max_age_hours} hours',)) ''', (f'-{max_age_hours} hours',))
return cursor.rowcount return cursor.rowcount
+1 -1
View File
@@ -443,7 +443,7 @@ TOOL_DEPENDENCIES = {
} }
} }
} }
} },
} }
+29
View File
@@ -0,0 +1,29 @@
"""Shared event pipeline for alerts and recordings."""
from __future__ import annotations
from typing import Any
from utils.alerts import get_alert_manager
from utils.recording import get_recording_manager
IGNORE_TYPES = {'keepalive', 'ping'}
def process_event(mode: str, event: dict | Any, event_type: str | None = None) -> None:
if event_type in IGNORE_TYPES:
return
if not isinstance(event, dict):
return
try:
get_recording_manager().record_event(mode, event, event_type)
except Exception:
# Recording failures should never break streaming
pass
try:
get_alert_manager().process_event(mode, event, event_type)
except Exception:
# Alert failures should never break streaming
pass
+6 -4
View File
@@ -85,11 +85,13 @@ atexit.register(cleanup_all_processes)
# Handle signals for graceful shutdown # Handle signals for graceful shutdown
def _signal_handler(signum, frame): 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 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: if signum == signal.SIGINT:
raise KeyboardInterrupt() raise KeyboardInterrupt()
sys.exit(0) sys.exit(0)
+222
View File
@@ -0,0 +1,222 @@
"""Session recording utilities for SSE/event streams."""
from __future__ import annotations
import json
import logging
import threading
import uuid
from dataclasses import dataclass
from datetime import datetime, timezone
from pathlib import Path
from typing import Any
from utils.database import get_db
logger = logging.getLogger('intercept.recording')
RECORDING_ROOT = Path(__file__).parent.parent / 'instance' / 'recordings'
@dataclass
class RecordingSession:
id: str
mode: str
label: str | None
file_path: Path
started_at: datetime
stopped_at: datetime | None = None
event_count: int = 0
size_bytes: int = 0
metadata: dict | None = None
_file_handle: Any | None = None
_lock: threading.Lock = threading.Lock()
def open(self) -> None:
self.file_path.parent.mkdir(parents=True, exist_ok=True)
self._file_handle = self.file_path.open('a', encoding='utf-8')
def close(self) -> None:
if self._file_handle:
self._file_handle.flush()
self._file_handle.close()
self._file_handle = None
def write_event(self, record: dict) -> None:
if not self._file_handle:
self.open()
line = json.dumps(record, ensure_ascii=True) + '\n'
with self._lock:
self._file_handle.write(line)
self._file_handle.flush()
self.event_count += 1
self.size_bytes += len(line.encode('utf-8'))
class RecordingManager:
def __init__(self) -> None:
self._active_by_mode: dict[str, RecordingSession] = {}
self._active_by_id: dict[str, RecordingSession] = {}
self._lock = threading.Lock()
def start_recording(self, mode: str, label: str | None = None, metadata: dict | None = None) -> RecordingSession:
with self._lock:
existing = self._active_by_mode.get(mode)
if existing:
return existing
session_id = str(uuid.uuid4())
started_at = datetime.now(timezone.utc)
filename = f"{mode}_{started_at.strftime('%Y%m%d_%H%M%S')}_{session_id}.jsonl"
file_path = RECORDING_ROOT / mode / filename
session = RecordingSession(
id=session_id,
mode=mode,
label=label,
file_path=file_path,
started_at=started_at,
metadata=metadata or {},
)
session.open()
self._active_by_mode[mode] = session
self._active_by_id[session_id] = session
with get_db() as conn:
conn.execute('''
INSERT INTO recording_sessions
(id, mode, label, started_at, file_path, event_count, size_bytes, metadata)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
''', (
session.id,
session.mode,
session.label,
session.started_at.isoformat(),
str(session.file_path),
session.event_count,
session.size_bytes,
json.dumps(session.metadata or {}),
))
return session
def stop_recording(self, mode: str | None = None, session_id: str | None = None) -> RecordingSession | None:
with self._lock:
session = None
if session_id:
session = self._active_by_id.get(session_id)
elif mode:
session = self._active_by_mode.get(mode)
if not session:
return None
session.stopped_at = datetime.now(timezone.utc)
session.close()
self._active_by_mode.pop(session.mode, None)
self._active_by_id.pop(session.id, None)
with get_db() as conn:
conn.execute('''
UPDATE recording_sessions
SET stopped_at = ?, event_count = ?, size_bytes = ?
WHERE id = ?
''', (
session.stopped_at.isoformat(),
session.event_count,
session.size_bytes,
session.id,
))
return session
def record_event(self, mode: str, event: dict, event_type: str | None = None) -> None:
if event_type in ('keepalive', 'ping'):
return
session = self._active_by_mode.get(mode)
if not session:
return
record = {
'timestamp': datetime.now(timezone.utc).isoformat(),
'mode': mode,
'event_type': event_type,
'event': event,
}
try:
session.write_event(record)
except Exception as e:
logger.debug(f"Recording write failed: {e}")
def list_recordings(self, limit: int = 50) -> list[dict]:
with get_db() as conn:
cursor = conn.execute('''
SELECT id, mode, label, started_at, stopped_at, file_path, event_count, size_bytes, metadata
FROM recording_sessions
ORDER BY started_at DESC
LIMIT ?
''', (limit,))
rows = []
for row in cursor:
rows.append({
'id': row['id'],
'mode': row['mode'],
'label': row['label'],
'started_at': row['started_at'],
'stopped_at': row['stopped_at'],
'file_path': row['file_path'],
'event_count': row['event_count'],
'size_bytes': row['size_bytes'],
'metadata': json.loads(row['metadata']) if row['metadata'] else {},
})
return rows
def get_recording(self, session_id: str) -> dict | None:
with get_db() as conn:
cursor = conn.execute('''
SELECT id, mode, label, started_at, stopped_at, file_path, event_count, size_bytes, metadata
FROM recording_sessions
WHERE id = ?
''', (session_id,))
row = cursor.fetchone()
if not row:
return None
return {
'id': row['id'],
'mode': row['mode'],
'label': row['label'],
'started_at': row['started_at'],
'stopped_at': row['stopped_at'],
'file_path': row['file_path'],
'event_count': row['event_count'],
'size_bytes': row['size_bytes'],
'metadata': json.loads(row['metadata']) if row['metadata'] else {},
}
def get_active(self) -> list[dict]:
with self._lock:
sessions = []
for session in self._active_by_mode.values():
sessions.append({
'id': session.id,
'mode': session.mode,
'label': session.label,
'started_at': session.started_at.isoformat(),
'event_count': session.event_count,
'size_bytes': session.size_bytes,
})
return sessions
_recording_manager: RecordingManager | None = None
_recording_lock = threading.Lock()
def get_recording_manager() -> RecordingManager:
global _recording_manager
with _recording_lock:
if _recording_manager is None:
_recording_manager = RecordingManager()
return _recording_manager
+3 -1
View File
@@ -26,7 +26,7 @@ from __future__ import annotations
from typing import Optional from typing import Optional
from .base import CommandBuilder, SDRCapabilities, SDRDevice, SDRType 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 .rtlsdr import RTLSDRCommandBuilder
from .limesdr import LimeSDRCommandBuilder from .limesdr import LimeSDRCommandBuilder
from .hackrf import HackRFCommandBuilder from .hackrf import HackRFCommandBuilder
@@ -229,4 +229,6 @@ __all__ = [
'validate_device_index', 'validate_device_index',
'validate_squelch', 'validate_squelch',
'get_capabilities_for_type', 'get_capabilities_for_type',
# Device probing
'probe_rtlsdr_device',
] ]
+37
View File
@@ -185,6 +185,43 @@ class AirspyCommandBuilder(CommandBuilder):
return cmd return cmd
def build_iq_capture_command(
self,
device: SDRDevice,
frequency_mhz: float,
sample_rate: int = 2048000,
gain: Optional[float] = None,
ppm: Optional[int] = None,
bias_t: bool = False,
output_format: str = 'cu8',
) -> list[str]:
"""
Build rx_sdr command for raw I/Q capture with Airspy.
Outputs unsigned 8-bit I/Q pairs to stdout for waterfall display.
"""
device_str = self._build_device_string(device)
freq_hz = int(frequency_mhz * 1e6)
cmd = [
'rx_sdr',
'-d', device_str,
'-f', str(freq_hz),
'-s', str(sample_rate),
'-F', 'CU8',
]
if gain is not None and gain > 0:
cmd.extend(['-g', self._format_gain(gain)])
if bias_t:
cmd.append('-T')
# Output to stdout
cmd.append('-')
return cmd
def get_capabilities(self) -> SDRCapabilities: def get_capabilities(self) -> SDRCapabilities:
"""Return Airspy capabilities.""" """Return Airspy capabilities."""
return self.CAPABILITIES return self.CAPABILITIES
+35
View File
@@ -186,6 +186,41 @@ class CommandBuilder(ABC):
"""Return hardware capabilities for this SDR type.""" """Return hardware capabilities for this SDR type."""
pass pass
def build_iq_capture_command(
self,
device: SDRDevice,
frequency_mhz: float,
sample_rate: int = 2048000,
gain: Optional[float] = None,
ppm: Optional[int] = None,
bias_t: bool = False,
output_format: str = 'cu8',
) -> list[str]:
"""
Build raw I/Q capture command for streaming samples to stdout.
Used for real-time waterfall/spectrum display. Output is unsigned
8-bit I/Q pairs (cu8) written continuously to stdout.
Args:
device: The SDR device to use
frequency_mhz: Center frequency in MHz
sample_rate: Sample rate in Hz (default 2048000)
gain: Gain in dB (None for auto)
ppm: PPM frequency correction
bias_t: Enable bias-T power (for active antennas)
output_format: Output sample format (default 'cu8')
Returns:
Command as list of strings for subprocess
Raises:
NotImplementedError: If the SDR type does not support I/Q capture.
"""
raise NotImplementedError(
f"{self.__class__.__name__} does not support raw I/Q capture"
)
@classmethod @classmethod
@abstractmethod @abstractmethod
def get_sdr_type(cls) -> SDRType: def get_sdr_type(cls) -> SDRType:
+62
View File
@@ -348,6 +348,68 @@ def detect_hackrf_devices() -> list[SDRDevice]:
return devices 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]: def detect_all_devices() -> list[SDRDevice]:
""" """
Detect all connected SDR devices across all supported hardware types. Detect all connected SDR devices across all supported hardware types.
+38
View File
@@ -185,6 +185,44 @@ class HackRFCommandBuilder(CommandBuilder):
return cmd return cmd
def build_iq_capture_command(
self,
device: SDRDevice,
frequency_mhz: float,
sample_rate: int = 2048000,
gain: Optional[float] = None,
ppm: Optional[int] = None,
bias_t: bool = False,
output_format: str = 'cu8',
) -> list[str]:
"""
Build rx_sdr command for raw I/Q capture with HackRF.
Outputs unsigned 8-bit I/Q pairs to stdout for waterfall display.
"""
device_str = self._build_device_string(device)
freq_hz = int(frequency_mhz * 1e6)
cmd = [
'rx_sdr',
'-d', device_str,
'-f', str(freq_hz),
'-s', str(sample_rate),
'-F', 'CU8',
]
if gain is not None and gain > 0:
lna, vga = self._split_gain(gain)
cmd.extend(['-g', f'LNA={lna},VGA={vga}'])
if bias_t:
cmd.append('-T')
# Output to stdout
cmd.append('-')
return cmd
def get_capabilities(self) -> SDRCapabilities: def get_capabilities(self) -> SDRCapabilities:
"""Return HackRF capabilities.""" """Return HackRF capabilities."""
return self.CAPABILITIES return self.CAPABILITIES
+35
View File
@@ -162,6 +162,41 @@ class LimeSDRCommandBuilder(CommandBuilder):
return cmd return cmd
def build_iq_capture_command(
self,
device: SDRDevice,
frequency_mhz: float,
sample_rate: int = 2048000,
gain: Optional[float] = None,
ppm: Optional[int] = None,
bias_t: bool = False,
output_format: str = 'cu8',
) -> list[str]:
"""
Build rx_sdr command for raw I/Q capture with LimeSDR.
Outputs unsigned 8-bit I/Q pairs to stdout for waterfall display.
Note: LimeSDR does not support bias-T, parameter is ignored.
"""
device_str = self._build_device_string(device)
freq_hz = int(frequency_mhz * 1e6)
cmd = [
'rx_sdr',
'-d', device_str,
'-f', str(freq_hz),
'-s', str(sample_rate),
'-F', 'CU8',
]
if gain is not None and gain > 0:
cmd.extend(['-g', f'LNAH={int(gain)}'])
# Output to stdout
cmd.append('-')
return cmd
def get_capabilities(self) -> SDRCapabilities: def get_capabilities(self) -> SDRCapabilities:
"""Return LimeSDR capabilities.""" """Return LimeSDR capabilities."""
return self.CAPABILITIES return self.CAPABILITIES
+39
View File
@@ -231,6 +231,45 @@ class RTLSDRCommandBuilder(CommandBuilder):
return cmd return cmd
def build_iq_capture_command(
self,
device: SDRDevice,
frequency_mhz: float,
sample_rate: int = 2048000,
gain: Optional[float] = None,
ppm: Optional[int] = None,
bias_t: bool = False,
output_format: str = 'cu8',
) -> list[str]:
"""
Build rtl_sdr command for raw I/Q capture.
Outputs unsigned 8-bit I/Q pairs to stdout for waterfall display.
"""
rtl_sdr_path = get_tool_path('rtl_sdr') or 'rtl_sdr'
freq_hz = int(frequency_mhz * 1e6)
cmd = [
rtl_sdr_path,
'-d', self._get_device_arg(device),
'-f', str(freq_hz),
'-s', str(sample_rate),
]
if gain is not None and gain > 0:
cmd.extend(['-g', str(gain)])
if ppm is not None and ppm != 0:
cmd.extend(['-p', str(ppm)])
if bias_t:
cmd.append('-T')
# Output to stdout
cmd.append('-')
return cmd
def get_capabilities(self) -> SDRCapabilities: def get_capabilities(self) -> SDRCapabilities:
"""Return RTL-SDR capabilities.""" """Return RTL-SDR capabilities."""
return self.CAPABILITIES return self.CAPABILITIES
+37
View File
@@ -163,6 +163,43 @@ class SDRPlayCommandBuilder(CommandBuilder):
return cmd return cmd
def build_iq_capture_command(
self,
device: SDRDevice,
frequency_mhz: float,
sample_rate: int = 2048000,
gain: Optional[float] = None,
ppm: Optional[int] = None,
bias_t: bool = False,
output_format: str = 'cu8',
) -> list[str]:
"""
Build rx_sdr command for raw I/Q capture with SDRPlay.
Outputs unsigned 8-bit I/Q pairs to stdout for waterfall display.
"""
device_str = self._build_device_string(device)
freq_hz = int(frequency_mhz * 1e6)
cmd = [
'rx_sdr',
'-d', device_str,
'-f', str(freq_hz),
'-s', str(sample_rate),
'-F', 'CU8',
]
if gain is not None and gain > 0:
cmd.extend(['-g', f'IFGR={int(gain)}'])
if bias_t:
cmd.append('-T')
# Output to stdout
cmd.append('-')
return cmd
def get_capabilities(self) -> SDRCapabilities: def get_capabilities(self) -> SDRCapabilities:
"""Return SDRPlay capabilities.""" """Return SDRPlay capabilities."""
return self.CAPABILITIES return self.CAPABILITIES
+21 -3
View File
@@ -225,7 +225,7 @@ class SSTVDecoder:
self._rtl_process = None self._rtl_process = None
self._running = False self._running = False
self._lock = threading.Lock() self._lock = threading.Lock()
self._callback: Callable[[DecodeProgress], None] | None = None self._callback: Callable[[dict], None] | None = None
self._output_dir = Path(output_dir) if output_dir else Path('instance/sstv_images') self._output_dir = Path(output_dir) if output_dir else Path('instance/sstv_images')
self._url_prefix = url_prefix self._url_prefix = url_prefix
self._images: list[SSTVImage] = [] self._images: list[SSTVImage] = []
@@ -253,7 +253,7 @@ class SSTVDecoder:
"""Return name of available decoder. Always available with pure Python.""" """Return name of available decoder. Always available with pure Python."""
return 'python-sstv' return 'python-sstv'
def set_callback(self, callback: Callable[[DecodeProgress], None]) -> None: def set_callback(self, callback: Callable[[dict], None]) -> None:
"""Set callback for decode progress updates.""" """Set callback for decode progress updates."""
self._callback = callback self._callback = callback
@@ -420,6 +420,10 @@ class SSTVDecoder:
chunk_counter += 1 chunk_counter += 1
# Scope: compute RMS/peak from raw int16 samples every chunk
rms_val = int(np.sqrt(np.mean(raw_samples.astype(np.float64) ** 2)))
peak_val = int(np.max(np.abs(raw_samples)))
if image_decoder is not None: if image_decoder is not None:
# Currently decoding an image # Currently decoding an image
complete = image_decoder.feed(samples) complete = image_decoder.feed(samples)
@@ -447,6 +451,7 @@ class SSTVDecoder:
message=f'Decoding {current_mode_name}: {pct}%', message=f'Decoding {current_mode_name}: {pct}%',
partial_image=partial_url, partial_image=partial_url,
)) ))
self._emit_scope(rms_val, peak_val, 'decoding')
if complete: if complete:
# Save image # Save image
@@ -479,6 +484,7 @@ class SSTVDecoder:
vis_detector.reset() vis_detector.reset()
# Emit signal level metrics every ~500ms (every 5th 100ms chunk) # Emit signal level metrics every ~500ms (every 5th 100ms chunk)
scope_tone: str | None = None
if chunk_counter % 5 == 0 and image_decoder is None: if chunk_counter % 5 == 0 and image_decoder is None:
rms = float(np.sqrt(np.mean(samples ** 2))) rms = float(np.sqrt(np.mean(samples ** 2)))
signal_level = min(100, int(rms * 500)) signal_level = min(100, int(rms * 500))
@@ -501,6 +507,8 @@ class SSTVDecoder:
else: else:
sstv_tone = None sstv_tone = None
scope_tone = sstv_tone
self._emit_progress(DecodeProgress( self._emit_progress(DecodeProgress(
status='detecting', status='detecting',
message='Listening...', message='Listening...',
@@ -509,6 +517,8 @@ class SSTVDecoder:
vis_state=vis_detector.state.value, vis_state=vis_detector.state.value,
)) ))
self._emit_scope(rms_val, peak_val, scope_tone)
except Exception as e: except Exception as e:
logger.error(f"Error in decode thread: {e}") logger.error(f"Error in decode thread: {e}")
if not self._running: if not self._running:
@@ -736,10 +746,18 @@ class SSTVDecoder:
"""Emit progress update to callback.""" """Emit progress update to callback."""
if self._callback: if self._callback:
try: try:
self._callback(progress) self._callback(progress.to_dict())
except Exception as e: except Exception as e:
logger.error(f"Error in progress callback: {e}") logger.error(f"Error in progress callback: {e}")
def _emit_scope(self, rms: int, peak: int, tone: str | None = None) -> None:
"""Emit scope signal levels to callback."""
if self._callback:
try:
self._callback({'type': 'sstv_scope', 'rms': rms, 'peak': peak, 'tone': tone})
except Exception:
pass
def decode_file(self, audio_path: str | Path) -> list[SSTVImage]: def decode_file(self, audio_path: str | Path) -> list[SSTVImage]:
"""Decode SSTV image(s) from an audio file. """Decode SSTV image(s) from an audio file.
+52
View File
@@ -526,6 +526,7 @@ class BaselineDiff:
def calculate_baseline_diff( def calculate_baseline_diff(
baseline: dict, baseline: dict,
current_wifi: list[dict], current_wifi: list[dict],
current_wifi_clients: list[dict],
current_bt: list[dict], current_bt: list[dict],
current_rf: list[dict], current_rf: list[dict],
sweep_id: int sweep_id: int
@@ -536,6 +537,7 @@ def calculate_baseline_diff(
Args: Args:
baseline: Baseline dict from database baseline: Baseline dict from database
current_wifi: Current WiFi devices current_wifi: Current WiFi devices
current_wifi_clients: Current WiFi clients
current_bt: Current Bluetooth devices current_bt: Current Bluetooth devices
current_rf: Current RF signals current_rf: Current RF signals
sweep_id: Current sweep ID sweep_id: Current sweep ID
@@ -569,6 +571,11 @@ def calculate_baseline_diff(
for d in baseline.get('wifi_networks', []) for d in baseline.get('wifi_networks', [])
if d.get('bssid') or d.get('mac') if d.get('bssid') or d.get('mac')
} }
baseline_wifi_clients = {
d.get('mac', d.get('address', '')).upper(): d
for d in baseline.get('wifi_clients', [])
if d.get('mac') or d.get('address')
}
baseline_bt = { baseline_bt = {
d.get('mac', d.get('address', '')).upper(): d d.get('mac', d.get('address', '')).upper(): d
for d in baseline.get('bt_devices', []) for d in baseline.get('bt_devices', [])
@@ -583,6 +590,9 @@ def calculate_baseline_diff(
# Compare WiFi # Compare WiFi
_compare_wifi(diff, baseline_wifi, current_wifi) _compare_wifi(diff, baseline_wifi, current_wifi)
# Compare WiFi clients
_compare_wifi_clients(diff, baseline_wifi_clients, current_wifi_clients)
# Compare Bluetooth # Compare Bluetooth
_compare_bluetooth(diff, baseline_bt, current_bt) _compare_bluetooth(diff, baseline_bt, current_bt)
@@ -631,6 +641,47 @@ def _compare_wifi(diff: BaselineDiff, baseline: dict, current: list[dict]) -> No
'rssi': device.get('power', device.get('signal')), 'rssi': device.get('power', device.get('signal')),
} }
)) ))
def _compare_wifi_clients(diff: BaselineDiff, baseline: dict, current: list[dict]) -> None:
"""Compare WiFi clients between baseline and current."""
current_macs = {
d.get('mac', d.get('address', '')).upper(): d
for d in current
if d.get('mac') or d.get('address')
}
# Find new clients
for mac, device in current_macs.items():
if mac not in baseline:
name = device.get('vendor', 'WiFi Client')
diff.new_devices.append(DeviceChange(
identifier=mac,
protocol='wifi_client',
change_type='new',
description=f'New WiFi client: {name}',
expected=False,
details={
'vendor': name,
'rssi': device.get('rssi'),
'associated_bssid': device.get('associated_bssid'),
}
))
# Find missing clients
for mac, device in baseline.items():
if mac not in current_macs:
name = device.get('vendor', 'WiFi Client')
diff.missing_devices.append(DeviceChange(
identifier=mac,
protocol='wifi_client',
change_type='missing',
description=f'Missing WiFi client: {name}',
expected=True,
details={
'vendor': name,
}
))
else: else:
# Check for changes # Check for changes
baseline_dev = baseline[mac] baseline_dev = baseline[mac]
@@ -798,6 +849,7 @@ def _calculate_baseline_health(diff: BaselineDiff, baseline: dict) -> None:
# Device churn penalty # Device churn penalty
total_baseline = ( total_baseline = (
len(baseline.get('wifi_networks', [])) + len(baseline.get('wifi_networks', [])) +
len(baseline.get('wifi_clients', [])) +
len(baseline.get('bt_devices', [])) + len(baseline.get('bt_devices', [])) +
len(baseline.get('rf_frequencies', [])) len(baseline.get('rf_frequencies', []))
) )
+78 -1
View File
@@ -30,6 +30,7 @@ class BaselineRecorder:
self.recording = False self.recording = False
self.current_baseline_id: int | None = None self.current_baseline_id: int | None = None
self.wifi_networks: dict[str, dict] = {} # BSSID -> network info self.wifi_networks: dict[str, dict] = {} # BSSID -> network info
self.wifi_clients: dict[str, dict] = {} # MAC -> client info
self.bt_devices: dict[str, dict] = {} # MAC -> device info self.bt_devices: dict[str, dict] = {} # MAC -> device info
self.rf_frequencies: dict[float, dict] = {} # Frequency -> signal info self.rf_frequencies: dict[float, dict] = {} # Frequency -> signal info
@@ -52,6 +53,7 @@ class BaselineRecorder:
""" """
self.recording = True self.recording = True
self.wifi_networks = {} self.wifi_networks = {}
self.wifi_clients = {}
self.bt_devices = {} self.bt_devices = {}
self.rf_frequencies = {} self.rf_frequencies = {}
@@ -79,6 +81,7 @@ class BaselineRecorder:
# Convert to lists for storage # Convert to lists for storage
wifi_list = list(self.wifi_networks.values()) wifi_list = list(self.wifi_networks.values())
wifi_client_list = list(self.wifi_clients.values())
bt_list = list(self.bt_devices.values()) bt_list = list(self.bt_devices.values())
rf_list = list(self.rf_frequencies.values()) rf_list = list(self.rf_frequencies.values())
@@ -86,6 +89,7 @@ class BaselineRecorder:
update_tscm_baseline( update_tscm_baseline(
self.current_baseline_id, self.current_baseline_id,
wifi_networks=wifi_list, wifi_networks=wifi_list,
wifi_clients=wifi_client_list,
bt_devices=bt_list, bt_devices=bt_list,
rf_frequencies=rf_list rf_frequencies=rf_list
) )
@@ -93,6 +97,7 @@ class BaselineRecorder:
summary = { summary = {
'baseline_id': self.current_baseline_id, 'baseline_id': self.current_baseline_id,
'wifi_count': len(wifi_list), 'wifi_count': len(wifi_list),
'wifi_client_count': len(wifi_client_list),
'bt_count': len(bt_list), 'bt_count': len(bt_list),
'rf_count': len(rf_list), 'rf_count': len(rf_list),
} }
@@ -160,6 +165,33 @@ class BaselineRecorder:
'last_seen': datetime.now().isoformat(), 'last_seen': datetime.now().isoformat(),
} }
def add_wifi_client(self, client: dict) -> None:
"""Add a WiFi client to the current baseline."""
if not self.recording:
return
mac = client.get('mac', client.get('address', '')).upper()
if not mac:
return
if mac in self.wifi_clients:
self.wifi_clients[mac].update({
'last_seen': datetime.now().isoformat(),
'rssi': client.get('rssi', self.wifi_clients[mac].get('rssi')),
'associated_bssid': client.get('associated_bssid', self.wifi_clients[mac].get('associated_bssid')),
})
else:
self.wifi_clients[mac] = {
'mac': mac,
'vendor': client.get('vendor', ''),
'rssi': client.get('rssi'),
'associated_bssid': client.get('associated_bssid'),
'probed_ssids': client.get('probed_ssids', []),
'probe_count': client.get('probe_count', len(client.get('probed_ssids', []))),
'first_seen': datetime.now().isoformat(),
'last_seen': datetime.now().isoformat(),
}
def add_rf_signal(self, signal: dict) -> None: def add_rf_signal(self, signal: dict) -> None:
"""Add an RF signal to the current baseline.""" """Add an RF signal to the current baseline."""
if not self.recording: if not self.recording:
@@ -197,6 +229,7 @@ class BaselineRecorder:
'recording': self.recording, 'recording': self.recording,
'baseline_id': self.current_baseline_id, 'baseline_id': self.current_baseline_id,
'wifi_count': len(self.wifi_networks), 'wifi_count': len(self.wifi_networks),
'wifi_client_count': len(self.wifi_clients),
'bt_count': len(self.bt_devices), 'bt_count': len(self.bt_devices),
'rf_count': len(self.rf_frequencies), 'rf_count': len(self.rf_frequencies),
} }
@@ -225,6 +258,11 @@ class BaselineComparator:
for d in baseline.get('bt_devices', []) for d in baseline.get('bt_devices', [])
if d.get('mac') or d.get('address') if d.get('mac') or d.get('address')
} }
self.baseline_wifi_clients = {
d.get('mac', d.get('address', '')).upper(): d
for d in baseline.get('wifi_clients', [])
if d.get('mac') or d.get('address')
}
self.baseline_rf = { self.baseline_rf = {
round(d.get('frequency', 0), 1): d round(d.get('frequency', 0), 1): d
for d in baseline.get('rf_frequencies', []) for d in baseline.get('rf_frequencies', [])
@@ -300,6 +338,37 @@ class BaselineComparator:
'matching_count': len(matching_devices), 'matching_count': len(matching_devices),
} }
def compare_wifi_clients(self, current_devices: list[dict]) -> dict:
"""Compare current WiFi clients against baseline."""
current_macs = {
d.get('mac', d.get('address', '')).upper(): d
for d in current_devices
if d.get('mac') or d.get('address')
}
new_devices = []
missing_devices = []
matching_devices = []
for mac, device in current_macs.items():
if mac not in self.baseline_wifi_clients:
new_devices.append(device)
else:
matching_devices.append(device)
for mac, device in self.baseline_wifi_clients.items():
if mac not in current_macs:
missing_devices.append(device)
return {
'new': new_devices,
'missing': missing_devices,
'matching': matching_devices,
'new_count': len(new_devices),
'missing_count': len(missing_devices),
'matching_count': len(matching_devices),
}
def compare_rf(self, current_signals: list[dict]) -> dict: def compare_rf(self, current_signals: list[dict]) -> dict:
"""Compare current RF signals against baseline.""" """Compare current RF signals against baseline."""
current_freqs = { current_freqs = {
@@ -334,6 +403,7 @@ class BaselineComparator:
def compare_all( def compare_all(
self, self,
wifi_devices: list[dict] | None = None, wifi_devices: list[dict] | None = None,
wifi_clients: list[dict] | None = None,
bt_devices: list[dict] | None = None, bt_devices: list[dict] | None = None,
rf_signals: list[dict] | None = None rf_signals: list[dict] | None = None
) -> dict: ) -> dict:
@@ -345,6 +415,7 @@ class BaselineComparator:
""" """
results = { results = {
'wifi': None, 'wifi': None,
'wifi_clients': None,
'bluetooth': None, 'bluetooth': None,
'rf': None, 'rf': None,
'total_new': 0, 'total_new': 0,
@@ -356,6 +427,11 @@ class BaselineComparator:
results['total_new'] += results['wifi']['new_count'] results['total_new'] += results['wifi']['new_count']
results['total_missing'] += results['wifi']['missing_count'] results['total_missing'] += results['wifi']['missing_count']
if wifi_clients is not None:
results['wifi_clients'] = self.compare_wifi_clients(wifi_clients)
results['total_new'] += results['wifi_clients']['new_count']
results['total_missing'] += results['wifi_clients']['missing_count']
if bt_devices is not None: if bt_devices is not None:
results['bluetooth'] = self.compare_bluetooth(bt_devices) results['bluetooth'] = self.compare_bluetooth(bt_devices)
results['total_new'] += results['bluetooth']['new_count'] results['total_new'] += results['bluetooth']['new_count']
@@ -371,6 +447,7 @@ class BaselineComparator:
def get_comparison_for_active_baseline( def get_comparison_for_active_baseline(
wifi_devices: list[dict] | None = None, wifi_devices: list[dict] | None = None,
wifi_clients: list[dict] | None = None,
bt_devices: list[dict] | None = None, bt_devices: list[dict] | None = None,
rf_signals: list[dict] | None = None rf_signals: list[dict] | None = None
) -> dict | None: ) -> dict | None:
@@ -385,4 +462,4 @@ def get_comparison_for_active_baseline(
return None return None
comparator = BaselineComparator(baseline) comparator = BaselineComparator(baseline)
return comparator.compare_all(wifi_devices, bt_devices, rf_signals) return comparator.compare_all(wifi_devices, wifi_clients, bt_devices, rf_signals)
+209 -71
View File
@@ -122,6 +122,11 @@ class DeviceProfile:
name: Optional[str] = None name: Optional[str] = None
manufacturer: Optional[str] = None manufacturer: Optional[str] = None
device_type: Optional[str] = None device_type: Optional[str] = None
tracker_type: Optional[str] = None
tracker_name: Optional[str] = None
tracker_confidence: Optional[str] = None
tracker_confidence_score: Optional[float] = None
tracker_evidence: list[str] = field(default_factory=list)
# Bluetooth-specific # Bluetooth-specific
services: list[str] = field(default_factory=list) services: list[str] = field(default_factory=list)
@@ -239,6 +244,11 @@ class DeviceProfile:
'name': self.name, 'name': self.name,
'manufacturer': self.manufacturer, 'manufacturer': self.manufacturer,
'device_type': self.device_type, 'device_type': self.device_type,
'tracker_type': self.tracker_type,
'tracker_name': self.tracker_name,
'tracker_confidence': self.tracker_confidence,
'tracker_confidence_score': self.tracker_confidence_score,
'tracker_evidence': self.tracker_evidence,
'ssid': self.ssid, 'ssid': self.ssid,
'frequency': self.frequency, 'frequency': self.frequency,
'first_seen': self.first_seen.isoformat() if self.first_seen else None, 'first_seen': self.first_seen.isoformat() if self.first_seen else None,
@@ -275,6 +285,25 @@ AUDIO_SERVICE_UUIDS = [
'00001203-0000-1000-8000-00805f9b34fb', # Generic Audio '00001203-0000-1000-8000-00805f9b34fb', # Generic Audio
] ]
_BT_BASE_UUID_SUFFIX = '-0000-1000-8000-00805f9b34fb'
def _normalize_bt_uuid(value: str) -> str:
"""Normalize BLE UUIDs to 16-bit where possible."""
if not value:
return ''
uuid = str(value).lower().strip()
if uuid.startswith('0x'):
uuid = uuid[2:]
if uuid.endswith(_BT_BASE_UUID_SUFFIX) and len(uuid) >= 8:
return uuid[4:8]
if len(uuid) == 4:
return uuid
return uuid
AUDIO_SERVICE_UUIDS_16 = {_normalize_bt_uuid(u) for u in AUDIO_SERVICE_UUIDS}
# Generic chipset vendors (often used in covert devices) # Generic chipset vendors (often used in covert devices)
GENERIC_CHIPSET_VENDORS = [ GENERIC_CHIPSET_VENDORS = [
'espressif', 'espressif',
@@ -416,9 +445,23 @@ class CorrelationEngine:
profile.name = device.get('name') or profile.name profile.name = device.get('name') or profile.name
profile.manufacturer = device.get('manufacturer') or profile.manufacturer profile.manufacturer = device.get('manufacturer') or profile.manufacturer
profile.device_type = device.get('type') or profile.device_type profile.device_type = device.get('type') or profile.device_type
profile.services = device.get('services', []) or profile.services services = device.get('services')
if not services:
services = device.get('service_uuids')
profile.services = services or profile.services
profile.company_id = device.get('company_id') or profile.company_id profile.company_id = device.get('company_id') or profile.company_id
profile.advertising_interval = device.get('advertising_interval') or profile.advertising_interval profile.advertising_interval = device.get('advertising_interval') or profile.advertising_interval
tracker_data = device.get('tracker') or {}
if tracker_data:
profile.tracker_type = tracker_data.get('type') or profile.tracker_type
profile.tracker_name = tracker_data.get('name') or profile.tracker_name
profile.tracker_confidence = tracker_data.get('confidence') or profile.tracker_confidence
profile.tracker_confidence_score = tracker_data.get('confidence_score') or profile.tracker_confidence_score
evidence = tracker_data.get('evidence')
if isinstance(evidence, list):
profile.tracker_evidence = evidence
elif evidence:
profile.tracker_evidence = [str(evidence)]
# Add RSSI sample # Add RSSI sample
rssi = device.get('rssi', device.get('signal')) rssi = device.get('rssi', device.get('signal'))
@@ -434,6 +477,19 @@ class CorrelationEngine:
# === Detection Logic === # === Detection Logic ===
# 1. Unknown manufacturer or generic chipset # 1. Unknown manufacturer or generic chipset
if not profile.manufacturer and mac and not device.get('is_randomized_mac'):
try:
first_octet = int(mac.split(':')[0], 16)
except (ValueError, IndexError):
first_octet = None
if first_octet is None or not (first_octet & 0x02):
try:
from data.oui import get_manufacturer
vendor = get_manufacturer(mac)
if vendor and vendor != 'Unknown':
profile.manufacturer = vendor
except Exception:
pass
if not profile.manufacturer: if not profile.manufacturer:
profile.add_indicator( profile.add_indicator(
IndicatorType.UNKNOWN_DEVICE, IndicatorType.UNKNOWN_DEVICE,
@@ -457,8 +513,8 @@ class CorrelationEngine:
# 3. Audio-capable services # 3. Audio-capable services
if profile.services: if profile.services:
audio_services = [s for s in profile.services normalized_services = {_normalize_bt_uuid(s) for s in profile.services if s}
if s.lower() in [u.lower() for u in AUDIO_SERVICE_UUIDS]] audio_services = [s for s in normalized_services if s in AUDIO_SERVICE_UUIDS_16]
if audio_services: if audio_services:
profile.add_indicator( profile.add_indicator(
IndicatorType.AUDIO_CAPABLE, IndicatorType.AUDIO_CAPABLE,
@@ -521,6 +577,38 @@ class CorrelationEngine:
# 9. Known tracker detection (AirTag, Tile, SmartTag, ESP32) # 9. Known tracker detection (AirTag, Tile, SmartTag, ESP32)
mac_prefix = mac[:8] if len(mac) >= 8 else '' mac_prefix = mac[:8] if len(mac) >= 8 else ''
tracker_detected = False tracker_detected = False
tracker_data = device.get('tracker') or {}
if tracker_data.get('is_tracker'):
tracker_detected = True
tracker_label = tracker_data.get('name') or tracker_data.get('type')
if tracker_label:
label_lower = str(tracker_label).lower()
if 'airtag' in label_lower or 'find my' in label_lower:
profile.add_indicator(
IndicatorType.AIRTAG_DETECTED,
f'Tracker detected: {tracker_label}',
{'mac': mac, 'tracker_type': tracker_label}
)
profile.device_type = 'AirTag'
elif 'tile' in label_lower:
profile.add_indicator(
IndicatorType.TILE_DETECTED,
f'Tracker detected: {tracker_label}',
{'mac': mac, 'tracker_type': tracker_label}
)
profile.device_type = 'Tile Tracker'
elif 'smarttag' in label_lower or 'samsung' in label_lower:
profile.add_indicator(
IndicatorType.SMARTTAG_DETECTED,
f'Tracker detected: {tracker_label}',
{'mac': mac, 'tracker_type': tracker_label}
)
profile.device_type = 'Samsung SmartTag'
else:
profile.device_type = tracker_label
elif not profile.device_type:
profile.device_type = 'Tracker'
# Check for tracker flags from BLE scanner (manufacturer ID detection) # Check for tracker flags from BLE scanner (manufacturer ID detection)
if device.get('is_airtag'): if device.get('is_airtag'):
@@ -674,15 +762,25 @@ class CorrelationEngine:
""" """
bssid = device.get('bssid', device.get('mac', '')).upper() bssid = device.get('bssid', device.get('mac', '')).upper()
profile = self.get_or_create_profile(bssid, 'wifi') profile = self.get_or_create_profile(bssid, 'wifi')
is_client = bool(device.get('is_client') or device.get('role') == 'client')
# Update profile data # Update profile data
ssid = device.get('ssid', device.get('essid', '')) ssid = device.get('ssid', device.get('essid', ''))
profile.ssid = ssid if ssid else profile.ssid if is_client:
profile.name = ssid or f'Hidden Network ({bssid[-8:]})' profile.name = device.get('name') or device.get('vendor') or profile.name or f'Client ({bssid[-8:]})'
profile.channel = device.get('channel') or profile.channel profile.device_type = 'client'
profile.encryption = device.get('encryption', device.get('privacy')) or profile.encryption profile.ssid = profile.ssid # Clients are not SSIDs
profile.beacon_interval = device.get('beacon_interval') or profile.beacon_interval profile.channel = device.get('channel') or profile.channel
profile.is_hidden = not ssid or ssid in ['', 'Hidden', '[Hidden]'] profile.encryption = profile.encryption
profile.beacon_interval = profile.beacon_interval
profile.is_hidden = False
else:
profile.ssid = ssid if ssid else profile.ssid
profile.name = ssid or f'Hidden Network ({bssid[-8:]})'
profile.channel = device.get('channel') or profile.channel
profile.encryption = device.get('encryption', device.get('privacy')) or profile.encryption
profile.beacon_interval = device.get('beacon_interval') or profile.beacon_interval
profile.is_hidden = not ssid or ssid in ['', 'Hidden', '[Hidden]']
# Extract manufacturer from OUI # Extract manufacturer from OUI
if bssid and len(bssid) >= 8: if bssid and len(bssid) >= 8:
@@ -700,78 +798,118 @@ class CorrelationEngine:
profile.indicators = [] profile.indicators = []
# === Detection Logic === # === Detection Logic ===
if is_client:
if not profile.manufacturer:
profile.add_indicator(
IndicatorType.UNKNOWN_DEVICE,
'Unknown client manufacturer',
{'mac': bssid}
)
# 1. Hidden or unnamed SSID if profile.detection_count >= 3:
if profile.is_hidden: profile.add_indicator(
profile.add_indicator( IndicatorType.PERSISTENT,
IndicatorType.HIDDEN_IDENTITY, f'Persistent client ({profile.detection_count} detections)',
'Hidden or empty SSID', {'count': profile.detection_count}
{'ssid': ssid} )
)
# 2. BSSID not in authorized list (would need baseline) rssi_stability = profile.get_rssi_stability()
# For now, mark as unknown if no manufacturer if rssi_stability > 0.7 and len(profile.rssi_samples) >= 5:
if not profile.manufacturer: profile.add_indicator(
profile.add_indicator( IndicatorType.STABLE_RSSI,
IndicatorType.UNKNOWN_DEVICE, f'Stable client signal (stability: {rssi_stability:.0%})',
'Unknown AP manufacturer', {'stability': rssi_stability}
{'bssid': bssid} )
)
# 3. Consumer device OUI in restricted environment if self.is_during_meeting():
consumer_ouis = ['tp-link', 'netgear', 'd-link', 'linksys', 'asus'] profile.add_indicator(
if profile.manufacturer and any(c in profile.manufacturer.lower() for c in consumer_ouis): IndicatorType.MEETING_CORRELATED,
profile.add_indicator( 'Detected during sensitive period',
IndicatorType.ROGUE_AP, {'during_meeting': True}
f'Consumer-grade AP detected: {profile.manufacturer}', )
{'manufacturer': profile.manufacturer}
)
# 4. Camera device patterns try:
camera_keywords = ['cam', 'camera', 'ipcam', 'dvr', 'nvr', 'wyze', first_octet = int(bssid.split(':')[0], 16)
'ring', 'arlo', 'nest', 'blink', 'eufy', 'yi'] if first_octet & 0x02:
if ssid and any(k in ssid.lower() for k in camera_keywords): profile.add_indicator(
profile.add_indicator( IndicatorType.MAC_ROTATION,
IndicatorType.AUDIO_CAPABLE, # Cameras often have mics 'Random/locally administered MAC detected',
f'Potential camera device: {ssid}', {'mac': bssid}
{'ssid': ssid} )
) except (ValueError, IndexError):
pass
else:
# 1. Hidden or unnamed SSID
if profile.is_hidden:
profile.add_indicator(
IndicatorType.HIDDEN_IDENTITY,
'Hidden or empty SSID',
{'ssid': ssid}
)
# 5. Persistent presence # 2. BSSID not in authorized list (would need baseline)
if profile.detection_count >= 3: # For now, mark as unknown if no manufacturer
profile.add_indicator( if not profile.manufacturer:
IndicatorType.PERSISTENT, profile.add_indicator(
f'Persistent AP ({profile.detection_count} detections)', IndicatorType.UNKNOWN_DEVICE,
{'count': profile.detection_count} 'Unknown AP manufacturer',
) {'bssid': bssid}
)
# 6. Stable RSSI (fixed placement) # 3. Consumer device OUI in restricted environment
rssi_stability = profile.get_rssi_stability() consumer_ouis = ['tp-link', 'netgear', 'd-link', 'linksys', 'asus']
if rssi_stability > 0.7 and len(profile.rssi_samples) >= 5: if profile.manufacturer and any(c in profile.manufacturer.lower() for c in consumer_ouis):
profile.add_indicator(
IndicatorType.STABLE_RSSI,
f'Stable signal (stability: {rssi_stability:.0%})',
{'stability': rssi_stability}
)
# 7. Meeting correlation
if self.is_during_meeting():
profile.add_indicator(
IndicatorType.MEETING_CORRELATED,
'Detected during sensitive period',
{'during_meeting': True}
)
# 8. Strong hidden AP (very suspicious)
if profile.is_hidden and profile.rssi_samples:
latest_rssi = profile.rssi_samples[-1][1]
if latest_rssi > -50:
profile.add_indicator( profile.add_indicator(
IndicatorType.ROGUE_AP, IndicatorType.ROGUE_AP,
f'Strong hidden AP (RSSI: {latest_rssi} dBm)', f'Consumer-grade AP detected: {profile.manufacturer}',
{'rssi': latest_rssi} {'manufacturer': profile.manufacturer}
) )
# 4. Camera device patterns
camera_keywords = ['cam', 'camera', 'ipcam', 'dvr', 'nvr', 'wyze',
'ring', 'arlo', 'nest', 'blink', 'eufy', 'yi']
if ssid and any(k in ssid.lower() for k in camera_keywords):
profile.add_indicator(
IndicatorType.AUDIO_CAPABLE, # Cameras often have mics
f'Potential camera device: {ssid}',
{'ssid': ssid}
)
# 5. Persistent presence
if profile.detection_count >= 3:
profile.add_indicator(
IndicatorType.PERSISTENT,
f'Persistent AP ({profile.detection_count} detections)',
{'count': profile.detection_count}
)
# 6. Stable RSSI (fixed placement)
rssi_stability = profile.get_rssi_stability()
if rssi_stability > 0.7 and len(profile.rssi_samples) >= 5:
profile.add_indicator(
IndicatorType.STABLE_RSSI,
f'Stable signal (stability: {rssi_stability:.0%})',
{'stability': rssi_stability}
)
# 7. Meeting correlation
if self.is_during_meeting():
profile.add_indicator(
IndicatorType.MEETING_CORRELATED,
'Detected during sensitive period',
{'during_meeting': True}
)
# 8. Strong hidden AP (very suspicious)
if profile.is_hidden and profile.rssi_samples:
latest_rssi = profile.rssi_samples[-1][1]
if latest_rssi > -50:
profile.add_indicator(
IndicatorType.ROGUE_AP,
f'Strong hidden AP (RSSI: {latest_rssi} dBm)',
{'rssi': latest_rssi}
)
self._apply_known_device_modifier(profile, bssid, 'wifi') self._apply_known_device_modifier(profile, bssid, 'wifi')
return profile return profile
+19 -1
View File
@@ -122,6 +122,10 @@ class ThreatDetector:
if 'mac' in client: if 'mac' in client:
self.baseline_wifi_macs.add(client['mac'].upper()) self.baseline_wifi_macs.add(client['mac'].upper())
for client in baseline.get('wifi_clients', []):
if 'mac' in client:
self.baseline_wifi_macs.add(client['mac'].upper())
# Bluetooth devices # Bluetooth devices
for device in baseline.get('bt_devices', []): for device in baseline.get('bt_devices', []):
if 'mac' in device: if 'mac' in device:
@@ -479,6 +483,7 @@ class ThreatDetector:
manufacturer = device.get('manufacturer', '') manufacturer = device.get('manufacturer', '')
device_type = device.get('type', '') device_type = device.get('type', '')
manufacturer_data = device.get('manufacturer_data') manufacturer_data = device.get('manufacturer_data')
tracker_data = device.get('tracker', {}) or {}
threats = [] threats = []
@@ -490,7 +495,20 @@ class ThreatDetector:
'reason': 'Device not present in baseline', 'reason': 'Device not present in baseline',
}) })
# Check for known trackers # Check for known trackers (v2 tracker data if available)
if tracker_data.get('is_tracker'):
tracker_label = tracker_data.get('name') or tracker_data.get('type') or 'Tracker'
confidence = str(tracker_data.get('confidence') or '').lower()
severity = 'high' if confidence in ('high', 'medium') else 'medium'
threats.append({
'type': 'tracker',
'severity': severity,
'reason': f"Tracker detected: {tracker_label}",
'tracker_type': tracker_label,
'details': tracker_data.get('evidence', []),
})
# Check for known trackers (legacy patterns)
tracker_info = is_known_tracker(name, manufacturer_data) tracker_info = is_known_tracker(name, manufacturer_data)
if tracker_info: if tracker_info:
threats.append({ threats.append({
+11 -1
View File
@@ -105,6 +105,7 @@ class TSCMReport:
# Statistics # Statistics
total_devices_scanned: int = 0 total_devices_scanned: int = 0
wifi_devices: int = 0 wifi_devices: int = 0
wifi_clients: int = 0
bluetooth_devices: int = 0 bluetooth_devices: int = 0
rf_signals: int = 0 rf_signals: int = 0
new_devices: int = 0 new_devices: int = 0
@@ -203,6 +204,7 @@ def generate_executive_summary(report: TSCMReport) -> str:
lines.append("SCAN STATISTICS:") lines.append("SCAN STATISTICS:")
lines.append(f" - Total devices scanned: {report.total_devices_scanned}") lines.append(f" - Total devices scanned: {report.total_devices_scanned}")
lines.append(f" - WiFi access points: {report.wifi_devices}") lines.append(f" - WiFi access points: {report.wifi_devices}")
lines.append(f" - WiFi clients: {report.wifi_clients}")
lines.append(f" - Bluetooth devices: {report.bluetooth_devices}") lines.append(f" - Bluetooth devices: {report.bluetooth_devices}")
lines.append(f" - RF signals: {report.rf_signals}") lines.append(f" - RF signals: {report.rf_signals}")
lines.append("") lines.append("")
@@ -430,6 +432,7 @@ def generate_technical_annex_json(report: TSCMReport) -> dict:
'statistics': { 'statistics': {
'total_devices': report.total_devices_scanned, 'total_devices': report.total_devices_scanned,
'wifi_devices': report.wifi_devices, 'wifi_devices': report.wifi_devices,
'wifi_clients': report.wifi_clients,
'bluetooth_devices': report.bluetooth_devices, 'bluetooth_devices': report.bluetooth_devices,
'rf_signals': report.rf_signals, 'rf_signals': report.rf_signals,
'new_devices': report.new_devices, 'new_devices': report.new_devices,
@@ -784,15 +787,17 @@ class TSCMReportBuilder:
def add_statistics( def add_statistics(
self, self,
wifi: int = 0, wifi: int = 0,
wifi_clients: int = 0,
bluetooth: int = 0, bluetooth: int = 0,
rf: int = 0, rf: int = 0,
new: int = 0, new: int = 0,
missing: int = 0 missing: int = 0
) -> 'TSCMReportBuilder': ) -> 'TSCMReportBuilder':
self.report.wifi_devices = wifi self.report.wifi_devices = wifi
self.report.wifi_clients = wifi_clients
self.report.bluetooth_devices = bluetooth self.report.bluetooth_devices = bluetooth
self.report.rf_signals = rf self.report.rf_signals = rf
self.report.total_devices_scanned = wifi + bluetooth + rf self.report.total_devices_scanned = wifi + wifi_clients + bluetooth + rf
self.report.new_devices = new self.report.new_devices = new
self.report.missing_devices = missing self.report.missing_devices = missing
return self return self
@@ -895,6 +900,10 @@ def generate_report(
if wifi_count is None: if wifi_count is None:
wifi_count = len(results.get('wifi_devices', results.get('wifi', []))) wifi_count = len(results.get('wifi_devices', results.get('wifi', [])))
wifi_client_count = results.get('wifi_client_count')
if wifi_client_count is None:
wifi_client_count = len(results.get('wifi_clients', []))
bt_count = results.get('bt_count') bt_count = results.get('bt_count')
if bt_count is None: if bt_count is None:
bt_count = len(results.get('bt_devices', results.get('bluetooth', []))) bt_count = len(results.get('bt_devices', results.get('bluetooth', [])))
@@ -905,6 +914,7 @@ def generate_report(
builder.add_statistics( builder.add_statistics(
wifi=wifi_count, wifi=wifi_count,
wifi_clients=wifi_client_count,
bluetooth=bt_count, bluetooth=bt_count,
rf=rf_count, rf=rf_count,
new=baseline_diff.get('summary', {}).get('new_devices', 0) if baseline_diff else 0, new=baseline_diff.get('summary', {}).get('new_devices', 0) if baseline_diff else 0,
+136
View File
@@ -0,0 +1,136 @@
"""FFT pipeline for real-time waterfall display.
Converts raw I/Q samples from SDR hardware into quantized power spectrum
frames suitable for binary WebSocket transmission.
"""
from __future__ import annotations
import struct
import numpy as np
def cu8_to_complex(raw: bytes) -> np.ndarray:
"""Convert unsigned 8-bit I/Q bytes to complex64.
RTL-SDR (and rx_sdr with -F cu8) outputs interleaved unsigned 8-bit
I/Q pairs where 128 is the zero point.
Args:
raw: Raw bytes, length must be even (I/Q pairs).
Returns:
Complex64 array of length len(raw) // 2.
"""
iq = np.frombuffer(raw, dtype=np.uint8).astype(np.float32)
# Normalize: 0 -> -1.0, 128 -> ~0.0, 255 -> +1.0
iq = (iq - 127.5) / 127.5
return iq[0::2] + 1j * iq[1::2]
def compute_power_spectrum(
samples: np.ndarray,
fft_size: int = 1024,
avg_count: int = 4,
) -> np.ndarray:
"""Compute averaged power spectrum in dBm.
Applies a Hann window, computes FFT, converts to power (dB),
and averages over multiple segments.
Args:
samples: Complex64 array, length >= fft_size * avg_count.
fft_size: Number of FFT bins.
avg_count: Number of segments to average.
Returns:
Float32 array of length fft_size with power in dB (fftshift'd).
"""
window = np.hanning(fft_size).astype(np.float32)
accum = np.zeros(fft_size, dtype=np.float32)
actual_avg = 0
for i in range(avg_count):
offset = i * fft_size
if offset + fft_size > len(samples):
break
segment = samples[offset : offset + fft_size] * window
spectrum = np.fft.fft(segment)
power = np.real(spectrum * np.conj(spectrum))
# Avoid log10(0)
power = np.maximum(power, 1e-20)
accum += 10.0 * np.log10(power)
actual_avg += 1
if actual_avg == 0:
return np.full(fft_size, -100.0, dtype=np.float32)
accum /= actual_avg
return np.fft.fftshift(accum).astype(np.float32)
def quantize_to_uint8(
power_db: np.ndarray,
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 (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
scaled = (power_db - db_min) / db_range * 255.0
clamped = np.clip(scaled, 0, 255).astype(np.uint8)
return clamped.tobytes()
def build_binary_frame(
start_freq: float,
end_freq: float,
quantized_bins: bytes,
) -> bytes:
"""Pack a binary waterfall frame for WebSocket transmission.
Wire format (little-endian):
[uint8 msg_type=0x01]
[float32 start_freq]
[float32 end_freq]
[uint16 bin_count]
[uint8[] bins]
Total size = 11 + bin_count bytes.
Args:
start_freq: Start frequency in MHz.
end_freq: End frequency in MHz.
quantized_bins: Pre-quantized uint8 bin data.
Returns:
Binary frame bytes.
"""
bin_count = len(quantized_bins)
header = struct.pack('<BffH', 0x01, start_freq, end_freq, bin_count)
return header + quantized_bins
+14 -1
View File
@@ -421,7 +421,20 @@ def get_vendor_from_mac(mac: str) -> str | None:
# Normalize MAC format # Normalize MAC format
mac_upper = mac.upper().replace('-', ':') mac_upper = mac.upper().replace('-', ':')
oui = mac_upper[:8] oui = mac_upper[:8]
return VENDOR_OUIS.get(oui) vendor = VENDOR_OUIS.get(oui)
if vendor:
return vendor
# Fallback to expanded OUI database if available
try:
from data.oui import get_manufacturer
manufacturer = get_manufacturer(mac_upper)
if manufacturer and manufacturer != 'Unknown':
return manufacturer
except Exception:
return None
return None
# ============================================================================= # =============================================================================
+1
View File
@@ -264,6 +264,7 @@ class WiFiAccessPoint:
return { return {
'bssid': self.bssid, 'bssid': self.bssid,
'essid': self.essid or '', 'essid': self.essid or '',
'vendor': self.vendor,
'power': str(self.rssi_current) if self.rssi_current else '-100', 'power': str(self.rssi_current) if self.rssi_current else '-100',
'channel': str(self.channel) if self.channel else '', 'channel': str(self.channel) if self.channel else '',
'privacy': self.security, 'privacy': self.security,
+12 -3
View File
@@ -667,6 +667,7 @@ class UnifiedWiFiScanner:
interface: Optional[str] = None, interface: Optional[str] = None,
band: str = 'all', band: str = 'all',
channel: Optional[int] = None, channel: Optional[int] = None,
channels: Optional[list[int]] = None,
) -> bool: ) -> bool:
""" """
Start continuous deep scan with airodump-ng. Start continuous deep scan with airodump-ng.
@@ -702,7 +703,7 @@ class UnifiedWiFiScanner:
self._deep_scan_stop_event.clear() self._deep_scan_stop_event.clear()
self._deep_scan_thread = threading.Thread( self._deep_scan_thread = threading.Thread(
target=self._run_deep_scan, target=self._run_deep_scan,
args=(iface, band, channel), args=(iface, band, channel, channels),
daemon=True, daemon=True,
) )
self._deep_scan_thread.start() self._deep_scan_thread.start()
@@ -766,7 +767,13 @@ class UnifiedWiFiScanner:
return True return True
def _run_deep_scan(self, interface: str, band: str, channel: Optional[int]): def _run_deep_scan(
self,
interface: str,
band: str,
channel: Optional[int],
channels: Optional[list[int]],
):
"""Background thread for running airodump-ng.""" """Background thread for running airodump-ng."""
from .parsers.airodump import parse_airodump_csv from .parsers.airodump import parse_airodump_csv
@@ -779,7 +786,9 @@ class UnifiedWiFiScanner:
# Build command # Build command
cmd = ['airodump-ng', '-w', output_prefix, '--output-format', 'csv'] cmd = ['airodump-ng', '-w', output_prefix, '--output-format', 'csv']
if channel: if channels:
cmd.extend(['-c', ','.join(str(c) for c in channels)])
elif channel:
cmd.extend(['-c', str(channel)]) cmd.extend(['-c', str(channel)])
elif band == '2.4': elif band == '2.4':
cmd.extend(['--band', 'bg']) cmd.extend(['--band', 'bg'])