Compare commits

..

93 Commits

Author SHA1 Message Date
Smittix 007400d2a7 Release v2.9.0 - iNTERCEPT rebrand and UI overhaul
- Rebrand from INTERCEPT to iNTERCEPT
- New logo design with 'i' and signal wave brackets
- Add animated landing page with "See the Invisible" tagline
- Fix tuning dial audio issues with debouncing and restart prevention
- Fix Listening Post scanner with proper signal hit logging
- Update setup script for apt-based Python package installation
- Add Instagram promo video template
- Add full-size logo assets for external use

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-10 01:00:17 +00:00
Smittix 1f60e64217 Improve apt_install error handling in setup script
Show actual error output when apt-get fails instead of silently
failing. Now displays which packages failed, the last 10 lines of
apt output, and suggests a manual fix command.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-09 12:53:02 +00:00
Smittix 69de7e4afd Add HackRF/Airspy troubleshooting guide for ADS-B and Listening Post
Document how to use non-RTL-SDR devices:
- ADS-B via readsb with SoapySDR (Remote mode or build from source)
- Listening Post via rx_fm from soapysdr-tools

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-08 21:22:26 +00:00
Smittix 29025059af Add HackRF/Airspy/LimeSDR/SDRPlay support to Listening Post
The Listening Post module now uses the SDR abstraction layer to support
non-RTL-SDR devices via rx_fm (SoapySDR). Previously only rtl_fm worked.

- Add sdr_type parameter to /audio/start and /scanner/start endpoints
- Use appropriate command builder based on SDR type
- Update /tools endpoint to report rx_fm and supported SDR types

Fixes compatibility issue reported by DragonOS users with HackRF/Airspy.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-08 21:20:26 +00:00
Smittix 6229c25872 Add aircraft watchlist feature to ADS-B dashboard
- Add/remove callsigns, registrations, or ICAO codes to watch
- Alert notification and sound when watched aircraft detected
- Filter view to show only watched aircraft
- Visual highlighting with cyan border and star icon
- Watchlist persisted to localStorage

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-08 21:13:49 +00:00
Smittix 73ac74a9d6 Add clickable squawk code reference on aircraft dashboard
Click any aircraft's squawk code to see its meaning and a full
reference table of common codes. Emergency codes highlighted in red.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-08 21:03:42 +00:00
Smittix ebb1e233d8 Add tagline 'See the Invisible' to branding
Update browser titles and headers across all pages with the new
tagline. Add 'Signal Intelligence Platform' as subtitle.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-08 20:55:41 +00:00
Smittix e719e32c73 Add SDRPlay device support via SoapySDR
Adds support for SDRPlay RSP devices (RSPdx, RSP1A, RSPduo, etc.)
through the SoapySDR interface. Closes #44.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-08 20:55:41 +00:00
Smittix 46ab5fe78d Update CHANGELOG for version 2.0.0 2026-01-08 20:15:31 +00:00
Smittix dc467aef91 Update README with new features and installation steps 2026-01-08 17:44:08 +00:00
Smittix 0bc915fe1f Add files via upload 2026-01-08 17:43:49 +00:00
Smittix b7f9ad786a Delete static/images/screenshots/logo-banner.png 2026-01-08 17:43:35 +00:00
Smittix 6c80521cf8 Add files via upload 2026-01-08 17:39:51 +00:00
Smittix a174884269 Remove LoRa/ISM mode (redundant with 433MHz)
The LoRa mode was removed because:
- rtl_433 cannot decode actual LoRa (CSS modulation)
- The 433MHz mode already handles ISM band devices
- True LoRa decoding requires specialized tools like gr-lora

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-08 17:07:36 +00:00
Smittix f3b1865a79 Add LoRa/ISM band monitoring mode
- Add new LoRa backend route (routes/lora.py) with:
  - Frequency band definitions (EU868, US915, AU915, AS923, IN865, ISM433)
  - Start/stop/stream/status endpoints using rtl_433
  - Device pattern matching for LoRa/LPWAN devices
  - Signal quality calculation from RSSI

- Add LoRa frontend UI with:
  - Navigation button in SDR/RF group
  - Band selector with channel presets
  - Visualization layout (radar, device types, signal quality, activity log)
  - Device card list with selection details
  - Header stats for devices and signals

- Fix Bias-T toggle visibility for Listening Post and LoRa modes

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-08 16:56:21 +00:00
Smittix 6c99651ac9 Move Bias-T toggle to prominent location in sidebar
Moved from bottom of sidebar to right after device capabilities,
before the Refresh Devices button. Now has orange gradient
background and is immediately visible without scrolling.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-08 16:43:53 +00:00
Smittix 0aaf888dd1 Make Bias-T toggle more prominent in sidebar
- Changed to orange styling to stand out
- Added "Power Settings" header with lightning icon
- Larger checkbox
- Added description text explaining purpose

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-08 16:40:30 +00:00
Smittix d947ce17a3 Hide waterfall and output console for Bluetooth mode
Bluetooth mode now has its own dedicated layout with device cards,
so the generic waterfall and output panels are hidden.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-08 14:57:26 +00:00
Smittix 97c957b70f Fix Bluetooth RSSI capture from initial device discovery
When a new device is discovered with bluetoothctl, extract and
capture the RSSI value from the discovery line instead of
discarding it.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-08 14:56:55 +00:00
Smittix 82830c86ac Fix listening post error, hide signal meter, improve BT detection
- Fix scannerStatus -> scannerStatusText element reference
- Hide global signal meter (individual panels show signal)
- Expand Bluetooth device classification patterns
- Add more audio, phone, wearable, computer patterns
- Add manufacturer-based device type inference

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-08 14:55:51 +00:00
Smittix d8e4189100 Fix listening post tuneToFrequency null element error
The code referenced 'scannerStatus' but the element ID is
'scannerStatusText'. Fixed all instances to use the correct ID.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-08 14:44:53 +00:00
Smittix 6bcde56525 Remove debug console.log statements
Clean up temporary debug logging from channel recommendation
and WiFi client card debugging.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-08 14:43:11 +00:00
Smittix 88ebe3c337 Redesign Bluetooth page to match WiFi layout
HTML:
- Create bt-layout-container with flex layout
- Left side: visualizations (radar, selected device, device types,
  tracker detection, signal distribution, FindMy detection)
- Right side: scrollable device card list

CSS:
- Add bt-layout-container styles matching wifi-layout-container
- Add bt-device-card styles with purple accent
- Add device type overview styles
- Add signal distribution bar styles
- Add responsive breakpoints

JavaScript:
- Update addBtDeviceCard to create cards in new device list
- Add selectBtDevice for device selection
- Add updateBtStatsPanels for device type and signal stats
- Add updateBtFindMyList for FindMy device tracking

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-08 14:41:56 +00:00
Smittix 5f4d1b05a8 Add debug logging for channel recommendation
Temporary console.log statements to diagnose why channel
recommendation always shows 1 and 36.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-08 14:37:33 +00:00
Smittix 370c46bddb Add CSS styling for WiFi client cards
- Add .wifi-client-card styles matching network card layout
- Purple border and subtle background for visual distinction
- Consistent font sizing with network cards

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-08 14:35:17 +00:00
Smittix 47b5e03bbb Add debug logging for WiFi client card issues
Temporary console.log statements to diagnose why clients
are not appearing in the device list.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-08 14:32:41 +00:00
Smittix 556ca59a99 Fix WiFi client updates, rogue AP detection, and channel recommendation
Backend:
- Send client updates when probes or signal change significantly
- Previously only new clients were reported, updates were ignored

Frontend:
- Add client cards to device list (was only showing networks)
- Fix rogue AP detection to check OUI - excludes legitimate mesh systems
- Improve channel recommendation with detailed usage breakdown
- Show per-channel interference counts for 2.4GHz
- Show unused channel count for 5GHz

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-08 14:27:15 +00:00
Smittix 81c5af474d Add visual rogue AP indicator for suspected evil twin detection
- Add rogueBssids Set to track all BSSIDs flagged as rogues
- Display red banner with pulsing animation on rogue network cards
- Apply red border and background tint to rogue AP cards
- Show prominent warning in selected device panel for rogue APs
- Change SSID color to red when viewing rogue AP details

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-08 14:18:53 +00:00
Smittix cdaee3f62f Fix hidden SSID, probe analysis, and device correlation
Hidden SSID fixes:
- Only track networks that are originally hidden (empty/Hidden ESSID)
- Reveal hidden SSIDs when network ESSID changes or client probes match
- Show count of hidden networks being monitored
- Show revealed SSIDs with checkmark

Probe analysis fixes:
- Call scheduleProbeAnalysisUpdate when client has probes
- Add periodic updateProbeAnalysis call every 2 seconds
- Properly trigger probe analysis from client handler

Other fixes:
- Remove drawNetworkGraph call (topology panel was removed)
- Add updateHiddenSsidDisplay to periodic updates
- Improve panel messages when no data available

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-08 13:56:41 +00:00
Smittix aab4288f67 Reorganize WiFi panels, remove PMKID, add aircrack integration
Layout changes:
- Security overview now next to network radar
- Channel utilization (2.4 GHz and 5 GHz) side by side
- Removed network topology panel
- Removed PMKID capture panel and functionality

Handshake improvements:
- Added "Crack with Aircrack-ng" button when handshake is captured
- Added /wifi/handshake/crack backend route
- Prompts for wordlist path with common defaults
- Shows password when found with notification
- 5 minute timeout with helpful error message

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-08 13:50:14 +00:00
Smittix bab49e4442 Improve WiFi layout and fix capture functionality
Layout changes:
- Move device list to right column beside visualizations
- Add dedicated WiFi device list panel with header and count
- Hide waterfall and generic output for WiFi mode
- Add responsive styles for smaller screens

Capture fixes:
- Fix handshake capture: add interface param, stop existing scan first
- Fix PMKID capture: add interface param, stop existing scan first
- Add proper error handling with try/catch for both capture functions
- Use monitorInterface variable when available

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-08 13:43:11 +00:00
Smittix 7608aca681 Hide waterfall canvas in WiFi mode
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-08 13:35:34 +00:00
Smittix 58907bdc4d Fix WiFi layout and remove redundant Target Signal panel
- Add min-height: 0 to output-panel to fix grid overflow scrolling
- Add min-height: 200px to output-content for device cards visibility
- Add max-height: 50vh to wifi-visuals to leave room for device list
- Make wifi-visuals scrollable when content exceeds max-height
- Remove Target Signal panel (redundant with Selected Device panel)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-08 13:33:12 +00:00
Smittix 8dfd92082c Move Selected Device panel to top of WiFi visuals
Moves the Selected Device panel to be the first element in the
WiFi visualizations grid so it's visible without scrolling.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-08 13:29:15 +00:00
Smittix e39304da90 Add aircraft image panel to ADS-B tab
- Add new panel to display aircraft photos when selected
- Fetch photos from planespotters API via /adsb/aircraft-photo endpoint
- Cache photos to avoid repeated API calls
- Show loading, no photo, and placeholder states appropriately
- Reduced map panel to span 2 to accommodate new image panel

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-08 13:26:47 +00:00
Smittix 31fd3f3f63 Add WiFi device selection and fix signal strength display
- Add Selected Device panel showing detailed info for networks/clients
- Make network cards clickable to view details
- Make probe analysis client entries clickable
- Fix signal strength to show "N/A" when airodump returns -1
- Add visual signal meter and action buttons to selected device panel

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-08 13:23:27 +00:00
Smittix e1ab24b36b Improve WiFi monitor mode and scan interface detection
- Verify monitor interface actually exists before returning success
- Check for common interface naming patterns (wlan0mon, wlan1mon, etc.)
- Add interface existence check before starting scan
- Show available interfaces in error messages for debugging
- Better logging of monitor mode and scan failures

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-08 13:11:23 +00:00
Smittix f5b92ddcf9 Fix ADS-B tab and improve WiFi scanning feedback
- Hide waterfall and output panels in aircraft mode (use dedicated visualization)
- Add aircraft list panel with clickable aircraft selection
- Add selected aircraft info panel showing altitude, speed, heading, squawk
- Add "Open Full Dashboard" button linking to dedicated aircraft radar
- Add debugging console logs and alert messages to WiFi scan function
- Better error feedback when WiFi interface not selected or scan fails

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-08 13:08:36 +00:00
Smittix d9ee87d4b4 Improve WiFi device selection visibility and error handling
- Added "Select Device" label to WiFi adapter dropdown
- Better error handling with user notifications when interfaces fail to load
- Shows loading state while detecting interfaces
- Clearer notification messages for found/missing interfaces

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-08 13:01:47 +00:00
Smittix 5e83db54ac Fix WiFi section scrolling to show channel recommendation and correlation
- Changed output-panel overflow from hidden to auto
- Allows scrolling to see all content including bottom panels

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-08 12:59:30 +00:00
Smittix de7b12a759 Streamline WiFi scanning with auto monitor mode and better device detection
- Auto-enable monitor mode when clicking Start Scanning (no manual step needed)
- Improved WiFi interface detection using airmon-ng for chipset info
- Added lsusb fallback for USB adapter identification
- Fixed interface display format in enableMonitorMode callback
- Better error handling and status notifications during scan startup

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-08 12:57:09 +00:00
Smittix 1236011174 Improve WiFi device identification, remove signal history, fix Listen button
- WiFi interfaces now show driver, chipset, and MAC address for easier identification
- Remove signal history feature from WiFi and Bluetooth sections (HTML, JS, CSS, API)
- Fix Listen button in Listening Post signal hits to properly tune to frequency
- Make stopAudio() async and improve tuneToFrequency() with proper awaits
- Fix Device Intelligence panel auto-expand and manufacturer display

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-08 12:53:38 +00:00
Smittix b60f2cdf81 Parse Bluetooth RSSI from bluetoothctl output
- Capture RSSI from [CHG] Device XX:XX RSSI: -XX lines
- Update device RSSI in real-time as bluetoothctl reports changes
- Auto-refresh selected device panel when RSSI updates
- Preserve RSSI when merging device updates

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-08 12:40:49 +00:00
Smittix 0c310ab068 Add Bluetooth selected device details panel
- New panel shows full device details when clicked
- Displays name, type, RSSI with signal bars, MAC, manufacturer
- Shows tracker/FindMy badges for detected trackers
- Buttons to enumerate services and copy MAC address
- Reorganized BT visualization layout

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-08 12:37:03 +00:00
Smittix a87f66cc0c Enlarge Selected Target panel for aircraft photos
- Increased max-height from 280px to 480px
- Added styling for photo container with cyan border
- Photo limited to 140px height with cover fit

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-08 12:34:12 +00:00
Smittix c05756357f Add requests to requirements.txt
Required for aircraft photo API calls.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-08 12:30:45 +00:00
Smittix f4b4b5febd Add Bluetooth device list and tracker panels
- Added clickable device list panel sorted by signal strength
- Added dedicated tracker detection panel for AirTags/Tiles
- Clicking a device selects it for signal tracking and targeting
- Devices show name, MAC, RSSI with color-coded signal strength

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-08 12:29:25 +00:00
Smittix 805290b17f Fix Listening Post audio issues
- Add auto-reconnect on audio player errors/stalls
- Fix tuneToFrequency to properly wait between stop/start
- Improve ffmpeg encoding: 96k bitrate, 44.1kHz output, low latency flags
- Increase stream chunk size for smoother playback

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-08 12:26:55 +00:00
Smittix fecc2237b8 Add aircraft photos from Planespotters.net
- Backend route to proxy photo requests from Planespotters API
- Frontend displays photo in Selected Target panel when available
- Photos are cached to avoid repeated API calls
- Clicking photo links to full image on Planespotters.net

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-08 12:22:29 +00:00
Smittix 471cc1ee94 Add bias_t parameter to build_adsb_command()
Completes bias-T support across all SDR command builder methods.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-08 12:17:02 +00:00
Smittix 41ebf59964 Remove bufsize=1 from sensor subprocess
Line buffering only works with text mode, not binary pipes.
Fixes RuntimeWarning about line buffering.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-08 12:15:14 +00:00
Smittix a5e9a3e1ce Add bias_t parameter to build_ism_command()
Same fix as build_fm_demod_command() - the parameter was being
passed but not defined in the method signatures.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-08 12:08:38 +00:00
Smittix 23689d9fe1 Add bias_t parameter to SDR command builders
The bias_t parameter was being passed to build_fm_demod_command()
but wasn't defined in the method signatures, causing an unexpected
keyword argument error.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-08 12:05:11 +00:00
Smittix 601d432fbf Add python3-venv to Debian package installs
Required for creating Python virtual environments on Ubuntu/Debian.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-08 12:00:23 +00:00
Smittix a21e9c508e Fix unbound variable error in dump1090 build
Wrap build in subshell to isolate EXIT trap, preventing
orig_dir unbound variable error at script exit.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-08 11:58:14 +00:00
Smittix 55b0c0509d Add progress bar to setup script
Shows step counter with visual progress bar during installation:
[3/15] ██████░░░░░░░░░░░░░░ 20% - Installing ffmpeg

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-08 11:55:17 +00:00
Smittix 563c6b79fa Suppress needrestart prompts on Ubuntu Server
Set DEBIAN_FRONTEND and NEEDRESTART_MODE to prevent the
"scanning processes/candidates/microcode" messages during apt installs.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-08 11:48:10 +00:00
Smittix 8d9e5f9d56 Add security hardening and bias-t support
Security improvements:
- Add interface name validation to prevent command injection
- Fix XSS vulnerability in pager message display
- Add security headers (X-Content-Type-Options, X-Frame-Options, etc.)
- Disable Werkzeug debug PIN
- Add security documentation

