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>
This commit is contained in:
Smittix
2026-01-10 01:00:17 +00:00
parent 1f60e64217
commit 007400d2a7
21 changed files with 6735 additions and 1916 deletions

View File

@@ -1,6 +1,36 @@
# Changelog
All notable changes to INTERCEPT will be documented in this file.
All notable changes to iNTERCEPT will be documented in this file.
## [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

8
app.py
View File

@@ -421,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")

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:

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
```
@@ -161,6 +186,144 @@ 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

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

898
promo/instagram-promo.html Normal file
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>

View File

@@ -14,3 +14,4 @@ pyserial>=3.5
# ruff>=0.1.0
# black>=23.0.0
# mypy>=1.0.0
flask-sock

256
routes/audio_websocket.py Normal file
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")

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
@@ -138,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
@@ -148,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
@@ -162,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':
@@ -182,8 +187,8 @@ 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):
@@ -220,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
@@ -425,45 +431,33 @@ def _start_audio_stream(frequency: float, modulation: str):
'-ac', '1',
'-i', 'pipe:0',
'-acodec', 'libmp3lame',
'-b:a', '128k',
'-ar', '44100',
'-f', 'mp3',
'-b:a', '96k',
'-ar', '44100', # Resample to standard rate for browser compatibility
'-flush_packets', '1',
'-fflags', '+nobuffer',
'-flags', '+low_delay',
'pipe:1'
]
try:
logger.info(f"Starting SDR ({sdr_type.value}): {' '.join(sdr_cmd)}")
audio_rtl_process = subprocess.Popen(
sdr_cmd,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE
)
# 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}")
logger.info(f"Starting ffmpeg: {' '.join(encoder_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.PIPE,
bufsize=0
bufsize=0,
start_new_session=True # Create new process group for clean shutdown
)
audio_rtl_process.stdout.close()
# Brief delay to check if processes started successfully
time.sleep(0.2)
if audio_rtl_process.poll() is not None:
stderr = audio_rtl_process.stderr.read().decode() if audio_rtl_process.stderr else ''
logger.error(f"SDR process exited immediately: {stderr}")
return
# 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"ffmpeg exited immediately: {stderr}")
logger.error(f"Audio pipeline exited immediately: {stderr}")
return
audio_running = True
@@ -485,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
@@ -658,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."""
@@ -738,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
@@ -787,17 +826,15 @@ def start_audio() -> Response:
_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. Check that rtl_fm and ffmpeg are installed, and that an SDR device is connected and not in use by another process.'
'message': 'Failed to start audio. Check SDR device.'
}), 500
@@ -821,28 +858,30 @@ def audio_status() -> Response:
@listening_post_bp.route('/audio/stream')
def stream_audio() -> Response:
"""Stream MP3 audio."""
# Wait briefly for audio to start (handles race condition with /audio/start)
for _ in range(10):
# 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.1)
time.sleep(0.05)
if not audio_running or not audio_process:
# Return empty audio response instead of JSON (browser audio element can't parse JSON)
return Response(b'', mimetype='audio/mpeg', status=204)
def generate():
chunk_size = 8192 # Larger chunks for smoother streaming
try:
while audio_running and audio_process and audio_process.poll() is None:
chunk = audio_process.stdout.read(chunk_size)
if not chunk:
# Small wait before checking again to avoid busy loop
time.sleep(0.01)
continue
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(),

View File

@@ -199,9 +199,21 @@ install_python_deps() {
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 venv
ok "Created venv/"
python3 -m venv --system-site-packages venv
ok "Created venv/ (with system site-packages)"
else
ok "Using existing venv/"
fi
@@ -209,12 +221,23 @@ install_python_deps() {
# shellcheck disable=SC1091
source venv/bin/activate
python -m pip install --upgrade pip setuptools wheel >/dev/null
python -m pip install --upgrade pip setuptools wheel >/dev/null 2>&1 || true
ok "Upgraded pip tooling"
progress "Installing Python dependencies"
python -m pip install -r requirements.txt
ok "Python dependencies installed"
# 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
}
@@ -373,7 +396,7 @@ install_debian_packages() {
export DEBIAN_FRONTEND=noninteractive
export NEEDRESTART_MODE=a
TOTAL_STEPS=16
TOTAL_STEPS=15
CURRENT_STEP=0
progress "Updating APT package lists"
@@ -409,8 +432,11 @@ install_debian_packages() {
progress "Installing gpsd"
apt_install gpsd gpsd-clients || true
progress "Installing Python venv"
apt_install python3-venv || 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

File diff suppressed because it is too large Load Diff

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;
}

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

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

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
static/js/core/app.js Normal file
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
static/js/core/audio.js Normal file
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
static/js/core/utils.js Normal file
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

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 - See the Invisible</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 - See the Invisible</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);