mirror of
https://github.com/smittix/intercept.git
synced 2026-04-24 06:40:00 -07:00
Adding spy stations aka the number stations including diplomatic stations
This commit is contained in:
@@ -19,6 +19,7 @@ def register_blueprints(app):
|
||||
from .correlation import correlation_bp
|
||||
from .listening_post import listening_post_bp
|
||||
from .tscm import tscm_bp, init_tscm_state
|
||||
from .spy_stations import spy_stations_bp
|
||||
|
||||
app.register_blueprint(pager_bp)
|
||||
app.register_blueprint(sensor_bp)
|
||||
@@ -37,6 +38,7 @@ def register_blueprints(app):
|
||||
app.register_blueprint(correlation_bp)
|
||||
app.register_blueprint(listening_post_bp)
|
||||
app.register_blueprint(tscm_bp)
|
||||
app.register_blueprint(spy_stations_bp)
|
||||
|
||||
# Initialize TSCM state with queue and lock from app
|
||||
import app as app_module
|
||||
|
||||
323
routes/spy_stations.py
Normal file
323
routes/spy_stations.py
Normal file
@@ -0,0 +1,323 @@
|
||||
"""Spy Stations routes - Number stations and diplomatic HF networks."""
|
||||
|
||||
from flask import Blueprint, jsonify, request
|
||||
|
||||
spy_stations_bp = Blueprint('spy_stations', __name__, url_prefix='/spy-stations')
|
||||
|
||||
# Active spy stations data from priyom.org
|
||||
STATIONS = [
|
||||
# Number Stations (Intelligence)
|
||||
{
|
||||
"id": "e06",
|
||||
"name": "E06",
|
||||
"nickname": "English Man",
|
||||
"type": "number",
|
||||
"country": "Russia",
|
||||
"country_code": "RU",
|
||||
"frequencies": [
|
||||
{"freq_khz": 4310, "primary": True},
|
||||
{"freq_khz": 4800, "primary": False},
|
||||
{"freq_khz": 5370, "primary": False},
|
||||
],
|
||||
"mode": "USB+carrier",
|
||||
"description": "Russian intelligence number station operated by 'Russian 6'. Male voice reads 5-figure groups. Broadcasts from Moscow, Orenburg, Smolensk, and Chita.",
|
||||
"operator": "Russian 6",
|
||||
"schedule": "Weekdays, 2 transmissions 1 hour apart",
|
||||
"source_url": "https://priyom.org/number-stations/english/e06"
|
||||
},
|
||||
{
|
||||
"id": "s06",
|
||||
"name": "S06",
|
||||
"nickname": "Russian Man",
|
||||
"type": "number",
|
||||
"country": "Russia",
|
||||
"country_code": "RU",
|
||||
"frequencies": [
|
||||
{"freq_khz": 4310, "primary": True},
|
||||
{"freq_khz": 4800, "primary": False},
|
||||
{"freq_khz": 5370, "primary": False},
|
||||
],
|
||||
"mode": "USB+carrier",
|
||||
"description": "Russian language mode of the Russian 6 operator. Male voice reads 5-figure groups in Russian.",
|
||||
"operator": "Russian 6",
|
||||
"schedule": "Same schedule as E06, alternating languages",
|
||||
"source_url": "https://priyom.org/number-stations/russian/s06"
|
||||
},
|
||||
{
|
||||
"id": "uvb76",
|
||||
"name": "UVB-76",
|
||||
"nickname": "The Buzzer",
|
||||
"type": "number",
|
||||
"country": "Russia",
|
||||
"country_code": "RU",
|
||||
"frequencies": [
|
||||
{"freq_khz": 4625, "primary": True},
|
||||
{"freq_khz": 5779, "primary": False},
|
||||
{"freq_khz": 6810, "primary": False},
|
||||
{"freq_khz": 7490, "primary": False},
|
||||
],
|
||||
"mode": "USB",
|
||||
"description": "Russian military command network. Continuous buzzing tone with occasional voice messages. Active since 1982. One of the most famous number stations.",
|
||||
"operator": "Russian Military",
|
||||
"schedule": "24/7 continuous operation",
|
||||
"source_url": "https://priyom.org/number-stations/russia/uvb-76"
|
||||
},
|
||||
{
|
||||
"id": "hm01",
|
||||
"name": "HM01",
|
||||
"nickname": "Cuban Numbers",
|
||||
"type": "number",
|
||||
"country": "Cuba",
|
||||
"country_code": "CU",
|
||||
"frequencies": [
|
||||
{"freq_khz": 9065, "primary": True},
|
||||
{"freq_khz": 9155, "primary": False},
|
||||
{"freq_khz": 9240, "primary": False},
|
||||
{"freq_khz": 9330, "primary": False},
|
||||
{"freq_khz": 10345, "primary": False},
|
||||
{"freq_khz": 10715, "primary": False},
|
||||
{"freq_khz": 10860, "primary": False},
|
||||
{"freq_khz": 11435, "primary": False},
|
||||
{"freq_khz": 11462, "primary": False},
|
||||
{"freq_khz": 11530, "primary": False},
|
||||
{"freq_khz": 11635, "primary": False},
|
||||
{"freq_khz": 12180, "primary": False},
|
||||
{"freq_khz": 13435, "primary": False},
|
||||
{"freq_khz": 14375, "primary": False},
|
||||
{"freq_khz": 16180, "primary": False},
|
||||
{"freq_khz": 17480, "primary": False},
|
||||
],
|
||||
"mode": "AM/OFDM",
|
||||
"description": "Cuban DGI intelligence station. Spanish female voice 'Atencion' followed by number groups. Also uses RDFT OFDM digital mode.",
|
||||
"operator": "DGI (Cuban Intelligence)",
|
||||
"schedule": "Multiple daily transmissions",
|
||||
"source_url": "https://priyom.org/number-stations/cuba/hm01"
|
||||
},
|
||||
# Diplomatic Stations
|
||||
{
|
||||
"id": "bulgaria_mfa",
|
||||
"name": "Bulgaria MFA",
|
||||
"nickname": "Sofia Diplomatic",
|
||||
"type": "diplomatic",
|
||||
"country": "Bulgaria",
|
||||
"country_code": "BG",
|
||||
"frequencies": [
|
||||
{"freq_khz": 5145, "primary": True},
|
||||
{"freq_khz": 6755, "primary": False},
|
||||
{"freq_khz": 7670, "primary": False},
|
||||
{"freq_khz": 9155, "primary": False},
|
||||
{"freq_khz": 10175, "primary": False},
|
||||
{"freq_khz": 11445, "primary": False},
|
||||
{"freq_khz": 14725, "primary": False},
|
||||
{"freq_khz": 18520, "primary": False},
|
||||
],
|
||||
"mode": "RFSM-8000/MIL-STD-188-110",
|
||||
"description": "Bulgarian Ministry of Foreign Affairs diplomatic network. Sofia to 14 embassies worldwide. Uses RFSM-8000 modem with MIL-STD-188-110.",
|
||||
"operator": "Bulgarian MFA",
|
||||
"schedule": "Daily scheduled transmissions",
|
||||
"source_url": "https://priyom.org/diplomatic/bulgaria"
|
||||
},
|
||||
{
|
||||
"id": "czechia_mfa",
|
||||
"name": "Czechia MFA",
|
||||
"nickname": "Czech Diplomatic",
|
||||
"type": "diplomatic",
|
||||
"country": "Czechia",
|
||||
"country_code": "CZ",
|
||||
"frequencies": [
|
||||
{"freq_khz": 6830, "primary": True},
|
||||
{"freq_khz": 8130, "primary": False},
|
||||
{"freq_khz": 10232, "primary": False},
|
||||
{"freq_khz": 13890, "primary": False},
|
||||
],
|
||||
"mode": "PACTOR-III",
|
||||
"description": "Czech diplomatic network using PACTOR-III. Callsigns OLZ52-OLZ88. MoD station OL1A also active.",
|
||||
"operator": "Czech MFA / MoD",
|
||||
"schedule": "Regular scheduled traffic",
|
||||
"source_url": "https://priyom.org/diplomatic/czechia"
|
||||
},
|
||||
{
|
||||
"id": "egypt_mfa",
|
||||
"name": "Egypt MFA",
|
||||
"nickname": "Egyptian Diplomatic",
|
||||
"type": "diplomatic",
|
||||
"country": "Egypt",
|
||||
"country_code": "EG",
|
||||
"frequencies": [
|
||||
{"freq_khz": 7830, "primary": True},
|
||||
{"freq_khz": 9048, "primary": False},
|
||||
{"freq_khz": 10780, "primary": False},
|
||||
{"freq_khz": 13950, "primary": False},
|
||||
],
|
||||
"mode": "SITOR/Codan 3012",
|
||||
"description": "Egyptian diplomatic network. 5-digit station IDs (66601=Washington, 11107=London). Uses SITOR and Codan 3012 modems.",
|
||||
"operator": "Egyptian MFA",
|
||||
"schedule": "Daily traffic windows",
|
||||
"source_url": "https://priyom.org/diplomatic/egypt"
|
||||
},
|
||||
{
|
||||
"id": "dprk_mfa",
|
||||
"name": "DPRK MFA",
|
||||
"nickname": "North Korea Diplomatic",
|
||||
"type": "diplomatic",
|
||||
"country": "North Korea",
|
||||
"country_code": "KP",
|
||||
"frequencies": [
|
||||
{"freq_khz": 7200, "primary": True},
|
||||
{"freq_khz": 9450, "primary": False},
|
||||
{"freq_khz": 11475, "primary": False},
|
||||
{"freq_khz": 13785, "primary": False},
|
||||
{"freq_khz": 15245, "primary": False},
|
||||
{"freq_khz": 17550, "primary": False},
|
||||
{"freq_khz": 21680, "primary": False},
|
||||
{"freq_khz": 25120, "primary": False},
|
||||
],
|
||||
"mode": "DPRK-ARQ (LSB/BFSK 600Bd/MSK 1200Bd)",
|
||||
"description": "North Korean diplomatic network spanning 7-25 MHz. Uses proprietary DPRK-ARQ protocol. Daily encrypted traffic to embassies.",
|
||||
"operator": "DPRK MFA",
|
||||
"schedule": "Daily, multiple time slots",
|
||||
"source_url": "https://priyom.org/diplomatic/north-korea"
|
||||
},
|
||||
{
|
||||
"id": "russia_mfa",
|
||||
"name": "Russia MFA",
|
||||
"nickname": "Russian Diplomatic",
|
||||
"type": "diplomatic",
|
||||
"country": "Russia",
|
||||
"country_code": "RU",
|
||||
"frequencies": [
|
||||
{"freq_khz": 5154, "primary": True},
|
||||
{"freq_khz": 7654, "primary": False},
|
||||
{"freq_khz": 9045, "primary": False},
|
||||
{"freq_khz": 10755, "primary": False},
|
||||
{"freq_khz": 13455, "primary": False},
|
||||
{"freq_khz": 16354, "primary": False},
|
||||
{"freq_khz": 18954, "primary": False},
|
||||
],
|
||||
"mode": "Perelivt/Serdolik/X06/OFDM",
|
||||
"description": "Extensive Russian diplomatic network using multiple proprietary modes including Perelivt, Serdolik, and OFDM variants.",
|
||||
"operator": "Russian MFA",
|
||||
"schedule": "24/7 network operations",
|
||||
"source_url": "https://priyom.org/diplomatic/russia"
|
||||
},
|
||||
{
|
||||
"id": "tunisia_mfa",
|
||||
"name": "Tunisia MFA",
|
||||
"nickname": "Tunisian Diplomatic",
|
||||
"type": "diplomatic",
|
||||
"country": "Tunisia",
|
||||
"country_code": "TN",
|
||||
"frequencies": [
|
||||
{"freq_khz": 5810, "primary": True},
|
||||
{"freq_khz": 7954, "primary": False},
|
||||
{"freq_khz": 8014, "primary": False},
|
||||
{"freq_khz": 8180, "primary": False},
|
||||
{"freq_khz": 10113, "primary": False},
|
||||
{"freq_khz": 10176, "primary": False},
|
||||
{"freq_khz": 11111, "primary": False},
|
||||
{"freq_khz": 12140, "primary": False},
|
||||
{"freq_khz": 13945, "primary": False},
|
||||
{"freq_khz": 14700, "primary": False},
|
||||
{"freq_khz": 14724, "primary": False},
|
||||
{"freq_khz": 15635, "primary": False},
|
||||
{"freq_khz": 16125, "primary": False},
|
||||
{"freq_khz": 16285, "primary": False},
|
||||
{"freq_khz": 16290, "primary": False},
|
||||
{"freq_khz": 18295, "primary": False},
|
||||
{"freq_khz": 19675, "primary": False},
|
||||
{"freq_khz": 23540, "primary": False},
|
||||
{"freq_khz": 24080, "primary": False},
|
||||
{"freq_khz": 24170, "primary": False},
|
||||
{"freq_khz": 26890, "primary": False},
|
||||
],
|
||||
"mode": "2G ALE/PACTOR-II",
|
||||
"description": "Tunisian MFA network. Callsigns STAT151-155. Uses 2G ALE for linking and PACTOR-II for traffic. MAPI email format.",
|
||||
"operator": "Tunisian MFA",
|
||||
"schedule": "Regular diplomatic traffic",
|
||||
"source_url": "https://priyom.org/diplomatic/tunisia"
|
||||
},
|
||||
{
|
||||
"id": "usa_state",
|
||||
"name": "US State Dept",
|
||||
"nickname": "American Diplomatic",
|
||||
"type": "diplomatic",
|
||||
"country": "United States",
|
||||
"country_code": "US",
|
||||
"frequencies": [
|
||||
{"freq_khz": 5749, "primary": True},
|
||||
{"freq_khz": 6903, "primary": False},
|
||||
{"freq_khz": 8059, "primary": False},
|
||||
{"freq_khz": 10734, "primary": False},
|
||||
{"freq_khz": 11169, "primary": False},
|
||||
{"freq_khz": 13504, "primary": False},
|
||||
{"freq_khz": 16284, "primary": False},
|
||||
{"freq_khz": 18249, "primary": False},
|
||||
{"freq_khz": 20811, "primary": False},
|
||||
{"freq_khz": 24884, "primary": False},
|
||||
],
|
||||
"mode": "2G ALE (MIL-STD-188-141A)",
|
||||
"description": "US State Department diplomatic network. 140+ embassy callsigns (KWX57=Warsaw, KRH50=Tokyo, etc.). Uses 2G ALE linking.",
|
||||
"operator": "US State Department",
|
||||
"schedule": "24/7 global network",
|
||||
"source_url": "https://priyom.org/diplomatic/united-states"
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
@spy_stations_bp.route('/stations')
|
||||
def get_stations():
|
||||
"""Return all spy stations, optionally filtered."""
|
||||
station_type = request.args.get('type')
|
||||
country = request.args.get('country')
|
||||
mode = request.args.get('mode')
|
||||
|
||||
filtered = STATIONS
|
||||
|
||||
if station_type:
|
||||
filtered = [s for s in filtered if s['type'] == station_type]
|
||||
|
||||
if country:
|
||||
filtered = [s for s in filtered if s['country_code'].upper() == country.upper()]
|
||||
|
||||
if mode:
|
||||
mode_lower = mode.lower()
|
||||
filtered = [s for s in filtered if mode_lower in s['mode'].lower()]
|
||||
|
||||
return jsonify({
|
||||
'status': 'success',
|
||||
'count': len(filtered),
|
||||
'stations': filtered
|
||||
})
|
||||
|
||||
|
||||
@spy_stations_bp.route('/stations/<station_id>')
|
||||
def get_station(station_id):
|
||||
"""Get a single station by ID."""
|
||||
for station in STATIONS:
|
||||
if station['id'] == station_id:
|
||||
return jsonify({
|
||||
'status': 'success',
|
||||
'station': station
|
||||
})
|
||||
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': 'Station not found'
|
||||
}), 404
|
||||
|
||||
|
||||
@spy_stations_bp.route('/filters')
|
||||
def get_filters():
|
||||
"""Return available filter options."""
|
||||
types = list(set(s['type'] for s in STATIONS))
|
||||
countries = sorted(list(set((s['country'], s['country_code']) for s in STATIONS)))
|
||||
modes = sorted(list(set(s['mode'].split('/')[0] for s in STATIONS)))
|
||||
|
||||
return jsonify({
|
||||
'status': 'success',
|
||||
'filters': {
|
||||
'types': types,
|
||||
'countries': [{'name': c[0], 'code': c[1]} for c in countries],
|
||||
'modes': modes
|
||||
}
|
||||
})
|
||||
355
static/css/modes/spy-stations.css
Normal file
355
static/css/modes/spy-stations.css
Normal file
@@ -0,0 +1,355 @@
|
||||
/**
|
||||
* Spy Stations Mode Styles
|
||||
* Number stations and diplomatic HF networks
|
||||
*/
|
||||
|
||||
/* ============================================
|
||||
MAIN LAYOUT
|
||||
============================================ */
|
||||
.spy-stations-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
padding: 16px;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.spy-stations-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 12px 16px;
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.spy-stations-title {
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.spy-stations-title svg {
|
||||
color: var(--accent-cyan);
|
||||
}
|
||||
|
||||
.spy-stations-count {
|
||||
font-size: 12px;
|
||||
color: var(--text-secondary);
|
||||
background: var(--bg-primary);
|
||||
padding: 4px 10px;
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
STATION GRID
|
||||
============================================ */
|
||||
.spy-stations-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
|
||||
gap: 12px;
|
||||
overflow-y: auto;
|
||||
flex: 1;
|
||||
padding: 4px;
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
STATION CARD
|
||||
============================================ */
|
||||
.spy-station-card {
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.spy-station-card:hover {
|
||||
border-color: var(--border-light);
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
/* Card Header */
|
||||
.spy-station-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 12px 14px;
|
||||
background: rgba(0, 0, 0, 0.2);
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.spy-station-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.spy-station-flag {
|
||||
font-size: 18px;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.spy-station-name {
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.spy-station-nickname {
|
||||
font-size: 12px;
|
||||
color: var(--text-secondary);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
/* Type Badge */
|
||||
.spy-station-badge {
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-size: 9px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
padding: 3px 8px;
|
||||
border-radius: 3px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.spy-badge-number {
|
||||
background: rgba(74, 158, 255, 0.15);
|
||||
color: var(--accent-cyan);
|
||||
border: 1px solid rgba(74, 158, 255, 0.3);
|
||||
}
|
||||
|
||||
.spy-badge-diplomatic {
|
||||
background: rgba(34, 197, 94, 0.15);
|
||||
color: var(--accent-green);
|
||||
border: 1px solid rgba(34, 197, 94, 0.3);
|
||||
}
|
||||
|
||||
/* Card Body */
|
||||
.spy-station-body {
|
||||
padding: 14px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.spy-station-meta {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.spy-station-meta-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.spy-meta-label {
|
||||
font-size: 9px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
color: var(--text-dim);
|
||||
}
|
||||
|
||||
.spy-meta-value {
|
||||
font-size: 12px;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.spy-meta-mode {
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-size: 10px;
|
||||
color: var(--accent-orange);
|
||||
}
|
||||
|
||||
/* Frequencies */
|
||||
.spy-station-freqs {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.spy-freq-list {
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-size: 11px;
|
||||
color: var(--accent-cyan);
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.spy-freq-grid {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.spy-freq-item {
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-size: 11px;
|
||||
color: var(--accent-cyan);
|
||||
background: var(--bg-secondary);
|
||||
padding: 4px 8px;
|
||||
border-radius: 4px;
|
||||
border: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
/* Description */
|
||||
.spy-station-desc {
|
||||
font-size: 11px;
|
||||
color: var(--text-secondary);
|
||||
line-height: 1.5;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 3;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* Card Footer */
|
||||
.spy-station-footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 10px 14px;
|
||||
background: rgba(0, 0, 0, 0.1);
|
||||
border-top: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
/* Tune Button */
|
||||
.spy-tune-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.03em;
|
||||
color: #000;
|
||||
background: var(--accent-green);
|
||||
border: none;
|
||||
padding: 8px 14px;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s ease;
|
||||
}
|
||||
|
||||
.spy-tune-btn:hover {
|
||||
background: var(--accent-cyan);
|
||||
transform: scale(1.02);
|
||||
}
|
||||
|
||||
.spy-tune-btn svg {
|
||||
stroke-width: 2.5;
|
||||
}
|
||||
|
||||
/* Details Button */
|
||||
.spy-details-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
font-size: 11px;
|
||||
color: var(--text-secondary);
|
||||
background: transparent;
|
||||
border: 1px solid var(--border-color);
|
||||
padding: 6px 12px;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s ease;
|
||||
}
|
||||
|
||||
.spy-details-btn:hover {
|
||||
color: var(--text-primary);
|
||||
border-color: var(--border-light);
|
||||
background: var(--bg-secondary);
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
EMPTY STATE
|
||||
============================================ */
|
||||
.spy-station-empty {
|
||||
grid-column: 1 / -1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 60px 20px;
|
||||
text-align: center;
|
||||
color: var(--text-dim);
|
||||
}
|
||||
|
||||
.spy-station-empty p {
|
||||
font-size: 13px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
FILTER CHECKBOX STYLING
|
||||
============================================ */
|
||||
#spystationsMode .inline-checkbox {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 11px;
|
||||
color: var(--text-secondary);
|
||||
cursor: pointer;
|
||||
padding: 4px 0;
|
||||
}
|
||||
|
||||
#spystationsMode .inline-checkbox input[type="checkbox"] {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
accent-color: var(--accent-cyan);
|
||||
}
|
||||
|
||||
#spystationsMode .inline-checkbox:hover {
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
RESPONSIVE
|
||||
============================================ */
|
||||
@media (max-width: 768px) {
|
||||
.spy-stations-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.spy-station-header {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.spy-station-badge {
|
||||
align-self: flex-start;
|
||||
}
|
||||
|
||||
.spy-station-meta {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.spy-station-footer {
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.spy-tune-btn,
|
||||
.spy-details-btn {
|
||||
width: 100%;
|
||||
justify-content: center;
|
||||
}
|
||||
}
|
||||
@@ -1580,6 +1580,40 @@ function initListeningPost() {
|
||||
e.preventDefault();
|
||||
tuneFreq(delta);
|
||||
});
|
||||
|
||||
// Check if we arrived from Spy Stations with a tune request
|
||||
checkIncomingTuneRequest();
|
||||
}
|
||||
|
||||
/**
|
||||
* Check for incoming tune request from Spy Stations or other pages
|
||||
*/
|
||||
function checkIncomingTuneRequest() {
|
||||
const tuneFreq = sessionStorage.getItem('tuneFrequency');
|
||||
const tuneMode = sessionStorage.getItem('tuneMode');
|
||||
|
||||
if (tuneFreq) {
|
||||
// Clear the session storage first
|
||||
sessionStorage.removeItem('tuneFrequency');
|
||||
sessionStorage.removeItem('tuneMode');
|
||||
|
||||
// Parse and validate frequency
|
||||
const freq = parseFloat(tuneFreq);
|
||||
if (!isNaN(freq) && freq >= 0.01 && freq <= 2000) {
|
||||
console.log('[LISTEN] Incoming tune request:', freq, 'MHz, mode:', tuneMode || 'default');
|
||||
|
||||
// Determine modulation (default to USB for HF/number stations)
|
||||
const mod = tuneMode || (freq < 30 ? 'usb' : 'am');
|
||||
|
||||
// Use quickTune to set frequency and modulation
|
||||
quickTune(freq, mod);
|
||||
|
||||
// Show notification
|
||||
if (typeof showNotification === 'function') {
|
||||
showNotification('Tuned to ' + freq.toFixed(3) + ' MHz', mod.toUpperCase() + ' mode');
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize when DOM is ready
|
||||
@@ -2265,6 +2299,7 @@ window.skipSignal = skipSignal;
|
||||
window.setBand = setBand;
|
||||
window.tuneFreq = tuneFreq;
|
||||
window.quickTune = quickTune;
|
||||
window.checkIncomingTuneRequest = checkIncomingTuneRequest;
|
||||
window.addFrequencyBookmark = addFrequencyBookmark;
|
||||
window.removeBookmark = removeBookmark;
|
||||
window.tuneToFrequency = tuneToFrequency;
|
||||
|
||||
461
static/js/modes/spy-stations.js
Normal file
461
static/js/modes/spy-stations.js
Normal file
@@ -0,0 +1,461 @@
|
||||
/**
|
||||
* Spy Stations Mode
|
||||
* Number stations and diplomatic HF radio networks
|
||||
*/
|
||||
|
||||
const SpyStations = (function() {
|
||||
// State
|
||||
let stations = [];
|
||||
let filteredStations = [];
|
||||
let activeFilters = {
|
||||
types: ['number', 'diplomatic'],
|
||||
countries: [],
|
||||
modes: []
|
||||
};
|
||||
|
||||
// Country flag emoji map
|
||||
const countryFlags = {
|
||||
'RU': '\u{1F1F7}\u{1F1FA}',
|
||||
'CU': '\u{1F1E8}\u{1F1FA}',
|
||||
'BG': '\u{1F1E7}\u{1F1EC}',
|
||||
'CZ': '\u{1F1E8}\u{1F1FF}',
|
||||
'EG': '\u{1F1EA}\u{1F1EC}',
|
||||
'KP': '\u{1F1F0}\u{1F1F5}',
|
||||
'TN': '\u{1F1F9}\u{1F1F3}',
|
||||
'US': '\u{1F1FA}\u{1F1F8}'
|
||||
};
|
||||
|
||||
/**
|
||||
* Initialize the spy stations mode
|
||||
*/
|
||||
function init() {
|
||||
fetchStations();
|
||||
checkTuneFrequency();
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch stations from the API
|
||||
*/
|
||||
async function fetchStations() {
|
||||
try {
|
||||
const response = await fetch('/spy-stations/stations');
|
||||
const data = await response.json();
|
||||
|
||||
if (data.status === 'success') {
|
||||
stations = data.stations;
|
||||
initFilters();
|
||||
applyFilters();
|
||||
updateStats();
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch spy stations:', err);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize filter checkboxes
|
||||
*/
|
||||
function initFilters() {
|
||||
// Get unique countries and modes
|
||||
const countries = [...new Set(stations.map(s => JSON.stringify({name: s.country, code: s.country_code})))].map(s => JSON.parse(s));
|
||||
const modes = [...new Set(stations.map(s => s.mode.split('/')[0]))].sort();
|
||||
|
||||
// Populate country filters
|
||||
const countryContainer = document.getElementById('countryFilters');
|
||||
if (countryContainer) {
|
||||
countryContainer.innerHTML = countries.map(c => `
|
||||
<label class="inline-checkbox">
|
||||
<input type="checkbox" data-country="${c.code}" checked onchange="SpyStations.applyFilters()">
|
||||
<span>${countryFlags[c.code] || ''} ${c.name}</span>
|
||||
</label>
|
||||
`).join('');
|
||||
}
|
||||
|
||||
// Populate mode filters
|
||||
const modeContainer = document.getElementById('modeFilters');
|
||||
if (modeContainer) {
|
||||
modeContainer.innerHTML = modes.map(m => `
|
||||
<label class="inline-checkbox">
|
||||
<input type="checkbox" data-mode="${m}" checked onchange="SpyStations.applyFilters()">
|
||||
<span style="font-family: 'JetBrains Mono', monospace; font-size: 10px;">${m}</span>
|
||||
</label>
|
||||
`).join('');
|
||||
}
|
||||
|
||||
// Set initial filter states
|
||||
activeFilters.countries = countries.map(c => c.code);
|
||||
activeFilters.modes = modes;
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply filters and render stations
|
||||
*/
|
||||
function applyFilters() {
|
||||
// Read type filters
|
||||
const typeNumber = document.getElementById('filterTypeNumber');
|
||||
const typeDiplomatic = document.getElementById('filterTypeDiplomatic');
|
||||
|
||||
activeFilters.types = [];
|
||||
if (typeNumber && typeNumber.checked) activeFilters.types.push('number');
|
||||
if (typeDiplomatic && typeDiplomatic.checked) activeFilters.types.push('diplomatic');
|
||||
|
||||
// Read country filters
|
||||
activeFilters.countries = [];
|
||||
document.querySelectorAll('#countryFilters input[data-country]:checked').forEach(cb => {
|
||||
activeFilters.countries.push(cb.dataset.country);
|
||||
});
|
||||
|
||||
// Read mode filters
|
||||
activeFilters.modes = [];
|
||||
document.querySelectorAll('#modeFilters input[data-mode]:checked').forEach(cb => {
|
||||
activeFilters.modes.push(cb.dataset.mode);
|
||||
});
|
||||
|
||||
// Apply filters
|
||||
filteredStations = stations.filter(s => {
|
||||
if (!activeFilters.types.includes(s.type)) return false;
|
||||
if (!activeFilters.countries.includes(s.country_code)) return false;
|
||||
const stationMode = s.mode.split('/')[0];
|
||||
if (!activeFilters.modes.includes(stationMode)) return false;
|
||||
return true;
|
||||
});
|
||||
|
||||
renderStations();
|
||||
}
|
||||
|
||||
/**
|
||||
* Render station cards
|
||||
*/
|
||||
function renderStations() {
|
||||
const container = document.getElementById('spyStationsGrid');
|
||||
if (!container) return;
|
||||
|
||||
if (filteredStations.length === 0) {
|
||||
container.innerHTML = `
|
||||
<div class="spy-station-empty">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="width: 48px; height: 48px; opacity: 0.3; margin-bottom: 12px;">
|
||||
<path d="M4.9 19.1C1 15.2 1 8.8 4.9 4.9"/>
|
||||
<path d="M7.8 16.2c-2.3-2.3-2.3-6.1 0-8.5"/>
|
||||
<circle cx="12" cy="12" r="2"/>
|
||||
<path d="M16.2 7.8c2.3 2.3 2.3 6.1 0 8.5"/>
|
||||
<path d="M19.1 4.9C23 8.8 23 15.1 19.1 19"/>
|
||||
</svg>
|
||||
<p>No stations match your filters</p>
|
||||
</div>
|
||||
`;
|
||||
return;
|
||||
}
|
||||
|
||||
container.innerHTML = filteredStations.map(station => renderStationCard(station)).join('');
|
||||
}
|
||||
|
||||
/**
|
||||
* Render a single station card
|
||||
*/
|
||||
function renderStationCard(station) {
|
||||
const flag = countryFlags[station.country_code] || '';
|
||||
const typeBadgeClass = station.type === 'number' ? 'spy-badge-number' : 'spy-badge-diplomatic';
|
||||
const typeBadgeText = station.type === 'number' ? 'NUMBER' : 'DIPLOMATIC';
|
||||
|
||||
const primaryFreq = station.frequencies.find(f => f.primary) || station.frequencies[0];
|
||||
const freqList = station.frequencies.slice(0, 4).map(f => formatFrequency(f.freq_khz)).join(', ');
|
||||
const moreFreqs = station.frequencies.length > 4 ? ` +${station.frequencies.length - 4} more` : '';
|
||||
|
||||
return `
|
||||
<div class="spy-station-card" data-station-id="${station.id}">
|
||||
<div class="spy-station-header">
|
||||
<div class="spy-station-title">
|
||||
<span class="spy-station-flag">${flag}</span>
|
||||
<span class="spy-station-name">${station.name}</span>
|
||||
${station.nickname ? `<span class="spy-station-nickname">- ${station.nickname}</span>` : ''}
|
||||
</div>
|
||||
<span class="spy-station-badge ${typeBadgeClass}">${typeBadgeText}</span>
|
||||
</div>
|
||||
<div class="spy-station-body">
|
||||
<div class="spy-station-meta">
|
||||
<div class="spy-station-meta-item">
|
||||
<span class="spy-meta-label">Origin</span>
|
||||
<span class="spy-meta-value">${station.country}</span>
|
||||
</div>
|
||||
<div class="spy-station-meta-item">
|
||||
<span class="spy-meta-label">Mode</span>
|
||||
<span class="spy-meta-value spy-meta-mode">${station.mode}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="spy-station-freqs">
|
||||
<span class="spy-meta-label">Frequencies</span>
|
||||
<span class="spy-freq-list">${freqList}${moreFreqs}</span>
|
||||
</div>
|
||||
<div class="spy-station-desc">${station.description}</div>
|
||||
</div>
|
||||
<div class="spy-station-footer">
|
||||
<button class="spy-tune-btn" onclick="SpyStations.tuneToStation('${station.id}', ${primaryFreq.freq_khz})">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="width: 14px; height: 14px;">
|
||||
<path d="M3 18v-6a9 9 0 0 1 18 0v6"/>
|
||||
<path d="M21 19a2 2 0 0 1-2 2h-1a2 2 0 0 1-2-2v-3a2 2 0 0 1 2-2h3zM3 19a2 2 0 0 0 2 2h1a2 2 0 0 0 2-2v-3a2 2 0 0 0-2-2H3z"/>
|
||||
</svg>
|
||||
Tune In
|
||||
</button>
|
||||
<button class="spy-details-btn" onclick="SpyStations.showDetails('${station.id}')">
|
||||
Details
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="width: 12px; height: 12px;">
|
||||
<polyline points="6 9 12 15 18 9"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Format frequency for display
|
||||
*/
|
||||
function formatFrequency(freqKhz) {
|
||||
if (freqKhz >= 1000) {
|
||||
return (freqKhz / 1000).toFixed(3) + ' MHz';
|
||||
}
|
||||
return freqKhz + ' kHz';
|
||||
}
|
||||
|
||||
/**
|
||||
* Tune to a station frequency
|
||||
*/
|
||||
function tuneToStation(stationId, freqKhz) {
|
||||
const freqMhz = freqKhz / 1000;
|
||||
sessionStorage.setItem('tuneFrequency', freqMhz.toString());
|
||||
sessionStorage.setItem('tuneMode', 'usb'); // Most number stations use USB
|
||||
|
||||
// Find the station for notification
|
||||
const station = stations.find(s => s.id === stationId);
|
||||
const stationName = station ? station.name : 'Station';
|
||||
|
||||
if (typeof showNotification === 'function') {
|
||||
showNotification('Tuning to ' + stationName, formatFrequency(freqKhz));
|
||||
}
|
||||
|
||||
// Switch to listening post mode
|
||||
if (typeof selectMode === 'function') {
|
||||
selectMode('listening');
|
||||
} else if (typeof switchMode === 'function') {
|
||||
switchMode('listening');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if we arrived from another page with a tune request
|
||||
*/
|
||||
function checkTuneFrequency() {
|
||||
// This is for the listening post to check - spy stations sets, listening post reads
|
||||
}
|
||||
|
||||
/**
|
||||
* Show station details modal
|
||||
*/
|
||||
function showDetails(stationId) {
|
||||
const station = stations.find(s => s.id === stationId);
|
||||
if (!station) return;
|
||||
|
||||
let modal = document.getElementById('spyStationDetailsModal');
|
||||
if (!modal) {
|
||||
modal = document.createElement('div');
|
||||
modal.id = 'spyStationDetailsModal';
|
||||
modal.className = 'signal-details-modal';
|
||||
document.body.appendChild(modal);
|
||||
}
|
||||
|
||||
const flag = countryFlags[station.country_code] || '';
|
||||
const allFreqs = station.frequencies.map(f => {
|
||||
const label = f.primary ? ' (primary)' : '';
|
||||
return `<span class="spy-freq-item">${formatFrequency(f.freq_khz)}${label}</span>`;
|
||||
}).join('');
|
||||
|
||||
modal.innerHTML = `
|
||||
<div class="signal-details-modal-backdrop" onclick="SpyStations.closeDetails()"></div>
|
||||
<div class="signal-details-modal-content">
|
||||
<div class="signal-details-modal-header">
|
||||
<h3>${flag} ${station.name} ${station.nickname ? '- ' + station.nickname : ''}</h3>
|
||||
<button class="signal-details-modal-close" onclick="SpyStations.closeDetails()">×</button>
|
||||
</div>
|
||||
<div class="signal-details-modal-body">
|
||||
<div class="signal-details-section">
|
||||
<div class="signal-details-title">Overview</div>
|
||||
<div class="signal-details-grid">
|
||||
<div class="signal-details-item">
|
||||
<span class="signal-details-label">Type</span>
|
||||
<span class="signal-details-value">${station.type === 'number' ? 'Number Station' : 'Diplomatic Network'}</span>
|
||||
</div>
|
||||
<div class="signal-details-item">
|
||||
<span class="signal-details-label">Country</span>
|
||||
<span class="signal-details-value">${station.country}</span>
|
||||
</div>
|
||||
<div class="signal-details-item">
|
||||
<span class="signal-details-label">Mode</span>
|
||||
<span class="signal-details-value">${station.mode}</span>
|
||||
</div>
|
||||
<div class="signal-details-item">
|
||||
<span class="signal-details-label">Operator</span>
|
||||
<span class="signal-details-value">${station.operator || 'Unknown'}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="signal-details-section">
|
||||
<div class="signal-details-title">Description</div>
|
||||
<p style="color: var(--text-secondary); font-size: 12px; line-height: 1.6;">${station.description}</p>
|
||||
</div>
|
||||
<div class="signal-details-section">
|
||||
<div class="signal-details-title">Frequencies (${station.frequencies.length})</div>
|
||||
<div class="spy-freq-grid">${allFreqs}</div>
|
||||
</div>
|
||||
${station.schedule ? `
|
||||
<div class="signal-details-section">
|
||||
<div class="signal-details-title">Schedule</div>
|
||||
<p style="color: var(--text-secondary); font-size: 12px;">${station.schedule}</p>
|
||||
</div>
|
||||
` : ''}
|
||||
${station.source_url ? `
|
||||
<div class="signal-details-section">
|
||||
<div class="signal-details-title">Source</div>
|
||||
<a href="${station.source_url}" target="_blank" rel="noopener" style="color: var(--accent-cyan); font-size: 12px;">${station.source_url}</a>
|
||||
</div>
|
||||
` : ''}
|
||||
</div>
|
||||
<div class="signal-details-modal-footer">
|
||||
<button class="spy-tune-btn" onclick="SpyStations.tuneToStation('${station.id}', ${station.frequencies[0].freq_khz}); SpyStations.closeDetails();">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="width: 14px; height: 14px;">
|
||||
<path d="M3 18v-6a9 9 0 0 1 18 0v6"/>
|
||||
<path d="M21 19a2 2 0 0 1-2 2h-1a2 2 0 0 1-2-2v-3a2 2 0 0 1 2-2h3zM3 19a2 2 0 0 0 2 2h1a2 2 0 0 0 2-2v-3a2 2 0 0 0-2-2H3z"/>
|
||||
</svg>
|
||||
Tune In
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
modal.classList.add('show');
|
||||
}
|
||||
|
||||
/**
|
||||
* Close details modal
|
||||
*/
|
||||
function closeDetails() {
|
||||
const modal = document.getElementById('spyStationDetailsModal');
|
||||
if (modal) {
|
||||
modal.classList.remove('show');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Show help modal
|
||||
*/
|
||||
function showHelp() {
|
||||
let modal = document.getElementById('spyStationsHelpModal');
|
||||
if (!modal) {
|
||||
modal = document.createElement('div');
|
||||
modal.id = 'spyStationsHelpModal';
|
||||
modal.className = 'signal-details-modal';
|
||||
document.body.appendChild(modal);
|
||||
}
|
||||
|
||||
modal.innerHTML = `
|
||||
<div class="signal-details-modal-backdrop" onclick="SpyStations.closeHelp()"></div>
|
||||
<div class="signal-details-modal-content">
|
||||
<div class="signal-details-modal-header">
|
||||
<h3>About Spy Stations</h3>
|
||||
<button class="signal-details-modal-close" onclick="SpyStations.closeHelp()">×</button>
|
||||
</div>
|
||||
<div class="signal-details-modal-body">
|
||||
<div class="signal-details-section">
|
||||
<div class="signal-details-title">Number Stations</div>
|
||||
<p style="color: var(--text-secondary); font-size: 12px; line-height: 1.6;">
|
||||
Number stations are shortwave radio transmissions believed to be used by intelligence agencies
|
||||
to communicate with spies in the field. They typically broadcast strings of numbers, letters,
|
||||
or words read by synthesized or live voices. These one-way broadcasts are encrypted using
|
||||
one-time pads, making them virtually unbreakable.
|
||||
</p>
|
||||
</div>
|
||||
<div class="signal-details-section">
|
||||
<div class="signal-details-title">Diplomatic Networks</div>
|
||||
<p style="color: var(--text-secondary); font-size: 12px; line-height: 1.6;">
|
||||
Foreign ministries maintain HF radio networks to communicate with embassies worldwide,
|
||||
especially in regions where satellite or internet connectivity may be unreliable or
|
||||
compromised. These networks use various digital modes like PACTOR, ALE, and proprietary
|
||||
protocols for encrypted diplomatic traffic.
|
||||
</p>
|
||||
</div>
|
||||
<div class="signal-details-section">
|
||||
<div class="signal-details-title">How to Listen</div>
|
||||
<p style="color: var(--text-secondary); font-size: 12px; line-height: 1.6;">
|
||||
Click "Tune In" on any station to open the Listening Post with the frequency pre-configured.
|
||||
Most number stations use USB (Upper Sideband) mode. You'll need an SDR capable of receiving
|
||||
HF frequencies (typically 3-30 MHz) and an appropriate antenna.
|
||||
</p>
|
||||
</div>
|
||||
<div class="signal-details-section">
|
||||
<div class="signal-details-title">Best Practices</div>
|
||||
<ul style="color: var(--text-secondary); font-size: 12px; line-height: 1.6; padding-left: 20px;">
|
||||
<li>HF propagation varies with time of day and solar conditions</li>
|
||||
<li>Use a long wire or loop antenna for best results</li>
|
||||
<li>Check schedules on priyom.org for transmission times</li>
|
||||
<li>Night time generally offers better long-distance reception</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="signal-details-section">
|
||||
<div class="signal-details-title">Data Sources</div>
|
||||
<p style="color: var(--text-secondary); font-size: 12px; line-height: 1.6;">
|
||||
Station data sourced from <a href="https://priyom.org" target="_blank" rel="noopener" style="color: var(--accent-cyan);">priyom.org</a>,
|
||||
a community-maintained database of number stations and related transmissions.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
modal.classList.add('show');
|
||||
}
|
||||
|
||||
/**
|
||||
* Close help modal
|
||||
*/
|
||||
function closeHelp() {
|
||||
const modal = document.getElementById('spyStationsHelpModal');
|
||||
if (modal) {
|
||||
modal.classList.remove('show');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update sidebar stats
|
||||
*/
|
||||
function updateStats() {
|
||||
const numberCount = stations.filter(s => s.type === 'number').length;
|
||||
const diplomaticCount = stations.filter(s => s.type === 'diplomatic').length;
|
||||
const countryCount = new Set(stations.map(s => s.country_code)).size;
|
||||
const freqCount = stations.reduce((sum, s) => sum + s.frequencies.length, 0);
|
||||
|
||||
const numberEl = document.getElementById('spyStatsNumber');
|
||||
const diplomaticEl = document.getElementById('spyStatsDiplomatic');
|
||||
const countriesEl = document.getElementById('spyStatsCountries');
|
||||
const freqsEl = document.getElementById('spyStatsFreqs');
|
||||
|
||||
if (numberEl) numberEl.textContent = numberCount;
|
||||
if (diplomaticEl) diplomaticEl.textContent = diplomaticCount;
|
||||
if (countriesEl) countriesEl.textContent = countryCount;
|
||||
if (freqsEl) freqsEl.textContent = freqCount;
|
||||
}
|
||||
|
||||
// Public API
|
||||
return {
|
||||
init,
|
||||
applyFilters,
|
||||
tuneToStation,
|
||||
showDetails,
|
||||
closeDetails,
|
||||
showHelp,
|
||||
closeHelp
|
||||
};
|
||||
})();
|
||||
|
||||
// Initialize when DOM is ready
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
// Will be initialized when mode is switched to spy stations
|
||||
});
|
||||
@@ -21,6 +21,7 @@
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/components/activity-timeline.css') }}">
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/components/device-cards.css') }}">
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/components/proximity-viz.css') }}">
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/modes/spy-stations.css') }}">
|
||||
</head>
|
||||
|
||||
<body>
|
||||
@@ -135,6 +136,11 @@
|
||||
<span class="mode-name">RTLAMR</span>
|
||||
<span class="mode-desc">Utility meters</span>
|
||||
</button>
|
||||
<button class="mode-card" onclick="selectMode('spystations')">
|
||||
<span class="mode-icon icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4.9 19.1C1 15.2 1 8.8 4.9 4.9"/><path d="M7.8 16.2c-2.3-2.3-2.3-6.1 0-8.5"/><circle cx="12" cy="12" r="2"/><path d="M16.2 7.8c2.3 2.3 2.3 6.1 0 8.5"/><path d="M19.1 4.9C23 8.8 23 15.1 19.1 19"/></svg></span>
|
||||
<span class="mode-name">Spy Stations</span>
|
||||
<span class="mode-desc">Number stations & diplomatic</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -280,6 +286,7 @@
|
||||
<button class="mode-nav-btn" onclick="switchMode('aprs')"><span class="nav-icon icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M20 10c0 6-8 12-8 12s-8-6-8-12a8 8 0 0 1 16 0Z"/><circle cx="12" cy="10" r="3"/></svg></span><span class="nav-label">APRS</span></button>
|
||||
<button class="mode-nav-btn" onclick="switchMode('satellite')"><span class="nav-icon icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M13 7L9 3 5 7l4 4"/><path d="m17 11 4 4-4 4-4-4"/><path d="m8 12 4 4 6-6-4-4-6 6"/><path d="m16 8 3-3"/><path d="M9 21a6 6 0 0 0-6-6"/></svg></span><span class="nav-label">Satellite</span></button>
|
||||
<button class="mode-nav-btn" onclick="switchMode('listening')"><span class="nav-icon icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="18" height="18" rx="2"/><path d="M3 9h18"/><path d="M9 21V9"/></svg></span><span class="nav-label">Listening Post</span></button>
|
||||
<button class="mode-nav-btn" onclick="switchMode('spystations')"><span class="nav-icon icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4.9 19.1C1 15.2 1 8.8 4.9 4.9"/><path d="M7.8 16.2c-2.3-2.3-2.3-6.1 0-8.5"/><circle cx="12" cy="12" r="2"/><path d="M16.2 7.8c2.3 2.3 2.3 6.1 0 8.5"/><path d="M19.1 4.9C23 8.8 23 15.1 19.1 19"/></svg></span><span class="nav-label">Spy Stations</span></button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mode-nav-dropdown" data-group="wireless">
|
||||
@@ -343,6 +350,7 @@
|
||||
<button class="mobile-nav-btn" data-mode="tscm" onclick="switchMode('tscm')"><span class="icon icon--sm"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg></span> TSCM</button>
|
||||
<button class="mobile-nav-btn" data-mode="satellite" onclick="switchMode('satellite')"><span class="icon icon--sm"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M13 7L9 3 5 7l4 4"/><path d="m17 11 4 4-4 4-4-4"/><path d="m8 12 4 4 6-6-4-4-6 6"/></svg></span> Sat</button>
|
||||
<button class="mobile-nav-btn" data-mode="listening" onclick="switchMode('listening')"><span class="icon icon--sm"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="3" width="18" height="18" rx="2"/><path d="M3 9h18"/><path d="M9 21V9"/></svg></span> Scanner</button>
|
||||
<button class="mobile-nav-btn" data-mode="spystations" onclick="switchMode('spystations')"><span class="icon icon--sm"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M4.9 19.1C1 15.2 1 8.8 4.9 4.9"/><circle cx="12" cy="12" r="2"/><path d="M19.1 4.9C23 8.8 23 15.1 19.1 19"/></svg></span> Spy</button>
|
||||
</nav>
|
||||
|
||||
<!-- Mobile Drawer Overlay -->
|
||||
@@ -455,6 +463,8 @@
|
||||
|
||||
{% include 'partials/modes/ais.html' %}
|
||||
|
||||
{% include 'partials/modes/spy-stations.html' %}
|
||||
|
||||
<button class="preset-btn" onclick="killAll()"
|
||||
style="width: 100%; margin-top: 10px; border-color: #ff3366; color: #ff3366;">
|
||||
Kill All Processes
|
||||
@@ -1447,6 +1457,28 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Spy Stations Dashboard -->
|
||||
<div id="spyStationsVisuals" class="spy-stations-container" style="display: none;">
|
||||
<div class="spy-stations-header">
|
||||
<div class="spy-stations-title">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="width: 20px; height: 20px;">
|
||||
<path d="M4.9 19.1C1 15.2 1 8.8 4.9 4.9"/>
|
||||
<path d="M7.8 16.2c-2.3-2.3-2.3-6.1 0-8.5"/>
|
||||
<circle cx="12" cy="12" r="2"/>
|
||||
<path d="M16.2 7.8c2.3 2.3 2.3 6.1 0 8.5"/>
|
||||
<path d="M19.1 4.9C23 8.8 23 15.1 19.1 19"/>
|
||||
</svg>
|
||||
Number Stations & Diplomatic Networks
|
||||
</div>
|
||||
<div class="spy-stations-count">
|
||||
<span id="spyStationsVisibleCount">0</span> stations
|
||||
</div>
|
||||
</div>
|
||||
<div class="spy-stations-grid" id="spyStationsGrid">
|
||||
<!-- Station cards populated by JavaScript -->
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Device Intelligence Dashboard (above waterfall for prominence) -->
|
||||
<div class="recon-panel collapsed" id="reconPanel">
|
||||
<div class="recon-header" onclick="toggleReconCollapse()" style="cursor: pointer;">
|
||||
@@ -1528,6 +1560,7 @@
|
||||
<script src="{{ url_for('static', filename='js/components/channel-chart.js') }}"></script>
|
||||
<script src="{{ url_for('static', filename='js/modes/wifi.js') }}"></script>
|
||||
<script src="{{ url_for('static', filename='js/modes/listening-post.js') }}"></script>
|
||||
<script src="{{ url_for('static', filename='js/modes/spy-stations.js') }}"></script>
|
||||
|
||||
<script>
|
||||
// ============================================
|
||||
@@ -1996,7 +2029,8 @@
|
||||
'pager': 'sdr', 'sensor': 'sdr',
|
||||
'aprs': 'sdr', 'satellite': 'sdr', 'listening': 'sdr',
|
||||
'wifi': 'wireless', 'bluetooth': 'wireless',
|
||||
'tscm': 'security'
|
||||
'tscm': 'security',
|
||||
'rtlamr': 'sdr', 'ais': 'sdr', 'spystations': 'sdr'
|
||||
};
|
||||
|
||||
// Remove has-active from all dropdowns
|
||||
@@ -2056,6 +2090,7 @@
|
||||
document.getElementById('aprsMode')?.classList.toggle('active', mode === 'aprs');
|
||||
document.getElementById('tscmMode')?.classList.toggle('active', mode === 'tscm');
|
||||
document.getElementById('aisMode')?.classList.toggle('active', mode === 'ais');
|
||||
document.getElementById('spystationsMode')?.classList.toggle('active', mode === 'spystations');
|
||||
const pagerStats = document.getElementById('pagerStats');
|
||||
const sensorStats = document.getElementById('sensorStats');
|
||||
const satelliteStats = document.getElementById('satelliteStats');
|
||||
@@ -2086,7 +2121,8 @@
|
||||
'listening': 'LISTENING POST',
|
||||
'aprs': 'APRS',
|
||||
'tscm': 'TSCM',
|
||||
'ais': 'AIS VESSELS'
|
||||
'ais': 'AIS VESSELS',
|
||||
'spystations': 'SPY STATIONS'
|
||||
};
|
||||
const activeModeIndicator = document.getElementById('activeModeIndicator');
|
||||
if (activeModeIndicator) activeModeIndicator.innerHTML = '<span class="pulse-dot"></span>' + (modeNames[mode] || mode.toUpperCase());
|
||||
@@ -2096,12 +2132,14 @@
|
||||
const listeningPostVisuals = document.getElementById('listeningPostVisuals');
|
||||
const aprsVisuals = document.getElementById('aprsVisuals');
|
||||
const tscmVisuals = document.getElementById('tscmVisuals');
|
||||
const spyStationsVisuals = document.getElementById('spyStationsVisuals');
|
||||
if (wifiLayoutContainer) wifiLayoutContainer.style.display = mode === 'wifi' ? 'flex' : 'none';
|
||||
if (btLayoutContainer) btLayoutContainer.style.display = mode === 'bluetooth' ? 'flex' : 'none';
|
||||
if (satelliteVisuals) satelliteVisuals.style.display = mode === 'satellite' ? 'block' : 'none';
|
||||
if (listeningPostVisuals) listeningPostVisuals.style.display = mode === 'listening' ? 'grid' : 'none';
|
||||
if (aprsVisuals) aprsVisuals.style.display = mode === 'aprs' ? 'flex' : 'none';
|
||||
if (tscmVisuals) tscmVisuals.style.display = mode === 'tscm' ? 'flex' : 'none';
|
||||
if (spyStationsVisuals) spyStationsVisuals.style.display = mode === 'spystations' ? 'flex' : 'none';
|
||||
|
||||
// Show/hide mode-specific timeline containers
|
||||
const pagerTimelineContainer = document.getElementById('pagerTimelineContainer');
|
||||
@@ -2120,7 +2158,8 @@
|
||||
'listening': 'Listening Post',
|
||||
'aprs': 'APRS Tracker',
|
||||
'tscm': 'TSCM Counter-Surveillance',
|
||||
'ais': 'AIS Vessel Tracker'
|
||||
'ais': 'AIS Vessel Tracker',
|
||||
'spystations': 'Spy Stations'
|
||||
};
|
||||
const outputTitle = document.getElementById('outputTitle');
|
||||
if (outputTitle) outputTitle.textContent = titles[mode] || 'Signal Monitor';
|
||||
@@ -2138,7 +2177,7 @@
|
||||
const reconBtn = document.getElementById('reconBtn');
|
||||
const intelBtn = document.querySelector('[onclick="exportDeviceDB()"]');
|
||||
const reconPanel = document.getElementById('reconPanel');
|
||||
if (mode === 'satellite' || mode === 'listening' || mode === 'aprs' || mode === 'tscm') {
|
||||
if (mode === 'satellite' || mode === 'listening' || mode === 'aprs' || mode === 'tscm' || mode === 'spystations') {
|
||||
if (reconPanel) reconPanel.style.display = 'none';
|
||||
if (reconBtn) reconBtn.style.display = 'none';
|
||||
if (intelBtn) intelBtn.style.display = 'none';
|
||||
@@ -2164,7 +2203,7 @@
|
||||
// Hide output console for modes with their own visualizations
|
||||
const outputEl = document.getElementById('output');
|
||||
const statusBar = document.querySelector('.status-bar');
|
||||
if (outputEl) outputEl.style.display = (mode === 'satellite' || mode === 'aprs' || mode === 'wifi' || mode === 'bluetooth' || mode === 'listening' || mode === 'tscm') ? 'none' : 'block';
|
||||
if (outputEl) outputEl.style.display = (mode === 'satellite' || mode === 'aprs' || mode === 'wifi' || mode === 'bluetooth' || mode === 'listening' || mode === 'tscm' || mode === 'spystations') ? 'none' : 'block';
|
||||
if (statusBar) statusBar.style.display = (mode === 'satellite') ? 'none' : 'flex';
|
||||
|
||||
// Load interfaces and initialize visualizations when switching modes
|
||||
@@ -2189,6 +2228,13 @@
|
||||
} else if (mode === 'satellite') {
|
||||
initPolarPlot();
|
||||
initSatelliteList();
|
||||
} else if (mode === 'listening') {
|
||||
// Check for incoming tune requests from Spy Stations
|
||||
if (typeof checkIncomingTuneRequest === 'function') {
|
||||
checkIncomingTuneRequest();
|
||||
}
|
||||
} else if (mode === 'spystations') {
|
||||
SpyStations.init();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
83
templates/partials/modes/spy-stations.html
Normal file
83
templates/partials/modes/spy-stations.html
Normal file
@@ -0,0 +1,83 @@
|
||||
<!-- SPY STATIONS MODE -->
|
||||
<div id="spystationsMode" class="mode-content" style="display: none;">
|
||||
<div class="section">
|
||||
<h3>Spy Stations</h3>
|
||||
<p style="color: var(--text-secondary); font-size: 11px; line-height: 1.5; margin-bottom: 15px;">
|
||||
Active number stations and diplomatic HF radio networks. Data sourced from priyom.org.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<h3>Filter by Type</h3>
|
||||
<div style="display: flex; flex-direction: column; gap: 6px;">
|
||||
<label class="inline-checkbox">
|
||||
<input type="checkbox" id="filterTypeNumber" checked onchange="SpyStations.applyFilters()">
|
||||
<span style="color: var(--accent-cyan);">Number Stations</span>
|
||||
</label>
|
||||
<label class="inline-checkbox">
|
||||
<input type="checkbox" id="filterTypeDiplomatic" checked onchange="SpyStations.applyFilters()">
|
||||
<span style="color: var(--accent-green);">Diplomatic Networks</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<h3>Filter by Country</h3>
|
||||
<div id="countryFilters" style="display: flex; flex-direction: column; gap: 4px; max-height: 200px; overflow-y: auto;">
|
||||
<!-- Populated by JavaScript -->
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<h3>Filter by Mode</h3>
|
||||
<div id="modeFilters" style="display: flex; flex-direction: column; gap: 4px; max-height: 150px; overflow-y: auto;">
|
||||
<!-- Populated by JavaScript -->
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<h3>Quick Stats</h3>
|
||||
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 8px;">
|
||||
<div style="background: rgba(0,0,0,0.2); padding: 8px; border-radius: 4px; text-align: center;">
|
||||
<div style="font-size: 18px; font-weight: bold; color: var(--accent-cyan);" id="spyStatsNumber">0</div>
|
||||
<div style="font-size: 9px; color: var(--text-muted);">NUMBER</div>
|
||||
</div>
|
||||
<div style="background: rgba(0,0,0,0.2); padding: 8px; border-radius: 4px; text-align: center;">
|
||||
<div style="font-size: 18px; font-weight: bold; color: var(--accent-green);" id="spyStatsDiplomatic">0</div>
|
||||
<div style="font-size: 9px; color: var(--text-muted);">DIPLOMATIC</div>
|
||||
</div>
|
||||
<div style="background: rgba(0,0,0,0.2); padding: 8px; border-radius: 4px; text-align: center;">
|
||||
<div style="font-size: 18px; font-weight: bold; color: var(--accent-orange);" id="spyStatsCountries">0</div>
|
||||
<div style="font-size: 9px; color: var(--text-muted);">COUNTRIES</div>
|
||||
</div>
|
||||
<div style="background: rgba(0,0,0,0.2); padding: 8px; border-radius: 4px; text-align: center;">
|
||||
<div style="font-size: 18px; font-weight: bold; color: var(--accent-purple);" id="spyStatsFreqs">0</div>
|
||||
<div style="font-size: 9px; color: var(--text-muted);">FREQUENCIES</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<h3>Help</h3>
|
||||
<button class="preset-btn" onclick="SpyStations.showHelp()" style="width: 100%;">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="width: 14px; height: 14px; margin-right: 6px; vertical-align: middle;">
|
||||
<circle cx="12" cy="12" r="10"/>
|
||||
<path d="M9.09 9a3 3 0 0 1 5.83 1c0 2-3 3-3 3"/>
|
||||
<line x1="12" y1="17" x2="12.01" y2="17"/>
|
||||
</svg>
|
||||
About Spy Stations
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<h3>Resources</h3>
|
||||
<div style="display: flex; flex-direction: column; gap: 6px;">
|
||||
<a href="https://priyom.org" target="_blank" rel="noopener" class="preset-btn" style="text-decoration: none; text-align: center;">
|
||||
Priyom.org
|
||||
</a>
|
||||
<a href="https://www.numbers-stations.com" target="_blank" rel="noopener" class="preset-btn" style="text-decoration: none; text-align: center;">
|
||||
Numbers-Stations.com
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
Reference in New Issue
Block a user