From 1b0d39c5b05abdc2667c236e86e4ebbd7f859b13 Mon Sep 17 00:00:00 2001 From: Marc Date: Sat, 24 Jan 2026 04:19:28 -0600 Subject: [PATCH] Adding spy stations aka the number stations including diplomatic stations --- routes/__init__.py | 2 + routes/spy_stations.py | 323 +++++++++++++++ static/css/modes/spy-stations.css | 355 ++++++++++++++++ static/js/modes/listening-post.js | 35 ++ static/js/modes/spy-stations.js | 461 +++++++++++++++++++++ templates/index.html | 56 ++- templates/partials/modes/spy-stations.html | 83 ++++ 7 files changed, 1310 insertions(+), 5 deletions(-) create mode 100644 routes/spy_stations.py create mode 100644 static/css/modes/spy-stations.css create mode 100644 static/js/modes/spy-stations.js create mode 100644 templates/partials/modes/spy-stations.html diff --git a/routes/__init__.py b/routes/__init__.py index 4ad630f..cbdb1ee 100644 --- a/routes/__init__.py +++ b/routes/__init__.py @@ -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 diff --git a/routes/spy_stations.py b/routes/spy_stations.py new file mode 100644 index 0000000..c1ce86f --- /dev/null +++ b/routes/spy_stations.py @@ -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/') +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 + } + }) diff --git a/static/css/modes/spy-stations.css b/static/css/modes/spy-stations.css new file mode 100644 index 0000000..31992b1 --- /dev/null +++ b/static/css/modes/spy-stations.css @@ -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; + } +} diff --git a/static/js/modes/listening-post.js b/static/js/modes/listening-post.js index 2df11bd..ee9b89c 100644 --- a/static/js/modes/listening-post.js +++ b/static/js/modes/listening-post.js @@ -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; diff --git a/static/js/modes/spy-stations.js b/static/js/modes/spy-stations.js new file mode 100644 index 0000000..c8dd05f --- /dev/null +++ b/static/js/modes/spy-stations.js @@ -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 => ` + + `).join(''); + } + + // Populate mode filters + const modeContainer = document.getElementById('modeFilters'); + if (modeContainer) { + modeContainer.innerHTML = modes.map(m => ` + + `).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 = ` +
+ + + + + + + +

No stations match your filters

+
+ `; + 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 ` +
+
+
+ ${flag} + ${station.name} + ${station.nickname ? `- ${station.nickname}` : ''} +
+ ${typeBadgeText} +
+
+
+
+ Origin + ${station.country} +
+
+ Mode + ${station.mode} +
+
+
+ Frequencies + ${freqList}${moreFreqs} +
+
${station.description}
+
+ +
+ `; + } + + /** + * 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 `${formatFrequency(f.freq_khz)}${label}`; + }).join(''); + + modal.innerHTML = ` +
+
+
+

${flag} ${station.name} ${station.nickname ? '- ' + station.nickname : ''}

+ +
+
+
+
Overview
+
+
+ Type + ${station.type === 'number' ? 'Number Station' : 'Diplomatic Network'} +
+
+ Country + ${station.country} +
+
+ Mode + ${station.mode} +
+
+ Operator + ${station.operator || 'Unknown'} +
+
+
+
+
Description
+

${station.description}

+
+
+
Frequencies (${station.frequencies.length})
+
${allFreqs}
+
+ ${station.schedule ? ` +
+
Schedule
+

${station.schedule}

+
+ ` : ''} + ${station.source_url ? ` +
+
Source
+ ${station.source_url} +
+ ` : ''} +
+ +
+ `; + + 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 = ` +
+
+
+

About Spy Stations

+ +
+
+
+
Number Stations
+

+ 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. +

+
+
+
Diplomatic Networks
+

+ 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. +

+
+
+
How to Listen
+

+ 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. +

+
+
+
Best Practices
+
    +
  • HF propagation varies with time of day and solar conditions
  • +
  • Use a long wire or loop antenna for best results
  • +
  • Check schedules on priyom.org for transmission times
  • +
  • Night time generally offers better long-distance reception
  • +
+
+
+
Data Sources
+

+ Station data sourced from priyom.org, + a community-maintained database of number stations and related transmissions. +

+
+
+
+ `; + + 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 +}); diff --git a/templates/index.html b/templates/index.html index 0c2b891..088342f 100644 --- a/templates/index.html +++ b/templates/index.html @@ -21,6 +21,7 @@ + @@ -135,6 +136,11 @@ RTLAMR Utility meters + @@ -280,6 +286,7 @@ +
@@ -343,6 +350,7 @@ + @@ -455,6 +463,8 @@ {% include 'partials/modes/ais.html' %} + {% include 'partials/modes/spy-stations.html' %} +
+ + +