Features:
- Add bias-t power support for SDR dongles across all modes

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-08 11:29:24 +00:00
Smittix c0f6ccaf2a Merge pull request #36 from JonanOribe/main
Thanks for the contribution! The Bluetooth tests look great and will help ensure stability as we continue development.
2026-01-08 10:38:04 +00:00
Jon Ander Oribe 9b3e4ec7fb Merge branch 'main' into main 2026-01-08 06:57:33 +01:00
Jon Ander Oribe 9d45eb21a4 Update .gitignore 2026-01-08 06:51:08 +01:00
Jon Ander Oribe bcf8fe59f5 Delete uv.lock 2026-01-08 06:47:15 +01:00
Jon Ander Oribe 5b411456c7 Update 2026-01-08 06:46:52 +01:00
Smittix 4432816934 Update README.md with project details and features 2026-01-07 21:45:09 +00:00
Smittix 5277537445 Update troubleshooting guide with new issues and solutions
Added additional troubleshooting steps for ADS-B mode and installation issues.
2026-01-07 21:44:22 +00:00
Smittix e73ce8cd8f Update README with new features and installation details 2026-01-07 21:38:54 +00:00
Smittix 120015d133 Update troubleshooting guide for clarity and details 2026-01-07 21:30:21 +00:00
Smittix f85cf61019 Revise hardware setup and installation instructions
Updated hardware setup documentation with new installation instructions and tool references.
2026-01-07 21:26:56 +00:00
Smittix 41226d173a Revise README for better clarity and organization
Updated README to improve clarity and structure, including installation instructions and troubleshooting.
2026-01-07 21:23:51 +00:00
Smittix 83244c85fe Add uv.lock to .gitignore
Prevent accidental commits of uv lock files since we use
requirements.txt for dependency management.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-07 20:46:14 +00:00
Smittix 27dd868d97 Add pager message filtering (closes #40)
Add ability to filter out unwanted pager messages from display:
- Hide "Tone Only" messages by default (toggle in UI)
- Custom keyword filter (comma-separated list)
- Filtered messages are still logged and counted, just hidden from view
- Filter settings persist in localStorage

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-07 20:14:46 +00:00
Smittix 45b35ea5b0 Consolidate setup scripts into single setup.sh
Merge the improved setup-dev.sh logic into setup.sh and remove the
separate dev script. The consolidated script includes:
- Stricter bash error handling (set -Eeuo pipefail)
- Cleaner output with info/ok/warn/fail helpers
- gpsd installation for GPS daemon support
- Required tools verification with hard fail
- Source build fallback for dump1090

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-07 20:07:40 +00:00
Smittix ac8b9f82cd Add gpsd installation to setup-dev.sh
Include gpsd daemon in the setup script for both macOS (via Homebrew)
and Debian/Ubuntu (via apt with gpsd-clients). Also add gpsd to the
required tools check.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-07 20:04:57 +00:00
Smittix 9d0e417f2a Simplify GPS to gpsd-only and streamline UI controls
Remove direct serial GPS dongle support in favor of gpsd daemon connectivity.
The UI now auto-connects to gpsd on page load and shows a GPS indicator when connected.
Simplify ADS-B dashboard controls bar for a cleaner, more compact layout.
Add setup-dev.sh for streamlined development environment setup.

- Remove GPSReader class and NMEA parsing (utils/gps.py)
- Consolidate to GPSDClient only with auto-connect endpoint
- Add GPS indicator with pulsing dot animation
- Compact controls bar with smaller fonts and tighter spacing
- Add aircraft database download banner/functionality

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-07 19:49:58 +00:00
Smittix 40369ccb7b Support /usr/sbin paths for aircrack-ng on Debian and add hcxtools
- Add get_tool_path() to check /usr/sbin and /sbin for tools
- Update wifi.py to use full paths for airmon-ng, airodump-ng, aireplay-ng, aircrack-ng
- Add hcxdumptool and hcxtools to setup.sh for Debian and macOS
- Update check_cmd() in setup.sh to also check /usr/sbin and /sbin

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-07 18:20:39 +00:00
Smittix 61ef3f7bdd Fix ADS-B tracking and aircraft database lookup
- Add debug stats (bytes_received, lines_received) to diagnose connection issues
- Capture stderr from dump1090 to show actual error messages on failure
- Add dump1090_running status to /adsb/status endpoint
- Fix aircraft_db.lookup() to handle Mictronics array format [reg, type, flags]
  instead of expecting dict format {r: reg, t: type}
- Add logging for first few SBS lines to help debug parsing issues

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-07 17:42:17 +00:00
Smittix bcb1a825d3 Prevent duplicate signal hits in Listening Post
Added duplicate detection to addSignalHit():
- Tracks recent signals by frequency in a Map
- Ignores same frequency within 5 seconds
- Auto-cleans entries older than 30 seconds

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-06 22:33:38 +00:00
Smittix 1f7a3fe664 Fix SDRType.LIMESDR -> SDRType.LIME_SDR typo
The enum is LIME_SDR (with underscore) but the code used LIMESDR,
causing an AttributeError on /adsb/tools endpoint.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-06 22:24:06 +00:00
Smittix dcd855896e Add pauses after each installation step for readability
Each package install now pauses for 1 second after completion
so the user can see what happened before it scrolls away.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-06 22:13:22 +00:00
Smittix 4778134ab6 Fix setup.sh - remove set -e, improve dump1090 build
- Changed set -e to set +e - handle errors explicitly instead
- set -e was causing silent early exits on any failure
- Improved dump1090 build with more dependencies
- Added BLADERF=no to skip optional BladeRF dependency
- Falls back to simpler antirez/dump1090 if FlightAware fails
- Better success/failure output for each package

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-06 22:03:12 +00:00
Smittix 300b19d1d6 Simplify Debian tool installation - always install everything
- Remove conditional MISSING_* checks - just install all tools
- apt-get will skip already-installed packages anyway
- Add verification step at end showing what actually got installed
- Better output showing success/failure for each package
- This fixes issues where flag-based logic was failing silently

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-06 22:00:27 +00:00
Smittix 945ae33361 Fix setup.sh to install system tools before Python deps
- Reorder: check tools -> install tools -> Python deps
- This ensures system tools install even if pip fails
- Make pip failures non-fatal (continue with warning)
- Auto-install python3-venv if needed on Debian
- Don't exit on venv creation failure, continue with tools

The previous order meant if pip failed (common on fresh installs),
the script would exit before installing rtl-sdr, multimon-ng, etc.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-06 21:57:57 +00:00
Smittix dbbcb6c5cc Add dump1090 build from source when not in repositories
On newer Debian versions (like Trixie), dump1090 isn't available
in the package repositories. Now automatically builds from the
FlightAware GitHub repo as a fallback:

- Installs build dependencies (build-essential, librtlsdr-dev, etc.)
- Clones from github.com/flightaware/dump1090
- Compiles and installs to /usr/local/bin

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-06 21:53:38 +00:00
Smittix 016959ad7c Make setup.sh fully automatic - no prompts required
- Remove all Y/n prompts for tool installation
- Install tools automatically when missing
- Show clear progress for each package being installed
- Show warnings if individual packages fail instead of silent failure
- Changed apt to apt-get for better script compatibility
- Auto-setup udev rules on Linux without prompting
- Auto-install Homebrew on macOS if missing

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-06 21:52:19 +00:00
Smittix 7a9599786c Add error handling to checkStatus() to prevent console errors
The periodic /status check was throwing uncaught promise errors
when the server was unavailable or restarting.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-06 21:48:42 +00:00
Smittix fa537390c5 Fix audio stream errors and improve error handling
- Fix "no supported source" error by returning empty audio response
  instead of JSON when audio not running (browser can't parse JSON)
- Add wait loop in stream endpoint to handle race condition with start
- Add logging for rtl_fm and ffmpeg commands
- Capture stderr to log actual process errors
- Check if processes exit immediately and log reason
- Improved error messages for users

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-06 21:42:55 +00:00
Smittix bb24bdb06c Fix aircraft dashboard audio endpoints (404 error)
Changed /spectrum/audio/* to /listening/audio/* to match the
actual listening_post blueprint URL prefix.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-06 21:37:36 +00:00
Smittix b55100d5c3 Fix misleading 'No RTL-SDR devices found' warning in Listening Post
The warning was triggered when ffmpeg was missing, but the message
incorrectly said "No RTL-SDR devices found". Now properly shows
"ffmpeg not found" with installation instructions.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-06 21:17:49 +00:00
Smittix 02cb9c751a Fix audio dependency to use ffmpeg instead of sox
The Listening Post actually uses ffmpeg for audio encoding, not sox.
Updated all documentation, setup scripts, and code to reflect this:

- Removed unused find_sox() function from listening_post.py
- Simplified tools endpoint to only check for ffmpeg
- Updated CHANGELOG, README, HARDWARE.md, Dockerfile
- Fixed setup.sh to check for ffmpeg
- Updated frontend warnings to mention ffmpeg

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-06 20:53:54 +00:00
Smittix 8555938f52 Add readsb warning for HackRF/LimeSDR ADS-B tracking
- Enhanced /adsb/tools endpoint to detect SoapySDR hardware and check for readsb
- Added UI warning in aircraft dashboard when HackRF/LimeSDR is detected but readsb is missing
- Warning includes expandable installation instructions

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-06 20:46:36 +00:00
Smittix a2a3ea62f1 Fix SoapySDR detection and HackRF ADS-B error message
- Try multiple SoapySDR utility names: SoapySDRUtil, soapy_sdr_util, soapysdr-util
- Improve error message for HackRF/LimeSDR ADS-B to mention readsb requirement

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-06 20:38:35 +00:00
Smittix 0d5310eb4b Fix DataStore subscript access for ADS-B tracking
Add __getitem__, __setitem__, and __delitem__ methods to DataStore
class to support dict-style subscript notation (store[key]).

Fixes TypeError: 'DataStore' object is not subscriptable

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-06 20:35:51 +00:00
Jon Ander Oribe a5a2692a5f Testing for Bluetooth & Black without running 2026-01-06 08:20:33 +01:00
56 changed files with 11861 additions and 4407 deletions
+4
View File
@@ -8,6 +8,7 @@ env/
venv/
.venv/
ENV/
uv.lock
# Logs
*.log
@@ -28,3 +29,6 @@ Thumbs.db
dist/
build/
*.egg-info/
# Package manager lock files
uv.lock
+40 -9
View File
@@ -1,19 +1,49 @@
# Changelog
All notable changes to INTERCEPT will be documented in this file.
All notable changes to iNTERCEPT will be documented in this file.
## [2.0.0] - 2025-01-06
## [2.9.0] - 2026-01-10
### Added
- **Landing Page** - Animated welcome screen with logo reveal and "See the Invisible" tagline
- **New Branding** - Redesigned logo featuring 'i' with signal wave brackets
- **Logo Assets** - Full-size SVG logos in `/static/img/` for external use
- **Instagram Promo** - Animated HTML promo video template in `/promo/` directory
- **Listening Post Scanner** - Fully functional frequency scanning with signal detection
- Scan button toggles between start/stop states
- Signal hits logged with Listen button to tune directly
- Proper 4-column display (Time, Frequency, Modulation, Action)
### Changed
- **Rebranding** - Application renamed from "INTERCEPT" to "iNTERCEPT"
- **Updated Tagline** - "Signal Intelligence & Counter Surveillance Platform"
- **Setup Script** - Now installs Python packages via apt first (more reliable on Debian/Ubuntu)
- Uses `--system-site-packages` for venv to leverage apt packages
- Added fallback logic when pip fails
- **Troubleshooting Docs** - Added sections for pip install issues and apt alternatives
### Fixed
- **Tuning Dial Audio** - Fixed audio stopping when using tuning knob
- Added restart prevention flags to avoid overlapping restarts
- Increased debounce time for smoother operation
- Added silent mode for programmatic value changes
- **Scanner Signal Hits** - Fixed table column count and colspan
- **Favicon** - Updated to new 'i' logo design
---
## [2.0.0] - 2026-01-06
### Added
- **Listening Post Mode** - New frequency scanner with automatic signal detection
- Scans frequency ranges and stops on detected signals
- Real-time audio monitoring with sox integration
- Real-time audio monitoring with ffmpeg integration
- Skip button to continue scanning after signal detection
- Configurable dwell time, squelch, and step size
- Preset frequency bands (FM broadcast, Air band, Marine, etc.)
- Activity log of detected signals
- **Aircraft Dashboard Improvements**
- Dependency warning when rtl_fm or sox not installed
- Dependency warning when rtl_fm or ffmpeg not installed
- Auto-restart audio when switching frequencies
- Fixed toolbar overflow with custom frequency input
- **Device Correlation** - Match WiFi and Bluetooth devices by manufacturer
@@ -29,10 +59,10 @@ All notable changes to INTERCEPT will be documented in this file.
- **Setup Script Rewrite**
- Full macOS support with Homebrew auto-installation
- Improved Debian/Ubuntu package detection
- Added sox to tool checks
- Added ffmpeg to tool checks
- Better error messages with platform-specific install commands
- **Dockerfile Updated**
- Added sox and libsox-fmt-all for Listening Post audio
- Added ffmpeg for Listening Post audio encoding
- Added dump1090 with fallback for different package names
### Fixed
@@ -50,7 +80,7 @@ All notable changes to INTERCEPT will be documented in this file.
---
## [1.2.0] - 2024-12-XX
## [1.2.0] - 2026-12-29
### Added
- Airspy SDR support
@@ -62,7 +92,7 @@ All notable changes to INTERCEPT will be documented in this file.
---
## [1.1.0] - 2024-XX-XX
## [1.1.0] - 2026-12-18
### Added
- Satellite tracking with TLE data
@@ -71,7 +101,7 @@ All notable changes to INTERCEPT will be documented in this file.
---
## [1.0.0] - 2024-XX-XX
## [1.0.0] - 2026-12-15
### Initial Release
- Pager decoding (POCSAG/FLEX)
@@ -80,3 +110,4 @@ All notable changes to INTERCEPT will be documented in this file.
- WiFi reconnaissance
- Bluetooth scanning
- Multi-SDR support (RTL-SDR, LimeSDR, HackRF)
+1 -2
View File
@@ -20,8 +20,7 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
# Pager decoder
multimon-ng \
# Audio tools for Listening Post
sox \
libsox-fmt-all \
ffmpeg \
# WiFi tools (aircrack-ng suite)
aircrack-ng \
iw \
+19 -86
View File
@@ -12,7 +12,7 @@
</p>
<p align="center">
<img src="static/images/screenshots/screenshot_main.png" alt="Screenshot">
<img src="static/images/screenshots/logo-banner.png" alt="Screenshot">
</p>
---
@@ -29,28 +29,11 @@
---
## Installation
## Installation / Debian / Ubuntu / MacOS
### macOS
**1. Install Homebrew** (if not already installed):
```bash
/bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)"
```
**2. Install dependencies:**
```bash
# Required
brew install python@3.11 librtlsdr multimon-ng rtl_433 sox
# For ADS-B aircraft tracking
brew install dump1090-mutability
# For WiFi scanning (optional)
brew install aircrack-ng
```
**3. Clone and run:**
**1. Clone and run:**
```bash
git clone https://github.com/smittix/intercept.git
cd intercept
@@ -58,36 +41,6 @@ cd intercept
sudo python3 intercept.py
```
### Debian / Ubuntu / Raspberry Pi OS
**1. Install dependencies:**
```bash
sudo apt update
sudo apt install -y python3 python3-pip python3-venv git
# Required SDR tools
sudo apt install -y rtl-sdr multimon-ng rtl-433 sox
# For ADS-B aircraft tracking (package name varies)
sudo apt install -y dump1090-mutability # or dump1090-fa
# For WiFi scanning (optional)
sudo apt install -y aircrack-ng
# For Bluetooth scanning (optional)
sudo apt install -y bluez bluetooth
```
**2. Clone and run:**
```bash
git clone https://github.com/smittix/intercept.git
cd intercept
./setup.sh
sudo python3 intercept.py
```
> **Note:** On Raspberry Pi or headless systems, you may need to run `sudo venv/bin/python intercept.py` if a virtual environment was created.
### Docker (Alternative)
```bash
@@ -109,50 +62,23 @@ After starting, open **http://localhost:5050** in your browser.
| Hardware | Purpose | Price |
|----------|---------|-------|
| **RTL-SDR** | Required for all SDR features | ~$25-35 |
| **WiFi adapter** | Monitor mode scanning (optional) | ~$20-40 |
| **WiFi adapter** | Must support promiscuous (monitor) mode | ~$20-40 |
| **Bluetooth adapter** | Device scanning (usually built-in) | - |
| **GPS** | Any Linux supported GPS Unit | ~10 |
Most features work with a basic RTL-SDR dongle (RTL2832U + R820T2).
---
| :exclamation: Not using an RTL-SDR Device? |
|-----------------------------------------------
|Intercept supports any device that SoapySDR supports. You must however have the correct module for your device installed! For example if you have an SDRPlay device you'd need to install soapysdr-module-sdrplay.
## Troubleshooting
### RTL-SDR not detected (Linux)
Add udev rules:
```bash
sudo bash -c 'cat > /etc/udev/rules.d/20-rtlsdr.rules << EOF
SUBSYSTEM=="usb", ATTRS{idVendor}=="0bda", ATTRS{idProduct}=="2838", MODE="0666"
SUBSYSTEM=="usb", ATTRS{idVendor}=="0bda", ATTRS{idProduct}=="2832", MODE="0666"
EOF'
sudo udevadm control --reload-rules && sudo udevadm trigger
```
Then unplug and replug your RTL-SDR.
### "externally-managed-environment" error (Ubuntu 23.04+)
The setup script handles this automatically by creating a virtual environment. Run:
```bash
./setup.sh
source venv/bin/activate
sudo venv/bin/python intercept.py
```
### dump1090 not available (Debian Trixie)
On newer Debian versions, dump1090 may not be in repositories. Install from FlightAware:
- https://flightaware.com/adsb/piaware/install
### Verify installation
```bash
python3 intercept.py --check-deps
```
| :exclamation: GPS Usage |
|-----------------------------------------------
|gpsd is needed for real time location. Intercept automatically checks to see if you're running gpsd in the background when any maps are rendered.
---
## Community
## Discord Server
<p align="center">
<a href="https://discord.gg/z3g3NJMe">Join our Discord</a>
@@ -165,11 +91,14 @@ python3 intercept.py --check-deps
- [Usage Guide](docs/USAGE.md) - Detailed instructions for each mode
- [Hardware Guide](docs/HARDWARE.md) - SDR hardware and advanced setup
- [Troubleshooting](docs/TROUBLESHOOTING.md) - Common issues and solutions
- [Security](docs/SECURITY.md) - Network security and best practices
---
## Disclaimer
This project was developed using AI as a coding partner, combining human direction with AI-assisted implementation. The goal: make Software Defined Radio more accessible by providing a clean, unified interface for common SDR tools.
**This software is for educational and authorized testing purposes only.**
- Only use with proper authorization
@@ -195,3 +124,7 @@ Created by **smittix** - [GitHub](https://github.com/smittix)
[aircrack-ng](https://www.aircrack-ng.org/) |
[Leaflet.js](https://leafletjs.com/) |
[Celestrak](https://celestrak.org/)
+1
View File
File diff suppressed because one or more lines are too long
+4
View File
@@ -0,0 +1,4 @@
{
"version": "2026-01-04_e27bf619",
"downloaded": "2026-01-07T14:55:20.680977Z"
}
+32
View File
@@ -45,6 +45,30 @@ _app_start_time = _time.time()
# Create Flask app
app = Flask(__name__)
# Disable Werkzeug debugger PIN (not needed for local development tool)
os.environ['WERKZEUG_DEBUG_PIN'] = 'off'
# ============================================
# SECURITY HEADERS
# ============================================
@app.after_request
def add_security_headers(response):
"""Add security headers to all responses."""
# Prevent MIME type sniffing
response.headers['X-Content-Type-Options'] = 'nosniff'
# Prevent clickjacking
response.headers['X-Frame-Options'] = 'SAMEORIGIN'
# Enable XSS filter
response.headers['X-XSS-Protection'] = '1; mode=block'
# Referrer policy
response.headers['Referrer-Policy'] = 'strict-origin-when-cross-origin'
# Permissions policy (disable unnecessary features)
response.headers['Permissions-Policy'] = 'geolocation=(self), microphone=()'
return response
# ============================================
# GLOBAL PROCESS MANAGEMENT
# ============================================
@@ -397,6 +421,14 @@ def main() -> None:
from routes import register_blueprints
register_blueprints(app)
# Initialize WebSocket for audio streaming
try:
from routes.audio_websocket import init_audio_websocket
init_audio_websocket(app)
print("WebSocket audio streaming enabled")
except ImportError as e:
print(f"WebSocket audio disabled (install flask-sock): {e}")
print(f"Open http://localhost:{args.port} in your browser")
print()
print("Press Ctrl+C to stop")
+1 -1
View File
@@ -7,7 +7,7 @@ import os
import sys
# Application version
VERSION = "2.0.0"
VERSION = "2.9.0"
def _get_env(key: str, default: str) -> str:
+6 -6
View File
@@ -21,7 +21,7 @@ INTERCEPT automatically detects connected devices.
/bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)"
# Core tools (required)
brew install python@3.11 librtlsdr multimon-ng rtl_433 sox
brew install python@3.11 librtlsdr multimon-ng rtl_433 ffmpeg
# ADS-B aircraft tracking
brew install dump1090-mutability
@@ -43,8 +43,8 @@ brew install hackrf soapyhackrf
sudo apt update
# Core tools (required)
sudo apt install -y python3 python3-pip python3-venv
sudo apt install -y rtl-sdr multimon-ng rtl-433 sox
sudo apt install -y python3 python3-pip python3-venv python3-skyfield
sudo apt install -y rtl-sdr multimon-ng rtl-433 ffmpeg
# ADS-B aircraft tracking
sudo apt install -y dump1090-mutability
@@ -162,7 +162,7 @@ Open **http://localhost:5050** in your browser.
| `multimon-ng` | multimon-ng | multimon-ng | Pager decoding |
| `rtl_433` | rtl-433 | rtl_433 | 433MHz sensors |
| `dump1090` | dump1090-mutability | dump1090-mutability | ADS-B tracking |
| `sox` | sox | sox | Listening Post audio |
| `ffmpeg` | ffmpeg | ffmpeg | Listening Post audio |
| `airmon-ng` | aircrack-ng | aircrack-ng | WiFi monitor mode |
| `airodump-ng` | aircrack-ng | aircrack-ng | WiFi scanning |
| `aireplay-ng` | aircrack-ng | aircrack-ng | WiFi deauth (optional) |
@@ -182,8 +182,7 @@ Open **http://localhost:5050** in your browser.
| Package | Purpose |
|---------|---------|
| `flask` | Web server |
| `skyfield` | Satellite tracking (optional) |
| `pyserial` | USB GPS dongle support (optional) |
| `skyfield` | Satellite tracking |
---
@@ -209,3 +208,4 @@ https://github.com/flightaware/dump1090
- **Bluetooth on macOS**: Uses native CoreBluetooth, bluez tools not needed
- **WiFi on macOS**: Monitor mode has limited support, full functionality on Linux
- **System tools**: `iw`, `iwconfig`, `rfkill`, `ip` are pre-installed on most Linux systems
+89
View File
@@ -0,0 +1,89 @@
# Security Considerations
INTERCEPT is designed as a **local signal intelligence tool** for personal use on trusted networks. This document outlines security considerations and best practices.
## Network Binding
By default, INTERCEPT binds to `0.0.0.0:5050`, making it accessible from any network interface. This is convenient for accessing the web UI from other devices on your local network, but has security implications:
### Recommendations
1. **Firewall Rules**: If you don't need remote access, configure your firewall to block external access to port 5050:
```bash
# Linux (iptables)
sudo iptables -A INPUT -p tcp --dport 5050 -s 127.0.0.1 -j ACCEPT
sudo iptables -A INPUT -p tcp --dport 5050 -j DROP
# macOS (pf)
echo "block in on en0 proto tcp from any to any port 5050" | sudo pfctl -ef -
```
2. **Bind to Localhost**: For local-only access, set the host environment variable:
```bash
export INTERCEPT_HOST=127.0.0.1
python intercept.py
```
3. **Trusted Networks Only**: Only run INTERCEPT on networks you trust. The application has no authentication mechanism.
## Authentication
INTERCEPT does **not** include authentication. This is by design for ease of use as a personal tool. If you need to expose INTERCEPT to untrusted networks:
1. Use a reverse proxy (nginx, Caddy) with authentication
2. Use a VPN to access your home network
3. Use SSH port forwarding: `ssh -L 5050:localhost:5050 your-server`
## Security Headers
INTERCEPT includes the following security headers on all responses:
| Header | Value | Purpose |
|--------|-------|---------|
| `X-Content-Type-Options` | `nosniff` | Prevent MIME type sniffing |
| `X-Frame-Options` | `SAMEORIGIN` | Prevent clickjacking |
| `X-XSS-Protection` | `1; mode=block` | Enable browser XSS filter |
| `Referrer-Policy` | `strict-origin-when-cross-origin` | Control referrer information |
| `Permissions-Policy` | `geolocation=(self), microphone=()` | Restrict browser features |
## Input Validation
All user inputs are validated before use:
- **Network interface names**: Validated against strict regex pattern
- **Bluetooth interface names**: Must match `hciX` format
- **MAC addresses**: Validated format
- **Frequencies**: Validated range and format
- **File paths**: Protected against directory traversal
- **HTML output**: All user-provided content is escaped
## Subprocess Execution
INTERCEPT executes external tools (rtl_fm, airodump-ng, etc.) via subprocess. Security measures:
- **No shell execution**: All subprocess calls use list arguments, not shell strings
- **Input validation**: All user-provided arguments are validated before use
- **Process isolation**: Each tool runs in its own process with limited permissions
## Debug Mode
Debug mode is **disabled by default**. If enabled via `INTERCEPT_DEBUG=true`:
- The Werkzeug debugger PIN is disabled (not needed for local tool)
- Additional logging is enabled
- Stack traces are shown on errors
**Never run in debug mode on untrusted networks.**
## Reporting Security Issues
If you discover a security vulnerability, please report it by:
1. Opening a GitHub issue (for non-sensitive issues)
2. Emailing the maintainer directly (for sensitive issues)
Please include:
- Description of the vulnerability
- Steps to reproduce
- Potential impact
- Suggested fix (if any)
+235 -27
View File
@@ -14,6 +14,37 @@ pip install -r requirements.txt
python3 -m pip install -r requirements.txt
```
### pip install fails for flask or skyfield
On newer Debian/Ubuntu systems, pip may fail with permission errors or dependency conflicts. **Use apt instead:**
```bash
# Install Python packages via apt (recommended for Debian/Ubuntu)
sudo apt install python3-flask python3-requests python3-serial python3-skyfield
# Then create venv with system packages
python3 -m venv --system-site-packages venv
source venv/bin/activate
sudo venv/bin/python intercept.py
```
### "error: externally-managed-environment" (pip blocked)
This is PEP 668 protection on Ubuntu 23.04+, Debian 12+, and similar systems. Solutions:
```bash
# Option 1: Use apt packages (recommended)
sudo apt install python3-flask python3-requests python3-serial python3-skyfield
python3 -m venv --system-site-packages venv
source venv/bin/activate
# Option 2: Use pipx for isolated install
pipx install flask
# Option 3: Force pip (not recommended)
pip install --break-system-packages flask
```
### "TypeError: 'type' object is not subscriptable"
This error occurs on Python 3.7 or 3.8. **INTERCEPT requires Python 3.9 or later.**
@@ -33,18 +64,12 @@ pip install -r requirements.txt
sudo venv/bin/python intercept.py
```
### "externally-managed-environment" error (Ubuntu 23.04+, Debian 12+)
### Alternative: Use the setup script
Modern systems use PEP 668 to protect system Python. Use a virtual environment:
The setup script handles all installation automatically, including apt packages:
```bash
# Option 1: Virtual environment (recommended)
python3 -m venv venv
source venv/bin/activate
pip install -r requirements.txt
sudo venv/bin/python intercept.py
# Option 2: Use the setup script (auto-creates venv if needed)
chmod +x setup.sh
./setup.sh
```
@@ -101,11 +126,204 @@ Then unplug and replug your RTL-SDR.
3. Check for other applications: `lsof | grep rtl`
### LimeSDR/HackRF not detected
Ensure the correct SoapySDR module for your hardware is installed first
1. Verify SoapySDR is installed: `SoapySDRUtil --info`
2. Check driver is loaded: `SoapySDRUtil --find`
3. May need udev rules or run as root
### Using HackRF/Airspy/LimeSDR with ADS-B
For non-RTL-SDR devices, ADS-B requires `readsb` compiled with SoapySDR support (standard dump1090 won't work).
**Option 1: Run readsb separately and connect via Remote mode**
1. Start readsb with your device:
```bash
# HackRF
readsb --device-type soapysdr --device driver=hackrf --net --quiet
# Airspy
readsb --device-type soapysdr --device driver=airspy --net --quiet
# LimeSDR
readsb --device-type soapysdr --device driver=lime --net --quiet
```
2. In Intercept's ADS-B dashboard:
- Check the **"Remote"** checkbox
- Enter Host: `localhost` and Port: `30003`
- Click **START**
3. Intercept will connect to readsb's SBS output on port 30003
**Option 2: Install readsb with SoapySDR support**
On Debian/Ubuntu:
```bash
# Install dependencies
sudo apt install build-essential debhelper librtlsdr-dev pkg-config \
libncurses5-dev libbladerf-dev libhackrf-dev liblimesuite-dev libsoapysdr-dev
# Clone and build
git clone https://github.com/wiedehopf/readsb.git
cd readsb
dpkg-buildpackage -b --no-sign
sudo dpkg -i ../readsb_*.deb
```
### Using HackRF/Airspy with Listening Post
The Listening Post requires `rx_fm` from SoapySDR utilities for non-RTL-SDR devices.
```bash
# Install SoapySDR utilities (includes rx_fm)
sudo apt install soapysdr-tools
# Verify rx_fm is available
which rx_fm
```
If `rx_fm` is installed, select your device from the SDR dropdown in the Listening Post - HackRF, Airspy, LimeSDR, and SDRPlay are all supported.
### Setting up Icecast for Listening Post Audio
The Listening Post uses Icecast for low-latency audio streaming (2-10 second latency). Intercept will automatically start Icecast when you begin listening, but you must install and configure it first.
**Install Icecast:**
```bash
# Ubuntu/Debian
sudo apt install icecast2
# macOS
brew install icecast
```
**Configure Icecast:**
During installation on Debian/Ubuntu, you'll be prompted to configure. Otherwise, edit `/etc/icecast2/icecast.xml`:
```xml
<icecast>
<authentication>
<!-- Source password - used by ffmpeg to send audio -->
<source-password>hackme</source-password>
<!-- Admin password for web interface -->
<admin-password>your-admin-password</admin-password>
</authentication>
<hostname>localhost</hostname>
<listen-socket>
<port>8000</port>
</listen-socket>
</icecast>
```
**Start Icecast:**
```bash
# Ubuntu/Debian (as service)
sudo systemctl enable icecast2
sudo systemctl start icecast2
# Or run directly
icecast -c /etc/icecast2/icecast.xml
# macOS
brew services start icecast
# Or: icecast -c /usr/local/etc/icecast.xml
```
**Verify Icecast is running:**
- Open http://localhost:8000 in your browser
- You should see the Icecast status page
**Configure Intercept (optional):**
The default configuration expects Icecast on `127.0.0.1:8000` with source password `hackme` and mount point `/listen.mp3`. To change these, modify the scanner config in your API calls or update the defaults in `routes/listening_post.py`:
```python
scanner_config = {
# ... other settings ...
'icecast_host': '127.0.0.1',
'icecast_port': 8000,
'icecast_mount': '/listen.mp3',
'icecast_source_password': 'hackme',
}
```
**Troubleshooting Icecast:**
- **"Connection refused" errors**: Ensure Icecast is running on the configured port
- **"Authentication failed"**: Check the source password matches between Icecast config and Intercept
- **No audio playing**: Check Icecast status page (http://localhost:8000) to verify the mount point is active
- **High latency**: Ensure nginx/reverse proxy isn't buffering - add `proxy_buffering off;` to nginx config
### Audio Streaming Issues - Detailed Debugging
If the Listening Post shows "Icecast mount not active" errors or audio doesn't play:
**1. Check the console output for errors**
Intercept now logs detailed error output. Look for lines starting with `[AUDIO]`:
```
[AUDIO] SDR errors: ... # Problems with rtl_fm/rx_fm (SDR not connected, device busy)
[AUDIO] FFmpeg errors: ... # Problems with ffmpeg (wrong password, codec issues)
```
**2. Verify SDR is connected and working**
```bash
# For RTL-SDR
rtl_test -t
# You should see: "Found 1 device(s)"
# If not, check USB connection and drivers
```
**3. Check Icecast password (macOS Homebrew)**
On macOS with Homebrew, the Icecast config is at `/opt/homebrew/etc/icecast.xml`. Check the source password:
```bash
grep source-password /opt/homebrew/etc/icecast.xml
```
If it's different from `hackme`, update it in the Listening Post Icecast config panel, or change the Icecast config and restart:
```bash
brew services restart icecast
```
**4. Verify ffmpeg has required codecs**
```bash
# Check MP3 encoder is available
ffmpeg -encoders 2>/dev/null | grep mp3
# Should show: libmp3lame
# If not, reinstall ffmpeg with all codecs:
# macOS: brew reinstall ffmpeg
# Linux: sudo apt install ffmpeg
```
**5. Test the pipeline manually**
Try running the audio pipeline directly to see errors:
```bash
# Test rtl_fm (should produce raw audio data)
rtl_fm -M am -f 118000000 -s 24000 -r 24000 -g 40 2>&1 | head -c 1000 | xxd | head
# Test ffmpeg to Icecast (replace PASSWORD with your source password)
rtl_fm -M am -f 118000000 -s 24000 -r 24000 -g 40 2>/dev/null | \
ffmpeg -f s16le -ar 24000 -ac 1 -i pipe:0 -c:a libmp3lame -b:a 64k \
-f mp3 -content_type audio/mpeg icecast://source:PASSWORD@127.0.0.1:8000/listen.mp3
```
**6. Common error messages and solutions**
| Error | Cause | Solution |
|-------|-------|----------|
| `No supported devices found` | SDR not connected | Plug in SDR, check USB |
| `Device or resource busy` | Another process using SDR | Click "Kill All Processes" |
| `401 Unauthorized` | Wrong Icecast password | Check password in Icecast config |
| `Connection refused` | Icecast not running | Start Icecast service |
| `Encoder libmp3lame not found` | ffmpeg missing codec | Reinstall ffmpeg with codecs |
## WiFi Issues
### Monitor mode fails
@@ -146,21 +364,6 @@ Run with sudo or add your user to the bluetooth group:
sudo usermod -a -G bluetooth $USER
```
## GPS Issues
### GPS dongle not detected
1. Install pyserial: `pip install pyserial`
2. Check device is connected:
- Linux: `ls /dev/ttyUSB* /dev/ttyACM*`
- macOS: `ls /dev/tty.usb*`
3. Add user to dialout group (Linux):
```bash
sudo usermod -a -G dialout $USER
```
4. Most GPS dongles use 9600 baud (default in INTERCEPT)
5. GPS needs clear sky view to get a fix
## Decoding Issues
### No messages appearing (Pager mode)
@@ -170,15 +373,20 @@ sudo usermod -a -G bluetooth $USER
3. Check pager services are active in your area
4. Ensure antenna is connected
### Cannot install dump1090 in Debian (ADS-B mode)
On newer Debian versions, dump1090 may not be in repositories. The recommended action is to build from source or use the setup.sh script which will do it for you.
### No aircraft appearing (ADS-B mode)
1. Verify dump1090 or readsb is installed
1. Verify dump1090 is installed
2. Check antenna is connected (1090 MHz antenna recommended)
3. Ensure clear view of sky
4. Set correct observer location for range calculations
4. Set correct observer location for range calculations or use gpsd
### Satellite passes not calculating
1. Ensure skyfield is installed: `pip install skyfield`
1. Ensure skyfield is installed: `apt install python3-skyfield`
2. Check TLE data is valid and recent
3. Verify observer location is set correctly
+18 -6
View File
@@ -1,8 +1,20 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100">
<rect width="100" height="100" fill="#000"/>
<path d="M50 5 L90 27.5 L90 72.5 L50 95 L10 72.5 L10 27.5 Z" stroke="#00d4ff" stroke-width="3" fill="none"/>
<path d="M30 50 Q40 35, 50 50 Q60 65, 70 50" stroke="#00d4ff" stroke-width="4" fill="none" stroke-linecap="round"/>
<path d="M35 50 Q42 40, 50 50 Q58 60, 65 50" stroke="#00ff88" stroke-width="3" fill="none" stroke-linecap="round"/>
<path d="M40 50 Q45 45, 50 50 Q55 55, 60 50" stroke="#ffffff" stroke-width="2" fill="none" stroke-linecap="round"/>
<circle cx="50" cy="50" r="4" fill="#00d4ff"/>
<!-- Background -->
<rect width="100" height="100" fill="#0a0a0f"/>
<!-- Signal brackets - left side -->
<path d="M15 30 Q5 50, 15 70" stroke="#00d4ff" stroke-width="4" fill="none" stroke-linecap="round" opacity="0.5"/>
<path d="M22 35 Q14 50, 22 65" stroke="#00d4ff" stroke-width="3.5" fill="none" stroke-linecap="round" opacity="0.7"/>
<path d="M29 40 Q23 50, 29 60" stroke="#00d4ff" stroke-width="3" fill="none" stroke-linecap="round"/>
<!-- Signal brackets - right side -->
<path d="M85 30 Q95 50, 85 70" stroke="#00d4ff" stroke-width="4" fill="none" stroke-linecap="round" opacity="0.5"/>
<path d="M78 35 Q86 50, 78 65" stroke="#00d4ff" stroke-width="3.5" fill="none" stroke-linecap="round" opacity="0.7"/>
<path d="M71 40 Q77 50, 71 60" stroke="#00d4ff" stroke-width="3" fill="none" stroke-linecap="round"/>
<!-- The 'i' letter -->
<circle cx="50" cy="22" r="7" fill="#00ff88"/>
<rect x="43" y="35" width="14" height="45" rx="2" fill="#00d4ff"/>
<rect x="36" y="35" width="28" height="5" rx="1" fill="#00d4ff"/>
<rect x="36" y="75" width="28" height="5" rx="1" fill="#00d4ff"/>
</svg>

Before

Width:  |  Height:  |  Size: 639 B

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.
+898
View File
@@ -0,0 +1,898 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>iNTERCEPT Promo</title>
<link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;600;700&family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
:root {
--cyan: #00d4ff;
--green: #00ff88;
--red: #ff3366;
--purple: #a855f7;
--orange: #ff9500;
--bg: #0a0a0f;
--bg-secondary: #12121a;
}
html, body {
width: 100%;
height: 100%;
background: #000;
overflow: hidden;
}
body {
display: flex;
align-items: center;
justify-content: center;
font-family: 'Inter', sans-serif;
}
/* Container maintains 9:16 aspect ratio and scales to fit */
.video-frame {
position: relative;
width: min(100vw, calc(100vh * 9 / 16));
height: min(100vh, calc(100vw * 16 / 9));
max-width: 1080px;
max-height: 1920px;
background: var(--bg);
color: #fff;
overflow: hidden;
/* Scale font size based on container width */
font-size: min(16px, calc(100vw * 16 / 1080));
}
/* Animated background grid */
.grid-bg {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
background-image:
linear-gradient(rgba(0, 212, 255, 0.03) 1px, transparent 1px),
linear-gradient(90deg, rgba(0, 212, 255, 0.03) 1px, transparent 1px);
background-size: 30px 30px;
animation: gridMove 20s linear infinite;
}
@keyframes gridMove {
0% { transform: translate(0, 0); }
100% { transform: translate(30px, 30px); }
}
/* Scanning line effect */
.scanline {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 2px;
background: linear-gradient(90deg, transparent, var(--cyan), transparent);
animation: scan 3s linear infinite;
opacity: 0.7;
z-index: 100;
}
@keyframes scan {
0% { top: 0; }
100% { top: 100%; }
}
/* Glowing orbs background */
.orb {
position: absolute;
border-radius: 50%;
filter: blur(50px);
opacity: 0.25;
animation: orbFloat 8s ease-in-out infinite;
}
.orb-1 {
width: 200px;
height: 200px;
background: var(--cyan);
top: 10%;
left: -10%;
animation-delay: 0s;
}
.orb-2 {
width: 150px;
height: 150px;
background: var(--purple);
bottom: 20%;
right: -5%;
animation-delay: 2s;
}
.orb-3 {
width: 120px;
height: 120px;
background: var(--green);
bottom: 40%;
left: 20%;
animation-delay: 4s;
}
@keyframes orbFloat {
0%, 100% { transform: translate(0, 0) scale(1); }
50% { transform: translate(30px, -30px) scale(1.1); }
}
/* Main content container */
.container {
position: relative;
z-index: 10;
width: 100%;
height: 100%;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 20px;
}
/* Scene management */
.scene {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 20px;
opacity: 0;
visibility: hidden;
transition: opacity 0.8s ease, visibility 0.8s ease;
}
.scene.active {
opacity: 1;
visibility: visible;
}
/* Scene 1: Logo reveal */
.logo-container {
text-align: center;
}
.logo-svg {
width: 140px;
height: 140px;
margin-bottom: 20px;
filter: drop-shadow(0 0 40px rgba(0, 212, 255, 0.5));
}
.logo-svg .signal-wave {
opacity: 0;
animation: signalReveal 0.5s ease forwards;
}
.logo-svg .signal-wave-1 { animation-delay: 0.5s; }
.logo-svg .signal-wave-2 { animation-delay: 0.7s; }
.logo-svg .signal-wave-3 { animation-delay: 0.9s; }
.logo-svg .signal-wave-4 { animation-delay: 0.5s; }
.logo-svg .signal-wave-5 { animation-delay: 0.7s; }
.logo-svg .signal-wave-6 { animation-delay: 0.9s; }
@keyframes signalReveal {
0% { opacity: 0; transform: scale(0.8); }
100% { opacity: 1; transform: scale(1); }
}
.logo-svg .logo-i {
opacity: 0;
animation: logoReveal 0.8s ease forwards 0.2s;
}
@keyframes logoReveal {
0% { opacity: 0; transform: translateY(20px); }
100% { opacity: 1; transform: translateY(0); }
}
.logo-svg .logo-dot {
animation: dotPulse 1.5s ease-in-out infinite 1s;
}
@keyframes dotPulse {
0%, 100% { filter: drop-shadow(0 0 5px rgba(0, 255, 136, 0.5)); }
50% { filter: drop-shadow(0 0 25px rgba(0, 255, 136, 1)); }
}
.title {
font-family: 'JetBrains Mono', monospace;
font-size: 42px;
font-weight: 700;
letter-spacing: 0.15em;
margin-bottom: 10px;
opacity: 0;
animation: titleReveal 1s ease forwards 1.2s;
}
@keyframes titleReveal {
0% { opacity: 0; transform: translateY(20px); letter-spacing: 0.3em; }
100% { opacity: 1; transform: translateY(0); letter-spacing: 0.15em; }
}
.tagline {
font-family: 'JetBrains Mono', monospace;
font-size: 18px;
color: var(--cyan);
letter-spacing: 0.1em;
opacity: 0;
animation: taglineReveal 0.8s ease forwards 1.8s;
}
@keyframes taglineReveal {
0% { opacity: 0; }
100% { opacity: 1; }
}
.subtitle {
font-size: 12px;
color: #888;
margin-top: 15px;
text-transform: uppercase;
letter-spacing: 0.05em;
opacity: 0;
animation: subtitleReveal 0.8s ease forwards 2.2s;
}
@keyframes subtitleReveal {
0% { opacity: 0; transform: translateY(20px); }
100% { opacity: 1; transform: translateY(0); }
}
/* Scene 2: Features */
.features-scene {
text-align: center;
}
.feature-title {
font-family: 'JetBrains Mono', monospace;
font-size: 24px;
color: var(--cyan);
margin-bottom: 30px;
text-transform: uppercase;
letter-spacing: 0.1em;
}
.feature-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 15px;
width: 100%;
max-width: 100%;
}
.feature-card {
background: rgba(255, 255, 255, 0.03);
border: 1px solid rgba(0, 212, 255, 0.2);
border-radius: 12px;
padding: 15px;
text-align: center;
opacity: 0;
transform: translateY(20px);
animation: featureReveal 0.6s ease forwards;
}
.feature-card:nth-child(1) { animation-delay: 0.2s; }
.feature-card:nth-child(2) { animation-delay: 0.4s; }
.feature-card:nth-child(3) { animation-delay: 0.6s; }
.feature-card:nth-child(4) { animation-delay: 0.8s; }
@keyframes featureReveal {
0% { opacity: 0; transform: translateY(20px); }
100% { opacity: 1; transform: translateY(0); }
}
.feature-icon {
font-size: 36px;
margin-bottom: 8px;
}
.feature-name {
font-family: 'JetBrains Mono', monospace;
font-size: 14px;
font-weight: 600;
margin-bottom: 4px;
}
.feature-desc {
font-size: 11px;
color: #888;
}
/* Scene 3: Modes showcase */
.modes-scene {
text-align: center;
}
.mode-showcase {
display: flex;
flex-direction: column;
gap: 8px;
width: 100%;
}
.mode-item {
display: flex;
align-items: center;
gap: 12px;
background: rgba(255, 255, 255, 0.02);
border-left: 3px solid var(--cyan);
padding: 10px 15px;
border-radius: 0 8px 8px 0;
opacity: 0;
transform: translateX(-30px);
animation: modeSlide 0.5s ease forwards;
}
.mode-item:nth-child(1) { animation-delay: 0.1s; border-color: var(--cyan); }
.mode-item:nth-child(2) { animation-delay: 0.2s; border-color: var(--green); }
.mode-item:nth-child(3) { animation-delay: 0.3s; border-color: var(--purple); }
.mode-item:nth-child(4) { animation-delay: 0.4s; border-color: var(--orange); }
.mode-item:nth-child(5) { animation-delay: 0.5s; border-color: var(--red); }
.mode-item:nth-child(6) { animation-delay: 0.6s; border-color: #00ffcc; }
.mode-item:nth-child(7) { animation-delay: 0.7s; border-color: #ff66cc; }
@keyframes modeSlide {
0% { opacity: 0; transform: translateX(-30px); }
100% { opacity: 1; transform: translateX(0); }
}
.mode-icon {
font-size: 22px;
width: 35px;
flex-shrink: 0;
}
.mode-info {
text-align: left;
}
.mode-name {
font-family: 'JetBrains Mono', monospace;
font-size: 14px;
font-weight: 600;
margin-bottom: 2px;
}
.mode-desc {
font-size: 10px;
color: #666;
}
/* Scene 4: UI Preview */
.ui-scene {
text-align: center;
}
.ui-preview {
width: 100%;
max-width: 100%;
background: var(--bg-secondary);
border-radius: 12px;
border: 1px solid rgba(0, 212, 255, 0.3);
overflow: hidden;
box-shadow: 0 0 60px rgba(0, 212, 255, 0.2);
}
.ui-header {
background: rgba(0, 0, 0, 0.5);
padding: 10px 15px;
display: flex;
align-items: center;
gap: 10px;
border-bottom: 1px solid rgba(0, 212, 255, 0.2);
}
.ui-logo-small {
width: 24px;
height: 24px;
}
.ui-title {
font-family: 'JetBrains Mono', monospace;
font-size: 14px;
font-weight: 600;
}
.ui-body {
padding: 12px;
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 10px;
}
.ui-card {
background: rgba(0, 0, 0, 0.3);
border-radius: 8px;
padding: 10px;
border: 1px solid rgba(255, 255, 255, 0.05);
}
.ui-card-header {
font-size: 8px;
color: var(--cyan);
text-transform: uppercase;
letter-spacing: 0.1em;
margin-bottom: 6px;
}
.ui-stat {
font-family: 'JetBrains Mono', monospace;
font-size: 22px;
font-weight: 700;
color: var(--green);
}
.ui-stat.cyan { color: var(--cyan); }
.ui-stat.orange { color: var(--orange); }
.ui-console {
grid-column: span 3;
background: rgba(0, 0, 0, 0.5);
border-radius: 8px;
padding: 10px;
font-family: 'JetBrains Mono', monospace;
font-size: 9px;
text-align: left;
border: 1px solid rgba(0, 212, 255, 0.1);
}
.console-line {
margin-bottom: 4px;
opacity: 0;
animation: consoleLine 0.3s ease forwards;
}
.console-line:nth-child(1) { animation-delay: 0.5s; }
.console-line:nth-child(2) { animation-delay: 0.8s; }
.console-line:nth-child(3) { animation-delay: 1.1s; }
.console-line:nth-child(4) { animation-delay: 1.4s; }
.console-line:nth-child(5) { animation-delay: 1.7s; }
@keyframes consoleLine {
0% { opacity: 0; transform: translateX(-10px); }
100% { opacity: 1; transform: translateX(0); }
}
.console-time { color: #666; }
.console-type { color: var(--cyan); }
.console-msg { color: var(--green); }
.console-freq { color: var(--orange); }
/* Scene 5: CTA */
.cta-scene {
text-align: center;
}
.cta-logo {
width: 100px;
height: 100px;
margin-bottom: 20px;
animation: ctaLogoPulse 2s ease-in-out infinite;
}
@keyframes ctaLogoPulse {
0%, 100% { filter: drop-shadow(0 0 20px rgba(0, 212, 255, 0.5)); transform: scale(1); }
50% { filter: drop-shadow(0 0 40px rgba(0, 212, 255, 0.8)); transform: scale(1.05); }
}
.cta-title {
font-family: 'JetBrains Mono', monospace;
font-size: 36px;
font-weight: 700;
letter-spacing: 0.1em;
margin-bottom: 12px;
}
.cta-tagline {
font-size: 18px;
color: var(--cyan);
margin-bottom: 30px;
}
.cta-btn {
display: inline-block;
padding: 12px 30px;
font-family: 'JetBrains Mono', monospace;
font-size: 14px;
font-weight: 600;
color: #000;
background: var(--cyan);
border-radius: 30px;
text-transform: uppercase;
letter-spacing: 0.1em;
animation: ctaBtnPulse 1.5s ease-in-out infinite;
}
@keyframes ctaBtnPulse {
0%, 100% { box-shadow: 0 0 20px rgba(0, 212, 255, 0.5); }
50% { box-shadow: 0 0 40px rgba(0, 212, 255, 0.8); }
}
.cta-url {
margin-top: 20px;
font-family: 'JetBrains Mono', monospace;
font-size: 12px;
color: #666;
}
/* Typing cursor effect */
.typing-cursor {
display: inline-block;
width: 3px;
height: 1em;
background: var(--cyan);
margin-left: 5px;
animation: blink 0.8s infinite;
}
@keyframes blink {
0%, 50% { opacity: 1; }
51%, 100% { opacity: 0; }
}
/* Progress bar */
.progress-bar {
position: absolute;
bottom: 20px;
left: 50%;
transform: translateX(-50%);
display: flex;
gap: 8px;
z-index: 1000;
}
.progress-dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: rgba(255, 255, 255, 0.2);
transition: all 0.3s ease;
}
.progress-dot.active {
background: var(--cyan);
box-shadow: 0 0 10px var(--cyan);
}
/* Decorative elements */
.corner-decoration {
position: absolute;
width: 40px;
height: 40px;
border: 1px solid rgba(0, 212, 255, 0.3);
}
.corner-tl {
top: 15px;
left: 15px;
border-right: none;
border-bottom: none;
}
.corner-tr {
top: 15px;
right: 15px;
border-left: none;
border-bottom: none;
}
.corner-bl {
bottom: 50px;
left: 15px;
border-right: none;
border-top: none;
}
.corner-br {
bottom: 50px;
right: 15px;
border-left: none;
border-top: none;
}
</style>
</head>
<body>
<div class="video-frame">
<!-- Background elements -->
<div class="grid-bg"></div>
<div class="scanline"></div>
<div class="orb orb-1"></div>
<div class="orb orb-2"></div>
<div class="orb orb-3"></div>
<!-- Corner decorations -->
<div class="corner-decoration corner-tl"></div>
<div class="corner-decoration corner-tr"></div>
<div class="corner-decoration corner-bl"></div>
<div class="corner-decoration corner-br"></div>
<!-- Scene 1: Logo Reveal -->
<div class="scene active" id="scene1">
<div class="logo-container">
<svg class="logo-svg" viewBox="0 0 100 100" fill="none" xmlns="http://www.w3.org/2000/svg">
<!-- Signal brackets - left side -->
<path class="signal-wave signal-wave-1" d="M15 30 Q5 50, 15 70" stroke="#00d4ff" stroke-width="3" fill="none" stroke-linecap="round" opacity="0.5"/>
<path class="signal-wave signal-wave-2" d="M22 35 Q14 50, 22 65" stroke="#00d4ff" stroke-width="2.5" fill="none" stroke-linecap="round" opacity="0.7"/>
<path class="signal-wave signal-wave-3" d="M29 40 Q23 50, 29 60" stroke="#00d4ff" stroke-width="2" fill="none" stroke-linecap="round"/>
<!-- Signal brackets - right side -->
<path class="signal-wave signal-wave-4" d="M85 30 Q95 50, 85 70" stroke="#00d4ff" stroke-width="3" fill="none" stroke-linecap="round" opacity="0.5"/>
<path class="signal-wave signal-wave-5" d="M78 35 Q86 50, 78 65" stroke="#00d4ff" stroke-width="2.5" fill="none" stroke-linecap="round" opacity="0.7"/>
<path class="signal-wave signal-wave-6" d="M71 40 Q77 50, 71 60" stroke="#00d4ff" stroke-width="2" fill="none" stroke-linecap="round"/>
<!-- The 'i' letter -->
<g class="logo-i">
<circle class="logo-dot" cx="50" cy="22" r="6" fill="#00ff88"/>
<rect x="44" y="35" width="12" height="45" rx="2" fill="#00d4ff"/>
<rect x="38" y="35" width="24" height="4" rx="1" fill="#00d4ff"/>
<rect x="38" y="76" width="24" height="4" rx="1" fill="#00d4ff"/>
</g>
</svg>
<h1 class="title">iNTERCEPT</h1>
<p class="tagline">// See the Invisible</p>
<p class="subtitle">Signal Intelligence & Counter Surveillance</p>
</div>
</div>
<!-- Scene 2: Features Grid -->
<div class="scene" id="scene2">
<div class="features-scene">
<h2 class="feature-title">Capabilities</h2>
<div class="feature-grid">
<div class="feature-card">
<div class="feature-icon">📡</div>
<div class="feature-name">SDR Scanning</div>
<div class="feature-desc">Multi-band reception</div>
</div>
<div class="feature-card">
<div class="feature-icon">🔐</div>
<div class="feature-name">Decryption</div>
<div class="feature-desc">Signal analysis</div>
</div>
<div class="feature-card">
<div class="feature-icon">🛰️</div>
<div class="feature-name">Tracking</div>
<div class="feature-desc">Real-time monitoring</div>
</div>
<div class="feature-card">
<div class="feature-icon">🔍</div>
<div class="feature-name">Detection</div>
<div class="feature-desc">Counter surveillance</div>
</div>
</div>
</div>
</div>
<!-- Scene 3: Modes List -->
<div class="scene" id="scene3">
<div class="modes-scene">
<div class="mode-showcase">
<div class="mode-item">
<div class="mode-icon">📟</div>
<div class="mode-info">
<div class="mode-name">PAGER</div>
<div class="mode-desc">POCSAG & FLEX decoding</div>
</div>
</div>
<div class="mode-item">
<div class="mode-icon">✈️</div>
<div class="mode-info">
<div class="mode-name">ADS-B</div>
<div class="mode-desc">Aircraft tracking</div>
</div>
</div>
<div class="mode-item">
<div class="mode-icon">📻</div>
<div class="mode-info">
<div class="mode-name">LISTENING POST</div>
<div class="mode-desc">RF monitoring & scanning</div>
</div>
</div>
<div class="mode-item">
<div class="mode-icon">📶</div>
<div class="mode-info">
<div class="mode-name">WiFi</div>
<div class="mode-desc">Network reconnaissance</div>
</div>
</div>
<div class="mode-item">
<div class="mode-icon">🔵</div>
<div class="mode-info">
<div class="mode-name">BLUETOOTH</div>
<div class="mode-desc">Device & tracker detection</div>
</div>
</div>
<div class="mode-item">
<div class="mode-icon">🌡️</div>
<div class="mode-info">
<div class="mode-name">SENSORS</div>
<div class="mode-desc">433MHz IoT decoding</div>
</div>
</div>
<div class="mode-item">
<div class="mode-icon">🛰️</div>
<div class="mode-info">
<div class="mode-name">SATELLITE</div>
<div class="mode-desc">Pass prediction & tracking</div>
</div>
</div>
</div>
</div>
</div>
<!-- Scene 4: UI Preview -->
<div class="scene" id="scene4">
<div class="ui-scene">
<div class="ui-preview">
<div class="ui-header">
<svg class="ui-logo-small" viewBox="0 0 100 100" fill="none">
<path d="M15 30 Q5 50, 15 70" stroke="#00d4ff" stroke-width="3" fill="none" stroke-linecap="round" opacity="0.5"/>
<path d="M22 35 Q14 50, 22 65" stroke="#00d4ff" stroke-width="2.5" fill="none" stroke-linecap="round" opacity="0.7"/>
<path d="M29 40 Q23 50, 29 60" stroke="#00d4ff" stroke-width="2" fill="none" stroke-linecap="round"/>
<path d="M85 30 Q95 50, 85 70" stroke="#00d4ff" stroke-width="3" fill="none" stroke-linecap="round" opacity="0.5"/>
<path d="M78 35 Q86 50, 78 65" stroke="#00d4ff" stroke-width="2.5" fill="none" stroke-linecap="round" opacity="0.7"/>
<path d="M71 40 Q77 50, 71 60" stroke="#00d4ff" stroke-width="2" fill="none" stroke-linecap="round"/>
<circle cx="50" cy="22" r="6" fill="#00ff88"/>
<rect x="44" y="35" width="12" height="45" rx="2" fill="#00d4ff"/>
<rect x="38" y="35" width="24" height="4" rx="1" fill="#00d4ff"/>
<rect x="38" y="76" width="24" height="4" rx="1" fill="#00d4ff"/>
</svg>
<span class="ui-title">iNTERCEPT</span>
</div>
<div class="ui-body">
<div class="ui-card">
<div class="ui-card-header">Messages</div>
<div class="ui-stat">2,847</div>
</div>
<div class="ui-card">
<div class="ui-card-header">Aircraft</div>
<div class="ui-stat cyan">42</div>
</div>
<div class="ui-card">
<div class="ui-card-header">Devices</div>
<div class="ui-stat orange">156</div>
</div>
<div class="ui-console">
<div class="console-line">
<span class="console-time">[14:32:07]</span>
<span class="console-type"> POCSAG </span>
<span class="console-msg">Signal intercepted</span>
<span class="console-freq"> 153.350 MHz</span>
</div>
<div class="console-line">
<span class="console-time">[14:32:09]</span>
<span class="console-type"> ADS-B </span>
<span class="console-msg">Aircraft detected: BA284</span>
<span class="console-freq"> FL350</span>
</div>
<div class="console-line">
<span class="console-time">[14:32:11]</span>
<span class="console-type"> BT </span>
<span class="console-msg">AirTag detected nearby</span>
<span class="console-freq"> -42 dBm</span>
</div>
<div class="console-line">
<span class="console-time">[14:32:14]</span>
<span class="console-type"> SENSOR </span>
<span class="console-msg">Temperature: 22.4C</span>
<span class="console-freq"> 433.92 MHz</span>
</div>
<div class="console-line">
<span class="console-time">[14:32:16]</span>
<span class="console-type"> SCAN </span>
<span class="console-msg">Signal found</span>
<span class="console-freq"> 145.500 MHz</span>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Scene 5: CTA -->
<div class="scene" id="scene5">
<div class="cta-scene">
<svg class="cta-logo" viewBox="0 0 100 100" fill="none">
<path d="M15 30 Q5 50, 15 70" stroke="#00d4ff" stroke-width="3" fill="none" stroke-linecap="round" opacity="0.5"/>
<path d="M22 35 Q14 50, 22 65" stroke="#00d4ff" stroke-width="2.5" fill="none" stroke-linecap="round" opacity="0.7"/>
<path d="M29 40 Q23 50, 29 60" stroke="#00d4ff" stroke-width="2" fill="none" stroke-linecap="round"/>
<path d="M85 30 Q95 50, 85 70" stroke="#00d4ff" stroke-width="3" fill="none" stroke-linecap="round" opacity="0.5"/>
<path d="M78 35 Q86 50, 78 65" stroke="#00d4ff" stroke-width="2.5" fill="none" stroke-linecap="round" opacity="0.7"/>
<path d="M71 40 Q77 50, 71 60" stroke="#00d4ff" stroke-width="2" fill="none" stroke-linecap="round"/>
<circle cx="50" cy="22" r="6" fill="#00ff88"/>
<rect x="44" y="35" width="12" height="45" rx="2" fill="#00d4ff"/>
<rect x="38" y="35" width="24" height="4" rx="1" fill="#00d4ff"/>
<rect x="38" y="76" width="24" height="4" rx="1" fill="#00d4ff"/>
</svg>
<h2 class="cta-title">iNTERCEPT</h2>
<p class="cta-tagline">See the Invisible</p>
<div class="cta-btn">Open Source</div>
<p class="cta-url">github.com/yourrepo/intercept</p>
</div>
</div>
<!-- Progress dots -->
<div class="progress-bar">
<div class="progress-dot active" data-scene="1"></div>
<div class="progress-dot" data-scene="2"></div>
<div class="progress-dot" data-scene="3"></div>
<div class="progress-dot" data-scene="4"></div>
<div class="progress-dot" data-scene="5"></div>
</div>
</div><!-- end video-frame -->
<script>
// Scene timing (in milliseconds)
const sceneTiming = [
{ scene: 1, duration: 4000 }, // Logo reveal
{ scene: 2, duration: 4000 }, // Features
{ scene: 3, duration: 5000 }, // Modes
{ scene: 4, duration: 5000 }, // UI Preview
{ scene: 5, duration: 4000 }, // CTA
];
let currentScene = 0;
function showScene(index) {
// Hide all scenes
document.querySelectorAll('.scene').forEach(s => s.classList.remove('active'));
document.querySelectorAll('.progress-dot').forEach(d => d.classList.remove('active'));
// Show current scene
const scene = document.getElementById(`scene${index + 1}`);
if (scene) {
scene.classList.add('active');
document.querySelector(`.progress-dot[data-scene="${index + 1}"]`).classList.add('active');
}
}
function nextScene() {
currentScene++;
if (currentScene >= sceneTiming.length) {
currentScene = 0; // Loop back to start
}
showScene(currentScene);
setTimeout(nextScene, sceneTiming[currentScene].duration);
}
// Start the animation sequence
setTimeout(nextScene, sceneTiming[0].duration);
// Keyboard controls for manual navigation
document.addEventListener('keydown', (e) => {
if (e.key === 'ArrowRight') {
currentScene = (currentScene + 1) % sceneTiming.length;
showScene(currentScene);
} else if (e.key === 'ArrowLeft') {
currentScene = (currentScene - 1 + sceneTiming.length) % sceneTiming.length;
showScene(currentScene);
} else if (e.key === ' ') {
// Spacebar to pause/resume could be added here
}
});
// Click on progress dots to jump to scene
document.querySelectorAll('.progress-dot').forEach(dot => {
dot.addEventListener('click', () => {
currentScene = parseInt(dot.dataset.scene) - 1;
showScene(currentScene);
});
});
</script>
</body>
</html>
+1
View File
@@ -40,6 +40,7 @@ Issues = "https://github.com/smittix/intercept/issues"
dev = [
"pytest>=7.0.0",
"pytest-cov>=4.0.0",
"pytest-mock>=3.15.1",
"ruff>=0.1.0",
"black>=23.0.0",
"mypy>=1.0.0",
+1
View File
@@ -4,6 +4,7 @@
# Testing
pytest>=7.0.0
pytest-cov>=4.0.0
pytest-mock>=3.15.1
# Code quality
ruff>=0.1.0
+2
View File
@@ -1,5 +1,6 @@
# Core dependencies
flask>=2.0.0
requests>=2.28.0
# Satellite tracking (optional - only needed for satellite features)
skyfield>=1.45
@@ -13,3 +14,4 @@ pyserial>=3.5
# ruff>=0.1.0
# black>=23.0.0
# mypy>=1.0.0
flask-sock
+162 -8
View File
@@ -35,6 +35,7 @@ from utils.constants import (
ADSB_UPDATE_INTERVAL,
DUMP1090_START_WAIT,
)
from utils import aircraft_db
adsb_bp = Blueprint('adsb', __name__, url_prefix='/adsb')
@@ -43,6 +44,14 @@ adsb_using_service = False
adsb_connected = False
adsb_messages_received = 0
adsb_last_message_time = None
adsb_bytes_received = 0
adsb_lines_received = 0
# Track ICAOs already looked up in aircraft database (avoid repeated lookups)
_looked_up_icaos: set[str] = set()
# Load aircraft database at module init
aircraft_db.load_database()
# Common installation paths for dump1090 (when not in PATH)
DUMP1090_PATHS = [
@@ -91,7 +100,7 @@ def check_dump1090_service():
def parse_sbs_stream(service_addr):
"""Parse SBS format data from dump1090 SBS port."""
global adsb_using_service, adsb_connected, adsb_messages_received, adsb_last_message_time
global adsb_using_service, adsb_connected, adsb_messages_received, adsb_last_message_time, adsb_bytes_received, adsb_lines_received
host, port = service_addr.split(':')
port = int(port)
@@ -111,12 +120,16 @@ def parse_sbs_stream(service_addr):
buffer = ""
last_update = time.time()
pending_updates = set()
adsb_bytes_received = 0
adsb_lines_received = 0
while adsb_using_service:
try:
data = sock.recv(SOCKET_BUFFER_SIZE).decode('utf-8', errors='ignore')
if not data:
logger.warning("SBS connection closed (no data)")
break
adsb_bytes_received += len(data)
buffer += data
while '\n' in buffer:
@@ -125,8 +138,15 @@ def parse_sbs_stream(service_addr):
if not line:
continue
adsb_lines_received += 1
# Log first few lines for debugging
if adsb_lines_received <= 3:
logger.info(f"SBS line {adsb_lines_received}: {line[:100]}")
parts = line.split(',')
if len(parts) < 11 or parts[0] != 'MSG':
if adsb_lines_received <= 5:
logger.debug(f"Skipping non-MSG line: {line[:50]}")
continue
msg_type = parts[1]
@@ -136,6 +156,18 @@ def parse_sbs_stream(service_addr):
aircraft = app_module.adsb_aircraft.get(icao) or {'icao': icao}
# Look up aircraft type from database (once per ICAO)
if icao not in _looked_up_icaos:
_looked_up_icaos.add(icao)
db_info = aircraft_db.lookup(icao)
if db_info:
if db_info['registration']:
aircraft['registration'] = db_info['registration']
if db_info['type_code']:
aircraft['type_code'] = db_info['type_code']
if db_info['type_desc']:
aircraft['type_desc'] = db_info['type_desc']
if msg_type == '1' and len(parts) > 10:
callsign = parts[10].strip()
if callsign:
@@ -154,7 +186,7 @@ def parse_sbs_stream(service_addr):
except (ValueError, TypeError):
pass
elif msg_type == '4' and len(parts) > 13:
elif msg_type == '4' and len(parts) > 16:
if parts[12]:
try:
aircraft['speed'] = int(float(parts[12]))
@@ -165,6 +197,11 @@ def parse_sbs_stream(service_addr):
aircraft['heading'] = int(float(parts[13]))
except (ValueError, TypeError):
pass
if parts[16]:
try:
aircraft['vertical_rate'] = int(float(parts[16]))
except (ValueError, TypeError):
pass
elif msg_type == '5' and len(parts) > 11:
if parts[10]:
@@ -213,25 +250,52 @@ def parse_sbs_stream(service_addr):
@adsb_bp.route('/tools')
def check_adsb_tools():
"""Check for ADS-B decoding tools."""
"""Check for ADS-B decoding tools and hardware."""
# Check available decoders
has_dump1090 = find_dump1090() is not None
has_readsb = shutil.which('readsb') is not None
has_rtl_adsb = shutil.which('rtl_adsb') is not None
# Check what SDR hardware is detected
devices = SDRFactory.detect_devices()
has_rtlsdr = any(d.sdr_type == SDRType.RTL_SDR for d in devices)
has_soapy_sdr = any(d.sdr_type in (SDRType.HACKRF, SDRType.LIME_SDR, SDRType.AIRSPY) for d in devices)
soapy_types = [d.sdr_type.value for d in devices if d.sdr_type in (SDRType.HACKRF, SDRType.LIME_SDR, SDRType.AIRSPY)]
# Determine if readsb is needed but missing
needs_readsb = has_soapy_sdr and not has_readsb
return jsonify({
'dump1090': find_dump1090() is not None,
'rtl_adsb': shutil.which('rtl_adsb') is not None
'dump1090': has_dump1090,
'readsb': has_readsb,
'rtl_adsb': has_rtl_adsb,
'has_rtlsdr': has_rtlsdr,
'has_soapy_sdr': has_soapy_sdr,
'soapy_types': soapy_types,
'needs_readsb': needs_readsb
})
@adsb_bp.route('/status')
def adsb_status():
"""Get ADS-B tracking status for debugging."""
# Check if dump1090 process is still running
dump1090_running = False
if app_module.adsb_process:
dump1090_running = app_module.adsb_process.poll() is None
return jsonify({
'tracking_active': adsb_using_service,
'connected_to_sbs': adsb_connected,
'messages_received': adsb_messages_received,
'bytes_received': adsb_bytes_received,
'lines_received': adsb_lines_received,
'last_message_time': adsb_last_message_time,
'aircraft_count': len(app_module.adsb_aircraft),
'aircraft': dict(app_module.adsb_aircraft), # Full aircraft data
'queue_size': app_module.adsb_queue.qsize(),
'dump1090_path': find_dump1090(),
'dump1090_running': dump1090_running,
'port_30003_open': check_dump1090_service() is not None
})
@@ -317,9 +381,11 @@ def start_adsb():
builder = SDRFactory.get_builder(sdr_type)
# Build ADS-B decoder command
bias_t = data.get('bias_t', False)
cmd = builder.build_adsb_command(
device=sdr_device,
gain=float(gain)
gain=float(gain),
bias_t=bias_t
)
# For RTL-SDR, ensure we use the found dump1090 path
@@ -330,13 +396,29 @@ def start_adsb():
app_module.adsb_process = subprocess.Popen(
cmd,
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL
stderr=subprocess.PIPE
)
time.sleep(DUMP1090_START_WAIT)
if app_module.adsb_process.poll() is not None:
return jsonify({'status': 'error', 'message': 'dump1090 failed to start. Check RTL-SDR device permissions or if another process is using it.'})
# Process exited - try to get error message
stderr_output = ''
if app_module.adsb_process.stderr:
try:
stderr_output = app_module.adsb_process.stderr.read().decode('utf-8', errors='ignore').strip()
except Exception:
pass
if sdr_type == SDRType.RTL_SDR:
error_msg = 'dump1090 failed to start. Check RTL-SDR device permissions or if another process is using it.'
if stderr_output:
error_msg += f' Error: {stderr_output[:200]}'
return jsonify({'status': 'error', 'message': error_msg})
else:
error_msg = f'ADS-B decoder failed to start for {sdr_type.value}. Ensure readsb is installed with SoapySDR support and the device is connected.'
if stderr_output:
error_msg += f' Error: {stderr_output[:200]}'
return jsonify({'status': 'error', 'message': error_msg})
adsb_using_service = True
thread = threading.Thread(target=parse_sbs_stream, args=(f'localhost:{ADSB_SBS_PORT}',), daemon=True)
@@ -363,6 +445,7 @@ def stop_adsb():
adsb_using_service = False
app_module.adsb_aircraft.clear()
_looked_up_icaos.clear()
return jsonify({'status': 'stopped'})
@@ -393,3 +476,74 @@ def stream_adsb():
def adsb_dashboard():
"""Popout ADS-B dashboard."""
return render_template('adsb_dashboard.html')
# ============================================
# AIRCRAFT DATABASE MANAGEMENT
# ============================================
@adsb_bp.route('/aircraft-db/status')
def aircraft_db_status():
"""Get aircraft database status."""
return jsonify(aircraft_db.get_db_status())
@adsb_bp.route('/aircraft-db/check-updates')
def aircraft_db_check_updates():
"""Check for aircraft database updates."""
result = aircraft_db.check_for_updates()
return jsonify(result)
@adsb_bp.route('/aircraft-db/download', methods=['POST'])
def aircraft_db_download():
"""Download/update aircraft database."""
global _looked_up_icaos
result = aircraft_db.download_database()
if result.get('success'):
# Clear lookup cache so new data is used
_looked_up_icaos.clear()
return jsonify(result)
@adsb_bp.route('/aircraft-db/delete', methods=['POST'])
def aircraft_db_delete():
"""Delete aircraft database."""
result = aircraft_db.delete_database()
return jsonify(result)
@adsb_bp.route('/aircraft-photo/<registration>')
def aircraft_photo(registration: str):
"""Fetch aircraft photo from Planespotters.net API."""
import requests
# Validate registration format (alphanumeric with dashes)
if not registration or not all(c.isalnum() or c == '-' for c in registration):
return jsonify({'error': 'Invalid registration'}), 400
try:
# Planespotters.net public API
url = f'https://api.planespotters.net/pub/photos/reg/{registration}'
resp = requests.get(url, timeout=5, headers={
'User-Agent': 'INTERCEPT-ADS-B/1.0'
})
if resp.status_code == 200:
data = resp.json()
if data.get('photos') and len(data['photos']) > 0:
photo = data['photos'][0]
return jsonify({
'success': True,
'thumbnail': photo.get('thumbnail_large', {}).get('src'),
'link': photo.get('link'),
'photographer': photo.get('photographer')
})
return jsonify({'success': False, 'error': 'No photo found'})
except requests.Timeout:
return jsonify({'success': False, 'error': 'Request timeout'}), 504
except Exception as e:
logger.debug(f"Error fetching aircraft photo: {e}")
return jsonify({'success': False, 'error': str(e)}), 500
+256
View File
@@ -0,0 +1,256 @@
"""WebSocket-based audio streaming for SDR."""
import subprocess
import threading
import time
import shutil
import json
from flask import Flask
# Try to import flask-sock
try:
from flask_sock import Sock
WEBSOCKET_AVAILABLE = True
except ImportError:
WEBSOCKET_AVAILABLE = False
Sock = None
from utils.logging import get_logger
logger = get_logger('intercept.audio_ws')
# Global state
audio_process = None
rtl_process = None
process_lock = threading.Lock()
current_config = {
'frequency': 118.0,
'modulation': 'am',
'squelch': 0,
'gain': 40,
'device': 0
}
def find_rtl_fm():
return shutil.which('rtl_fm')
def find_ffmpeg():
return shutil.which('ffmpeg')
def kill_audio_processes():
"""Kill any running audio processes."""
global audio_process, rtl_process
if audio_process:
try:
audio_process.terminate()
audio_process.wait(timeout=0.5)
except:
try:
audio_process.kill()
except:
pass
audio_process = None
if rtl_process:
try:
rtl_process.terminate()
rtl_process.wait(timeout=0.5)
except:
try:
rtl_process.kill()
except:
pass
rtl_process = None
# Kill any orphaned processes
try:
subprocess.run(['pkill', '-9', '-f', 'rtl_fm'], capture_output=True, timeout=1)
except:
pass
time.sleep(0.3)
def start_audio_stream(config):
"""Start rtl_fm + ffmpeg pipeline, return the ffmpeg process."""
global audio_process, rtl_process, current_config
kill_audio_processes()
rtl_fm = find_rtl_fm()
ffmpeg = find_ffmpeg()
if not rtl_fm or not ffmpeg:
logger.error("rtl_fm or ffmpeg not found")
return None
current_config.update(config)
freq = config.get('frequency', 118.0)
mod = config.get('modulation', 'am')
squelch = config.get('squelch', 0)
gain = config.get('gain', 40)
device = config.get('device', 0)
# Sample rates based on modulation
if mod == 'wfm':
sample_rate = 170000
resample_rate = 32000
elif mod in ['usb', 'lsb']:
sample_rate = 12000
resample_rate = 12000
else:
sample_rate = 24000
resample_rate = 24000
freq_hz = int(freq * 1e6)
rtl_cmd = [
rtl_fm,
'-M', mod,
'-f', str(freq_hz),
'-s', str(sample_rate),
'-r', str(resample_rate),
'-g', str(gain),
'-d', str(device),
'-l', str(squelch),
]
# Encode to MP3 for browser compatibility
ffmpeg_cmd = [
ffmpeg,
'-hide_banner',
'-loglevel', 'error',
'-f', 's16le',
'-ar', str(resample_rate),
'-ac', '1',
'-i', 'pipe:0',
'-acodec', 'libmp3lame',
'-b:a', '128k',
'-f', 'mp3',
'-flush_packets', '1',
'pipe:1'
]
try:
logger.info(f"Starting rtl_fm: {freq} MHz, {mod}")
rtl_process = subprocess.Popen(
rtl_cmd,
stdout=subprocess.PIPE,
stderr=subprocess.DEVNULL
)
audio_process = subprocess.Popen(
ffmpeg_cmd,
stdin=rtl_process.stdout,
stdout=subprocess.PIPE,
stderr=subprocess.DEVNULL,
bufsize=0
)
rtl_process.stdout.close()
# Check processes started
time.sleep(0.2)
if rtl_process.poll() is not None or audio_process.poll() is not None:
logger.error("Audio process failed to start")
kill_audio_processes()
return None
return audio_process
except Exception as e:
logger.error(f"Failed to start audio: {e}")
kill_audio_processes()
return None
def init_audio_websocket(app: Flask):
"""Initialize WebSocket audio streaming."""
if not WEBSOCKET_AVAILABLE:
logger.warning("flask-sock not installed, WebSocket audio disabled")
return
sock = Sock(app)
@sock.route('/ws/audio')
def audio_stream(ws):
"""WebSocket endpoint for audio streaming."""
logger.info("WebSocket audio client connected")
proc = None
streaming = False
try:
while True:
# Check for messages from client (non-blocking with timeout)
try:
msg = ws.receive(timeout=0.01)
if msg:
data = json.loads(msg)
cmd = data.get('cmd')
if cmd == 'start':
config = data.get('config', {})
logger.info(f"Starting audio: {config}")
with process_lock:
proc = start_audio_stream(config)
if proc:
streaming = True
ws.send(json.dumps({'status': 'started'}))
else:
ws.send(json.dumps({'status': 'error', 'message': 'Failed to start'}))
elif cmd == 'stop':
logger.info("Stopping audio")
streaming = False
with process_lock:
kill_audio_processes()
proc = None
ws.send(json.dumps({'status': 'stopped'}))
elif cmd == 'tune':
# Change frequency/modulation - restart stream
config = data.get('config', {})
logger.info(f"Retuning: {config}")
with process_lock:
proc = start_audio_stream(config)
if proc:
streaming = True
ws.send(json.dumps({'status': 'tuned'}))
else:
streaming = False
ws.send(json.dumps({'status': 'error', 'message': 'Failed to tune'}))
except TimeoutError:
pass
except Exception as e:
if "timed out" not in str(e).lower():
logger.error(f"WebSocket receive error: {e}")
# Stream audio data if active
if streaming and proc and proc.poll() is None:
try:
chunk = proc.stdout.read(4096)
if chunk:
ws.send(chunk)
except Exception as e:
logger.error(f"Audio read error: {e}")
streaming = False
elif streaming:
# Process died
streaming = False
ws.send(json.dumps({'status': 'error', 'message': 'Audio process died'}))
else:
time.sleep(0.01)
except Exception as e:
logger.info(f"WebSocket closed: {e}")
finally:
with process_lock:
kill_audio_processes()
logger.info("WebSocket audio client disconnected")
+80 -10
View File
@@ -21,6 +21,7 @@ import app as app_module
from utils.dependencies import check_tool
from utils.logging import bluetooth_logger as logger
from utils.sse import format_sse
from utils.validation import validate_bluetooth_interface
from data.oui import OUI_DATABASE, load_oui_database, get_manufacturer
from data.patterns import AIRTAG_PREFIXES, TILE_PREFIXES, SAMSUNG_TRACKER
from utils.constants import (
@@ -43,42 +44,76 @@ def classify_bt_device(name, device_class, services, manufacturer=None):
name_lower = (name or '').lower()
mfr_lower = (manufacturer or '').lower()
# Audio devices - check name patterns first
audio_patterns = [
'airpod', 'earbud', 'headphone', 'headset', 'speaker', 'audio', 'beats', 'bose',
'jbl', 'sony wh', 'sony wf', 'sennheiser', 'jabra', 'soundcore', 'anker', 'buds',
'earphone', 'pod', 'soundbar', 'skullcandy', 'marshall', 'b&o', 'bang', 'olufsen'
'earphone', 'pod', 'soundbar', 'skullcandy', 'marshall', 'b&o', 'bang', 'olufsen',
'powerbeats', 'soundlink', 'soundsport', 'quietcomfort', 'qc35', 'qc45', 'nc700',
'wh-1000', 'wf-1000', 'linkbuds', 'freebuds', 'galaxy buds', 'pixel buds',
'echo dot', 'homepod', 'sonos', 'ue boom', 'flip', 'charge', 'xtreme', 'pulse'
]
if any(x in name_lower for x in audio_patterns):
return 'audio'
# Wearables
wearable_patterns = [
'watch', 'band', 'fitbit', 'garmin', 'mi band', 'miband', 'amazfit',
'galaxy watch', 'gear', 'versa', 'sense', 'charge', 'inspire'
'galaxy watch', 'gear', 'versa', 'sense', 'charge', 'inspire', 'fenix',
'forerunner', 'venu', 'vivoactive', 'instinct', 'apple watch', 'gt 2', 'gt2'
]
if any(x in name_lower for x in wearable_patterns):
return 'wearable'
# Phones - check name patterns
phone_patterns = [
'iphone', 'galaxy', 'pixel', 'phone', 'android', 'oneplus', 'huawei', 'xiaomi'
'iphone', 'galaxy', 'pixel', 'phone', 'android', 'oneplus', 'huawei', 'xiaomi',
'redmi', 'poco', 'realme', 'oppo', 'vivo', 'motorola', 'nokia', 'lg-', 'sm-',
'moto g', 'moto e', 'note', 'ultra', 'pro max', 's21', 's22', 's23', 's24'
]
if any(x in name_lower for x in phone_patterns):
return 'phone'
tracker_patterns = ['airtag', 'tile', 'smarttag', 'chipolo', 'find my']
# Trackers
tracker_patterns = ['airtag', 'tile', 'smarttag', 'chipolo', 'find my', 'findmy']
if any(x in name_lower for x in tracker_patterns):
return 'tracker'
input_patterns = ['keyboard', 'mouse', 'controller', 'gamepad', 'remote']
# Input devices
input_patterns = ['keyboard', 'mouse', 'controller', 'gamepad', 'remote', 'trackpad',
'magic keyboard', 'magic mouse', 'magic trackpad', 'mx master', 'mx keys',
'logitech k', 'logitech m', 'razer', 'dualshock', 'dualsense', 'xbox']
if any(x in name_lower for x in input_patterns):
return 'input'
if mfr_lower in ['bose', 'jbl', 'sony', 'sennheiser', 'jabra', 'beats']:
# Computers/laptops
computer_patterns = ['macbook', 'imac', 'mac pro', 'mac mini', 'dell', 'hp ', 'lenovo',
'thinkpad', 'surface', 'chromebook', 'laptop', 'desktop', 'pc']
if any(x in name_lower for x in computer_patterns):
return 'computer'
# Check manufacturer for device type inference
audio_manufacturers = ['bose', 'jbl', 'sony', 'sennheiser', 'jabra', 'beats',
'bang & olufsen', 'audio-technica', 'skullcandy', 'anker', 'plantronics']
if mfr_lower in audio_manufacturers:
return 'audio'
if mfr_lower in ['fitbit', 'garmin']:
wearable_manufacturers = ['fitbit', 'garmin']
if mfr_lower in wearable_manufacturers:
return 'wearable'
if mfr_lower == 'tile':
return 'tracker'
phone_manufacturers = ['samsung', 'xiaomi', 'huawei', 'oneplus', 'google', 'oppo', 'vivo', 'realme']
if mfr_lower in phone_manufacturers:
return 'phone'
computer_manufacturers = ['dell', 'hp', 'lenovo', 'microsoft', 'intel']
if mfr_lower in computer_manufacturers:
return 'computer'
# Check device class if available
if device_class:
major_class = (device_class >> 8) & 0x1F
if major_class == 1:
@@ -218,18 +253,43 @@ def stream_bt_scan(process, scan_mode):
line = re.sub(r'\r', '', line)
if 'Device' in line:
# Check for RSSI update: [CHG] Device XX:XX:XX RSSI: -65
rssi_match = re.search(r'([0-9A-Fa-f]{2}:[0-9A-Fa-f]{2}:[0-9A-Fa-f]{2}:[0-9A-Fa-f]{2}:[0-9A-Fa-f]{2}:[0-9A-Fa-f]{2}).*RSSI:\s*(-?\d+)', line)
if rssi_match:
mac = rssi_match.group(1).upper()
rssi = int(rssi_match.group(2))
if mac in app_module.bt_devices:
app_module.bt_devices[mac]['rssi'] = rssi
app_module.bt_devices[mac]['last_seen'] = time.time()
# Send RSSI update
app_module.bt_queue.put({
**app_module.bt_devices[mac],
'type': 'device',
'device_type': app_module.bt_devices[mac].get('type', 'other'),
'action': 'update',
})
continue
# Check for new device: [NEW] Device XX:XX:XX Name
match = re.search(r'([0-9A-Fa-f]{2}:[0-9A-Fa-f]{2}:[0-9A-Fa-f]{2}:[0-9A-Fa-f]{2}:[0-9A-Fa-f]{2}:[0-9A-Fa-f]{2})\s*(.*)', line)
if match:
mac = match.group(1).upper()
name = match.group(2).strip()
# Extract RSSI from name if present
rssi_in_name = re.search(r'RSSI:\s*(-?\d+)', name)
initial_rssi = int(rssi_in_name.group(1)) if rssi_in_name else None
# Remove "RSSI: -XX" from name
name = re.sub(r'\s*RSSI:\s*-?\d+\s*', '', name).strip()
manufacturer = get_manufacturer(mac)
device = {
'mac': mac,
'name': name or '[Unknown]',
'manufacturer': manufacturer,
'type': classify_bt_device(name, None, None, manufacturer),
'rssi': None,
'rssi': initial_rssi,
'last_seen': time.time()
}
@@ -304,9 +364,14 @@ def start_bt_scan():
data = request.json
scan_mode = data.get('mode', 'hcitool')
interface = data.get('interface', 'hci0')
scan_ble = data.get('scan_ble', True)
# Validate Bluetooth interface name
try:
interface = validate_bluetooth_interface(data.get('interface', 'hci0'))
except ValueError as e:
return jsonify({'status': 'error', 'message': str(e)}), 400
app_module.bt_interface = interface
app_module.bt_devices = {}
@@ -388,7 +453,12 @@ def stop_bt_scan():
def reset_bt_adapter():
"""Reset Bluetooth adapter."""
data = request.json
interface = data.get('interface', 'hci0')
# Validate Bluetooth interface name
try:
interface = validate_bluetooth_interface(data.get('interface', 'hci0'))
except ValueError as e:
return jsonify({'status': 'error', 'message': str(e)}), 400
with app_module.bt_lock:
if app_module.bt_process:
+44 -154
View File
@@ -1,9 +1,8 @@
"""GPS dongle routes for USB GPS device support."""
"""GPS routes for gpsd daemon support."""
from __future__ import annotations
import queue
import threading
import time
from typing import Generator
@@ -12,15 +11,11 @@ from flask import Blueprint, jsonify, request, Response
from utils.logging import get_logger
from utils.sse import format_sse
from utils.gps import (
detect_gps_devices,
is_serial_available,
get_gps_reader,
start_gps,
start_gpsd,
stop_gps,
get_current_position,
GPSPosition,
GPSDClient,
)
logger = get_logger('intercept.gps')
@@ -44,93 +39,42 @@ def _position_callback(position: GPSPosition) -> None:
pass
@gps_bp.route('/available')
def check_gps_available():
"""Check if GPS dongle support is available."""
return jsonify({
'available': is_serial_available(),
'message': None if is_serial_available() else 'pyserial not installed - run: pip install pyserial'
})
@gps_bp.route('/auto-connect', methods=['POST'])
def auto_connect_gps():
"""
Automatically connect to gpsd if available.
@gps_bp.route('/gpsd/check')
def check_gpsd_available():
"""Check if gpsd is reachable."""
Called on page load to seamlessly enable GPS if gpsd is running.
Returns current status if already connected.
"""
import socket
host = request.args.get('host', 'localhost')
port = int(request.args.get('port', 2947))
# Check if already running
reader = get_gps_reader()
if reader and reader.is_running:
position = reader.position
return jsonify({
'status': 'connected',
'source': 'gpsd',
'has_fix': position is not None,
'position': position.to_dict() if position else None
})
# Try to connect to gpsd on localhost:2947
host = 'localhost'
port = 2947
# First check if gpsd is reachable
try:
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
sock.settimeout(2.0)
sock.settimeout(1.0)
sock.connect((host, port))
sock.close()
except Exception:
return jsonify({
'available': True,
'host': host,
'port': port,
'message': f'gpsd reachable at {host}:{port}'
'status': 'unavailable',
'message': 'gpsd not running'
})
except Exception as e:
return jsonify({
'available': False,
'host': host,
'port': port,
'message': f'Cannot connect to gpsd at {host}:{port}: {e}'
})
@gps_bp.route('/devices')
def list_gps_devices():
"""List available GPS serial devices."""
if not is_serial_available():
return jsonify({
'status': 'error',
'message': 'pyserial not installed'
}), 503
devices = detect_gps_devices()
return jsonify({
'status': 'ok',
'devices': devices
})
@gps_bp.route('/start', methods=['POST'])
def start_gps_reader():
"""Start GPS reader on specified device."""
if not is_serial_available():
return jsonify({
'status': 'error',
'message': 'pyserial not installed'
}), 503
# Check if already running
reader = get_gps_reader()
if reader and reader.is_running:
return jsonify({
'status': 'error',
'message': 'GPS reader already running'
}), 409
data = request.json or {}
device_path = data.get('device')
baudrate = data.get('baudrate', 9600)
if not device_path:
return jsonify({
'status': 'error',
'message': 'Device path required'
}), 400
# Validate baudrate
valid_baudrates = [4800, 9600, 19200, 38400, 57600, 115200]
if baudrate not in valid_baudrates:
return jsonify({
'status': 'error',
'message': f'Invalid baudrate. Valid options: {valid_baudrates}'
}), 400
# Clear the queue
while not _gps_queue.empty():
@@ -139,80 +83,26 @@ def start_gps_reader():
except queue.Empty:
break
# Start the GPS reader with callback pre-registered (avoids race condition)
success = start_gps(device_path, baudrate, callback=_position_callback)
if success:
return jsonify({
'status': 'started',
'device': device_path,
'baudrate': baudrate,
'source': 'serial'
})
else:
reader = get_gps_reader()
error = reader.error if reader else 'Unknown error'
return jsonify({
'status': 'error',
'message': f'Failed to start GPS reader: {error}'
}), 500
@gps_bp.route('/gpsd/start', methods=['POST'])
def start_gpsd_client():
"""Start GPS client connected to gpsd."""
# Check if already running
reader = get_gps_reader()
if reader and reader.is_running:
return jsonify({
'status': 'error',
'message': 'GPS reader already running'
}), 409
data = request.json or {}
host = data.get('host', 'localhost')
port = data.get('port', 2947)
# Validate port
try:
port = int(port)
if not (1 <= port <= 65535):
raise ValueError("Port out of range")
except (ValueError, TypeError):
return jsonify({
'status': 'error',
'message': 'Invalid port number'
}), 400
# Clear the queue
while not _gps_queue.empty():
try:
_gps_queue.get_nowait()
except queue.Empty:
break
# Start the gpsd client with callback pre-registered
# Start the gpsd client
success = start_gpsd(host, port, callback=_position_callback)
if success:
return jsonify({
'status': 'started',
'host': host,
'port': port,
'source': 'gpsd'
'status': 'connected',
'source': 'gpsd',
'has_fix': False,
'position': None
})
else:
reader = get_gps_reader()
error = reader.error if reader else 'Unknown error'
return jsonify({
'status': 'error',
'message': f'Failed to connect to gpsd: {error}'
}), 500
'status': 'unavailable',
'message': 'Failed to connect to gpsd'
})
@gps_bp.route('/stop', methods=['POST'])
def stop_gps_reader():
"""Stop GPS reader."""
"""Stop GPS client."""
reader = get_gps_reader()
if reader:
reader.remove_callback(_position_callback)
@@ -224,7 +114,7 @@ def stop_gps_reader():
@gps_bp.route('/status')
def get_gps_status():
"""Get current GPS reader status."""
"""Get current GPS client status."""
reader = get_gps_reader()
if not reader:
@@ -233,7 +123,7 @@ def get_gps_status():
'device': None,
'position': None,
'error': None,
'message': 'GPS reader not started'
'message': 'GPS client not started'
})
position = reader.position
@@ -262,7 +152,7 @@ def get_position():
if not reader or not reader.is_running:
return jsonify({
'status': 'error',
'message': 'GPS reader not running'
'message': 'GPS client not running'
}), 400
else:
return jsonify({
@@ -273,22 +163,22 @@ def get_position():
@gps_bp.route('/debug')
def debug_gps():
"""Debug endpoint showing GPS reader state."""
"""Debug endpoint showing GPS client state."""
reader = get_gps_reader()
if not reader:
return jsonify({
'reader': None,
'message': 'No GPS reader initialized'
'message': 'No GPS client initialized'
})
position = reader.position
source = 'gpsd' if isinstance(reader, GPSDClient) else 'serial'
return jsonify({
'running': reader.is_running,
'source': source,
'source': 'gpsd',
'device': reader.device_path,
'baudrate': reader.baudrate,
'host': reader.host,
'port': reader.port,
'has_position': position is not None,
'position': position.to_dict() if position else None,
'last_update': reader.last_update.isoformat() if reader.last_update else None,
+231 -90
View File
@@ -5,6 +5,8 @@ from __future__ import annotations
import json
import os
import queue
import select
import signal
import shutil
import subprocess
import threading
@@ -21,6 +23,7 @@ from utils.constants import (
SSE_KEEPALIVE_INTERVAL,
PROCESS_TERMINATE_TIMEOUT,
)
from utils.sdr import SDRFactory, SDRType
logger = get_logger('intercept.listening_post')
@@ -54,6 +57,8 @@ scanner_config = {
'scan_delay': 0.1, # Seconds between frequency hops (keep low for fast scanning)
'device': 0,
'gain': 40,
'bias_t': False, # Bias-T power for external LNA
'sdr_type': 'rtlsdr', # SDR type: rtlsdr, hackrf, airspy, limesdr, sdrplay
}
# Activity log
@@ -74,14 +79,16 @@ def find_rtl_fm() -> str | None:
return shutil.which('rtl_fm')
def find_rx_fm() -> str | None:
"""Find rx_fm binary (SoapySDR FM demodulator for HackRF/Airspy/LimeSDR)."""
return shutil.which('rx_fm')
def find_ffmpeg() -> str | None:
"""Find ffmpeg for audio encoding."""
return shutil.which('ffmpeg')
def find_sox() -> str | None:
"""Find sox for audio encoding."""
return shutil.which('sox')
def add_activity_log(event_type: str, frequency: float, details: str = ''):
@@ -133,9 +140,6 @@ def scanner_loop():
last_signal_time = 0
signal_detected = False
# Convert step from kHz to MHz
step_mhz = scanner_config['step'] / 1000.0
try:
while scanner_running:
# Check if paused
@@ -143,6 +147,13 @@ def scanner_loop():
time.sleep(0.1)
continue
# Read config values on each iteration (allows live updates)
step_mhz = scanner_config['step'] / 1000.0
squelch = scanner_config['squelch']
mod = scanner_config['modulation']
gain = scanner_config['gain']
device = scanner_config['device']
scanner_current_freq = current_freq
# Notify clients of frequency change
@@ -157,7 +168,6 @@ def scanner_loop():
# Start rtl_fm at this frequency
freq_hz = int(current_freq * 1e6)
mod = scanner_config['modulation']
# Sample rates
if mod == 'wfm':
@@ -177,9 +187,12 @@ def scanner_loop():
'-f', str(freq_hz),
'-s', str(sample_rate),
'-r', str(resample_rate),
'-g', str(scanner_config['gain']),
'-d', str(scanner_config['device']),
'-g', str(gain),
'-d', str(device),
]
# Add bias-t flag if enabled (for external LNA power)
if scanner_config.get('bias_t', False):
rtl_cmd.append('-T')
try:
# Start rtl_fm
@@ -212,21 +225,22 @@ def scanner_loop():
# Analyze audio level
audio_detected = False
rms = 0
threshold = 3000
threshold = 500
if len(audio_data) > 100:
import struct
samples = struct.unpack(f'{len(audio_data)//2}h', audio_data)
# Calculate RMS level (root mean square)
rms = (sum(s*s for s in samples) / len(samples)) ** 0.5
# WFM (broadcast FM) has much higher audio output - needs higher threshold
# AM/NFM have lower output levels
# Threshold based on squelch setting
# Lower squelch = more sensitive (lower threshold)
# squelch 0 = very sensitive, squelch 100 = only strong signals
if mod == 'wfm':
# WFM: threshold 4000-12000 based on squelch
threshold = 4000 + (scanner_config['squelch'] * 80)
# WFM: threshold 500-10000 based on squelch
threshold = 500 + (squelch * 95)
else:
# AM/NFM: threshold 1500-8000 based on squelch
threshold = 1500 + (scanner_config['squelch'] * 65)
# AM/NFM: threshold 300-6500 based on squelch
threshold = 300 + (squelch * 62)
audio_detected = rms > threshold
@@ -340,14 +354,19 @@ def _start_audio_stream(frequency: float, modulation: str):
# Stop any existing stream
_stop_audio_stream_internal()
rtl_fm_path = find_rtl_fm()
ffmpeg_path = find_ffmpeg()
if not rtl_fm_path or not ffmpeg_path:
if not ffmpeg_path:
logger.error("ffmpeg not found")
return
freq_hz = int(frequency * 1e6)
# Determine SDR type and build appropriate command
sdr_type_str = scanner_config.get('sdr_type', 'rtlsdr')
try:
sdr_type = SDRType(sdr_type_str)
except ValueError:
sdr_type = SDRType.RTL_SDR
# Set sample rates based on modulation
if modulation == 'wfm':
sample_rate = 170000
resample_rate = 32000
@@ -358,48 +377,93 @@ def _start_audio_stream(frequency: float, modulation: str):
sample_rate = 24000
resample_rate = 24000
rtl_cmd = [
rtl_fm_path,
'-M', modulation,
'-f', str(freq_hz),
'-s', str(sample_rate),
'-r', str(resample_rate),
'-g', str(scanner_config['gain']),
'-d', str(scanner_config['device']),
'-l', str(scanner_config['squelch']),
]
# Build the SDR command based on device type
if sdr_type == SDRType.RTL_SDR:
# Use rtl_fm for RTL-SDR devices
rtl_fm_path = find_rtl_fm()
if not rtl_fm_path:
logger.error("rtl_fm not found")
return
freq_hz = int(frequency * 1e6)
sdr_cmd = [
rtl_fm_path,
'-M', modulation,
'-f', str(freq_hz),
'-s', str(sample_rate),
'-r', str(resample_rate),
'-g', str(scanner_config['gain']),
'-d', str(scanner_config['device']),
'-l', str(scanner_config['squelch']),
]
if scanner_config.get('bias_t', False):
sdr_cmd.append('-T')
else:
# Use SDR abstraction layer for HackRF, Airspy, LimeSDR, SDRPlay
rx_fm_path = find_rx_fm()
if not rx_fm_path:
logger.error(f"rx_fm not found - required for {sdr_type.value}. Install SoapySDR utilities.")
return
# Create device and get command builder
device = SDRFactory.create_default_device(sdr_type, index=scanner_config['device'])
builder = SDRFactory.get_builder(sdr_type)
# Build FM demod command
sdr_cmd = builder.build_fm_demod_command(
device=device,
frequency_mhz=frequency,
sample_rate=resample_rate,
gain=float(scanner_config['gain']),
modulation=modulation,
squelch=scanner_config['squelch'],
bias_t=scanner_config.get('bias_t', False)
)
# Ensure we use the found rx_fm path
sdr_cmd[0] = rx_fm_path
encoder_cmd = [
ffmpeg_path,
'-hide_banner',
'-loglevel', 'error',
'-f', 's16le',
'-ar', str(resample_rate),
'-ac', '1',
'-i', 'pipe:0',
'-acodec', 'libmp3lame',
'-b:a', '128k',
'-ar', '44100',
'-f', 'mp3',
'-b:a', '64k',
'-flush_packets', '1',
'pipe:1'
]
try:
audio_rtl_process = subprocess.Popen(
rtl_cmd,
stdout=subprocess.PIPE,
stderr=subprocess.DEVNULL
)
# Use shell pipe for reliable streaming (Python subprocess piping can be unreliable)
shell_cmd = f"{' '.join(sdr_cmd)} 2>/dev/null | {' '.join(encoder_cmd)}"
logger.info(f"Starting audio pipeline: {shell_cmd}")
audio_rtl_process = None # Not used in shell mode
audio_process = subprocess.Popen(
encoder_cmd,
stdin=audio_rtl_process.stdout,
shell_cmd,
shell=True,
stdout=subprocess.PIPE,
stderr=subprocess.DEVNULL,
bufsize=0
stderr=subprocess.PIPE,
bufsize=0,
start_new_session=True # Create new process group for clean shutdown
)
audio_rtl_process.stdout.close()
# Brief delay to check if process started successfully
time.sleep(0.3)
if audio_process.poll() is not None:
stderr = audio_process.stderr.read().decode() if audio_process.stderr else ''
logger.error(f"Audio pipeline exited immediately: {stderr}")
return
audio_running = True
audio_frequency = frequency
audio_modulation = modulation
logger.info(f"Audio stream started: {frequency} MHz ({modulation}) via {sdr_type.value}")
except Exception as e:
logger.error(f"Failed to start audio stream: {e}")
@@ -415,31 +479,38 @@ def _stop_audio_stream_internal():
"""Internal stop (must hold lock)."""
global audio_process, audio_rtl_process, audio_running, audio_frequency
if audio_process:
try:
audio_process.terminate()
audio_process.wait(timeout=1)
except:
try:
audio_process.kill()
except:
pass
audio_process = None
if audio_rtl_process:
try:
audio_rtl_process.terminate()
audio_rtl_process.wait(timeout=1)
except:
try:
audio_rtl_process.kill()
except:
pass
audio_rtl_process = None
# Set flag first to stop any streaming
audio_running = False
audio_frequency = 0.0
# Kill the shell process and its children
if audio_process:
try:
# Kill entire process group (rtl_fm, ffmpeg, shell)
try:
os.killpg(os.getpgid(audio_process.pid), signal.SIGKILL)
except (ProcessLookupError, PermissionError):
audio_process.kill()
audio_process.wait(timeout=0.5)
except:
pass
audio_process = None
audio_rtl_process = None
# Kill any orphaned rtl_fm and ffmpeg processes
try:
subprocess.run(['pkill', '-9', 'rtl_fm'], capture_output=True, timeout=0.5)
except:
pass
try:
subprocess.run(['pkill', '-9', '-f', 'ffmpeg.*pipe:0'], capture_output=True, timeout=0.5)
except:
pass
# Pause for SDR device to be released (important for frequency/modulation changes)
time.sleep(0.7)
# ============================================
# API ENDPOINTS
@@ -449,16 +520,23 @@ def _stop_audio_stream_internal():
def check_tools() -> Response:
"""Check for required tools."""
rtl_fm = find_rtl_fm()
rx_fm = find_rx_fm()
ffmpeg = find_ffmpeg()
sox = find_sox()
can_stream = ffmpeg is not None or sox is not None
# Determine which SDR types are supported
supported_sdr_types = []
if rtl_fm:
supported_sdr_types.append('rtlsdr')
if rx_fm:
# rx_fm from SoapySDR supports these types
supported_sdr_types.extend(['hackrf', 'airspy', 'limesdr', 'sdrplay'])
return jsonify({
'rtl_fm': rtl_fm is not None,
'rx_fm': rx_fm is not None,
'ffmpeg': ffmpeg is not None,
'sox': sox is not None,
'can_stream': can_stream,
'available': rtl_fm is not None and can_stream
'available': (rtl_fm is not None or rx_fm is not None) and ffmpeg is not None,
'supported_sdr_types': supported_sdr_types
})
@@ -487,6 +565,8 @@ def start_scanner() -> Response:
scanner_config['scan_delay'] = float(data.get('scan_delay', 0.5))
scanner_config['device'] = int(data.get('device', 0))
scanner_config['gain'] = int(data.get('gain', 40))
scanner_config['bias_t'] = bool(data.get('bias_t', False))
scanner_config['sdr_type'] = str(data.get('sdr_type', 'rtlsdr')).lower()
except (ValueError, TypeError) as e:
return jsonify({
'status': 'error',
@@ -500,12 +580,20 @@ def start_scanner() -> Response:
'message': 'start_freq must be less than end_freq'
}), 400
# Check tools
if not find_rtl_fm():
return jsonify({
'status': 'error',
'message': 'rtl_fm not found. Install rtl-sdr tools.'
}), 503
# Check tools based on SDR type
sdr_type = scanner_config['sdr_type']
if sdr_type == 'rtlsdr':
if not find_rtl_fm():
return jsonify({
'status': 'error',
'message': 'rtl_fm not found. Install rtl-sdr tools.'
}), 503
else:
if not find_rx_fm():
return jsonify({
'status': 'error',
'message': f'rx_fm not found. Install SoapySDR utilities for {sdr_type}.'
}), 503
# Start scanner thread
scanner_running = True
@@ -571,6 +659,42 @@ def skip_signal() -> Response:
})
@listening_post_bp.route('/scanner/config', methods=['POST'])
def update_scanner_config() -> Response:
"""Update scanner config while running (step, squelch, gain, dwell)."""
data = request.json or {}
updated = []
if 'step' in data:
scanner_config['step'] = float(data['step'])
updated.append(f"step={data['step']}kHz")
if 'squelch' in data:
scanner_config['squelch'] = int(data['squelch'])
updated.append(f"squelch={data['squelch']}")
if 'gain' in data:
scanner_config['gain'] = int(data['gain'])
updated.append(f"gain={data['gain']}")
if 'dwell_time' in data:
scanner_config['dwell_time'] = int(data['dwell_time'])
updated.append(f"dwell={data['dwell_time']}s")
if 'modulation' in data:
scanner_config['modulation'] = str(data['modulation']).lower()
updated.append(f"mod={data['modulation']}")
if updated:
logger.info(f"Scanner config updated: {', '.join(updated)}")
return jsonify({
'status': 'updated',
'config': scanner_config
})
@listening_post_bp.route('/scanner/status')
def scanner_status() -> Response:
"""Get scanner status."""
@@ -651,6 +775,8 @@ def start_audio() -> Response:
"""Start audio at specific frequency (manual mode)."""
global scanner_running
logger.info("Audio start request received")
# Stop scanner if running
if scanner_running:
scanner_running = False
@@ -664,6 +790,7 @@ def start_audio() -> Response:
squelch = int(data.get('squelch', 0))
gain = int(data.get('gain', 40))
device = int(data.get('device', 0))
sdr_type = str(data.get('sdr_type', 'rtlsdr')).lower()
except (ValueError, TypeError) as e:
return jsonify({
'status': 'error',
@@ -683,25 +810,31 @@ def start_audio() -> Response:
'message': f'Invalid modulation. Use: {", ".join(valid_mods)}'
}), 400
valid_sdr_types = ['rtlsdr', 'hackrf', 'airspy', 'limesdr', 'sdrplay']
if sdr_type not in valid_sdr_types:
return jsonify({
'status': 'error',
'message': f'Invalid sdr_type. Use: {", ".join(valid_sdr_types)}'
}), 400
# Update config for audio
scanner_config['squelch'] = squelch
scanner_config['gain'] = gain
scanner_config['device'] = device
scanner_config['sdr_type'] = sdr_type
_start_audio_stream(frequency, modulation)
if audio_running:
add_activity_log('manual_tune', frequency, f'Manual tune to {frequency} MHz ({modulation.upper()})')
return jsonify({
'status': 'started',
'frequency': frequency,
'modulation': modulation,
'stream_url': '/listening/audio/stream'
'modulation': modulation
})
else:
return jsonify({
'status': 'error',
'message': 'Failed to start audio'
'message': 'Failed to start audio. Check SDR device.'
}), 500
@@ -725,22 +858,30 @@ def audio_status() -> Response:
@listening_post_bp.route('/audio/stream')
def stream_audio() -> Response:
"""Stream MP3 audio."""
# Wait for audio to be ready (up to 2 seconds for modulation/squelch changes)
for _ in range(40):
if audio_running and audio_process:
break
time.sleep(0.05)
if not audio_running or not audio_process:
return jsonify({
'status': 'error',
'message': 'Audio not running'
}), 400
return Response(b'', mimetype='audio/mpeg', status=204)
def generate():
chunk_size = 4096
try:
while audio_running and audio_process and audio_process.poll() is None:
chunk = audio_process.stdout.read(chunk_size)
if not chunk:
break
yield chunk
except Exception as e:
logger.error(f"Audio stream error: {e}")
# Use select to avoid blocking forever
ready, _, _ = select.select([audio_process.stdout], [], [], 2.0)
if ready:
chunk = audio_process.stdout.read(4096)
if chunk:
yield chunk
else:
break
except GeneratorExit:
pass
except:
pass
return Response(
generate(),
+3 -1
View File
@@ -233,6 +233,7 @@ def start_decoding() -> Response:
builder = SDRFactory.get_builder(sdr_device.sdr_type)
# Build FM demodulation command
bias_t = data.get('bias_t', False)
rtl_cmd = builder.build_fm_demod_command(
device=sdr_device,
frequency_mhz=freq,
@@ -240,7 +241,8 @@ def start_decoding() -> Response:
gain=float(gain) if gain and gain != '0' else None,
ppm=int(ppm) if ppm and ppm != '0' else None,
modulation='fm',
squelch=squelch if squelch and squelch != 0 else None
squelch=squelch if squelch and squelch != 0 else None,
bias_t=bias_t
)
multimon_cmd = ['multimon-ng', '-t', 'raw'] + decoders + ['-f', 'alpha', '-']
+4 -3
View File
@@ -114,11 +114,13 @@ def start_sensor() -> Response:
builder = SDRFactory.get_builder(sdr_device.sdr_type)
# Build ISM band decoder command
bias_t = data.get('bias_t', False)
cmd = builder.build_ism_command(
device=sdr_device,
frequency_mhz=freq,
gain=float(gain) if gain and gain != 0 else None,
ppm=int(ppm) if ppm and ppm != 0 else None
ppm=int(ppm) if ppm and ppm != 0 else None,
bias_t=bias_t
)
full_cmd = ' '.join(cmd)
@@ -128,8 +130,7 @@ def start_sensor() -> Response:
app_module.sensor_process = subprocess.Popen(
cmd,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
bufsize=1
stderr=subprocess.PIPE
)
# Start output thread
-62
View File
@@ -9,8 +9,6 @@ from utils.database import (
set_setting,
delete_setting,
get_all_settings,
get_signal_history,
add_signal_reading,
get_correlations,
)
from utils.logging import get_logger
@@ -145,66 +143,6 @@ def delete_single_setting(key: str) -> Response:
}), 500
# =============================================================================
# Signal History Endpoints
# =============================================================================
@settings_bp.route('/signal-history/<mode>/<device_id>', methods=['GET'])
def get_device_signal_history(mode: str, device_id: str) -> Response:
"""Get signal strength history for a device."""
limit = request.args.get('limit', 100, type=int)
since_minutes = request.args.get('since', 60, type=int)
# Validate mode
valid_modes = ['wifi', 'bluetooth', 'adsb', 'pager', 'sensor']
if mode not in valid_modes:
return jsonify({
'status': 'error',
'message': f'Invalid mode. Valid modes: {valid_modes}'
}), 400
try:
history = get_signal_history(mode, device_id, limit, since_minutes)
return jsonify({
'status': 'success',
'mode': mode,
'device_id': device_id,
'history': history
})
except Exception as e:
logger.error(f"Error getting signal history: {e}")
return jsonify({
'status': 'error',
'message': str(e)
}), 500
@settings_bp.route('/signal-history', methods=['POST'])
def add_signal_history() -> Response:
"""Add a signal strength reading (for internal use)."""
data = request.json or {}
mode = data.get('mode')
device_id = data.get('device_id')
signal_strength = data.get('signal_strength')
if not all([mode, device_id, signal_strength is not None]):
return jsonify({
'status': 'error',
'message': 'mode, device_id, and signal_strength are required'
}), 400
try:
add_signal_reading(mode, device_id, signal_strength, data.get('metadata'))
return jsonify({'status': 'success'})
except Exception as e:
logger.error(f"Error adding signal reading: {e}")
return jsonify({
'status': 'error',
'message': str(e)
}), 500
# =============================================================================
# Device Correlation Endpoints
# =============================================================================
+310 -31
View File
@@ -16,10 +16,10 @@ from typing import Any, Generator
from flask import Blueprint, jsonify, request, Response
import app as app_module
from utils.dependencies import check_tool
from utils.dependencies import check_tool, get_tool_path
from utils.logging import wifi_logger as logger
from utils.process import is_valid_mac, is_valid_channel
from utils.validation import validate_wifi_channel, validate_mac_address
from utils.validation import validate_wifi_channel, validate_mac_address, validate_network_interface
from utils.sse import format_sse
from data.oui import get_manufacturer
from utils.constants import (
@@ -105,12 +105,18 @@ def detect_wifi_interfaces():
current_iface = line.split()[1]
elif current_iface and 'type' in line:
iface_type = line.split()[-1]
interfaces.append({
iface_info = {
'name': current_iface,
'type': iface_type,
'monitor_capable': True,
'status': 'up'
})
'status': 'up',
'driver': '',
'chipset': '',
'mac': ''
}
# Get additional interface details
iface_info.update(_get_interface_details(current_iface))
interfaces.append(iface_info)
current_iface = None
except FileNotFoundError:
# Fall back to iwconfig if iw is not available
@@ -119,12 +125,17 @@ def detect_wifi_interfaces():
for line in result.stdout.split('\n'):
if 'IEEE 802.11' in line:
iface = line.split()[0]
interfaces.append({
iface_info = {
'name': iface,
'type': 'managed',
'monitor_capable': True,
'status': 'up'
})
'status': 'up',
'driver': '',
'chipset': '',
'mac': ''
}
iface_info.update(_get_interface_details(iface))
interfaces.append(iface_info)
except FileNotFoundError:
logger.debug("Neither iw nor iwconfig found")
except subprocess.SubprocessError as e:
@@ -137,6 +148,101 @@ def detect_wifi_interfaces():
return interfaces
def _get_interface_details(iface_name):
"""Get additional details about a WiFi interface (driver, chipset, MAC)."""
import os
details = {'driver': '', 'chipset': '', 'mac': ''}
# Get MAC address
try:
mac_path = f'/sys/class/net/{iface_name}/address'
with open(mac_path, 'r') as f:
details['mac'] = f.read().strip().upper()
except (FileNotFoundError, IOError):
pass
# Get driver name
try:
driver_link = f'/sys/class/net/{iface_name}/device/driver'
if os.path.islink(driver_link):
driver_path = os.readlink(driver_link)
details['driver'] = os.path.basename(driver_path)
except (FileNotFoundError, IOError, OSError):
pass
# Try airmon-ng first for chipset info (most reliable for WiFi adapters)
try:
result = subprocess.run(['airmon-ng'], capture_output=True, text=True, timeout=5)
for line in result.stdout.split('\n'):
# airmon-ng output format: PHY Interface Driver Chipset
parts = line.split('\t')
if len(parts) >= 4:
if parts[1].strip() == iface_name or parts[1].strip().startswith(iface_name):
if parts[2].strip():
details['driver'] = parts[2].strip()
if parts[3].strip():
details['chipset'] = parts[3].strip()
break
# Also try space-separated format
parts = line.split()
if len(parts) >= 4:
if parts[1] == iface_name or parts[1].startswith(iface_name):
details['driver'] = parts[2]
details['chipset'] = ' '.join(parts[3:])
break
except (FileNotFoundError, subprocess.TimeoutExpired, subprocess.SubprocessError):
pass
# Fallback: Get chipset info from USB or PCI sysfs
if not details['chipset']:
try:
device_path = f'/sys/class/net/{iface_name}/device'
if os.path.exists(device_path):
# Try to get USB product name
for usb_path in [f'{device_path}/product', f'{device_path}/../product']:
try:
with open(usb_path, 'r') as f:
details['chipset'] = f.read().strip()
break
except (FileNotFoundError, IOError):
pass
# If no USB product, try lsusb for USB devices
if not details['chipset']:
try:
# Get USB bus/device info
uevent_path = f'{device_path}/uevent'
with open(uevent_path, 'r') as f:
for line in f:
if line.startswith('PRODUCT='):
# PRODUCT format: vendor/product/bcdDevice
product = line.split('=')[1].strip()
parts = product.split('/')
if len(parts) >= 2:
vid = parts[0].zfill(4)
pid = parts[1].zfill(4)
# Try lsusb to get device name
try:
lsusb = subprocess.run(
['lsusb', '-d', f'{vid}:{pid}'],
capture_output=True, text=True, timeout=5
)
if lsusb.stdout:
# Format: Bus XXX Device YYY: ID vid:pid Name
usb_parts = lsusb.stdout.split(f'{vid}:{pid}')
if len(usb_parts) > 1:
details['chipset'] = usb_parts[1].strip()
except (FileNotFoundError, subprocess.TimeoutExpired):
pass
break
except (FileNotFoundError, IOError):
pass
except (FileNotFoundError, IOError, OSError):
pass
return details
def parse_airodump_csv(csv_path):
"""Parse airodump-ng CSV output file."""
networks = {}
@@ -253,6 +359,20 @@ def stream_airodump_output(process, csv_path):
'action': 'new',
**client
})
else:
# Send update if probes changed or signal changed significantly
old_client = app_module.wifi_clients[mac]
old_probes = old_client.get('probes', '')
new_probes = client.get('probes', '')
old_power = int(old_client.get('power', -100) or -100)
new_power = int(client.get('power', -100) or -100)
if new_probes != old_probes or abs(new_power - old_power) >= 5:
app_module.wifi_queue.put({
'type': 'client',
'action': 'update',
**client
})
app_module.wifi_networks = networks
app_module.wifi_clients = clients
@@ -303,11 +423,13 @@ def get_wifi_interfaces():
def toggle_monitor_mode():
"""Enable or disable monitor mode on an interface."""
data = request.json
interface = data.get('interface')
action = data.get('action', 'start')
if not interface:
return jsonify({'status': 'error', 'message': 'No interface specified'})
# Validate interface name to prevent command injection
try:
interface = validate_network_interface(data.get('interface'))
except ValueError as e:
return jsonify({'status': 'error', 'message': str(e)}), 400
if action == 'start':
if check_tool('airmon-ng'):
@@ -345,10 +467,11 @@ def toggle_monitor_mode():
interfaces_before = get_wireless_interfaces()
kill_processes = data.get('kill_processes', False)
airmon_path = get_tool_path('airmon-ng')
if kill_processes:
subprocess.run(['airmon-ng', 'check', 'kill'], capture_output=True, timeout=10)
subprocess.run([airmon_path, 'check', 'kill'], capture_output=True, timeout=10)
result = subprocess.run(['airmon-ng', 'start', interface],
result = subprocess.run([airmon_path, 'start', interface],
capture_output=True, text=True, timeout=15)
output = result.stdout + result.stderr
@@ -405,8 +528,35 @@ def toggle_monitor_mode():
if not monitor_iface:
monitor_iface = interface + 'mon'
# Verify the interface actually exists
def interface_exists(iface_name):
return os.path.exists(f'/sys/class/net/{iface_name}')
if not interface_exists(monitor_iface):
# Try common naming patterns
candidates = [
interface + 'mon',
interface.replace('wlan', 'wlan') + 'mon',
'wlan0mon', 'wlan1mon',
interface # Maybe it stayed the same but in monitor mode
]
for candidate in candidates:
if interface_exists(candidate):
monitor_iface = candidate
break
else:
# List all wireless interfaces to help debug
all_wireless = [f for f in os.listdir('/sys/class/net')
if os.path.exists(f'/sys/class/net/{f}/wireless') or 'mon' in f or f.startswith('wl')]
logger.error(f"Monitor interface not found. Tried: {monitor_iface}. Available: {all_wireless}")
return jsonify({
'status': 'error',
'message': f'Monitor interface not created. airmon-ng output: {output[:500]}. Available interfaces: {all_wireless}'
})
app_module.wifi_monitor_interface = monitor_iface
app_module.wifi_queue.put({'type': 'info', 'text': f'Monitor mode enabled on {app_module.wifi_monitor_interface}'})
logger.info(f"Monitor mode enabled on {monitor_iface}")
return jsonify({'status': 'success', 'monitor_interface': app_module.wifi_monitor_interface})
except Exception as e:
@@ -429,7 +579,8 @@ def toggle_monitor_mode():
else: # stop
if check_tool('airmon-ng'):
try:
subprocess.run(['airmon-ng', 'stop', app_module.wifi_monitor_interface or interface],
airmon_path = get_tool_path('airmon-ng')
subprocess.run([airmon_path, 'stop', app_module.wifi_monitor_interface or interface],
capture_output=True, text=True, timeout=15)
app_module.wifi_monitor_interface = None
return jsonify({'status': 'success', 'message': 'Monitor mode disabled'})
@@ -456,13 +607,31 @@ def start_wifi_scan():
return jsonify({'status': 'error', 'message': 'Scan already running'})
data = request.json
interface = data.get('interface') or app_module.wifi_monitor_interface
channel = data.get('channel')
band = data.get('band', 'abg')
# Use provided interface or fall back to stored monitor interface
interface = data.get('interface')
if interface:
try:
interface = validate_network_interface(interface)
except ValueError as e:
return jsonify({'status': 'error', 'message': str(e)}), 400
else:
interface = app_module.wifi_monitor_interface
if not interface:
return jsonify({'status': 'error', 'message': 'No monitor interface available.'})
# Verify interface exists
if not os.path.exists(f'/sys/class/net/{interface}'):
all_wireless = [f for f in os.listdir('/sys/class/net')
if os.path.exists(f'/sys/class/net/{f}/wireless') or 'mon' in f or f.startswith('wl')]
return jsonify({
'status': 'error',
'message': f'Interface "{interface}" does not exist. Available: {all_wireless}'
})
app_module.wifi_networks = {}
app_module.wifi_clients = {}
@@ -480,8 +649,9 @@ def start_wifi_scan():
except OSError:
pass
airodump_path = get_tool_path('airodump-ng')
cmd = [
'airodump-ng',
airodump_path,
'-w', csv_path,
'--output-format', 'csv,pcap',
'--band', band,
@@ -512,11 +682,12 @@ def start_wifi_scan():
error_msg = re.sub(r'\x1b\[[0-9;]*m', '', error_msg)
if 'No such device' in error_msg or 'No such interface' in error_msg:
error_msg = f'Interface "{interface}" not found.'
error_msg = f'Interface "{interface}" not found. Make sure monitor mode is enabled.'
elif 'Operation not permitted' in error_msg:
error_msg = 'Permission denied. Try running with sudo.'
return jsonify({'status': 'error', 'message': error_msg})
logger.error(f"airodump-ng failed for interface '{interface}': {error_msg}")
return jsonify({'status': 'error', 'message': error_msg, 'interface': interface})
thread = threading.Thread(target=stream_airodump_output, args=(app_module.wifi_process, csv_path))
thread.daemon = True
@@ -554,7 +725,16 @@ def send_deauth():
target_bssid = data.get('bssid')
target_client = data.get('client', 'FF:FF:FF:FF:FF:FF')
count = data.get('count', 5)
interface = data.get('interface') or app_module.wifi_monitor_interface
# Validate interface
interface = data.get('interface')
if interface:
try:
interface = validate_network_interface(interface)
except ValueError as e:
return jsonify({'status': 'error', 'message': str(e)}), 400
else:
interface = app_module.wifi_monitor_interface
if not target_bssid:
return jsonify({'status': 'error', 'message': 'Target BSSID required'})
@@ -579,8 +759,9 @@ def send_deauth():
return jsonify({'status': 'error', 'message': 'aireplay-ng not found'})
try:
aireplay_path = get_tool_path('aireplay-ng')
cmd = [
'aireplay-ng',
aireplay_path,
'--deauth', str(count),
'-a', target_bssid,
'-c', target_client,
@@ -608,7 +789,16 @@ def capture_handshake():
data = request.json
target_bssid = data.get('bssid')
channel = data.get('channel')
interface = data.get('interface') or app_module.wifi_monitor_interface
# Validate interface
interface = data.get('interface')
if interface:
try:
interface = validate_network_interface(interface)
except ValueError as e:
return jsonify({'status': 'error', 'message': str(e)}), 400
else:
interface = app_module.wifi_monitor_interface
if not target_bssid or not channel:
return jsonify({'status': 'error', 'message': 'BSSID and channel required'})
@@ -625,8 +815,9 @@ def capture_handshake():
capture_path = f'/tmp/intercept_handshake_{target_bssid.replace(":", "")}'
airodump_path = get_tool_path('airodump-ng')
cmd = [
'airodump-ng',
airodump_path,
'-c', str(channel),
'--bssid', target_bssid,
'-w', capture_path,
@@ -664,14 +855,16 @@ def check_handshake_status():
try:
if target_bssid and is_valid_mac(target_bssid):
result = subprocess.run(
['aircrack-ng', '-a', '2', '-b', target_bssid, capture_file],
capture_output=True, text=True, timeout=10
)
output = result.stdout + result.stderr
if '1 handshake' in output or ('handshake' in output.lower() and 'wpa' in output.lower()):
if '0 handshake' not in output:
handshake_found = True
aircrack_path = get_tool_path('aircrack-ng')
if aircrack_path:
result = subprocess.run(
[aircrack_path, '-a', '2', '-b', target_bssid, capture_file],
capture_output=True, text=True, timeout=10
)
output = result.stdout + result.stderr
if '1 handshake' in output or ('handshake' in output.lower() and 'wpa' in output.lower()):
if '0 handshake' not in output:
handshake_found = True
except subprocess.TimeoutExpired:
pass
except Exception as e:
@@ -694,7 +887,16 @@ def capture_pmkid():
data = request.json
target_bssid = data.get('bssid')
channel = data.get('channel')
interface = data.get('interface') or app_module.wifi_monitor_interface
# Validate interface
interface = data.get('interface')
if interface:
try:
interface = validate_network_interface(interface)
except ValueError as e:
return jsonify({'status': 'error', 'message': str(e)}), 400
else:
interface = app_module.wifi_monitor_interface
if not target_bssid:
return jsonify({'status': 'error', 'message': 'BSSID required'})
@@ -785,6 +987,83 @@ def stop_pmkid():
return jsonify({'status': 'stopped'})
@wifi_bp.route('/handshake/crack', methods=['POST'])
def crack_handshake():
"""Crack a captured handshake using aircrack-ng."""
data = request.json
capture_file = data.get('capture_file', '')
target_bssid = data.get('bssid', '')
wordlist = data.get('wordlist', '')
# Validate paths to prevent path traversal
if not capture_file.startswith('/tmp/intercept_handshake_') or '..' in capture_file:
return jsonify({'status': 'error', 'message': 'Invalid capture file path'}), 400
if '..' in wordlist:
return jsonify({'status': 'error', 'message': 'Invalid wordlist path'}), 400
if not os.path.exists(capture_file):
return jsonify({'status': 'error', 'message': 'Capture file not found'}), 404
if not os.path.exists(wordlist):
return jsonify({'status': 'error', 'message': 'Wordlist file not found'}), 404
if target_bssid and not is_valid_mac(target_bssid):
return jsonify({'status': 'error', 'message': 'Invalid BSSID format'}), 400
aircrack_path = get_tool_path('aircrack-ng')
if not aircrack_path:
return jsonify({'status': 'error', 'message': 'aircrack-ng not found'}), 500
try:
cmd = [aircrack_path, '-a', '2', '-w', wordlist]
if target_bssid:
cmd.extend(['-b', target_bssid])
cmd.append(capture_file)
logger.info(f"Starting aircrack-ng: {' '.join(cmd)}")
# Run aircrack-ng with a timeout (this could take a while)
result = subprocess.run(
cmd,
capture_output=True,
text=True,
timeout=300 # 5 minute timeout
)
output = result.stdout + result.stderr
# Check if password was found
# Aircrack-ng outputs "KEY FOUND! [ password ]" when successful
if 'KEY FOUND!' in output:
# Extract the password
import re
match = re.search(r'KEY FOUND!\s*\[\s*(.+?)\s*\]', output)
if match:
password = match.group(1)
logger.info(f"Password cracked for {target_bssid}: {password}")
return jsonify({
'status': 'success',
'password': password,
'bssid': target_bssid
})
# Password not found
return jsonify({
'status': 'not_found',
'message': 'Password not in wordlist'
})
except subprocess.TimeoutExpired:
return jsonify({
'status': 'timeout',
'message': 'Cracking timed out after 5 minutes. Try a smaller wordlist or use hashcat.'
})
except Exception as e:
logger.error(f"Crack error: {e}")
return jsonify({'status': 'error', 'message': str(e)}), 500
@wifi_bp.route('/networks')
def get_wifi_networks():
"""Get current list of discovered networks."""
Executable → Regular
+439 -436
View File
@@ -1,18 +1,60 @@
#!/bin/bash
#
# INTERCEPT Setup Script
# Installs dependencies for macOS and Debian/Ubuntu
#
#!/usr/bin/env bash
# INTERCEPT Setup Script (best-effort installs, hard-fail verification)
set -e
# ---- Force bash even if launched with sh ----
if [ -z "${BASH_VERSION:-}" ]; then
echo "[x] This script must be run with bash (not sh)."
echo " Run: bash $0"
exec bash "$0" "$@"
fi
# Colors for output
set -Eeuo pipefail
# Ensure admin paths are searchable (many tools live here)
export PATH="/usr/local/sbin:/usr/sbin:/sbin:/opt/homebrew/sbin:/opt/homebrew/bin:$PATH"
# ----------------------------
# Pretty output
# ----------------------------
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
BLUE='\033[0;34m'
NC='\033[0m' # No Color
NC='\033[0m'
info() { echo -e "${BLUE}[*]${NC} $*"; }
ok() { echo -e "${GREEN}[✓]${NC} $*"; }
warn() { echo -e "${YELLOW}[!]${NC} $*"; }
fail() { echo -e "${RED}[x]${NC} $*"; }
# ----------------------------
# Progress tracking
# ----------------------------
CURRENT_STEP=0
TOTAL_STEPS=0
progress() {
local msg="$1"
((CURRENT_STEP++)) || true
local pct=$((CURRENT_STEP * 100 / TOTAL_STEPS))
local filled=$((pct / 5))
local empty=$((20 - filled))
local bar=$(printf '█%.0s' $(seq 1 $filled 2>/dev/null) || true)
bar+=$(printf '░%.0s' $(seq 1 $empty 2>/dev/null) || true)
echo -e "${BLUE}[${CURRENT_STEP}/${TOTAL_STEPS}]${NC} ${bar} ${pct}% - ${msg}"
}
on_error() {
local line="$1"
local cmd="${2:-unknown}"
fail "Setup failed at line ${line}: ${cmd}"
exit 1
}
trap 'on_error $LINENO "$BASH_COMMAND"' ERR
# ----------------------------
# Banner
# ----------------------------
echo -e "${BLUE}"
echo " ___ _ _ _____ _____ ____ ____ _____ ____ _____ "
echo " |_ _| \\ | |_ _| ____| _ \\ / ___| ____| _ \\_ _|"
@@ -20,475 +62,436 @@ echo " | || \\| | | | | _| | |_) | | | _| | |_) || | "
echo " | || |\\ | | | | |___| _ <| |___| |___| __/ | | "
echo " |___|_| \\_| |_| |_____|_| \\_\\\\____|_____|_| |_| "
echo -e "${NC}"
echo "Signal Intelligence Platform - Setup Script"
echo "INTERCEPT - Setup Script"
echo "============================================"
echo ""
echo
# ----------------------------
# Helpers
# ----------------------------
cmd_exists() {
local c="$1"
command -v "$c" >/dev/null 2>&1 && return 0
[[ -x "/usr/sbin/$c" || -x "/sbin/$c" || -x "/usr/local/sbin/$c" || -x "/opt/homebrew/sbin/$c" ]] && return 0
return 1
}
have_any() {
local c
for c in "$@"; do
cmd_exists "$c" && return 0
done
return 1
}
need_sudo() {
if [[ "$(id -u)" -eq 0 ]]; then
SUDO=""
ok "Running as root"
else
if cmd_exists sudo; then
SUDO="sudo"
else
fail "sudo is not installed and you're not root."
echo "Either run as root or install sudo first."
exit 1
fi
fi
}
# Detect OS
detect_os() {
if [[ "$OSTYPE" == "darwin"* ]]; then
OS="macos"
PKG_MANAGER="brew"
elif [[ -f /etc/debian_version ]]; then
OS="debian"
PKG_MANAGER="apt"
else
OS="unknown"
PKG_MANAGER="unknown"
fi
echo -e "${BLUE}Detected OS:${NC} $OS"
if [[ "${OSTYPE:-}" == "darwin"* ]]; then
OS="macos"
elif [[ -f /etc/debian_version ]]; then
OS="debian"
else
OS="unknown"
fi
info "Detected OS: ${OS}"
[[ "$OS" != "unknown" ]] || { fail "Unsupported OS (macOS + Debian/Ubuntu only)."; exit 1; }
}
# Check if a command exists
check_cmd() {
command -v "$1" &> /dev/null
}
# ----------------------------
# Required tool checks (with alternates)
# ----------------------------
missing_required=()
# Check if a package is installable (Debian)
pkg_available() {
local candidate
candidate=$(apt-cache policy "$1" 2>/dev/null | grep "Candidate:" | awk '{print $2}')
[ -n "$candidate" ] && [ "$candidate" != "(none)" ]
}
check_required() {
local label="$1"; shift
local desc="$1"; shift
# Setup sudo command
setup_sudo() {
if [ "$(id -u)" -eq 0 ]; then
SUDO=""
echo -e "${BLUE}Running as root${NC}"
elif check_cmd sudo; then
SUDO="sudo"
else
echo -e "${RED}Error: Not running as root and sudo is not installed${NC}"
exit 1
fi
}
# ============================================
# PYTHON DEPENDENCIES
# ============================================
install_python_deps() {
echo ""
echo -e "${BLUE}[1/3] Installing Python dependencies...${NC}"
if ! check_cmd python3; then
echo -e "${RED}Error: Python 3 is not installed${NC}"
if [[ "$OS" == "macos" ]]; then
echo "Install with: brew install python@3.11"
else
echo "Install with: sudo apt install python3"
fi
exit 1
fi
# Check Python version
PYTHON_VERSION=$(python3 -c 'import sys; print(f"{sys.version_info.major}.{sys.version_info.minor}")')
PYTHON_MAJOR=$(python3 -c 'import sys; print(sys.version_info.major)')
PYTHON_MINOR=$(python3 -c 'import sys; print(sys.version_info.minor)')
echo "Python version: $PYTHON_VERSION"
if [ "$PYTHON_MAJOR" -lt 3 ] || ([ "$PYTHON_MAJOR" -eq 3 ] && [ "$PYTHON_MINOR" -lt 9 ]); then
echo -e "${RED}Error: Python 3.9 or later is required (you have $PYTHON_VERSION)${NC}"
if [[ "$OS" == "macos" ]]; then
echo "Upgrade with: brew install python@3.11"
else
echo "Upgrade with: sudo apt install python3.11"
fi
exit 1
fi
# Install dependencies
if [ -n "$VIRTUAL_ENV" ]; then
echo "Using virtual environment: $VIRTUAL_ENV"
pip install -r requirements.txt
elif [ -f "venv/bin/activate" ]; then
echo "Found existing venv, activating..."
source venv/bin/activate
pip install -r requirements.txt
else
# Try pip install, fall back to venv if needed (PEP 668)
if python3 -m pip install -r requirements.txt 2>/dev/null; then
echo -e "${GREEN}Python dependencies installed${NC}"
return
fi
echo -e "${YELLOW}Creating virtual environment...${NC}"
if [ -d "venv" ] && [ ! -f "venv/bin/activate" ]; then
rm -rf venv
fi
if ! python3 -m venv venv; then
echo -e "${RED}Error: Failed to create virtual environment${NC}"
if [[ "$OS" == "debian" ]]; then
echo "Install with: sudo apt install python3-venv"
fi
exit 1
fi
source venv/bin/activate
pip install -r requirements.txt
echo ""
echo -e "${YELLOW}NOTE: Virtual environment created.${NC}"
echo "Activate with: source venv/bin/activate"
fi
echo -e "${GREEN}Python dependencies installed${NC}"
}
# ============================================
# TOOL CHECKING
# ============================================
check_tool() {
local cmd=$1
local desc=$2
local category=$3
if check_cmd "$cmd"; then
echo -e " ${GREEN}${NC} $cmd - $desc"
return 0
else
echo -e " ${RED}${NC} $cmd - $desc ${YELLOW}(not found)${NC}"
MISSING_TOOLS+=("$cmd")
case "$category" in
core) MISSING_CORE=true ;;
audio) MISSING_AUDIO=true ;;
wifi) MISSING_WIFI=true ;;
bluetooth) MISSING_BLUETOOTH=true ;;
esac
return 1
fi
if have_any "$@"; then
ok "${label} - ${desc}"
else
warn "${label} - ${desc} (missing, required)"
missing_required+=("$label")
fi
}
check_tools() {
echo ""
echo -e "${BLUE}[2/3] Checking external tools...${NC}"
echo ""
info "Checking required tools..."
missing_required=()
MISSING_TOOLS=()
MISSING_CORE=false
MISSING_AUDIO=false
MISSING_WIFI=false
MISSING_BLUETOOTH=false
echo
info "Core SDR:"
check_required "rtl_fm" "RTL-SDR FM demodulator" rtl_fm
check_required "rtl_test" "RTL-SDR device detection" rtl_test
check_required "multimon-ng" "Pager decoder" multimon-ng
check_required "rtl_433" "433MHz sensor decoder" rtl_433 rtl433
check_required "dump1090" "ADS-B decoder" dump1090
echo "Core SDR Tools:"
check_tool "rtl_fm" "RTL-SDR FM demodulator" "core"
check_tool "rtl_test" "RTL-SDR device detection" "core"
check_tool "multimon-ng" "Pager decoder" "core"
check_tool "rtl_433" "433MHz sensor decoder" "core"
check_tool "dump1090" "ADS-B decoder" "core"
echo
info "GPS:"
check_required "gpsd" "GPS daemon" gpsd
echo ""
echo "Audio Tools:"
check_tool "sox" "Audio player/processor" "audio"
# ffmpeg is optional alternative to sox
if check_cmd ffmpeg; then
echo -e " ${GREEN}${NC} ffmpeg - Audio encoder (optional)"
fi
echo
info "Audio:"
check_required "ffmpeg" "Audio encoder/decoder" ffmpeg
echo ""
echo "WiFi Tools:"
check_tool "airmon-ng" "WiFi monitor mode" "wifi"
check_tool "airodump-ng" "WiFi scanner" "wifi"
# aireplay-ng is optional (for deauth)
if check_cmd aireplay-ng; then
echo -e " ${GREEN}${NC} aireplay-ng - Deauthentication (optional)"
fi
echo
info "WiFi:"
check_required "airmon-ng" "Monitor mode helper" airmon-ng
check_required "airodump-ng" "WiFi scanner" airodump-ng
check_required "aireplay-ng" "Injection/deauth" aireplay-ng
check_required "hcxdumptool" "PMKID capture" hcxdumptool
check_required "hcxpcapngtool" "PMKID/pcapng conversion" hcxpcapngtool
echo ""
echo "Bluetooth Tools:"
check_tool "hcitool" "Bluetooth scanner" "bluetooth"
check_tool "bluetoothctl" "Bluetooth controller" "bluetooth"
check_tool "hciconfig" "Bluetooth adapter config" "bluetooth"
echo
info "Bluetooth:"
check_required "bluetoothctl" "Bluetooth controller CLI" bluetoothctl
check_required "hcitool" "Bluetooth scan utility" hcitool
check_required "hciconfig" "Bluetooth adapter config" hciconfig
echo ""
echo "Optional (LimeSDR/HackRF):"
if check_cmd SoapySDRUtil; then
echo -e " ${GREEN}${NC} SoapySDRUtil - SoapySDR support"
else
echo -e " ${YELLOW}-${NC} SoapySDRUtil - Not installed (optional)"
fi
if [ ${#MISSING_TOOLS[@]} -gt 0 ]; then
echo ""
echo -e "${YELLOW}Some tools are missing.${NC}"
else
echo ""
echo -e "${GREEN}All tools installed!${NC}"
fi
echo
info "SoapySDR:"
check_required "SoapySDRUtil" "SoapySDR CLI utility" SoapySDRUtil
echo
}
# ============================================
# macOS INSTALLATION
# ============================================
install_macos_tools() {
echo ""
echo -e "${BLUE}[3/3] Installing tools (macOS)...${NC}"
echo ""
# ----------------------------
# Python venv + deps
# ----------------------------
check_python_version() {
if ! cmd_exists python3; then
fail "python3 not found."
[[ "$OS" == "macos" ]] && echo "Install with: brew install python"
[[ "$OS" == "debian" ]] && echo "Install with: sudo apt-get install python3"
exit 1
fi
if [ ${#MISSING_TOOLS[@]} -eq 0 ]; then
echo -e "${GREEN}All tools are already installed!${NC}"
return
fi
local ver
ver="$(python3 -c 'import sys; print(f"{sys.version_info.major}.{sys.version_info.minor}")')"
info "Python version: ${ver}"
# Check for Homebrew
if ! check_cmd brew; then
echo -e "${YELLOW}Homebrew is not installed.${NC}"
echo ""
read -p "Would you like to install Homebrew? [Y/n] " -n 1 -r
echo ""
if [[ ! $REPLY =~ ^[Nn]$ ]]; then
echo "Installing Homebrew..."
/bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)"
# Add brew to PATH for this session
if [[ -f /opt/homebrew/bin/brew ]]; then
eval "$(/opt/homebrew/bin/brew shellenv)"
elif [[ -f /usr/local/bin/brew ]]; then
eval "$(/usr/local/bin/brew shellenv)"
fi
else
echo ""
echo "Skipping tool installation. Install manually with:"
show_macos_manual
return
fi
fi
echo ""
echo -e "${YELLOW}The following will be installed:${NC}"
$MISSING_CORE && echo " - Core SDR tools (rtl-sdr, multimon-ng, rtl_433, dump1090)"
$MISSING_AUDIO && echo " - Audio tools (sox)"
$MISSING_WIFI && echo " - WiFi tools (aircrack-ng)"
echo ""
read -p "Install missing tools? [Y/n] " -n 1 -r
echo ""
if [[ ! $REPLY =~ ^[Nn]$ ]]; then
# Core SDR tools
if $MISSING_CORE; then
echo ""
echo -e "${BLUE}Installing Core SDR tools...${NC}"
brew install librtlsdr multimon-ng rtl_433 2>/dev/null || true
# dump1090
if ! check_cmd dump1090; then
brew install dump1090-mutability 2>/dev/null || \
echo -e "${YELLOW}Note: dump1090 may need manual installation${NC}"
fi
fi
# Audio tools
if $MISSING_AUDIO; then
echo ""
echo -e "${BLUE}Installing Audio tools...${NC}"
brew install sox
fi
# WiFi tools
if $MISSING_WIFI; then
echo ""
echo -e "${BLUE}Installing WiFi tools...${NC}"
brew install aircrack-ng
fi
echo ""
echo -e "${GREEN}Tool installation complete!${NC}"
else
show_macos_manual
fi
python3 - <<'PY'
import sys
raise SystemExit(0 if sys.version_info >= (3,9) else 1)
PY
ok "Python version OK (>= 3.9)"
}
show_macos_manual() {
echo ""
echo -e "${BLUE}Manual installation (macOS):${NC}"
echo ""
echo "# Required tools"
echo "brew install librtlsdr multimon-ng rtl_433 sox"
echo ""
echo "# ADS-B tracking"
echo "brew install dump1090-mutability"
echo ""
echo "# WiFi scanning (optional)"
echo "brew install aircrack-ng"
install_python_deps() {
progress "Setting up Python environment"
check_python_version
if [[ ! -f requirements.txt ]]; then
warn "requirements.txt not found; skipping Python dependency install."
return 0
fi
# On Debian/Ubuntu, try apt packages first as they're more reliable
if [[ "$OS" == "debian" ]]; then
info "Installing Python packages via apt (more reliable on Debian/Ubuntu)..."
$SUDO apt-get install -y python3-flask python3-requests python3-serial >/dev/null 2>&1 || true
# skyfield may not be available in all distros, try apt first then pip
if ! $SUDO apt-get install -y python3-skyfield >/dev/null 2>&1; then
warn "python3-skyfield not in apt, will try pip later"
fi
ok "Installed available Python packages via apt"
fi
if [[ ! -d venv ]]; then
python3 -m venv --system-site-packages venv
ok "Created venv/ (with system site-packages)"
else
ok "Using existing venv/"
fi
# shellcheck disable=SC1091
source venv/bin/activate
python -m pip install --upgrade pip setuptools wheel >/dev/null 2>&1 || true
ok "Upgraded pip tooling"
progress "Installing Python dependencies"
# Try pip install, but don't fail if apt packages already satisfied deps
if ! python -m pip install -r requirements.txt 2>/dev/null; then
warn "Some pip packages failed - checking if apt packages cover them..."
# Verify critical packages are available
python -c "import flask; import requests" 2>/dev/null || {
fail "Critical Python packages (flask, requests) not installed"
echo "Try: sudo apt install python3-flask python3-requests"
exit 1
}
ok "Core Python dependencies available"
else
ok "Python dependencies installed"
fi
echo
}
# ============================================
# DEBIAN INSTALLATION
# ============================================
install_debian_tools() {
echo ""
echo -e "${BLUE}[3/3] Installing tools (Debian/Ubuntu)...${NC}"
echo ""
# ----------------------------
# macOS install (Homebrew)
# ----------------------------
ensure_brew() {
cmd_exists brew && return 0
warn "Homebrew not found. Installing Homebrew..."
/bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)"
if [ ${#MISSING_TOOLS[@]} -eq 0 ]; then
echo -e "${GREEN}All tools are already installed!${NC}"
return
fi
if [[ -x /opt/homebrew/bin/brew ]]; then
eval "$(/opt/homebrew/bin/brew shellenv)"
elif [[ -x /usr/local/bin/brew ]]; then
eval "$(/usr/local/bin/brew shellenv)"
fi
echo -e "${YELLOW}The following will be installed:${NC}"
$MISSING_CORE && echo " - Core SDR tools (rtl-sdr, multimon-ng, rtl-433, dump1090)"
$MISSING_AUDIO && echo " - Audio tools (sox)"
$MISSING_WIFI && echo " - WiFi tools (aircrack-ng)"
$MISSING_BLUETOOTH && echo " - Bluetooth tools (bluez)"
echo ""
read -p "Install missing tools? [Y/n] " -n 1 -r
echo ""
if [[ ! $REPLY =~ ^[Nn]$ ]]; then
echo "Updating package lists..."
$SUDO apt update
# Core SDR tools
if $MISSING_CORE; then
echo ""
echo -e "${BLUE}Installing Core SDR tools...${NC}"
$SUDO apt install -y rtl-sdr multimon-ng 2>/dev/null || true
# rtl-433 (package name varies)
if pkg_available rtl-433; then
$SUDO apt install -y rtl-433
elif pkg_available rtl433; then
$SUDO apt install -y rtl433
else
echo -e "${YELLOW}Note: rtl-433 not in repositories, install manually${NC}"
fi
# dump1090 (package varies by distribution)
if ! check_cmd dump1090; then
if pkg_available dump1090-fa; then
$SUDO apt install -y dump1090-fa
elif pkg_available dump1090-mutability; then
$SUDO apt install -y dump1090-mutability
elif pkg_available dump1090; then
$SUDO apt install -y dump1090
else
echo ""
echo -e "${YELLOW}Note: dump1090 not in repositories.${NC}"
echo "Install from: https://flightaware.com/adsb/piaware/install"
fi
fi
fi
# Audio tools
if $MISSING_AUDIO; then
echo ""
echo -e "${BLUE}Installing Audio tools...${NC}"
$SUDO apt install -y sox
fi
# WiFi tools
if $MISSING_WIFI; then
echo ""
echo -e "${BLUE}Installing WiFi tools...${NC}"
$SUDO apt install -y aircrack-ng
fi
# Bluetooth tools
if $MISSING_BLUETOOTH; then
echo ""
echo -e "${BLUE}Installing Bluetooth tools...${NC}"
$SUDO apt install -y bluez bluetooth
fi
echo ""
echo -e "${GREEN}Tool installation complete!${NC}"
# Setup udev rules
setup_udev_rules
else
show_debian_manual
fi
cmd_exists brew || { fail "Homebrew install failed. Install manually then re-run."; exit 1; }
}
show_debian_manual() {
echo ""
echo -e "${BLUE}Manual installation (Debian/Ubuntu):${NC}"
echo ""
echo "# Required tools"
echo "sudo apt install rtl-sdr multimon-ng rtl-433 sox"
echo ""
echo "# ADS-B tracking"
echo "sudo apt install dump1090-mutability # or dump1090-fa"
echo ""
echo "# WiFi scanning (optional)"
echo "sudo apt install aircrack-ng"
echo ""
echo "# Bluetooth scanning (optional)"
echo "sudo apt install bluez bluetooth"
brew_install() {
local pkg="$1"
if brew list --formula "$pkg" >/dev/null 2>&1; then
ok "brew: ${pkg} already installed"
return 0
fi
info "brew: installing ${pkg}..."
brew install "$pkg"
ok "brew: installed ${pkg}"
}
setup_udev_rules() {
if [ -f /etc/udev/rules.d/20-rtlsdr.rules ]; then
return
install_macos_packages() {
TOTAL_STEPS=12
CURRENT_STEP=0
progress "Checking Homebrew"
ensure_brew
progress "Installing RTL-SDR libraries"
brew_install librtlsdr
progress "Installing multimon-ng"
brew_install multimon-ng
progress "Installing ffmpeg"
brew_install ffmpeg
progress "Installing rtl_433"
brew_install rtl_433
progress "Installing dump1090"
(brew_install dump1090-mutability) || warn "dump1090 not available via Homebrew"
progress "Installing aircrack-ng"
brew_install aircrack-ng
progress "Installing hcxtools"
brew_install hcxtools
progress "Installing SoapySDR"
brew_install soapysdr
progress "Installing gpsd"
brew_install gpsd
warn "macOS note: hcitool/hciconfig are Linux (BlueZ) utilities and often unavailable on macOS."
echo
}
# ----------------------------
# Debian/Ubuntu install (APT)
# ----------------------------
apt_install() {
local pkgs="$*"
local output
local ret=0
output=$($SUDO apt-get install -y --no-install-recommends "$@" 2>&1) || ret=$?
if [[ $ret -ne 0 ]]; then
fail "Failed to install: $pkgs"
echo "$output" | tail -10
fail "Try running: sudo apt-get update && sudo apt-get install -y $pkgs"
return 1
fi
}
apt_try_install_any() {
local p
for p in "$@"; do
if $SUDO apt-get install -y --no-install-recommends "$p" >/dev/null 2>&1; then
ok "apt: installed ${p}"
return 0
fi
done
return 1
}
install_dump1090_from_source_debian() {
info "dump1090 not available via APT. Building from source (required)..."
apt_install build-essential git pkg-config \
librtlsdr-dev libusb-1.0-0-dev \
libncurses-dev tcl-dev python3-dev
# Run in subshell to isolate EXIT trap
(
tmp_dir="$(mktemp -d)"
trap 'rm -rf "$tmp_dir"' EXIT
info "Cloning FlightAware dump1090..."
git clone --depth 1 https://github.com/flightaware/dump1090.git "$tmp_dir/dump1090" >/dev/null 2>&1 \
|| { fail "Failed to clone FlightAware dump1090"; exit 1; }
cd "$tmp_dir/dump1090"
info "Compiling FlightAware dump1090..."
if make BLADERF=no RTLSDR=yes >/dev/null 2>&1; then
$SUDO install -m 0755 dump1090 /usr/local/bin/dump1090
ok "dump1090 installed successfully (FlightAware)."
exit 0
fi
echo ""
read -p "Setup RTL-SDR udev rules? [Y/n] " -n 1 -r
echo ""
warn "FlightAware build failed. Falling back to antirez/dump1090..."
rm -rf "$tmp_dir/dump1090"
git clone --depth 1 https://github.com/antirez/dump1090.git "$tmp_dir/dump1090" >/dev/null 2>&1 \
|| { fail "Failed to clone antirez dump1090"; exit 1; }
if [[ ! $REPLY =~ ^[Nn]$ ]]; then
$SUDO bash -c 'cat > /etc/udev/rules.d/20-rtlsdr.rules << EOF
cd "$tmp_dir/dump1090"
info "Compiling antirez dump1090..."
make >/dev/null 2>&1 || { fail "Failed to build dump1090 from source (required)."; exit 1; }
$SUDO install -m 0755 dump1090 /usr/local/bin/dump1090
ok "dump1090 installed successfully (antirez)."
)
}
setup_udev_rules_debian() {
[[ -d /etc/udev/rules.d ]] || { warn "udev not found; skipping RTL-SDR udev rules."; return 0; }
local rules_file="/etc/udev/rules.d/20-rtlsdr.rules"
[[ -f "$rules_file" ]] && { ok "RTL-SDR udev rules already present: $rules_file"; return 0; }
info "Installing RTL-SDR udev rules..."
$SUDO tee "$rules_file" >/dev/null <<'EOF'
SUBSYSTEM=="usb", ATTRS{idVendor}=="0bda", ATTRS{idProduct}=="2838", MODE="0666"
SUBSYSTEM=="usb", ATTRS{idVendor}=="0bda", ATTRS{idProduct}=="2832", MODE="0666"
EOF'
$SUDO udevadm control --reload-rules
$SUDO udevadm trigger
echo -e "${GREEN}udev rules installed!${NC}"
echo "Please unplug and replug your RTL-SDR."
fi
EOF
$SUDO udevadm control --reload-rules || true
$SUDO udevadm trigger || true
ok "udev rules installed. Unplug/replug your RTL-SDR if connected."
echo
}
# ============================================
install_debian_packages() {
need_sudo
# Suppress needrestart prompts (Ubuntu Server 22.04+)
export DEBIAN_FRONTEND=noninteractive
export NEEDRESTART_MODE=a
TOTAL_STEPS=15
CURRENT_STEP=0
progress "Updating APT package lists"
$SUDO apt-get update -y >/dev/null
progress "Installing RTL-SDR"
apt_install rtl-sdr
progress "Installing multimon-ng"
apt_install multimon-ng
progress "Installing ffmpeg"
apt_install ffmpeg
progress "Installing rtl_433"
apt_try_install_any rtl-433 rtl433 || warn "rtl-433 not available"
progress "Installing aircrack-ng"
apt_install aircrack-ng || true
progress "Installing hcxdumptool"
apt_install hcxdumptool || true
progress "Installing hcxtools"
apt_install hcxtools || true
progress "Installing Bluetooth tools"
apt_install bluez bluetooth || true
progress "Installing SoapySDR"
apt_install soapysdr-tools || true
progress "Installing gpsd"
apt_install gpsd gpsd-clients || true
progress "Installing Python packages"
apt_install python3-venv python3-pip || true
# Install Python packages via apt (more reliable than pip on modern Debian/Ubuntu)
$SUDO apt-get install -y python3-flask python3-requests python3-serial >/dev/null 2>&1 || true
$SUDO apt-get install -y python3-skyfield >/dev/null 2>&1 || true
progress "Installing dump1090"
if ! cmd_exists dump1090; then
apt_try_install_any dump1090-fa dump1090-mutability dump1090 || true
fi
cmd_exists dump1090 || install_dump1090_from_source_debian
progress "Configuring udev rules"
setup_udev_rules_debian
}
# ----------------------------
# Final summary / hard fail
# ----------------------------
final_summary_and_hard_fail() {
check_tools
echo "============================================"
if [[ "${#missing_required[@]}" -eq 0 ]]; then
ok "All REQUIRED tools are installed."
else
fail "Missing REQUIRED tools:"
for t in "${missing_required[@]}"; do echo " - $t"; done
echo
fail "Exiting because required tools are missing."
echo
warn "If you are on macOS: hcitool/hciconfig are Linux (BlueZ) tools and may not be installable."
warn "If you truly require them everywhere, you must restrict supported platforms or provide alternatives."
exit 1
fi
echo
echo "To start INTERCEPT:"
echo " source venv/bin/activate"
echo " sudo python intercept.py"
echo
echo "Then open http://localhost:5050 in your browser"
echo
}
# ----------------------------
# MAIN
# ============================================
# ----------------------------
main() {
detect_os
detect_os
if [[ "$OS" == "unknown" ]]; then
echo -e "${RED}Unsupported OS. This script supports macOS and Debian/Ubuntu.${NC}"
exit 1
fi
if [[ "$OS" == "macos" ]]; then
install_macos_packages
else
install_debian_packages
fi
if [[ "$OS" == "debian" ]]; then
setup_sudo
fi
install_python_deps
check_tools
if [[ "$OS" == "macos" ]]; then
install_macos_tools
else
install_debian_tools
fi
echo ""
echo "============================================"
echo -e "${GREEN}Setup complete!${NC}"
echo ""
echo "To start INTERCEPT:"
if [ -d "venv" ]; then
echo " source venv/bin/activate"
if [[ "$OS" == "debian" ]]; then
echo " sudo venv/bin/python intercept.py"
else
echo " sudo python3 intercept.py"
fi
else
if [[ "$OS" == "debian" ]]; then
echo " sudo python3 intercept.py"
else
echo " sudo python3 intercept.py"
fi
fi
echo ""
echo "Then open http://localhost:5050 in your browser"
echo ""
install_python_deps
final_summary_and_hard_fail
}
main "$@"
+68 -7
View File
@@ -346,7 +346,7 @@ body {
/* Selected aircraft panel */
.selected-aircraft {
flex-shrink: 0;
max-height: 280px;
max-height: 480px;
overflow-y: auto;
}
@@ -354,6 +354,18 @@ body {
padding: 12px;
}
#aircraftPhotoContainer {
margin-bottom: 12px;
}
#aircraftPhotoContainer img {
max-height: 140px;
width: 100%;
object-fit: cover;
border-radius: 6px;
border: 1px solid rgba(0, 212, 255, 0.3);
}
.selected-callsign {
font-family: 'Orbitron', monospace;
font-size: 20px;
@@ -406,6 +418,7 @@ body {
}
.aircraft-item {
position: relative;
background: rgba(0, 0, 0, 0.3);
border: 1px solid rgba(74, 158, 255, 0.15);
border-radius: 4px;
@@ -478,11 +491,28 @@ body {
grid-row: 2;
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 10px 20px;
padding: 10px 20px;
flex-wrap: nowrap;
gap: 8px;
padding: 8px 15px;
background: var(--bg-panel);
border-top: 1px solid rgba(74, 158, 255, 0.3);
font-size: 11px;
overflow-x: auto;
}
.controls-bar label {
display: flex;
align-items: center;
gap: 3px;
white-space: nowrap;
cursor: pointer;
}
.controls-bar select,
.controls-bar input[type="text"],
.controls-bar input[type="number"] {
padding: 3px 5px;
font-size: 10px;
}
.control-group {
@@ -653,9 +683,11 @@ body {
/* Airband Audio Controls */
.airband-divider {
width: 1px;
height: 24px;
background: var(--border-color);
margin: 0 10px;
height: 20px;
background: var(--accent-cyan);
opacity: 0.4;
margin: 0 5px;
flex-shrink: 0;
}
.airband-controls {
@@ -775,3 +807,32 @@ body {
border-radius: 3px;
background: rgba(0, 0, 0, 0.4);
}
/* GPS Indicator */
.gps-indicator {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 2px 8px;
background: rgba(34, 197, 94, 0.15);
border: 1px solid #22c55e;
border-radius: 12px;
font-size: 10px;
font-weight: 600;
color: #22c55e;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.gps-indicator .gps-dot {
width: 6px;
height: 6px;
background: #22c55e;
border-radius: 50%;
animation: gps-pulse 2s ease-in-out infinite;
}
@keyframes gps-pulse {
0%, 100% { opacity: 1; transform: scale(1); }
50% { opacity: 0.5; transform: scale(0.8); }
}
+1464 -39
View File
File diff suppressed because it is too large Load Diff
+32
View File
@@ -692,4 +692,36 @@ body {
.controls-bar {
grid-row: 4;
}
}
/* Embedded Mode Styles */
body.embedded {
background: transparent;
min-height: auto;
}
body.embedded .header {
background: rgba(10, 12, 16, 0.95);
border-bottom: 1px solid var(--border-color);
}
body.embedded .header .logo {
font-size: 14px;
}
body.embedded .header .logo span {
font-size: 10px;
}
body.embedded .dashboard {
padding: 10px;
gap: 10px;
}
body.embedded .panel {
background: rgba(15, 18, 24, 0.95);
}
body.embedded .controls-bar {
padding: 10px 15px;
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 99 KiB

+51
View File
@@ -0,0 +1,51 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg width="512" height="512" viewBox="0 0 100 100" fill="none" xmlns="http://www.w3.org/2000/svg">
<!-- iNTERCEPT Logo - Signal Intelligence Platform (Dark Background Version) -->
<!-- Dark background -->
<rect width="100" height="100" fill="#0a0a0f"/>
<!-- Subtle grid pattern -->
<defs>
<pattern id="grid" width="10" height="10" patternUnits="userSpaceOnUse">
<path d="M 10 0 L 0 0 0 10" fill="none" stroke="#1a1a2e" stroke-width="0.5"/>
</pattern>
</defs>
<rect width="100" height="100" fill="url(#grid)"/>
<!-- Outer glow effect -->
<defs>
<filter id="glow" x="-50%" y="-50%" width="200%" height="200%">
<feGaussianBlur stdDeviation="2" result="coloredBlur"/>
<feMerge>
<feMergeNode in="coloredBlur"/>
<feMergeNode in="SourceGraphic"/>
</feMerge>
</filter>
</defs>
<g filter="url(#glow)">
<!-- Signal brackets - left side (signal waves emanating) -->
<path d="M15 30 Q5 50, 15 70" stroke="#00d4ff" stroke-width="3" fill="none" stroke-linecap="round" opacity="0.5"/>
<path d="M22 35 Q14 50, 22 65" stroke="#00d4ff" stroke-width="2.5" fill="none" stroke-linecap="round" opacity="0.7"/>
<path d="M29 40 Q23 50, 29 60" stroke="#00d4ff" stroke-width="2" fill="none" stroke-linecap="round"/>
<!-- Signal brackets - right side (signal waves emanating) -->
<path d="M85 30 Q95 50, 85 70" stroke="#00d4ff" stroke-width="3" fill="none" stroke-linecap="round" opacity="0.5"/>
<path d="M78 35 Q86 50, 78 65" stroke="#00d4ff" stroke-width="2.5" fill="none" stroke-linecap="round" opacity="0.7"/>
<path d="M71 40 Q77 50, 71 60" stroke="#00d4ff" stroke-width="2" fill="none" stroke-linecap="round"/>
<!-- The 'i' letter - center element -->
<!-- dot of i (green accent - represents active signal) -->
<circle cx="50" cy="22" r="6" fill="#00ff88"/>
<!-- stem of i with styled terminals -->
<rect x="44" y="35" width="12" height="45" rx="2" fill="#00d4ff"/>
<!-- top terminal bar -->
<rect x="38" y="35" width="24" height="4" rx="1" fill="#00d4ff"/>
<!-- bottom terminal bar -->
<rect x="38" y="76" width="24" height="4" rx="1" fill="#00d4ff"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 2.4 KiB

+27
View File
@@ -0,0 +1,27 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg width="512" height="512" viewBox="0 0 100 100" fill="none" xmlns="http://www.w3.org/2000/svg">
<!-- iNTERCEPT Logo - Signal Intelligence Platform -->
<!-- Signal brackets - left side (signal waves emanating) -->
<path d="M15 30 Q5 50, 15 70" stroke="#00d4ff" stroke-width="3" fill="none" stroke-linecap="round" opacity="0.5"/>
<path d="M22 35 Q14 50, 22 65" stroke="#00d4ff" stroke-width="2.5" fill="none" stroke-linecap="round" opacity="0.7"/>
<path d="M29 40 Q23 50, 29 60" stroke="#00d4ff" stroke-width="2" fill="none" stroke-linecap="round"/>
<!-- Signal brackets - right side (signal waves emanating) -->
<path d="M85 30 Q95 50, 85 70" stroke="#00d4ff" stroke-width="3" fill="none" stroke-linecap="round" opacity="0.5"/>
<path d="M78 35 Q86 50, 78 65" stroke="#00d4ff" stroke-width="2.5" fill="none" stroke-linecap="round" opacity="0.7"/>
<path d="M71 40 Q77 50, 71 60" stroke="#00d4ff" stroke-width="2" fill="none" stroke-linecap="round"/>
<!-- The 'i' letter - center element -->
<!-- dot of i (green accent - represents active signal) -->
<circle cx="50" cy="22" r="6" fill="#00ff88"/>
<!-- stem of i with styled terminals -->
<rect x="44" y="35" width="12" height="45" rx="2" fill="#00d4ff"/>
<!-- top terminal bar -->
<rect x="38" y="35" width="24" height="4" rx="1" fill="#00d4ff"/>
<!-- bottom terminal bar -->
<rect x="38" y="76" width="24" height="4" rx="1" fill="#00d4ff"/>
</svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

+226
View File
@@ -0,0 +1,226 @@
/**
* Intercept - Radio Knob Component
* Interactive rotary knob control with drag-to-rotate
*/
class RadioKnob {
constructor(element, options = {}) {
this.element = element;
this.value = parseFloat(element.dataset.value) || 0;
this.min = parseFloat(element.dataset.min) || 0;
this.max = parseFloat(element.dataset.max) || 100;
this.step = parseFloat(element.dataset.step) || 1;
this.rotation = this.valueToRotation(this.value);
this.isDragging = false;
this.startY = 0;
this.startRotation = 0;
this.sensitivity = options.sensitivity || 1.5;
this.onChange = options.onChange || null;
this.bindEvents();
this.updateVisual();
}
valueToRotation(value) {
const range = this.max - this.min;
const normalized = (value - this.min) / range;
return normalized * 270 - 135; // -135 to +135 degrees
}
rotationToValue(rotation) {
const normalized = (rotation + 135) / 270;
let value = this.min + normalized * (this.max - this.min);
// Snap to step
value = Math.round(value / this.step) * this.step;
return Math.max(this.min, Math.min(this.max, value));
}
bindEvents() {
// Mouse events
this.element.addEventListener('mousedown', (e) => this.startDrag(e));
document.addEventListener('mousemove', (e) => this.drag(e));
document.addEventListener('mouseup', () => this.endDrag());
// Touch support
this.element.addEventListener('touchstart', (e) => {
e.preventDefault();
this.startDrag(e.touches[0]);
}, { passive: false });
document.addEventListener('touchmove', (e) => {
if (this.isDragging) {
e.preventDefault();
this.drag(e.touches[0]);
}
}, { passive: false });
document.addEventListener('touchend', () => this.endDrag());
// Scroll wheel support
this.element.addEventListener('wheel', (e) => this.handleWheel(e), { passive: false });
// Double-click to reset
this.element.addEventListener('dblclick', () => this.reset());
}
startDrag(e) {
this.isDragging = true;
this.startY = e.clientY;
this.startRotation = this.rotation;
this.element.style.cursor = 'grabbing';
this.element.classList.add('active');
// Play click sound if available
if (typeof playClickSound === 'function') {
playClickSound();
}
}
drag(e) {
if (!this.isDragging) return;
const deltaY = this.startY - e.clientY;
let newRotation = this.startRotation + deltaY * this.sensitivity;
// Clamp rotation
newRotation = Math.max(-135, Math.min(135, newRotation));
this.rotation = newRotation;
this.value = this.rotationToValue(this.rotation);
this.updateVisual();
this.dispatchChange();
}
endDrag() {
if (!this.isDragging) return;
this.isDragging = false;
this.element.style.cursor = 'grab';
this.element.classList.remove('active');
}
handleWheel(e) {
e.preventDefault();
const delta = e.deltaY > 0 ? -this.step : this.step;
const multiplier = e.shiftKey ? 5 : 1; // Faster with shift key
this.setValue(this.value + delta * multiplier);
// Play click sound if available
if (typeof playClickSound === 'function') {
playClickSound();
}
}
setValue(value, silent = false) {
this.value = Math.max(this.min, Math.min(this.max, value));
this.rotation = this.valueToRotation(this.value);
this.updateVisual();
if (!silent) {
this.dispatchChange();
}
}
getValue() {
return this.value;
}
reset() {
const defaultValue = parseFloat(this.element.dataset.default) ||
(this.min + this.max) / 2;
this.setValue(defaultValue);
}
updateVisual() {
this.element.style.transform = `rotate(${this.rotation}deg)`;
// Update associated value display
const valueDisplayId = this.element.id.replace('Knob', 'Value');
const valueDisplay = document.getElementById(valueDisplayId);
if (valueDisplay) {
valueDisplay.textContent = Math.round(this.value);
}
// Update data attribute
this.element.dataset.value = this.value;
}
dispatchChange() {
// Custom callback
if (this.onChange) {
this.onChange(this.value, this);
}
// Custom event
this.element.dispatchEvent(new CustomEvent('knobchange', {
detail: { value: this.value, knob: this },
bubbles: true
}));
}
}
/**
* Tuning Dial - Larger rotary control for frequency tuning
*/
class TuningDial extends RadioKnob {
constructor(element, options = {}) {
super(element, {
sensitivity: options.sensitivity || 0.8,
...options
});
this.fineStep = options.fineStep || 0.025;
this.coarseStep = options.coarseStep || 0.2;
}
handleWheel(e) {
e.preventDefault();
const step = e.shiftKey ? this.fineStep : this.coarseStep;
const delta = e.deltaY > 0 ? -step : step;
this.setValue(this.value + delta);
}
// Override to not round to step for smooth tuning
rotationToValue(rotation) {
const normalized = (rotation + 135) / 270;
let value = this.min + normalized * (this.max - this.min);
return Math.max(this.min, Math.min(this.max, value));
}
updateVisual() {
this.element.style.transform = `rotate(${this.rotation}deg)`;
// Update associated value display with decimals
const valueDisplayId = this.element.id.replace('Dial', 'Value');
const valueDisplay = document.getElementById(valueDisplayId);
if (valueDisplay) {
valueDisplay.textContent = this.value.toFixed(3);
}
this.element.dataset.value = this.value;
}
}
/**
* Initialize all radio knobs on the page
*/
function initRadioKnobs() {
// Initialize standard knobs
document.querySelectorAll('.radio-knob').forEach(element => {
if (!element._knob) {
element._knob = new RadioKnob(element);
}
});
// Initialize tuning dials
document.querySelectorAll('.tuning-dial').forEach(element => {
if (!element._dial) {
element._dial = new TuningDial(element);
}
});
}
// Auto-initialize on DOM ready
document.addEventListener('DOMContentLoaded', initRadioKnobs);
// Export for use in modules
if (typeof module !== 'undefined' && module.exports) {
module.exports = { RadioKnob, TuningDial, initRadioKnobs };
}
+547
View File
@@ -0,0 +1,547 @@
/**
* Intercept - Core Application Logic
* Global state, mode switching, and shared functionality
*/
// ============== GLOBAL STATE ==============
// Mode state flags
let eventSource = null;
let isRunning = false;
let isSensorRunning = false;
let isAdsbRunning = false;
let isWifiRunning = false;
let isBtRunning = false;
let currentMode = 'pager';
// Message counters
let msgCount = 0;
let pocsagCount = 0;
let flexCount = 0;
let sensorCount = 0;
let filteredCount = 0;
// Device list (populated from server via Jinja2)
let deviceList = [];
// Auto-scroll setting
let autoScroll = localStorage.getItem('autoScroll') !== 'false';
// Mute setting
let muted = localStorage.getItem('audioMuted') === 'true';
// Observer location (load from localStorage or default to London)
let observerLocation = (function() {
const saved = localStorage.getItem('observerLocation');
if (saved) {
try {
const parsed = JSON.parse(saved);
if (parsed.lat && parsed.lon) return parsed;
} catch (e) {}
}
return { lat: 51.5074, lon: -0.1278 };
})();
// Message storage for export
let allMessages = [];
// Track unique sensor devices
let uniqueDevices = new Set();
// SDR device usage tracking
let sdrDeviceUsage = {};
// ============== DISCLAIMER HANDLING ==============
function checkDisclaimer() {
const accepted = localStorage.getItem('disclaimerAccepted');
if (accepted === 'true') {
document.getElementById('disclaimerModal').classList.add('disclaimer-hidden');
}
}
function acceptDisclaimer() {
localStorage.setItem('disclaimerAccepted', 'true');
document.getElementById('disclaimerModal').classList.add('disclaimer-hidden');
}
function declineDisclaimer() {
document.getElementById('disclaimerModal').classList.add('disclaimer-hidden');
document.getElementById('rejectionPage').classList.remove('disclaimer-hidden');
}
// ============== HEADER CLOCK ==============
function updateHeaderClock() {
const now = new Date();
const utc = now.toISOString().substring(11, 19);
document.getElementById('headerUtcTime').textContent = utc;
}
// ============== HEADER STATS SYNC ==============
function syncHeaderStats() {
// Pager stats
document.getElementById('headerMsgCount').textContent = msgCount;
document.getElementById('headerPocsagCount').textContent = pocsagCount;
document.getElementById('headerFlexCount').textContent = flexCount;
// Sensor stats
document.getElementById('headerSensorCount').textContent = document.getElementById('sensorCount')?.textContent || '0';
document.getElementById('headerDeviceTypeCount').textContent = document.getElementById('deviceCount')?.textContent || '0';
// WiFi stats
document.getElementById('headerApCount').textContent = document.getElementById('apCount')?.textContent || '0';
document.getElementById('headerClientCount').textContent = document.getElementById('clientCount')?.textContent || '0';
document.getElementById('headerHandshakeCount').textContent = document.getElementById('handshakeCount')?.textContent || '0';
document.getElementById('headerDroneCount').textContent = document.getElementById('droneCount')?.textContent || '0';
// Bluetooth stats
document.getElementById('headerBtDeviceCount').textContent = document.getElementById('btDeviceCount')?.textContent || '0';
document.getElementById('headerBtBeaconCount').textContent = document.getElementById('btBeaconCount')?.textContent || '0';
// Aircraft stats
document.getElementById('headerAircraftCount').textContent = document.getElementById('aircraftCount')?.textContent || '0';
document.getElementById('headerAdsbMsgCount').textContent = document.getElementById('adsbMsgCount')?.textContent || '0';
document.getElementById('headerIcaoCount').textContent = document.getElementById('icaoCount')?.textContent || '0';
// Satellite stats
document.getElementById('headerPassCount').textContent = document.getElementById('passCount')?.textContent || '0';
}
// ============== MODE SWITCHING ==============
function switchMode(mode) {
// Stop any running scans when switching modes
if (isRunning && typeof stopDecoding === 'function') stopDecoding();
if (isSensorRunning && typeof stopSensorDecoding === 'function') stopSensorDecoding();
if (isWifiRunning && typeof stopWifiScan === 'function') stopWifiScan();
if (isBtRunning && typeof stopBtScan === 'function') stopBtScan();
if (isAdsbRunning && typeof stopAdsbScan === 'function') stopAdsbScan();
currentMode = mode;
// Remove active from all nav buttons, then add to the correct one
document.querySelectorAll('.mode-nav-btn').forEach(btn => btn.classList.remove('active'));
const modeMap = {
'pager': 'pager', 'sensor': '433', 'aircraft': 'aircraft',
'satellite': 'satellite', 'wifi': 'wifi', 'bluetooth': 'bluetooth',
'listening': 'listening'
};
document.querySelectorAll('.mode-nav-btn').forEach(btn => {
const label = btn.querySelector('.nav-label');
if (label && label.textContent.toLowerCase().includes(modeMap[mode])) {
btn.classList.add('active');
}
});
// Toggle mode content visibility
document.getElementById('pagerMode').classList.toggle('active', mode === 'pager');
document.getElementById('sensorMode').classList.toggle('active', mode === 'sensor');
document.getElementById('aircraftMode').classList.toggle('active', mode === 'aircraft');
document.getElementById('satelliteMode').classList.toggle('active', mode === 'satellite');
document.getElementById('wifiMode').classList.toggle('active', mode === 'wifi');
document.getElementById('bluetoothMode').classList.toggle('active', mode === 'bluetooth');
document.getElementById('listeningPostMode').classList.toggle('active', mode === 'listening');
// Toggle stats visibility
document.getElementById('pagerStats').style.display = mode === 'pager' ? 'flex' : 'none';
document.getElementById('sensorStats').style.display = mode === 'sensor' ? 'flex' : 'none';
document.getElementById('aircraftStats').style.display = mode === 'aircraft' ? 'flex' : 'none';
document.getElementById('satelliteStats').style.display = mode === 'satellite' ? 'flex' : 'none';
document.getElementById('wifiStats').style.display = mode === 'wifi' ? 'flex' : 'none';
document.getElementById('btStats').style.display = mode === 'bluetooth' ? 'flex' : 'none';
// Hide signal meter - individual panels show signal strength where needed
document.getElementById('signalMeter').style.display = 'none';
// Update header stats groups
document.getElementById('headerPagerStats').classList.toggle('active', mode === 'pager');
document.getElementById('headerSensorStats').classList.toggle('active', mode === 'sensor');
document.getElementById('headerAircraftStats').classList.toggle('active', mode === 'aircraft');
document.getElementById('headerSatelliteStats').classList.toggle('active', mode === 'satellite');
document.getElementById('headerWifiStats').classList.toggle('active', mode === 'wifi');
document.getElementById('headerBtStats').classList.toggle('active', mode === 'bluetooth');
// Show/hide dashboard buttons in nav bar
document.getElementById('adsbDashboardBtn').style.display = mode === 'aircraft' ? 'inline-flex' : 'none';
document.getElementById('satelliteDashboardBtn').style.display = mode === 'satellite' ? 'inline-flex' : 'none';
// Update active mode indicator
const modeNames = {
'pager': 'PAGER',
'sensor': '433MHZ',
'aircraft': 'AIRCRAFT',
'satellite': 'SATELLITE',
'wifi': 'WIFI',
'bluetooth': 'BLUETOOTH',
'listening': 'LISTENING POST'
};
document.getElementById('activeModeIndicator').innerHTML = '<span class="pulse-dot"></span>' + modeNames[mode];
// Toggle layout containers
document.getElementById('wifiLayoutContainer').style.display = mode === 'wifi' ? 'flex' : 'none';
document.getElementById('btLayoutContainer').style.display = mode === 'bluetooth' ? 'flex' : 'none';
// Respect the "Show Radar Display" checkbox for aircraft mode
const showRadar = document.getElementById('adsbEnableMap')?.checked;
document.getElementById('aircraftVisuals').style.display = (mode === 'aircraft' && showRadar) ? 'grid' : 'none';
document.getElementById('satelliteVisuals').style.display = mode === 'satellite' ? 'block' : 'none';
document.getElementById('listeningPostVisuals').style.display = mode === 'listening' ? 'grid' : 'none';
// Update output panel title based on mode
const titles = {
'pager': 'Pager Decoder',
'sensor': '433MHz Sensor Monitor',
'aircraft': 'ADS-B Aircraft Tracker',
'satellite': 'Satellite Monitor',
'wifi': 'WiFi Scanner',
'bluetooth': 'Bluetooth Scanner',
'listening': 'Listening Post'
};
document.getElementById('outputTitle').textContent = titles[mode] || 'Signal Monitor';
// Show/hide Device Intelligence for modes that use it
const reconBtn = document.getElementById('reconBtn');
const intelBtn = document.querySelector('[onclick="exportDeviceDB()"]');
if (mode === 'satellite' || mode === 'aircraft' || mode === 'listening') {
document.getElementById('reconPanel').style.display = 'none';
if (reconBtn) reconBtn.style.display = 'none';
if (intelBtn) intelBtn.style.display = 'none';
} else {
if (reconBtn) reconBtn.style.display = 'inline-block';
if (intelBtn) intelBtn.style.display = 'inline-block';
if (typeof reconEnabled !== 'undefined' && reconEnabled) {
document.getElementById('reconPanel').style.display = 'block';
}
}
// Show RTL-SDR device section for modes that use it
document.getElementById('rtlDeviceSection').style.display =
(mode === 'pager' || mode === 'sensor' || mode === 'aircraft' || mode === 'listening') ? 'block' : 'none';
// Toggle mode-specific tool status displays
document.getElementById('toolStatusPager').style.display = (mode === 'pager') ? 'grid' : 'none';
document.getElementById('toolStatusSensor').style.display = (mode === 'sensor') ? 'grid' : 'none';
document.getElementById('toolStatusAircraft').style.display = (mode === 'aircraft') ? 'grid' : 'none';
// Hide waterfall and output console for modes with their own visualizations
document.querySelector('.waterfall-container').style.display =
(mode === 'satellite' || mode === 'listening' || mode === 'aircraft' || mode === 'wifi' || mode === 'bluetooth') ? 'none' : 'block';
document.getElementById('output').style.display =
(mode === 'satellite' || mode === 'aircraft' || mode === 'wifi' || mode === 'bluetooth') ? 'none' : 'block';
document.querySelector('.status-bar').style.display = (mode === 'satellite') ? 'none' : 'flex';
// Load interfaces and initialize visualizations when switching modes
if (mode === 'wifi') {
if (typeof refreshWifiInterfaces === 'function') refreshWifiInterfaces();
if (typeof initRadar === 'function') initRadar();
if (typeof initWatchList === 'function') initWatchList();
} else if (mode === 'bluetooth') {
if (typeof refreshBtInterfaces === 'function') refreshBtInterfaces();
if (typeof initBtRadar === 'function') initBtRadar();
} else if (mode === 'aircraft') {
if (typeof checkAdsbTools === 'function') checkAdsbTools();
if (typeof initAircraftRadar === 'function') initAircraftRadar();
} else if (mode === 'satellite') {
if (typeof initPolarPlot === 'function') initPolarPlot();
if (typeof initSatelliteList === 'function') initSatelliteList();
} else if (mode === 'listening') {
if (typeof checkScannerTools === 'function') checkScannerTools();
if (typeof checkAudioTools === 'function') checkAudioTools();
if (typeof populateScannerDeviceSelect === 'function') populateScannerDeviceSelect();
if (typeof populateAudioDeviceSelect === 'function') populateAudioDeviceSelect();
}
}
// ============== SECTION COLLAPSE ==============
function toggleSection(el) {
el.closest('.section').classList.toggle('collapsed');
}
// ============== THEME MANAGEMENT ==============
function toggleTheme() {
const html = document.documentElement;
const currentTheme = html.getAttribute('data-theme');
const newTheme = currentTheme === 'light' ? 'dark' : 'light';
html.setAttribute('data-theme', newTheme);
localStorage.setItem('theme', newTheme);
// Update button text
const btn = document.getElementById('themeToggle');
if (btn) {
btn.textContent = newTheme === 'light' ? '🌙' : '☀️';
}
}
function loadTheme() {
const savedTheme = localStorage.getItem('theme') || 'dark';
document.documentElement.setAttribute('data-theme', savedTheme);
const btn = document.getElementById('themeToggle');
if (btn) {
btn.textContent = savedTheme === 'light' ? '🌙' : '☀️';
}
}
// ============== AUTO-SCROLL ==============
function toggleAutoScroll() {
autoScroll = !autoScroll;
localStorage.setItem('autoScroll', autoScroll);
updateAutoScrollButton();
}
function updateAutoScrollButton() {
const btn = document.getElementById('autoScrollBtn');
if (btn) {
btn.innerHTML = autoScroll ? '⬇ AUTO-SCROLL ON' : '⬇ AUTO-SCROLL OFF';
btn.classList.toggle('active', autoScroll);
}
}
// ============== SDR DEVICE MANAGEMENT ==============
function getSelectedDevice() {
return document.getElementById('deviceSelect').value;
}
function getSelectedSDRType() {
return document.getElementById('sdrTypeSelect').value;
}
function reserveDevice(deviceIndex, modeId) {
sdrDeviceUsage[modeId] = deviceIndex;
}
function releaseDevice(modeId) {
delete sdrDeviceUsage[modeId];
}
function checkDeviceAvailability(requestingMode) {
const selectedDevice = parseInt(getSelectedDevice());
for (const [mode, device] of Object.entries(sdrDeviceUsage)) {
if (mode !== requestingMode && device === selectedDevice) {
alert(`Device ${selectedDevice} is currently in use by ${mode} mode. Please select a different device or stop the other scan first.`);
return false;
}
}
return true;
}
// ============== BIAS-T SETTINGS ==============
function saveBiasTSetting() {
const enabled = document.getElementById('biasT')?.checked || false;
localStorage.setItem('biasTEnabled', enabled);
}
function getBiasTEnabled() {
return document.getElementById('biasT')?.checked || false;
}
function loadBiasTSetting() {
const saved = localStorage.getItem('biasTEnabled');
if (saved === 'true') {
const checkbox = document.getElementById('biasT');
if (checkbox) checkbox.checked = true;
}
}
// ============== REMOTE SDR ==============
function toggleRemoteSDR() {
const useRemote = document.getElementById('useRemoteSDR').checked;
const configDiv = document.getElementById('remoteSDRConfig');
const localControls = document.querySelectorAll('#sdrTypeSelect, #deviceSelect');
if (useRemote) {
configDiv.style.display = 'block';
localControls.forEach(el => el.disabled = true);
} else {
configDiv.style.display = 'none';
localControls.forEach(el => el.disabled = false);
}
}
function getRemoteSDRConfig() {
const useRemote = document.getElementById('useRemoteSDR')?.checked;
if (!useRemote) return null;
const host = document.getElementById('rtlTcpHost')?.value || 'localhost';
const port = parseInt(document.getElementById('rtlTcpPort')?.value || '1234');
if (!host || isNaN(port)) {
alert('Please enter valid rtl_tcp host and port');
return false;
}
return { host, port };
}
// ============== OUTPUT DISPLAY ==============
function showInfo(text) {
const output = document.getElementById('output');
if (!output) return;
const placeholder = output.querySelector('.placeholder');
if (placeholder) placeholder.remove();
const infoEl = document.createElement('div');
infoEl.className = 'info-msg';
infoEl.style.cssText = 'padding: 12px 15px; margin-bottom: 8px; background: #0a0a0a; border: 1px solid #1a1a1a; border-left: 2px solid #00d4ff; font-family: "JetBrains Mono", monospace; font-size: 11px; color: #888; word-break: break-all;';
infoEl.textContent = text;
output.insertBefore(infoEl, output.firstChild);
}
function showError(text) {
const output = document.getElementById('output');
if (!output) return;
const placeholder = output.querySelector('.placeholder');
if (placeholder) placeholder.remove();
const errorEl = document.createElement('div');
errorEl.className = 'error-msg';
errorEl.style.cssText = 'padding: 12px 15px; margin-bottom: 8px; background: #1a0a0a; border: 1px solid #2a1a1a; border-left: 2px solid #ff3366; font-family: "JetBrains Mono", monospace; font-size: 11px; color: #ff6688; word-break: break-all;';
errorEl.textContent = '⚠ ' + text;
output.insertBefore(errorEl, output.firstChild);
}
// ============== OBSERVER LOCATION ==============
function saveObserverLocation() {
const lat = parseFloat(document.getElementById('adsbObsLat')?.value || document.getElementById('obsLat')?.value);
const lon = parseFloat(document.getElementById('adsbObsLon')?.value || document.getElementById('obsLon')?.value);
if (!isNaN(lat) && !isNaN(lon)) {
observerLocation = { lat, lon };
localStorage.setItem('observerLocation', JSON.stringify(observerLocation));
// Sync both input sets
const adsbLat = document.getElementById('adsbObsLat');
const adsbLon = document.getElementById('adsbObsLon');
const satLat = document.getElementById('obsLat');
const satLon = document.getElementById('obsLon');
if (adsbLat) adsbLat.value = lat.toFixed(4);
if (adsbLon) adsbLon.value = lon.toFixed(4);
if (satLat) satLat.value = lat.toFixed(4);
if (satLon) satLon.value = lon.toFixed(4);
}
}
function useGeolocation() {
if ('geolocation' in navigator) {
navigator.geolocation.getCurrentPosition(
(position) => {
const lat = position.coords.latitude;
const lon = position.coords.longitude;
observerLocation = { lat, lon };
localStorage.setItem('observerLocation', JSON.stringify(observerLocation));
// Update all input fields
const adsbLat = document.getElementById('adsbObsLat');
const adsbLon = document.getElementById('adsbObsLon');
const satLat = document.getElementById('obsLat');
const satLon = document.getElementById('obsLon');
if (adsbLat) adsbLat.value = lat.toFixed(4);
if (adsbLon) adsbLon.value = lon.toFixed(4);
if (satLat) satLat.value = lat.toFixed(4);
if (satLon) satLon.value = lon.toFixed(4);
showInfo(`Location set to ${lat.toFixed(4)}, ${lon.toFixed(4)}`);
},
(error) => {
showError('Geolocation failed: ' + error.message);
}
);
} else {
showError('Geolocation not supported by browser');
}
}
// ============== EXPORT FUNCTIONS ==============
function exportCSV() {
if (allMessages.length === 0) {
alert('No messages to export');
return;
}
const headers = ['Timestamp', 'Protocol', 'Address', 'Function', 'Type', 'Message'];
const csv = [headers.join(',')];
allMessages.forEach(msg => {
const row = [
msg.timestamp || '',
msg.protocol || '',
msg.address || '',
msg.function || '',
msg.msg_type || '',
'"' + (msg.message || '').replace(/"/g, '""') + '"'
];
csv.push(row.join(','));
});
downloadFile(csv.join('\n'), 'intercept_messages.csv', 'text/csv');
}
function exportJSON() {
if (allMessages.length === 0) {
alert('No messages to export');
return;
}
downloadFile(JSON.stringify(allMessages, null, 2), 'intercept_messages.json', 'application/json');
}
// ============== INITIALIZATION ==============
function initApp() {
// Check disclaimer
checkDisclaimer();
// Load theme
loadTheme();
// Start clock
updateHeaderClock();
setInterval(updateHeaderClock, 1000);
// Start stats sync
setInterval(syncHeaderStats, 500);
// Load bias-T setting
loadBiasTSetting();
// Initialize observer location inputs
const adsbLatInput = document.getElementById('adsbObsLat');
const adsbLonInput = document.getElementById('adsbObsLon');
const obsLatInput = document.getElementById('obsLat');
const obsLonInput = document.getElementById('obsLon');
if (adsbLatInput) adsbLatInput.value = observerLocation.lat.toFixed(4);
if (adsbLonInput) adsbLonInput.value = observerLocation.lon.toFixed(4);
if (obsLatInput) obsLatInput.value = observerLocation.lat.toFixed(4);
if (obsLonInput) obsLonInput.value = observerLocation.lon.toFixed(4);
// Update UI state
updateAutoScrollButton();
// Make sections collapsible
document.querySelectorAll('.section h3').forEach(h3 => {
h3.addEventListener('click', function() {
this.parentElement.classList.toggle('collapsed');
});
});
// Collapse all sections by default (except SDR Device which is first)
document.querySelectorAll('.section').forEach((section, index) => {
if (index > 0) {
section.classList.add('collapsed');
}
});
}
// Run initialization when DOM is ready
document.addEventListener('DOMContentLoaded', initApp);
+281
View File
@@ -0,0 +1,281 @@
/**
* Intercept - Audio System
* Web Audio API alerts, notifications, and sound effects
*/
// ============== AUDIO STATE ==============
let audioContext = null;
let audioMuted = localStorage.getItem('audioMuted') === 'true';
let notificationsEnabled = false;
// ============== AUDIO CONTEXT ==============
/**
* Initialize the Web Audio API context
* Must be called after user interaction due to browser autoplay policies
*/
function initAudio() {
if (!audioContext) {
audioContext = new (window.AudioContext || window.webkitAudioContext)();
}
return audioContext;
}
/**
* Get or create the audio context
* @returns {AudioContext}
*/
function getAudioContext() {
if (!audioContext) {
audioContext = new (window.AudioContext || window.webkitAudioContext)();
}
return audioContext;
}
// ============== ALERT SOUNDS ==============
/**
* Play a basic alert beep
* Used for message received notifications
*/
function playAlert() {
if (audioMuted || !audioContext) return;
try {
const oscillator = audioContext.createOscillator();
const gainNode = audioContext.createGain();
oscillator.connect(gainNode);
gainNode.connect(audioContext.destination);
oscillator.frequency.value = 880;
oscillator.type = 'sine';
gainNode.gain.setValueAtTime(0.3, audioContext.currentTime);
gainNode.gain.exponentialRampToValueAtTime(0.01, audioContext.currentTime + 0.2);
oscillator.start(audioContext.currentTime);
oscillator.stop(audioContext.currentTime + 0.2);
} catch (e) {
console.warn('Audio alert failed:', e);
}
}
/**
* Play alert sound by type
* @param {string} type - 'emergency', 'military', 'warning', 'info'
*/
function playAlertSound(type) {
if (audioMuted) return;
try {
const ctx = getAudioContext();
const oscillator = ctx.createOscillator();
const gainNode = ctx.createGain();
oscillator.connect(gainNode);
gainNode.connect(ctx.destination);
switch (type) {
case 'emergency':
// Urgent two-tone alert for emergencies
oscillator.frequency.setValueAtTime(880, ctx.currentTime);
oscillator.frequency.setValueAtTime(660, ctx.currentTime + 0.15);
oscillator.frequency.setValueAtTime(880, ctx.currentTime + 0.3);
gainNode.gain.setValueAtTime(0.3, ctx.currentTime);
gainNode.gain.exponentialRampToValueAtTime(0.01, ctx.currentTime + 0.5);
oscillator.start(ctx.currentTime);
oscillator.stop(ctx.currentTime + 0.5);
break;
case 'military':
// Single tone for military aircraft detection
oscillator.frequency.setValueAtTime(523, ctx.currentTime);
gainNode.gain.setValueAtTime(0.2, ctx.currentTime);
gainNode.gain.exponentialRampToValueAtTime(0.01, ctx.currentTime + 0.3);
oscillator.start(ctx.currentTime);
oscillator.stop(ctx.currentTime + 0.3);
break;
case 'warning':
// Warning tone (descending)
oscillator.frequency.setValueAtTime(660, ctx.currentTime);
oscillator.frequency.exponentialRampToValueAtTime(440, ctx.currentTime + 0.3);
gainNode.gain.setValueAtTime(0.25, ctx.currentTime);
gainNode.gain.exponentialRampToValueAtTime(0.01, ctx.currentTime + 0.3);
oscillator.start(ctx.currentTime);
oscillator.stop(ctx.currentTime + 0.3);
break;
case 'info':
default:
// Simple info tone
oscillator.frequency.setValueAtTime(440, ctx.currentTime);
gainNode.gain.setValueAtTime(0.15, ctx.currentTime);
gainNode.gain.exponentialRampToValueAtTime(0.01, ctx.currentTime + 0.15);
oscillator.start(ctx.currentTime);
oscillator.stop(ctx.currentTime + 0.15);
break;
}
} catch (e) {
console.warn('Audio alert failed:', e);
}
}
/**
* Play scanner signal detected sound
* A distinctive ascending tone for radio scanner
*/
function playSignalDetectedSound() {
if (audioMuted) return;
try {
const ctx = getAudioContext();
const oscillator = ctx.createOscillator();
const gainNode = ctx.createGain();
oscillator.connect(gainNode);
gainNode.connect(ctx.destination);
// Ascending tone
oscillator.frequency.setValueAtTime(400, ctx.currentTime);
oscillator.frequency.exponentialRampToValueAtTime(800, ctx.currentTime + 0.15);
oscillator.type = 'sine';
gainNode.gain.setValueAtTime(0.2, ctx.currentTime);
gainNode.gain.exponentialRampToValueAtTime(0.01, ctx.currentTime + 0.2);
oscillator.start(ctx.currentTime);
oscillator.stop(ctx.currentTime + 0.2);
} catch (e) {
console.warn('Signal detected sound failed:', e);
}
}
/**
* Play a click sound for UI feedback
*/
function playClickSound() {
if (audioMuted) return;
try {
const ctx = getAudioContext();
const oscillator = ctx.createOscillator();
const gainNode = ctx.createGain();
oscillator.connect(gainNode);
gainNode.connect(ctx.destination);
oscillator.frequency.value = 1000;
oscillator.type = 'square';
gainNode.gain.setValueAtTime(0.1, ctx.currentTime);
gainNode.gain.exponentialRampToValueAtTime(0.01, ctx.currentTime + 0.05);
oscillator.start(ctx.currentTime);
oscillator.stop(ctx.currentTime + 0.05);
} catch (e) {
console.warn('Click sound failed:', e);
}
}
// ============== MUTE CONTROL ==============
/**
* Toggle mute state
*/
function toggleMute() {
audioMuted = !audioMuted;
localStorage.setItem('audioMuted', audioMuted);
updateMuteButton();
}
/**
* Set mute state
* @param {boolean} muted - Whether audio should be muted
*/
function setMuted(muted) {
audioMuted = muted;
localStorage.setItem('audioMuted', audioMuted);
updateMuteButton();
}
/**
* Get current mute state
* @returns {boolean}
*/
function isMuted() {
return audioMuted;
}
/**
* Update mute button UI
*/
function updateMuteButton() {
const btn = document.getElementById('muteBtn');
if (btn) {
btn.innerHTML = audioMuted ? '🔇 UNMUTE' : '🔊 MUTE';
btn.classList.toggle('muted', audioMuted);
}
}
// ============== DESKTOP NOTIFICATIONS ==============
/**
* Request notification permission from user
*/
function requestNotificationPermission() {
if ('Notification' in window && Notification.permission === 'default') {
Notification.requestPermission().then(permission => {
notificationsEnabled = permission === 'granted';
if (notificationsEnabled && typeof showInfo === 'function') {
showInfo('🔔 Desktop notifications enabled');
}
});
}
}
/**
* Show a desktop notification
* @param {string} title - Notification title
* @param {string} body - Notification body
*/
function showNotification(title, body) {
if (notificationsEnabled && document.hidden) {
new Notification(title, {
body: body,
icon: '/favicon.ico',
tag: 'intercept-' + Date.now()
});
}
}
// ============== INITIALIZATION ==============
/**
* Initialize audio system
* Should be called on first user interaction
*/
function initAudioSystem() {
// Initialize audio context
initAudio();
// Update mute button state
updateMuteButton();
// Check notification permission
if ('Notification' in window) {
if (Notification.permission === 'granted') {
notificationsEnabled = true;
} else if (Notification.permission === 'default') {
// Will request on first interaction
document.addEventListener('click', function requestOnce() {
requestNotificationPermission();
document.removeEventListener('click', requestOnce);
}, { once: true });
}
}
}
// Initialize on first user interaction (required for Web Audio API)
document.addEventListener('click', function initOnInteraction() {
initAudio();
document.removeEventListener('click', initOnInteraction);
}, { once: true });
+273
View File
@@ -0,0 +1,273 @@
/**
* Intercept - Core Utility Functions
* Pure utility functions with no DOM dependencies
*/
// ============== HTML ESCAPING ==============
/**
* Escape HTML to prevent XSS
* @param {string} text - Text to escape
* @returns {string} Escaped HTML
*/
function escapeHtml(text) {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
/**
* Escape text for use in HTML attributes (especially onclick handlers)
* @param {string} text - Text to escape
* @returns {string} Escaped attribute value
*/
function escapeAttr(text) {
if (text === null || text === undefined) return '';
var s = String(text);
s = s.replace(/&/g, '&amp;');
s = s.replace(/'/g, '&#39;');
s = s.replace(/"/g, '&quot;');
s = s.replace(/</g, '&lt;');
s = s.replace(/>/g, '&gt;');
return s;
}
// ============== VALIDATION ==============
/**
* Validate MAC address format (XX:XX:XX:XX:XX:XX)
* @param {string} mac - MAC address to validate
* @returns {boolean} True if valid
*/
function isValidMac(mac) {
return /^([0-9A-Fa-f]{2}:){5}[0-9A-Fa-f]{2}$/.test(mac);
}
/**
* Validate WiFi channel (1-200 covers all bands)
* @param {string|number} ch - Channel number
* @returns {boolean} True if valid
*/
function isValidChannel(ch) {
const num = parseInt(ch, 10);
return !isNaN(num) && num >= 1 && num <= 200;
}
// ============== TIME FORMATTING ==============
/**
* Get relative time string from timestamp
* @param {string} timestamp - Time string in HH:MM:SS format
* @returns {string} Relative time like "5s ago", "2m ago"
*/
function getRelativeTime(timestamp) {
if (!timestamp) return '';
const now = new Date();
const parts = timestamp.split(':');
const msgTime = new Date();
msgTime.setHours(parseInt(parts[0]), parseInt(parts[1]), parseInt(parts[2]));
const diff = Math.floor((now - msgTime) / 1000);
if (diff < 5) return 'just now';
if (diff < 60) return diff + 's ago';
if (diff < 3600) return Math.floor(diff / 60) + 'm ago';
return timestamp;
}
/**
* Format UTC time string
* @param {Date} date - Date object
* @returns {string} UTC time in HH:MM:SS format
*/
function formatUtcTime(date) {
return date.toISOString().substring(11, 19);
}
// ============== DISTANCE CALCULATIONS ==============
/**
* Calculate distance between two points in nautical miles
* Uses Haversine formula
* @param {number} lat1 - Latitude of first point
* @param {number} lon1 - Longitude of first point
* @param {number} lat2 - Latitude of second point
* @param {number} lon2 - Longitude of second point
* @returns {number} Distance in nautical miles
*/
function calculateDistanceNm(lat1, lon1, lat2, lon2) {
const R = 3440.065; // Earth radius in nautical miles
const dLat = (lat2 - lat1) * Math.PI / 180;
const dLon = (lon2 - lon1) * Math.PI / 180;
const a = Math.sin(dLat/2) * Math.sin(dLat/2) +
Math.cos(lat1 * Math.PI / 180) * Math.cos(lat2 * Math.PI / 180) *
Math.sin(dLon/2) * Math.sin(dLon/2);
const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1-a));
return R * c;
}
/**
* Calculate distance between two points in kilometers
* @param {number} lat1 - Latitude of first point
* @param {number} lon1 - Longitude of first point
* @param {number} lat2 - Latitude of second point
* @param {number} lon2 - Longitude of second point
* @returns {number} Distance in kilometers
*/
function calculateDistanceKm(lat1, lon1, lat2, lon2) {
const R = 6371; // Earth radius in kilometers
const dLat = (lat2 - lat1) * Math.PI / 180;
const dLon = (lon2 - lon1) * Math.PI / 180;
const a = Math.sin(dLat/2) * Math.sin(dLat/2) +
Math.cos(lat1 * Math.PI / 180) * Math.cos(lat2 * Math.PI / 180) *
Math.sin(dLon/2) * Math.sin(dLon/2);
const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1-a));
return R * c;
}
// ============== FILE OPERATIONS ==============
/**
* Download content as a file
* @param {string} content - File content
* @param {string} filename - Name for the downloaded file
* @param {string} type - MIME type
*/
function downloadFile(content, filename, type) {
const blob = new Blob([content], { type });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = filename;
a.click();
URL.revokeObjectURL(url);
}
// ============== FREQUENCY FORMATTING ==============
/**
* Format frequency value with proper units
* @param {number} freqMhz - Frequency in MHz
* @param {number} decimals - Number of decimal places (default 3)
* @returns {string} Formatted frequency string
*/
function formatFrequency(freqMhz, decimals = 3) {
return freqMhz.toFixed(decimals) + ' MHz';
}
/**
* Parse frequency string to MHz
* @param {string} freqStr - Frequency string (e.g., "118.0", "118.0 MHz")
* @returns {number} Frequency in MHz
*/
function parseFrequency(freqStr) {
return parseFloat(freqStr.replace(/[^\d.-]/g, ''));
}
// ============== LOCAL STORAGE HELPERS ==============
/**
* Get item from localStorage with JSON parsing
* @param {string} key - Storage key
* @param {*} defaultValue - Default value if key doesn't exist
* @returns {*} Parsed value or default
*/
function getStorageItem(key, defaultValue = null) {
const saved = localStorage.getItem(key);
if (saved === null) return defaultValue;
try {
return JSON.parse(saved);
} catch (e) {
return saved;
}
}
/**
* Set item in localStorage with JSON stringification
* @param {string} key - Storage key
* @param {*} value - Value to store
*/
function setStorageItem(key, value) {
if (typeof value === 'object') {
localStorage.setItem(key, JSON.stringify(value));
} else {
localStorage.setItem(key, value);
}
}
// ============== ARRAY/OBJECT UTILITIES ==============
/**
* Debounce function execution
* @param {Function} func - Function to debounce
* @param {number} wait - Wait time in milliseconds
* @returns {Function} Debounced function
*/
function debounce(func, wait) {
let timeout;
return function executedFunction(...args) {
const later = () => {
clearTimeout(timeout);
func(...args);
};
clearTimeout(timeout);
timeout = setTimeout(later, wait);
};
}
/**
* Throttle function execution
* @param {Function} func - Function to throttle
* @param {number} limit - Time limit in milliseconds
* @returns {Function} Throttled function
*/
function throttle(func, limit) {
let inThrottle;
return function executedFunction(...args) {
if (!inThrottle) {
func(...args);
inThrottle = true;
setTimeout(() => inThrottle = false, limit);
}
};
}
// ============== NUMBER FORMATTING ==============
/**
* Format large numbers with K/M suffixes
* @param {number} num - Number to format
* @returns {string} Formatted string
*/
function formatNumber(num) {
if (num >= 1000000) {
return (num / 1000000).toFixed(1) + 'M';
}
if (num >= 1000) {
return (num / 1000).toFixed(1) + 'K';
}
return num.toString();
}
/**
* Clamp a number between min and max
* @param {number} num - Number to clamp
* @param {number} min - Minimum value
* @param {number} max - Maximum value
* @returns {number} Clamped value
*/
function clamp(num, min, max) {
return Math.min(Math.max(num, min), max);
}
/**
* Map a value from one range to another
* @param {number} value - Value to map
* @param {number} inMin - Input range minimum
* @param {number} inMax - Input range maximum
* @param {number} outMin - Output range minimum
* @param {number} outMax - Output range maximum
* @returns {number} Mapped value
*/
function mapRange(value, inMin, inMax, outMin, outMax) {
return (value - inMin) * (outMax - outMin) / (inMax - inMin) + outMin;
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+1822 -2644
View File
File diff suppressed because it is too large Load Diff
+31 -5
View File
@@ -3,7 +3,7 @@
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>SATELLITE COMMAND // INTERCEPT</title>
<title>SATELLITE COMMAND // iNTERCEPT - See the Invisible</title>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500;600&display=swap" rel="stylesheet">
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" />
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
@@ -16,7 +16,7 @@
<header class="header">
<div class="logo">
SATELLITE COMMAND
<span>// INTERCEPT</span>
<span>// iNTERCEPT - See the Invisible</span>
</div>
<div class="stats-badges">
<div class="stat-badge">
@@ -183,6 +183,10 @@
</main>
<script>
// Check if embedded mode
const urlParams = new URLSearchParams(window.location.search);
const isEmbedded = urlParams.get('embedded') === 'true';
// Dashboard state
let passes = [];
let selectedPass = null;
@@ -223,7 +227,29 @@
calculatePasses();
}
function setupEmbeddedMode() {
if (isEmbedded) {
// Hide back link when embedded
const backLink = document.querySelector('.back-link');
if (backLink) backLink.style.display = 'none';
// Add embedded class to body for CSS adjustments
document.body.classList.add('embedded');
// Compact the header slightly
const header = document.querySelector('.header');
if (header) header.style.padding = '10px 20px';
// Hide decorative elements
const gridBg = document.querySelector('.grid-bg');
const scanline = document.querySelector('.scanline');
if (gridBg) gridBg.style.display = 'none';
if (scanline) scanline.style.display = 'none';
}
}
document.addEventListener('DOMContentLoaded', () => {
setupEmbeddedMode();
initGroundMap();
updateClock();
setInterval(updateClock, 1000);
@@ -361,7 +387,7 @@
const cx = canvas.width / 2;
const cy = canvas.height / 2;
const radius = Math.min(cx, cy) - 40;
const radius = Math.max(10, Math.min(cx, cy) - 40);
ctx.fillStyle = '#0a0a0f';
ctx.fillRect(0, 0, canvas.width, canvas.height);
@@ -720,7 +746,7 @@
const cx = canvas.width / 2;
const cy = canvas.height / 2;
const radius = Math.min(cx, cy) - 40;
const radius = Math.max(10, Math.min(cx, cy) - 40);
ctx.fillStyle = '#0a0a0f';
ctx.fillRect(0, 0, canvas.width, canvas.height);
@@ -818,7 +844,7 @@
const cx = canvas.width / 2;
const cy = canvas.height / 2;
const radius = Math.min(cx, cy) - 40;
const radius = Math.max(10, Math.min(cx, cy) - 40);
if (el > -5) {
const posEl = Math.max(0, el);
+129
View File
@@ -0,0 +1,129 @@
import pytest
import json
import subprocess
from unittest.mock import MagicMock, patch
from flask import Flask
from routes.bluetooth import bluetooth_bp, classify_bt_device, detect_tracker
@pytest.fixture(autouse=True)
def mock_app_module(mocker):
mock_app = mocker.patch("routes.bluetooth.app_module")
mock_app.bt_devices = {}
mock_app.bt_beacons = {}
mock_app.bt_services = {}
mock_app.bt_queue = MagicMock()
mock_app.bt_lock = MagicMock()
mock_app.bt_process = None
mock_app.bt_interface = "hci0"
return mock_app
@pytest.fixture
def app():
app = Flask(__name__)
app.register_blueprint(bluetooth_bp)
return app
@pytest.fixture
def client(app):
return app.test_client()
def test_classify_bt_device_by_name():
"""Test classification based on common naming patterns."""
assert classify_bt_device("Sony WH-1000XM4", None, None) == "audio"
assert classify_bt_device("iPhone 15", None, None) == "phone"
assert classify_bt_device("Garmin Fenix", None, None) == "wearable"
assert classify_bt_device("Microsoft Mouse", None, None) == "input"
assert classify_bt_device("AirTag", None, None) == "tracker"
assert classify_bt_device("Generic Device", None, None) == "other"
def test_classify_bt_device_by_class():
"""Test classification based on Bluetooth Class of Device (CoD)."""
assert classify_bt_device(None, 0x0100, None) == "computer"
assert classify_bt_device(None, 0x0200, None) == "phone"
assert classify_bt_device(None, 0x0400, None) == "audio"
def test_detect_tracker_by_mac():
"""Test tracker detection using MAC OUI prefixes."""
# Assuming 'FF:FF:FF' is a mock prefix in patterns for testing
with patch("routes.bluetooth.TILE_PREFIXES", ["FF:FF"]):
result = detect_tracker("FF:FF:00:11:22:33", "Unknown")
assert result["type"] == "tile"
def test_detect_tracker_by_name():
"""Test tracker detection using name strings."""
result = detect_tracker("00:11:22:33:44:55", "My AirTag")
assert result["type"] == "airtag"
assert result["risk"] == "high"
# --- Route Tests ---
def test_get_interfaces_route(client, mocker):
"""Test the /interfaces endpoint with mocked system output."""
mock_run = mocker.patch("subprocess.run")
# Mocking hciconfig output for a Linux system
mock_run.return_value = MagicMock(
stdout="hci0:\tType: Primary Bus: USB\n\tBD Address: 00:11:22:33:44:55 ACL MTU: 1021:8 SCO MTU: 64:1\n\tUP RUNNING\n"
)
mocker.patch("platform.system", return_value="Linux")
mocker.patch("routes.bluetooth.check_tool", return_value=True)
response = client.get("/bt/interfaces")
data = response.get_json()
assert response.status_code == 200
assert data["interfaces"][0]["name"] == "hci0"
assert data["interfaces"][0]["status"] == "up"
assert data["tools"]["hcitool"] is True
def test_stop_scan_route(client, mock_app_module):
"""Test stopping a running scan process."""
mock_process = MagicMock()
mock_app_module.bt_process = mock_process
response = client.post("/bt/scan/stop")
assert response.status_code == 200
assert response.get_json()["status"] == "stopped"
mock_process.terminate.assert_called_once()
def test_enum_services_error_no_mac(client):
"""Test service enumeration validation."""
response = client.post("/bt/enum", json={})
assert response.status_code == 200
assert response.get_json()["status"] == "error"
def test_get_devices_route(client, mock_app_module):
"""Test retrieving the current device list from memory."""
mock_app_module.bt_devices = {"00:11:22:33:44:55": {"mac": "00:11:22:33:44:55", "name": "Test Device"}}
response = client.get("/bt/devices")
data = response.get_json()
assert response.status_code == 200
assert len(data["devices"]) == 1
assert data["devices"][0]["name"] == "Test Device"
def test_reload_oui_route(client, mocker):
"""Test the OUI database reload functionality."""
mocker.patch("routes.bluetooth.load_oui_database", return_value={"001122": "Test Corp"})
response = client.post("/bt/reload-oui")
data = response.get_json()
assert response.status_code == 200
assert data["status"] == "success"
assert data["entries"] > 0
+268
View File
@@ -0,0 +1,268 @@
"""Aircraft database for ICAO hex to type/registration lookup."""
from __future__ import annotations
import json
import logging
import os
import threading
import time
from datetime import datetime
from typing import Any
from urllib.request import urlopen, Request
from urllib.error import URLError
logger = logging.getLogger('intercept.aircraft_db')
# Database file location (project root)
DB_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
DB_FILE = os.path.join(DB_DIR, 'aircraft_db.json')
DB_META_FILE = os.path.join(DB_DIR, 'aircraft_db_meta.json')
# Mictronics database URLs (raw GitHub)
AIRCRAFT_DB_URL = 'https://raw.githubusercontent.com/Mictronics/readsb-protobuf/dev/webapp/src/db/aircrafts.json'
TYPES_DB_URL = 'https://raw.githubusercontent.com/Mictronics/readsb-protobuf/dev/webapp/src/db/types.json'
GITHUB_API_URL = 'https://api.github.com/repos/Mictronics/readsb-protobuf/commits?path=webapp/src/db/aircrafts.json&per_page=1'
# In-memory cache
_aircraft_cache: dict[str, dict[str, str]] = {}
_types_cache: dict[str, str] = {}
_cache_lock = threading.Lock()
_db_loaded = False
_db_version: str | None = None
_update_available: bool = False
_latest_version: str | None = None
def get_db_status() -> dict[str, Any]:
"""Get current database status."""
exists = os.path.exists(DB_FILE)
meta = _load_meta()
return {
'installed': exists,
'version': meta.get('version') if meta else None,
'downloaded': meta.get('downloaded') if meta else None,
'aircraft_count': len(_aircraft_cache) if _db_loaded else 0,
'update_available': _update_available,
'latest_version': _latest_version,
}
def _load_meta() -> dict[str, Any] | None:
"""Load database metadata."""
try:
if os.path.exists(DB_META_FILE):
with open(DB_META_FILE, 'r') as f:
return json.load(f)
except Exception as e:
logger.warning(f"Error loading aircraft db meta: {e}")
return None
def _save_meta(version: str) -> None:
"""Save database metadata."""
try:
meta = {
'version': version,
'downloaded': datetime.utcnow().isoformat() + 'Z',
}
with open(DB_META_FILE, 'w') as f:
json.dump(meta, f, indent=2)
except Exception as e:
logger.warning(f"Error saving aircraft db meta: {e}")
def load_database() -> bool:
"""Load aircraft database into memory. Returns True if successful."""
global _aircraft_cache, _types_cache, _db_loaded, _db_version
if not os.path.exists(DB_FILE):
logger.info("Aircraft database not installed")
return False
try:
with _cache_lock:
with open(DB_FILE, 'r') as f:
data = json.load(f)
_aircraft_cache = data.get('aircraft', {})
_types_cache = data.get('types', {})
_db_loaded = True
meta = _load_meta()
_db_version = meta.get('version') if meta else 'unknown'
logger.info(f"Loaded aircraft database: {len(_aircraft_cache)} aircraft, {len(_types_cache)} types")
return True
except Exception as e:
logger.error(f"Error loading aircraft database: {e}")
return False
def lookup(icao: str) -> dict[str, str] | None:
"""
Look up aircraft by ICAO hex code.
Returns dict with keys: registration, type_code, type_desc
Or None if not found.
"""
if not _db_loaded:
return None
icao_upper = icao.upper()
with _cache_lock:
aircraft = _aircraft_cache.get(icao_upper)
if not aircraft:
return None
# Database format is array: [registration, type_code, flags, ...]
# Handle both list format (from Mictronics) and dict format (legacy)
if isinstance(aircraft, list):
reg = aircraft[0] if len(aircraft) > 0 else ''
type_code = aircraft[1] if len(aircraft) > 1 else ''
else:
# Dict format fallback
reg = aircraft.get('r', '')
type_code = aircraft.get('t', '')
# Look up type description
type_desc = ''
if type_code and type_code in _types_cache:
type_desc = _types_cache[type_code]
return {
'registration': reg,
'type_code': type_code,
'type_desc': type_desc,
}
def check_for_updates() -> dict[str, Any]:
"""
Check GitHub for database updates.
Returns status dict with update_available flag.
"""
global _update_available, _latest_version
try:
req = Request(GITHUB_API_URL, headers={'User-Agent': 'Intercept-SIGINT'})
with urlopen(req, timeout=10) as response:
commits = json.loads(response.read().decode('utf-8'))
if commits and len(commits) > 0:
latest_sha = commits[0]['sha'][:8]
latest_date = commits[0]['commit']['committer']['date']
_latest_version = f"{latest_date[:10]}_{latest_sha}"
meta = _load_meta()
current_version = meta.get('version') if meta else None
_update_available = current_version != _latest_version
return {
'success': True,
'current_version': current_version,
'latest_version': _latest_version,
'update_available': _update_available,
}
except URLError as e:
logger.warning(f"Failed to check for updates: {e}")
return {'success': False, 'error': str(e)}
except Exception as e:
logger.warning(f"Error checking for updates: {e}")
return {'success': False, 'error': str(e)}
return {'success': False, 'error': 'Unknown error'}
def download_database(progress_callback=None) -> dict[str, Any]:
"""
Download latest aircraft database from Mictronics repo.
Returns status dict.
"""
global _update_available
try:
if progress_callback:
progress_callback('Downloading aircraft database...')
# Download aircraft database
req = Request(AIRCRAFT_DB_URL, headers={'User-Agent': 'Intercept-SIGINT'})
with urlopen(req, timeout=60) as response:
aircraft_data = json.loads(response.read().decode('utf-8'))
if progress_callback:
progress_callback('Downloading type codes...')
# Download types database
req = Request(TYPES_DB_URL, headers={'User-Agent': 'Intercept-SIGINT'})
with urlopen(req, timeout=30) as response:
types_data = json.loads(response.read().decode('utf-8'))
if progress_callback:
progress_callback('Processing database...')
# Combine into single file
combined = {
'aircraft': aircraft_data,
'types': types_data,
}
# Save to file
with open(DB_FILE, 'w') as f:
json.dump(combined, f, separators=(',', ':')) # Compact JSON
# Get version from GitHub
version = datetime.utcnow().strftime('%Y-%m-%d')
try:
req = Request(GITHUB_API_URL, headers={'User-Agent': 'Intercept-SIGINT'})
with urlopen(req, timeout=10) as response:
commits = json.loads(response.read().decode('utf-8'))
if commits:
sha = commits[0]['sha'][:8]
date = commits[0]['commit']['committer']['date'][:10]
version = f"{date}_{sha}"
except Exception:
pass
_save_meta(version)
_update_available = False
# Reload into memory
load_database()
return {
'success': True,
'message': f'Downloaded {len(aircraft_data)} aircraft, {len(types_data)} types',
'version': version,
}
except URLError as e:
logger.error(f"Download failed: {e}")
return {'success': False, 'error': f'Download failed: {e}'}
except Exception as e:
logger.error(f"Error downloading database: {e}")
return {'success': False, 'error': str(e)}
def delete_database() -> dict[str, Any]:
"""Delete local database files."""
global _aircraft_cache, _types_cache, _db_loaded, _db_version
try:
with _cache_lock:
_aircraft_cache = {}
_types_cache = {}
_db_loaded = False
_db_version = None
if os.path.exists(DB_FILE):
os.remove(DB_FILE)
if os.path.exists(DB_META_FILE):
os.remove(DB_META_FILE)
return {'success': True, 'message': 'Database deleted'}
except Exception as e:
return {'success': False, 'error': str(e)}
+17
View File
@@ -99,6 +99,23 @@ class DataStore:
with self._lock:
return key in self.data
def __getitem__(self, key: str) -> Any:
"""Get an entry using subscript notation."""
with self._lock:
return self.data[key]
def __setitem__(self, key: str, value: Any) -> None:
"""Set an entry using subscript notation."""
with self._lock:
self.data[key] = value
self.timestamps[key] = time.time()
def __delitem__(self, key: str) -> None:
"""Delete an entry using subscript notation."""
with self._lock:
del self.data[key]
del self.timestamps[key]
def cleanup(self) -> int:
"""
Remove entries older than max_age.
+21 -1
View File
@@ -1,15 +1,35 @@
from __future__ import annotations
import logging
import os
import shutil
from typing import Any
logger = logging.getLogger('intercept.dependencies')
# Additional paths to search for tools (e.g., /usr/sbin on Debian)
EXTRA_TOOL_PATHS = ['/usr/sbin', '/sbin']
def check_tool(name: str) -> bool:
"""Check if a tool is installed."""
return shutil.which(name) is not None
return get_tool_path(name) is not None
def get_tool_path(name: str) -> str | None:
"""Get the full path to a tool, checking standard PATH and extra locations."""
# First check standard PATH
path = shutil.which(name)
if path:
return path
# Check additional paths (e.g., /usr/sbin for aircrack-ng on Debian)
for extra_path in EXTRA_TOOL_PATHS:
full_path = os.path.join(extra_path, name)
if os.path.isfile(full_path) and os.access(full_path, os.X_OK):
return full_path
return None
# Comprehensive tool dependency definitions
+28 -483
View File
@@ -1,32 +1,20 @@
"""
GPS dongle support for INTERCEPT.
GPS support for INTERCEPT via gpsd daemon.
Provides detection and reading of USB GPS dongles via serial port.
Parses NMEA sentences to extract location data.
Provides GPS location data by connecting to the gpsd daemon.
"""
from __future__ import annotations
import logging
import os
import re
import glob
import threading
import time
from dataclasses import dataclass
from datetime import datetime
from typing import Optional, Callable, Union
from typing import Optional, Callable
logger = logging.getLogger('intercept.gps')
# Try to import serial, but don't fail if not available
try:
import serial
SERIAL_AVAILABLE = True
except ImportError:
SERIAL_AVAILABLE = False
logger.warning("pyserial not installed - GPS dongle support disabled")
@dataclass
class GPSPosition:
@@ -34,10 +22,10 @@ class GPSPosition:
latitude: float
longitude: float
altitude: Optional[float] = None
speed: Optional[float] = None # knots
speed: Optional[float] = None # m/s
heading: Optional[float] = None # degrees
satellites: Optional[int] = None
fix_quality: int = 0 # 0=invalid, 1=GPS, 2=DGPS
fix_quality: int = 0 # 0=unknown, 1=no fix, 2=2D fix, 3=3D fix
timestamp: Optional[datetime] = None
device: Optional[str] = None
@@ -56,407 +44,6 @@ class GPSPosition:
}
def detect_gps_devices() -> list[dict]:
"""
Detect potential GPS serial devices.
Returns a list of device info dictionaries.
"""
devices = []
# Common GPS device patterns by platform
patterns = []
if os.name == 'posix':
# Linux
patterns.extend([
'/dev/ttyUSB*', # USB serial adapters
'/dev/ttyACM*', # USB CDC ACM devices (many GPS)
'/dev/gps*', # gpsd symlinks
])
# macOS
patterns.extend([
'/dev/tty.usbserial*',
'/dev/tty.usbmodem*',
'/dev/cu.usbserial*',
'/dev/cu.usbmodem*',
])
for pattern in patterns:
for path in glob.glob(pattern):
# Try to get device info
device_info = {
'path': path,
'name': os.path.basename(path),
'type': 'serial',
}
# Check if it's readable
if os.access(path, os.R_OK):
device_info['accessible'] = True
else:
device_info['accessible'] = False
device_info['error'] = 'Permission denied'
devices.append(device_info)
return devices
def parse_nmea_coordinate(coord: str, direction: str) -> Optional[float]:
"""
Parse NMEA coordinate format to decimal degrees.
NMEA format: DDDMM.MMMM or DDMM.MMMM
"""
if not coord or not direction:
return None
try:
# Find the decimal point
dot_pos = coord.index('.')
# Degrees are everything before the last 2 digits before decimal
degrees = int(coord[:dot_pos - 2])
minutes = float(coord[dot_pos - 2:])
result = degrees + (minutes / 60.0)
# Apply direction
if direction in ('S', 'W'):
result = -result
return result
except (ValueError, IndexError):
return None
def parse_gga(parts: list[str]) -> Optional[GPSPosition]:
"""
Parse GPGGA/GNGGA sentence (Global Positioning System Fix Data).
Format: $GPGGA,time,lat,N/S,lon,E/W,quality,satellites,hdop,altitude,M,...
"""
if len(parts) < 10:
return None
try:
fix_quality = int(parts[6]) if parts[6] else 0
# No fix
if fix_quality == 0:
return None
lat = parse_nmea_coordinate(parts[2], parts[3])
lon = parse_nmea_coordinate(parts[4], parts[5])
if lat is None or lon is None:
return None
# Parse optional fields
satellites = int(parts[7]) if parts[7] else None
altitude = float(parts[9]) if parts[9] else None
# Parse time (HHMMSS.sss)
timestamp = None
if parts[1]:
try:
time_str = parts[1].split('.')[0]
if len(time_str) >= 6:
now = datetime.utcnow()
timestamp = now.replace(
hour=int(time_str[0:2]),
minute=int(time_str[2:4]),
second=int(time_str[4:6]),
microsecond=0
)
except (ValueError, IndexError):
pass
return GPSPosition(
latitude=lat,
longitude=lon,
altitude=altitude,
satellites=satellites,
fix_quality=fix_quality,
timestamp=timestamp,
)
except (ValueError, IndexError) as e:
logger.debug(f"GGA parse error: {e}")
return None
def parse_rmc(parts: list[str]) -> Optional[GPSPosition]:
"""
Parse GPRMC/GNRMC sentence (Recommended Minimum).
Format: $GPRMC,time,status,lat,N/S,lon,E/W,speed,heading,date,...
"""
if len(parts) < 8:
return None
try:
# Check status (A=active/valid, V=void/invalid)
if parts[2] != 'A':
return None
lat = parse_nmea_coordinate(parts[3], parts[4])
lon = parse_nmea_coordinate(parts[5], parts[6])
if lat is None or lon is None:
return None
# Parse optional fields
speed = float(parts[7]) if parts[7] else None # knots
heading = float(parts[8]) if len(parts) > 8 and parts[8] else None
# Parse timestamp
timestamp = None
if parts[1] and len(parts) > 9 and parts[9]:
try:
time_str = parts[1].split('.')[0]
date_str = parts[9]
if len(time_str) >= 6 and len(date_str) >= 6:
timestamp = datetime(
year=2000 + int(date_str[4:6]),
month=int(date_str[2:4]),
day=int(date_str[0:2]),
hour=int(time_str[0:2]),
minute=int(time_str[2:4]),
second=int(time_str[4:6]),
)
except (ValueError, IndexError):
pass
return GPSPosition(
latitude=lat,
longitude=lon,
speed=speed,
heading=heading,
timestamp=timestamp,
fix_quality=1, # RMC with A status means valid fix
)
except (ValueError, IndexError) as e:
logger.debug(f"RMC parse error: {e}")
return None
def parse_nmea_sentence(sentence: str) -> Optional[GPSPosition]:
"""
Parse an NMEA sentence and extract position data.
Supports: GGA, RMC sentences (with GP, GN, GL prefixes)
"""
sentence = sentence.strip()
# Validate checksum if present
if '*' in sentence:
data, checksum = sentence.rsplit('*', 1)
if data.startswith('$'):
data = data[1:]
# Calculate checksum
calc_checksum = 0
for char in data:
calc_checksum ^= ord(char)
try:
if int(checksum, 16) != calc_checksum:
logger.debug(f"Checksum mismatch: {sentence}")
return None
except ValueError:
pass
# Remove $ prefix if present
if sentence.startswith('$'):
sentence = sentence[1:]
# Remove checksum for parsing
if '*' in sentence:
sentence = sentence.split('*')[0]
parts = sentence.split(',')
if not parts:
return None
msg_type = parts[0]
# Handle various NMEA talker IDs (GP=GPS, GN=GNSS, GL=GLONASS, GA=Galileo)
if msg_type.endswith('GGA'):
return parse_gga(parts)
elif msg_type.endswith('RMC'):
return parse_rmc(parts)
return None
class GPSReader:
"""
Reads GPS data from a serial device.
Runs in a background thread and maintains current position.
"""
def __init__(self, device_path: str, baudrate: int = 9600):
self.device_path = device_path
self.baudrate = baudrate
self._position: Optional[GPSPosition] = None
self._lock = threading.Lock()
self._running = False
self._thread: Optional[threading.Thread] = None
self._serial: Optional['serial.Serial'] = None
self._last_update: Optional[datetime] = None
self._error: Optional[str] = None
self._callbacks: list[Callable[[GPSPosition], None]] = []
@property
def position(self) -> Optional[GPSPosition]:
"""Get the current GPS position."""
with self._lock:
return self._position
@property
def is_running(self) -> bool:
"""Check if the reader is running."""
return self._running
@property
def last_update(self) -> Optional[datetime]:
"""Get the time of the last position update."""
with self._lock:
return self._last_update
@property
def error(self) -> Optional[str]:
"""Get any error message."""
with self._lock:
return self._error
def add_callback(self, callback: Callable[[GPSPosition], None]) -> None:
"""Add a callback to be called on position updates."""
self._callbacks.append(callback)
def remove_callback(self, callback: Callable[[GPSPosition], None]) -> None:
"""Remove a position update callback."""
if callback in self._callbacks:
self._callbacks.remove(callback)
def start(self) -> bool:
"""Start reading GPS data in a background thread."""
if not SERIAL_AVAILABLE:
self._error = "pyserial not installed"
return False
if self._running:
return True
try:
self._serial = serial.Serial(
self.device_path,
baudrate=self.baudrate,
timeout=1.0
)
self._running = True
self._error = None
self._thread = threading.Thread(target=self._read_loop, daemon=True)
self._thread.start()
logger.info(f"Started GPS reader on {self.device_path}")
return True
except serial.SerialException as e:
self._error = str(e)
logger.error(f"Failed to open GPS device {self.device_path}: {e}")
return False
def stop(self) -> None:
"""Stop reading GPS data."""
self._running = False
if self._serial:
try:
self._serial.close()
except Exception:
pass
self._serial = None
if self._thread:
self._thread.join(timeout=2.0)
self._thread = None
logger.info(f"Stopped GPS reader on {self.device_path}")
def _read_loop(self) -> None:
"""Background thread loop for reading GPS data."""
buffer = ""
sentence_count = 0
bytes_read = 0
print(f"[GPS] Read loop started on {self.device_path} at {self.baudrate} baud", flush=True)
while self._running and self._serial:
try:
# Read available data
waiting = self._serial.in_waiting
if waiting:
data = self._serial.read(waiting)
bytes_read += len(data)
if bytes_read <= 500 or bytes_read % 1000 == 0:
print(f"[GPS] Read {len(data)} bytes (total: {bytes_read})", flush=True)
buffer += data.decode('ascii', errors='ignore')
# Process complete lines
while '\n' in buffer:
line, buffer = buffer.split('\n', 1)
line = line.strip()
if line.startswith('$'):
sentence_count += 1
# Log first few sentences and periodically after that
if sentence_count <= 10 or sentence_count % 50 == 0:
print(f"[GPS] NMEA [{sentence_count}]: {line[:70]}", flush=True)
position = parse_nmea_sentence(line)
if position:
print(f"[GPS] FIX: {position.latitude:.6f}, {position.longitude:.6f} (sats: {position.satellites}, quality: {position.fix_quality})", flush=True)
position.device = self.device_path
self._update_position(position)
else:
time.sleep(0.1)
except serial.SerialException as e:
logger.error(f"GPS read error: {e}")
with self._lock:
self._error = str(e)
break
except Exception as e:
logger.debug(f"GPS parse error: {e}")
def _update_position(self, position: GPSPosition) -> None:
"""Update the current position and notify callbacks."""
with self._lock:
# Merge data from different sentence types
if self._position:
# Keep altitude from GGA if RMC doesn't have it
if position.altitude is None and self._position.altitude:
position.altitude = self._position.altitude
# Keep satellites from GGA
if position.satellites is None and self._position.satellites:
position.satellites = self._position.satellites
self._position = position
self._last_update = datetime.utcnow()
self._error = None
# Notify callbacks
for callback in self._callbacks:
try:
callback(position)
except Exception as e:
logger.error(f"GPS callback error: {e}")
class GPSDClient:
"""
Connects to gpsd daemon for GPS data.
@@ -506,14 +93,9 @@ class GPSDClient:
@property
def device_path(self) -> str:
"""Return gpsd connection info (for compatibility with GPSReader)."""
"""Return gpsd connection info."""
return f"gpsd://{self.host}:{self.port}"
@property
def baudrate(self) -> int:
"""Return 0 for gpsd (for compatibility with GPSReader)."""
return 0
def add_callback(self, callback: Callable[[GPSPosition], None]) -> None:
"""Add a callback to be called on position updates."""
self._callbacks.append(callback)
@@ -667,7 +249,7 @@ class GPSDClient:
latitude=lat,
longitude=lon,
altitude=msg.get('alt'),
speed=msg.get('speed'), # m/s in gpsd (not knots)
speed=msg.get('speed'), # m/s in gpsd
heading=msg.get('track'),
fix_quality=mode,
timestamp=timestamp,
@@ -692,47 +274,15 @@ class GPSDClient:
logger.error(f"GPS callback error: {e}")
# Type alias for GPS source (either serial reader or gpsd client)
GPSSource = Union[GPSReader, GPSDClient]
# Global GPS reader instance
_gps_reader: Optional[GPSSource] = None
# Global GPS client instance
_gps_client: Optional[GPSDClient] = None
_gps_lock = threading.Lock()
def get_gps_reader() -> Optional[GPSSource]:
"""Get the global GPS reader/client instance."""
def get_gps_reader() -> Optional[GPSDClient]:
"""Get the global GPS client instance."""
with _gps_lock:
return _gps_reader
def start_gps(device_path: str, baudrate: int = 9600,
callback: Optional[Callable[[GPSPosition], None]] = None) -> bool:
"""
Start the global GPS reader.
Args:
device_path: Path to the GPS serial device
baudrate: Serial baudrate (default 9600)
callback: Optional callback for position updates (registered before start to avoid race condition)
Returns:
True if started successfully
"""
global _gps_reader
with _gps_lock:
# Stop existing reader if any
if _gps_reader:
_gps_reader.stop()
_gps_reader = GPSReader(device_path, baudrate)
# Register callback BEFORE starting to avoid race condition
if callback:
_gps_reader.add_callback(callback)
return _gps_reader.start()
return _gps_client
def start_gpsd(host: str = 'localhost', port: int = 2947,
@@ -748,40 +298,35 @@ def start_gpsd(host: str = 'localhost', port: int = 2947,
Returns:
True if started successfully
"""
global _gps_reader
global _gps_client
with _gps_lock:
# Stop existing reader if any
if _gps_reader:
_gps_reader.stop()
# Stop existing client if any
if _gps_client:
_gps_client.stop()
_gps_reader = GPSDClient(host, port)
_gps_client = GPSDClient(host, port)
# Register callback BEFORE starting to avoid race condition
if callback:
_gps_reader.add_callback(callback)
_gps_client.add_callback(callback)
return _gps_reader.start()
return _gps_client.start()
def stop_gps() -> None:
"""Stop the global GPS reader/client."""
global _gps_reader
"""Stop the global GPS client."""
global _gps_client
with _gps_lock:
if _gps_reader:
_gps_reader.stop()
_gps_reader = None
if _gps_client:
_gps_client.stop()
_gps_client = None
def get_current_position() -> Optional[GPSPosition]:
"""Get the current GPS position from the global reader."""
reader = get_gps_reader()
if reader:
return reader.position
"""Get the current GPS position from the global client."""
client = get_gps_reader()
if client:
return client.position
return None
def is_serial_available() -> bool:
"""Check if pyserial is available."""
return SERIAL_AVAILABLE
+206
View File
@@ -0,0 +1,206 @@
"""
Process health monitoring and auto-restart functionality.
"""
from __future__ import annotations
import logging
import threading
import time
from dataclasses import dataclass, field
from datetime import datetime
from typing import Callable, Dict, Optional, Any
logger = logging.getLogger('intercept.process_monitor')
@dataclass
class ProcessInfo:
"""Information about a monitored process."""
name: str
process: Any # subprocess.Popen
started_at: datetime = field(default_factory=datetime.now)
restart_count: int = 0
last_restart: Optional[datetime] = None
restart_callback: Optional[Callable] = None
max_restarts: int = 3
backoff_seconds: float = 5.0
enabled: bool = True
class ProcessMonitor:
"""
Monitor and auto-restart processes.
Usage:
monitor = ProcessMonitor()
monitor.register('pager', process, restart_callback=start_pager)
monitor.start()
"""
def __init__(self, check_interval: float = 5.0):
self.processes: Dict[str, ProcessInfo] = {}
self.check_interval = check_interval
self._running = False
self._thread: Optional[threading.Thread] = None
self._lock = threading.Lock()
def register(
self,
name: str,
process: Any,
restart_callback: Optional[Callable] = None,
max_restarts: int = 3,
backoff_seconds: float = 5.0
) -> None:
"""
Register a process for monitoring.
Args:
name: Unique name for the process
process: The subprocess.Popen object
restart_callback: Function to call to restart the process
max_restarts: Maximum number of automatic restarts
backoff_seconds: Base backoff time between restarts
"""
with self._lock:
self.processes[name] = ProcessInfo(
name=name,
process=process,
restart_callback=restart_callback,
max_restarts=max_restarts,
backoff_seconds=backoff_seconds
)
logger.info(f"Registered process for monitoring: {name}")
def unregister(self, name: str) -> None:
"""Remove a process from monitoring."""
with self._lock:
if name in self.processes:
del self.processes[name]
logger.info(f"Unregistered process: {name}")
def update_process(self, name: str, process: Any) -> None:
"""Update the process object for a registered name."""
with self._lock:
if name in self.processes:
self.processes[name].process = process
self.processes[name].started_at = datetime.now()
def start(self) -> None:
"""Start the monitoring thread."""
if self._running:
return
self._running = True
self._thread = threading.Thread(target=self._monitor_loop, daemon=True)
self._thread.start()
logger.info("Process monitor started")
def stop(self) -> None:
"""Stop the monitoring thread."""
self._running = False
if self._thread:
self._thread.join(timeout=self.check_interval + 1)
logger.info("Process monitor stopped")
def _monitor_loop(self) -> None:
"""Main monitoring loop."""
while self._running:
self._check_all_processes()
time.sleep(self.check_interval)
def _check_all_processes(self) -> None:
"""Check health of all registered processes."""
with self._lock:
for name, info in list(self.processes.items()):
if not info.enabled:
continue
if info.process is None:
continue
# Check if process has terminated
return_code = info.process.poll()
if return_code is not None:
logger.warning(
f"Process '{name}' terminated with code {return_code}"
)
self._handle_crash(name, info)
def _handle_crash(self, name: str, info: ProcessInfo) -> None:
"""Handle a crashed process."""
if info.restart_callback is None:
logger.info(f"No restart callback for '{name}', skipping auto-restart")
return
if info.restart_count >= info.max_restarts:
logger.error(
f"Process '{name}' exceeded max restarts ({info.max_restarts}), "
"disabling auto-restart"
)
info.enabled = False
return
# Calculate backoff with exponential increase
backoff = info.backoff_seconds * (2 ** info.restart_count)
logger.info(
f"Attempting to restart '{name}' in {backoff:.1f}s "
f"(attempt {info.restart_count + 1}/{info.max_restarts})"
)
# Wait for backoff period
time.sleep(backoff)
# Attempt restart
try:
info.restart_callback()
info.restart_count += 1
info.last_restart = datetime.now()
logger.info(f"Successfully restarted '{name}'")
except Exception as e:
logger.error(f"Failed to restart '{name}': {e}")
info.restart_count += 1
def get_status(self) -> Dict[str, Any]:
"""
Get status of all monitored processes.
Returns:
Dict with process status information
"""
with self._lock:
status = {}
for name, info in self.processes.items():
is_running = (
info.process is not None and
info.process.poll() is None
)
status[name] = {
'running': is_running,
'started_at': info.started_at.isoformat() if info.started_at else None,
'restart_count': info.restart_count,
'last_restart': info.last_restart.isoformat() if info.last_restart else None,
'auto_restart_enabled': info.enabled,
'return_code': info.process.poll() if info.process else None
}
return status
def reset_restart_count(self, name: str) -> None:
"""Reset the restart count for a process (e.g., after manual restart)."""
with self._lock:
if name in self.processes:
self.processes[name].restart_count = 0
self.processes[name].enabled = True
def is_healthy(self) -> bool:
"""Check if all processes are healthy."""
with self._lock:
for info in self.processes.values():
if info.process is not None and info.process.poll() is not None:
return False
return True
# Global monitor instance
process_monitor = ProcessMonitor()
+3
View File
@@ -31,6 +31,7 @@ from .rtlsdr import RTLSDRCommandBuilder
from .limesdr import LimeSDRCommandBuilder
from .hackrf import HackRFCommandBuilder
from .airspy import AirspyCommandBuilder
from .sdrplay import SDRPlayCommandBuilder
from .validation import (
SDRValidationError,
validate_frequency,
@@ -51,6 +52,7 @@ class SDRFactory:
SDRType.LIME_SDR: LimeSDRCommandBuilder,
SDRType.HACKRF: HackRFCommandBuilder,
SDRType.AIRSPY: AirspyCommandBuilder,
SDRType.SDRPLAY: SDRPlayCommandBuilder,
}
@classmethod
@@ -217,6 +219,7 @@ __all__ = [
'LimeSDRCommandBuilder',
'HackRFCommandBuilder',
'AirspyCommandBuilder',
'SDRPlayCommandBuilder',
# Validation
'SDRValidationError',
'validate_frequency',
+15 -3
View File
@@ -64,7 +64,8 @@ class AirspyCommandBuilder(CommandBuilder):
gain: Optional[float] = None,
ppm: Optional[int] = None,
modulation: str = "fm",
squelch: Optional[int] = None
squelch: Optional[int] = None,
bias_t: bool = False
) -> list[str]:
"""
Build SoapySDR rx_fm command for FM demodulation.
@@ -87,6 +88,9 @@ class AirspyCommandBuilder(CommandBuilder):
if squelch is not None and squelch > 0:
cmd.extend(['-l', str(squelch)])
if bias_t:
cmd.extend(['-T'])
# Output to stdout
cmd.append('-')
@@ -95,7 +99,8 @@ class AirspyCommandBuilder(CommandBuilder):
def build_adsb_command(
self,
device: SDRDevice,
gain: Optional[float] = None
gain: Optional[float] = None,
bias_t: bool = False
) -> list[str]:
"""
Build dump1090/readsb command with SoapySDR support for ADS-B decoding.
@@ -115,6 +120,9 @@ class AirspyCommandBuilder(CommandBuilder):
if gain is not None:
cmd.extend(['--gain', str(int(gain))])
if bias_t:
cmd.extend(['--enable-bias-t'])
return cmd
def build_ism_command(
@@ -122,7 +130,8 @@ class AirspyCommandBuilder(CommandBuilder):
device: SDRDevice,
frequency_mhz: float = 433.92,
gain: Optional[float] = None,
ppm: Optional[int] = None
ppm: Optional[int] = None,
bias_t: bool = False
) -> list[str]:
"""
Build rtl_433 command with SoapySDR support for ISM band decoding.
@@ -141,6 +150,9 @@ class AirspyCommandBuilder(CommandBuilder):
if gain is not None and gain > 0:
cmd.extend(['-g', str(int(gain))])
if bias_t:
cmd.extend(['-T'])
return cmd
def get_capabilities(self) -> SDRCapabilities:
+10 -3
View File
@@ -19,6 +19,7 @@ class SDRType(Enum):
LIME_SDR = "limesdr"
HACKRF = "hackrf"
AIRSPY = "airspy"
SDRPLAY = "sdrplay"
# Future support
# USRP = "usrp"
# BLADE_RF = "bladerf"
@@ -93,7 +94,8 @@ class CommandBuilder(ABC):
gain: Optional[float] = None,
ppm: Optional[int] = None,
modulation: str = "fm",
squelch: Optional[int] = None
squelch: Optional[int] = None,
bias_t: bool = False
) -> list[str]:
"""
Build FM demodulation command (for pager decoding).
@@ -106,6 +108,7 @@ class CommandBuilder(ABC):
ppm: PPM frequency correction
modulation: Modulation type (fm, am, etc.)
squelch: Squelch level
bias_t: Enable bias-T power (for active antennas)
Returns:
Command as list of strings for subprocess
@@ -116,7 +119,8 @@ class CommandBuilder(ABC):
def build_adsb_command(
self,
device: SDRDevice,
gain: Optional[float] = None
gain: Optional[float] = None,
bias_t: bool = False
) -> list[str]:
"""
Build ADS-B decoder command.
@@ -124,6 +128,7 @@ class CommandBuilder(ABC):
Args:
device: The SDR device to use
gain: Gain in dB (None for auto)
bias_t: Enable bias-T power (for active antennas)
Returns:
Command as list of strings for subprocess
@@ -136,7 +141,8 @@ class CommandBuilder(ABC):
device: SDRDevice,
frequency_mhz: float = 433.92,
gain: Optional[float] = None,
ppm: Optional[int] = None
ppm: Optional[int] = None,
bias_t: bool = False
) -> list[str]:
"""
Build ISM band decoder command (433MHz sensors).
@@ -146,6 +152,7 @@ class CommandBuilder(ABC):
frequency_mhz: Center frequency in MHz (default 433.92)
gain: Gain in dB (None for auto)
ppm: PPM frequency correction
bias_t: Enable bias-T power (for active antennas)
Returns:
Command as list of strings for subprocess
+16 -3
View File
@@ -29,12 +29,14 @@ def _get_capabilities_for_type(sdr_type: SDRType) -> SDRCapabilities:
from .limesdr import LimeSDRCommandBuilder
from .hackrf import HackRFCommandBuilder
from .airspy import AirspyCommandBuilder
from .sdrplay import SDRPlayCommandBuilder
builders = {
SDRType.RTL_SDR: RTLSDRCommandBuilder,
SDRType.LIME_SDR: LimeSDRCommandBuilder,
SDRType.HACKRF: HackRFCommandBuilder,
SDRType.AIRSPY: AirspyCommandBuilder,
SDRType.SDRPLAY: SDRPlayCommandBuilder,
}
builder_class = builders.get(sdr_type)
@@ -64,6 +66,7 @@ def _driver_to_sdr_type(driver: str) -> Optional[SDRType]:
'hackrf': SDRType.HACKRF,
'airspy': SDRType.AIRSPY,
'airspyhf': SDRType.AIRSPY, # Airspy HF+ uses same builder
'sdrplay': SDRType.SDRPLAY,
# Future support
# 'uhd': SDRType.USRP,
# 'bladerf': SDRType.BLADE_RF,
@@ -144,6 +147,15 @@ def detect_rtlsdr_devices() -> list[SDRDevice]:
return devices
def _find_soapy_util() -> str | None:
"""Find SoapySDR utility command (name varies by distribution)."""
# Try different command names used across distributions
for cmd in ['SoapySDRUtil', 'soapy_sdr_util', 'soapysdr-util']:
if _check_tool(cmd):
return cmd
return None
def detect_soapy_devices(skip_types: Optional[set[SDRType]] = None) -> list[SDRDevice]:
"""
Detect SDR devices via SoapySDR.
@@ -156,13 +168,14 @@ def detect_soapy_devices(skip_types: Optional[set[SDRType]] = None) -> list[SDRD
devices: list[SDRDevice] = []
skip_types = skip_types or set()
if not _check_tool('SoapySDRUtil'):
logger.debug("SoapySDRUtil not found, skipping SoapySDR detection")
soapy_cmd = _find_soapy_util()
if not soapy_cmd:
logger.debug("SoapySDR utility not found, skipping SoapySDR detection")
return devices
try:
result = subprocess.run(
['SoapySDRUtil', '--find'],
[soapy_cmd, '--find'],
capture_output=True,
text=True,
timeout=10
+15 -3
View File
@@ -60,7 +60,8 @@ class HackRFCommandBuilder(CommandBuilder):
gain: Optional[float] = None,
ppm: Optional[int] = None,
modulation: str = "fm",
squelch: Optional[int] = None
squelch: Optional[int] = None,
bias_t: bool = False
) -> list[str]:
"""
Build SoapySDR rx_fm command for FM demodulation.
@@ -84,6 +85,9 @@ class HackRFCommandBuilder(CommandBuilder):
if squelch is not None and squelch > 0:
cmd.extend(['-l', str(squelch)])
if bias_t:
cmd.extend(['-T'])
# Output to stdout
cmd.append('-')
@@ -92,7 +96,8 @@ class HackRFCommandBuilder(CommandBuilder):
def build_adsb_command(
self,
device: SDRDevice,
gain: Optional[float] = None
gain: Optional[float] = None,
bias_t: bool = False
) -> list[str]:
"""
Build dump1090/readsb command with SoapySDR support for ADS-B decoding.
@@ -112,6 +117,9 @@ class HackRFCommandBuilder(CommandBuilder):
if gain is not None:
cmd.extend(['--gain', str(int(gain))])
if bias_t:
cmd.extend(['--enable-bias-t'])
return cmd
def build_ism_command(
@@ -119,7 +127,8 @@ class HackRFCommandBuilder(CommandBuilder):
device: SDRDevice,
frequency_mhz: float = 433.92,
gain: Optional[float] = None,
ppm: Optional[int] = None
ppm: Optional[int] = None,
bias_t: bool = False
) -> list[str]:
"""
Build rtl_433 command with SoapySDR support for ISM band decoding.
@@ -138,6 +147,9 @@ class HackRFCommandBuilder(CommandBuilder):
if gain is not None and gain > 0:
cmd.extend(['-g', str(int(gain))])
if bias_t:
cmd.extend(['-T'])
return cmd
def get_capabilities(self) -> SDRCapabilities:
+9 -3
View File
@@ -41,12 +41,14 @@ class LimeSDRCommandBuilder(CommandBuilder):
gain: Optional[float] = None,
ppm: Optional[int] = None,
modulation: str = "fm",
squelch: Optional[int] = None
squelch: Optional[int] = None,
bias_t: bool = False
) -> list[str]:
"""
Build SoapySDR rx_fm command for FM demodulation.
For pager decoding with LimeSDR.
Note: LimeSDR does not support bias-T, parameter is ignored.
"""
device_str = self._build_device_string(device)
@@ -73,13 +75,15 @@ class LimeSDRCommandBuilder(CommandBuilder):
def build_adsb_command(
self,
device: SDRDevice,
gain: Optional[float] = None
gain: Optional[float] = None,
bias_t: bool = False
) -> list[str]:
"""
Build dump1090 command with SoapySDR support for ADS-B decoding.
Uses dump1090 compiled with SoapySDR support, or readsb as alternative.
Note: Requires dump1090 with SoapySDR support or readsb.
Note: LimeSDR does not support bias-T, parameter is ignored.
"""
device_str = self._build_device_string(device)
@@ -102,12 +106,14 @@ class LimeSDRCommandBuilder(CommandBuilder):
device: SDRDevice,
frequency_mhz: float = 433.92,
gain: Optional[float] = None,
ppm: Optional[int] = None
ppm: Optional[int] = None,
bias_t: bool = False
) -> list[str]:
"""
Build rtl_433 command with SoapySDR support for ISM band decoding.
rtl_433 has native SoapySDR support via -d flag.
Note: LimeSDR does not support bias-T, parameter is ignored.
"""
device_str = self._build_device_string(device)
+15 -3
View File
@@ -45,7 +45,8 @@ class RTLSDRCommandBuilder(CommandBuilder):
gain: Optional[float] = None,
ppm: Optional[int] = None,
modulation: str = "fm",
squelch: Optional[int] = None
squelch: Optional[int] = None,
bias_t: bool = False
) -> list[str]:
"""
Build rtl_fm command for FM demodulation.
@@ -69,6 +70,9 @@ class RTLSDRCommandBuilder(CommandBuilder):
if squelch is not None and squelch > 0:
cmd.extend(['-l', str(squelch)])
if bias_t:
cmd.extend(['-T'])
# Output to stdout for piping
cmd.append('-')
@@ -77,7 +81,8 @@ class RTLSDRCommandBuilder(CommandBuilder):
def build_adsb_command(
self,
device: SDRDevice,
gain: Optional[float] = None
gain: Optional[float] = None,
bias_t: bool = False
) -> list[str]:
"""
Build dump1090 command for ADS-B decoding.
@@ -104,6 +109,9 @@ class RTLSDRCommandBuilder(CommandBuilder):
if gain is not None:
cmd.extend(['--gain', str(int(gain))])
if bias_t:
cmd.extend(['--enable-bias-t'])
return cmd
def build_ism_command(
@@ -111,7 +119,8 @@ class RTLSDRCommandBuilder(CommandBuilder):
device: SDRDevice,
frequency_mhz: float = 433.92,
gain: Optional[float] = None,
ppm: Optional[int] = None
ppm: Optional[int] = None,
bias_t: bool = False
) -> list[str]:
"""
Build rtl_433 command for ISM band sensor decoding.
@@ -131,6 +140,9 @@ class RTLSDRCommandBuilder(CommandBuilder):
if ppm is not None and ppm != 0:
cmd.extend(['-p', str(ppm)])
if bias_t:
cmd.extend(['-T'])
return cmd
def get_capabilities(self) -> SDRCapabilities:
+143
View File
@@ -0,0 +1,143 @@
"""
SDRPlay command builder implementation.
Uses SoapySDR-based tools for FM demodulation and signal capture.
SDRPlay RSP devices support 1 kHz to 2 GHz frequency range.
"""
from __future__ import annotations
from typing import Optional
from .base import CommandBuilder, SDRCapabilities, SDRDevice, SDRType
class SDRPlayCommandBuilder(CommandBuilder):
"""SDRPlay command builder using SoapySDR tools."""
# SDRPlay RSP capabilities (RSPdx, RSP1A, RSPduo, etc.)
CAPABILITIES = SDRCapabilities(
sdr_type=SDRType.SDRPLAY,
freq_min_mhz=0.001, # 1 kHz
freq_max_mhz=2000.0, # 2 GHz
gain_min=0.0,
gain_max=59.0, # IFGR range
sample_rates=[62500, 96000, 125000, 192000, 250000, 384000, 500000, 1000000, 2000000],
supports_bias_t=True,
supports_ppm=False, # SDRPlay has TCXO, no PPM needed
tx_capable=False
)
def _build_device_string(self, device: SDRDevice) -> str:
"""Build SoapySDR device string for SDRPlay."""
if device.serial and device.serial != 'N/A':
return f'driver=sdrplay,serial={device.serial}'
return 'driver=sdrplay'
def build_fm_demod_command(
self,
device: SDRDevice,
frequency_mhz: float,
sample_rate: int = 22050,
gain: Optional[float] = None,
ppm: Optional[int] = None,
modulation: str = "fm",
squelch: Optional[int] = None,
bias_t: bool = False
) -> list[str]:
"""
Build SoapySDR rx_fm command for FM demodulation.
For pager decoding with SDRPlay.
"""
device_str = self._build_device_string(device)
cmd = [
'rx_fm',
'-d', device_str,
'-f', f'{frequency_mhz}M',
'-M', modulation,
'-s', str(sample_rate),
]
if gain is not None and gain > 0:
cmd.extend(['-g', f'IFGR={int(gain)}'])
if squelch is not None and squelch > 0:
cmd.extend(['-l', str(squelch)])
if bias_t:
cmd.extend(['-T'])
# Output to stdout
cmd.append('-')
return cmd
def build_adsb_command(
self,
device: SDRDevice,
gain: Optional[float] = None,
bias_t: bool = False
) -> list[str]:
"""
Build dump1090/readsb command with SoapySDR support for ADS-B decoding.
Uses readsb which has better SoapySDR support.
"""
device_str = self._build_device_string(device)
cmd = [
'readsb',
'--net',
'--device-type', 'soapysdr',
'--device', device_str,
'--quiet'
]
if gain is not None:
cmd.extend(['--gain', str(int(gain))])
if bias_t:
cmd.extend(['--enable-bias-t'])
return cmd
def build_ism_command(
self,
device: SDRDevice,
frequency_mhz: float = 433.92,
gain: Optional[float] = None,
ppm: Optional[int] = None,
bias_t: bool = False
) -> list[str]:
"""
Build rtl_433 command with SoapySDR support for ISM band decoding.
rtl_433 has native SoapySDR support via -d flag.
"""
device_str = self._build_device_string(device)
cmd = [
'rtl_433',
'-d', device_str,
'-f', f'{frequency_mhz}M',
'-F', 'json'
]
if gain is not None and gain > 0:
cmd.extend(['-g', str(int(gain))])
if bias_t:
cmd.extend(['-T'])
return cmd
def get_capabilities(self) -> SDRCapabilities:
"""Return SDRPlay capabilities."""
return self.CAPABILITIES
@classmethod
def get_sdr_type(cls) -> SDRType:
"""Return SDR type."""
return SDRType.SDRPLAY
+61
View File
@@ -195,3 +195,64 @@ def sanitize_device_name(name: str | None) -> str:
return ''
# Escape HTML and limit length
return escape_html(str(name)[:64])
def validate_network_interface(name: Any) -> str:
"""
Validate network interface name to prevent command injection.
Interface names must:
- Start with a letter
- Contain only alphanumeric, underscore, or hyphen
- Be 1-15 characters long (Linux IFNAMSIZ limit)
Args:
name: Interface name to validate
Returns:
Validated interface name
Raises:
ValueError: If interface name is invalid
"""
if not name or not isinstance(name, str):
raise ValueError("Interface name is required")
name = name.strip()
if not name:
raise ValueError("Interface name cannot be empty")
if len(name) > 15:
raise ValueError(f"Interface name too long (max 15 chars): {name}")
# Must start with letter, contain only alphanumeric/underscore/hyphen
if not re.match(r'^[a-zA-Z][a-zA-Z0-9_-]*$', name):
raise ValueError(f"Invalid interface name: {name}")
return name
def validate_bluetooth_interface(name: Any) -> str:
"""
Validate Bluetooth interface name (hciX format).
Args:
name: Interface name to validate
Returns:
Validated interface name
Raises:
ValueError: If interface name is invalid
"""
if not name or not isinstance(name, str):
raise ValueError("Bluetooth interface name is required")
name = name.strip()
# Must be hciX format where X is a number 0-255
if not re.match(r'^hci([0-9]|[1-9][0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5])$', name):
raise ValueError(f"Invalid Bluetooth interface name (expected hciX): {name}")
return name