diff --git a/routes/spy_stations.py b/routes/spy_stations.py index c1ce86f..d0446ee 100644 --- a/routes/spy_stations.py +++ b/routes/spy_stations.py @@ -93,6 +93,200 @@ STATIONS = [ "schedule": "Multiple daily transmissions", "source_url": "https://priyom.org/number-stations/cuba/hm01" }, + { + "id": "e07", + "name": "E07", + "nickname": "7-dash", + "type": "number", + "country": "Russia", + "country_code": "RU", + "frequencies": [ + {"freq_khz": 5292, "primary": True}, + {"freq_khz": 6388, "primary": False}, + {"freq_khz": 7482, "primary": False}, + {"freq_khz": 8576, "primary": False}, + ], + "mode": "USB", + "description": "Russian intelligence station using distinctive 7-dash interval signal. Female voice reading 5-figure groups in English. Part of the 'Russian 7' operator network.", + "operator": "Russian 7", + "schedule": "Irregular, typically evenings UTC", + "source_url": "https://priyom.org/number-stations/english/e07" + }, + { + "id": "e11", + "name": "E11", + "nickname": "Mazielka", + "type": "number", + "country": "Poland", + "country_code": "PL", + "frequencies": [ + {"freq_khz": 4030, "primary": True}, + {"freq_khz": 5240, "primary": False}, + {"freq_khz": 6910, "primary": False}, + ], + "mode": "USB", + "description": "Polish intelligence number station. Female voice reads 5-figure groups in English. Named after distinctive melody interval signal.", + "operator": "ABW (Polish Intelligence)", + "schedule": "Weekly transmissions", + "source_url": "https://priyom.org/number-stations/english/e11" + }, + { + "id": "e17z", + "name": "E17z", + "nickname": "Israeli Numbers", + "type": "number", + "country": "Israel", + "country_code": "IL", + "frequencies": [ + {"freq_khz": 4779, "primary": True}, + {"freq_khz": 5091, "primary": False}, + {"freq_khz": 6446, "primary": False}, + ], + "mode": "USB", + "description": "Israeli intelligence number station. Female voice with distinctive Hebrew-accented English. Transmits 5-figure groups with phonetic alphabet.", + "operator": "Mossad (suspected)", + "schedule": "Irregular schedule", + "source_url": "https://priyom.org/number-stations/english/e17z" + }, + { + "id": "g06", + "name": "G06", + "nickname": "Russian German", + "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": "German language mode of Russian 6 operator. Male synthesized voice reads 5-figure groups in German. Shares frequencies with E06/S06.", + "operator": "Russian 6", + "schedule": "Same schedule as E06", + "source_url": "https://priyom.org/number-stations/german/g06" + }, + { + "id": "v02a", + "name": "V02a", + "nickname": "Cuban Spy Numbers", + "type": "number", + "country": "Cuba", + "country_code": "CU", + "frequencies": [ + {"freq_khz": 5855, "primary": True}, + {"freq_khz": 9330, "primary": False}, + {"freq_khz": 11635, "primary": False}, + ], + "mode": "AM", + "description": "Cuban intelligence station using AM mode. Female Spanish voice reading 4-figure groups. Related to HM01 but separate schedule.", + "operator": "DGI (Cuban Intelligence)", + "schedule": "Evening transmissions, weekdays", + "source_url": "https://priyom.org/number-stations/spanish/v02a" + }, + { + "id": "v07", + "name": "V07", + "nickname": "Russian 7 Voice", + "type": "number", + "country": "Russia", + "country_code": "RU", + "frequencies": [ + {"freq_khz": 3756, "primary": True}, + {"freq_khz": 4625, "primary": False}, + ], + "mode": "USB", + "description": "Russian voice number station. Female voice reads 5-figure groups in Russian. Part of Russian 7 operator network. Often shares 4625 kHz with UVB-76.", + "operator": "Russian 7", + "schedule": "Irregular transmissions", + "source_url": "https://priyom.org/number-stations/russian/v07" + }, + { + "id": "s11a", + "name": "S11a", + "nickname": "Russian Phonetic", + "type": "number", + "country": "Russia", + "country_code": "RU", + "frequencies": [ + {"freq_khz": 4560, "primary": True}, + {"freq_khz": 5200, "primary": False}, + ], + "mode": "USB", + "description": "Russian phonetic alphabet number station. Male voice reads 5-letter groups using Russian phonetic alphabet (Anna, Boris, etc.).", + "operator": "GRU (suspected)", + "schedule": "Weekly scheduled transmissions", + "source_url": "https://priyom.org/number-stations/russian/s11a" + }, + { + "id": "v13", + "name": "V13", + "nickname": "The Pip", + "type": "number", + "country": "Russia", + "country_code": "RU", + "frequencies": [ + {"freq_khz": 3756, "primary": True}, + {"freq_khz": 5448, "primary": False}, + ], + "mode": "USB", + "description": "Russian military channel marker known as 'The Pip'. Continuous short beep every 1 second with occasional voice messages. Sister station to UVB-76.", + "operator": "Russian Military", + "schedule": "24/7 continuous operation", + "source_url": "https://priyom.org/military-stations/russia/the-pip" + }, + { + "id": "v24", + "name": "V24", + "nickname": "Air Horn", + "type": "number", + "country": "Russia", + "country_code": "RU", + "frequencies": [ + {"freq_khz": 3243, "primary": True}, + ], + "mode": "USB", + "description": "Russian channel marker known as 'Air Horn' due to distinctive foghorn-like sound. Continuous tone with occasional voice messages in Russian.", + "operator": "Russian Military", + "schedule": "24/7 continuous operation", + "source_url": "https://priyom.org/military-stations/russia/the-air-horn" + }, + { + "id": "vc01", + "name": "VC01", + "nickname": "Chinese Robot", + "type": "number", + "country": "China", + "country_code": "CN", + "frequencies": [ + {"freq_khz": 8300, "primary": True}, + {"freq_khz": 9725, "primary": False}, + {"freq_khz": 11430, "primary": False}, + {"freq_khz": 13750, "primary": False}, + ], + "mode": "AM", + "description": "Chinese intelligence number station. Robotic female voice reading 4-figure groups in Chinese. Distinctive electronic music interval signal.", + "operator": "MSS (Chinese Intelligence)", + "schedule": "Daily transmissions", + "source_url": "https://priyom.org/number-stations/chinese/vc01" + }, + { + "id": "v22", + "name": "V22", + "nickname": "Chinese Lady", + "type": "number", + "country": "China", + "country_code": "CN", + "frequencies": [ + {"freq_khz": 7883, "primary": True}, + {"freq_khz": 9170, "primary": False}, + ], + "mode": "AM", + "description": "Chinese number station using female voice. Reads 4-figure groups in Mandarin Chinese. Often reported in Southeast Asian target areas.", + "operator": "MSS (Chinese Intelligence)", + "schedule": "Evening transmissions UTC", + "source_url": "https://priyom.org/number-stations/chinese/v22" + }, # Diplomatic Stations { "id": "bulgaria_mfa", @@ -261,6 +455,114 @@ STATIONS = [ "schedule": "24/7 global network", "source_url": "https://priyom.org/diplomatic/united-states" }, + { + "id": "morocco_mfa", + "name": "Morocco MFA", + "nickname": "Moroccan Diplomatic", + "type": "diplomatic", + "country": "Morocco", + "country_code": "MA", + "frequencies": [ + {"freq_khz": 8010, "primary": True}, + {"freq_khz": 11205, "primary": False}, + {"freq_khz": 14620, "primary": False}, + ], + "mode": "PACTOR-II/ALE", + "description": "Moroccan Ministry of Foreign Affairs diplomatic network. Links Rabat with embassies in Europe and Africa. Uses PACTOR-II and 2G ALE.", + "operator": "Moroccan MFA", + "schedule": "Daily scheduled traffic", + "source_url": "https://priyom.org/diplomatic/morocco" + }, + { + "id": "poland_mfa", + "name": "Poland MFA", + "nickname": "Polish Diplomatic", + "type": "diplomatic", + "country": "Poland", + "country_code": "PL", + "frequencies": [ + {"freq_khz": 6825, "primary": True}, + {"freq_khz": 9250, "primary": False}, + {"freq_khz": 13485, "primary": False}, + ], + "mode": "STANAG-4285/ALE", + "description": "Polish Ministry of Foreign Affairs HF network. Uses NATO STANAG-4285 modem with 2G ALE linking. Connects Warsaw with global embassies.", + "operator": "Polish MFA", + "schedule": "Regular diplomatic traffic", + "source_url": "https://priyom.org/diplomatic/poland" + }, + { + "id": "france_mfa", + "name": "France MFA", + "nickname": "French Diplomatic", + "type": "diplomatic", + "country": "France", + "country_code": "FR", + "frequencies": [ + {"freq_khz": 6910, "primary": True}, + {"freq_khz": 10640, "primary": False}, + {"freq_khz": 13870, "primary": False}, + {"freq_khz": 16840, "primary": False}, + ], + "mode": "MIL-STD-188-110/ALE", + "description": "French Ministry of Foreign Affairs network. Extensive global coverage with Paris hub. Uses MIL-STD-188-110 with 2G/3G ALE linking protocols.", + "operator": "French MFA", + "schedule": "24/7 network operations", + "source_url": "https://priyom.org/diplomatic/france" + }, + { + "id": "romania_mfa", + "name": "Romania MFA", + "nickname": "Romanian Diplomatic", + "type": "diplomatic", + "country": "Romania", + "country_code": "RO", + "frequencies": [ + {"freq_khz": 5390, "primary": True}, + {"freq_khz": 8158, "primary": False}, + {"freq_khz": 11555, "primary": False}, + ], + "mode": "PACTOR-III/ALE", + "description": "Romanian diplomatic network linking Bucharest with embassies. Uses PACTOR-III for traffic and 2G ALE for channel establishment.", + "operator": "Romanian MFA", + "schedule": "Scheduled daily windows", + "source_url": "https://priyom.org/diplomatic/romania" + }, + { + "id": "algeria_mfa", + "name": "Algeria MFA", + "nickname": "Algerian Diplomatic", + "type": "diplomatic", + "country": "Algeria", + "country_code": "DZ", + "frequencies": [ + {"freq_khz": 7706, "primary": True}, + {"freq_khz": 10235, "primary": False}, + {"freq_khz": 14385, "primary": False}, + ], + "mode": "SITOR-B/PACTOR", + "description": "Algerian Ministry of Foreign Affairs network. Links Algiers with African and European embassies. Uses SITOR-B and PACTOR modes.", + "operator": "Algerian MFA", + "schedule": "Daily scheduled transmissions", + "source_url": "https://priyom.org/diplomatic/algeria" + }, + { + "id": "egypt_mfa_m14a", + "name": "Egypt MFA M14a", + "nickname": "Egyptian Extended", + "type": "diplomatic", + "country": "Egypt", + "country_code": "EG", + "frequencies": [ + {"freq_khz": 12175, "primary": True}, + {"freq_khz": 16360, "primary": False}, + ], + "mode": "Codan 3012/SITOR", + "description": "Extended Egyptian diplomatic network frequencies. Higher frequency allocations for long-distance embassy communications to Asia and Americas.", + "operator": "Egyptian MFA", + "schedule": "Daily traffic windows", + "source_url": "https://priyom.org/diplomatic/egypt" + }, ] diff --git a/static/css/ais_dashboard.css b/static/css/ais_dashboard.css index 4382bd5..cf17513 100644 --- a/static/css/ais_dashboard.css +++ b/static/css/ais_dashboard.css @@ -208,6 +208,38 @@ body { color: var(--accent-green); } +/* Signal quality states */ +.strip-stat.signal-stat .strip-value { + letter-spacing: 2px; +} + +.strip-stat.signal-stat.good { + background: rgba(34, 197, 94, 0.1); + border-color: rgba(34, 197, 94, 0.3); +} + +.strip-stat.signal-stat.good .strip-value { + color: var(--accent-green); +} + +.strip-stat.signal-stat.warning { + background: rgba(245, 158, 11, 0.1); + border-color: rgba(245, 158, 11, 0.3); +} + +.strip-stat.signal-stat.warning .strip-value { + color: var(--accent-orange); +} + +.strip-stat.signal-stat.poor { + background: rgba(239, 68, 68, 0.1); + border-color: rgba(239, 68, 68, 0.3); +} + +.strip-stat.signal-stat.poor .strip-value { + color: var(--accent-red); +} + .strip-divider { width: 1px; height: 24px; diff --git a/static/css/modes/spy-stations.css b/static/css/modes/spy-stations.css index 31992b1..d488ecb 100644 --- a/static/css/modes/spy-stations.css +++ b/static/css/modes/spy-stations.css @@ -11,8 +11,9 @@ flex-direction: column; gap: 16px; padding: 16px; - height: 100%; - overflow: hidden; + min-height: 0; + flex: 1; + overflow-y: auto; } .spy-stations-header { @@ -54,9 +55,8 @@ display: grid; grid-template-columns: repeat(auto-fill, minmax(320px, 1fr)); gap: 12px; - overflow-y: auto; - flex: 1; padding: 4px; + padding-bottom: 20px; } /* ============================================ @@ -66,8 +66,9 @@ background: var(--bg-card); border: 1px solid var(--border-color); border-radius: 8px; - overflow: hidden; transition: all 0.2s ease; + display: flex; + flex-direction: column; } .spy-station-card:hover { @@ -144,6 +145,7 @@ display: flex; flex-direction: column; gap: 12px; + flex: 1; } .spy-station-meta { @@ -211,10 +213,6 @@ 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 */ @@ -225,6 +223,49 @@ padding: 10px 14px; background: rgba(0, 0, 0, 0.1); border-top: 1px solid var(--border-color); + flex-shrink: 0; + margin-top: auto; +} + +/* Frequency Selector Group */ +.spy-tune-group { + display: flex; + align-items: center; + gap: 6px; + flex: 1; +} + +.spy-freq-select { + font-family: 'JetBrains Mono', monospace; + font-size: 10px; + padding: 6px 8px; + background: var(--bg-secondary); + border: 1px solid var(--border-color); + border-radius: 4px; + color: var(--text-primary); + min-width: 120px; + cursor: pointer; +} + +.spy-freq-select:hover { + border-color: var(--border-light); +} + +.spy-freq-select:focus { + outline: none; + border-color: var(--accent-cyan); +} + +/* Clickable frequency items in details modal */ +.spy-freq-clickable { + cursor: pointer; + transition: all 0.15s ease; +} + +.spy-freq-clickable:hover { + background: var(--accent-cyan); + color: #000; + border-color: var(--accent-cyan); } /* Tune Button */ @@ -295,6 +336,13 @@ margin-top: 8px; } +/* ============================================ + MODE VISIBILITY - Ensure sidebar shows when active + ============================================ */ +#spystationsMode.active { + display: block !important; +} + /* ============================================ FILTER CHECKBOX STYLING ============================================ */ @@ -321,6 +369,22 @@ /* ============================================ RESPONSIVE ============================================ */ + +/* Large desktop (1200px+) */ +@media (min-width: 1200px) { + .spy-stations-grid { + grid-template-columns: repeat(auto-fill, minmax(350px, 1fr)); + } +} + +/* Desktop/Tablet landscape (1024px) */ +@media (max-width: 1024px) { + .spy-stations-grid { + grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); + } +} + +/* Tablet portrait (768px) */ @media (max-width: 768px) { .spy-stations-grid { grid-template-columns: 1fr; @@ -341,7 +405,8 @@ } } -@media (max-width: 480px) { +/* Small tablet / large phone (640px) */ +@media (max-width: 640px) { .spy-station-footer { flex-direction: column; gap: 8px; @@ -351,5 +416,51 @@ .spy-details-btn { width: 100%; justify-content: center; + min-height: 44px; + } + + .spy-tune-group { + width: 100%; + flex-direction: column; + } + + .spy-freq-select { + width: 100%; + min-height: 44px; + } +} + +/* Mobile (480px) */ +@media (max-width: 480px) { + .spy-stations-container { + padding: 8px; + } + + .spy-station-body { + padding: 10px; + } + + .spy-stations-header { + flex-direction: column; + align-items: flex-start; + gap: 8px; + padding: 10px 12px; + } + + .spy-station-desc { + -webkit-line-clamp: 2; + } +} + +/* Touch device compliance */ +@media (pointer: coarse) { + .spy-tune-btn, + .spy-details-btn, + .spy-freq-select { + min-height: 44px; + } + + .spy-freq-clickable { + padding: 8px 12px; } } diff --git a/static/js/modes/spy-stations.js b/static/js/modes/spy-stations.js index c8dd05f..8f65fa2 100644 --- a/static/js/modes/spy-stations.js +++ b/static/js/modes/spy-stations.js @@ -22,7 +22,14 @@ const SpyStations = (function() { 'EG': '\u{1F1EA}\u{1F1EC}', 'KP': '\u{1F1F0}\u{1F1F5}', 'TN': '\u{1F1F9}\u{1F1F3}', - 'US': '\u{1F1FA}\u{1F1F8}' + 'US': '\u{1F1FA}\u{1F1F8}', + 'PL': '\u{1F1F5}\u{1F1F1}', + 'IL': '\u{1F1EE}\u{1F1F1}', + 'CN': '\u{1F1E8}\u{1F1F3}', + 'MA': '\u{1F1F2}\u{1F1E6}', + 'FR': '\u{1F1EB}\u{1F1F7}', + 'RO': '\u{1F1F7}\u{1F1F4}', + 'DZ': '\u{1F1E9}\u{1F1FF}' }; /** @@ -121,6 +128,7 @@ const SpyStations = (function() { }); renderStations(); + updateStats(true); } /** @@ -161,6 +169,39 @@ const SpyStations = (function() { 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` : ''; + // Build tune button with frequency selector if multiple frequencies + let tuneSection; + if (station.frequencies.length > 1) { + const options = station.frequencies.map(f => { + const label = formatFrequency(f.freq_khz) + (f.primary ? ' (primary)' : ''); + return ``; + }).join(''); + tuneSection = ` +
+ + +
+ `; + } else { + tuneSection = ` + + `; + } + return `
@@ -189,13 +230,7 @@ const SpyStations = (function() {
${station.description}
+
+ -- + SIGNAL +
00:00:00 SESSION @@ -155,9 +159,16 @@ maxRange: 0, fastestSpeed: 0, closestDistance: Infinity, - sessionStart: null + sessionStart: null, + messagesReceived: 0, + messagesPerSecond: 0 }; + // Session timer + let sessionTimerInterval = null; + let messageRateInterval = null; + let lastMessageCount = 0; + // Ship type to icon mapping const SHIP_ICONS = { 30: '🐟', // Fishing @@ -374,11 +385,11 @@ .then(data => { if (data.status === 'started' || data.status === 'already_running') { isTracking = true; - stats.sessionStart = Date.now(); document.getElementById('startBtn').textContent = 'STOP'; document.getElementById('startBtn').classList.add('active'); document.getElementById('trackingDot').classList.add('active'); document.getElementById('trackingStatus').textContent = 'TRACKING'; + startSessionTimer(); startSSE(); } else { alert(data.message || 'Failed to start'); @@ -396,6 +407,8 @@ document.getElementById('startBtn').classList.remove('active'); document.getElementById('trackingDot').classList.remove('active'); document.getElementById('trackingStatus').textContent = 'STANDBY'; + stopSessionTimer(); + updateSignalQuality(); if (eventSource) { eventSource.close(); eventSource = null; @@ -429,6 +442,7 @@ vessels[mmsi] = data; stats.totalVesselsSeen.add(mmsi); + stats.messagesReceived++; // Update statistics if (data.speed && data.speed > stats.fastestSpeed) { @@ -630,15 +644,6 @@ document.getElementById('stripMaxRange').textContent = stats.maxRange.toFixed(1); document.getElementById('stripFastest').textContent = stats.fastestSpeed > 0 ? stats.fastestSpeed.toFixed(1) : '-'; document.getElementById('stripClosest').textContent = stats.closestDistance < Infinity ? stats.closestDistance.toFixed(1) : '-'; - - if (stats.sessionStart) { - const elapsed = Math.floor((Date.now() - stats.sessionStart) / 1000); - const h = Math.floor(elapsed / 3600); - const m = Math.floor((elapsed % 3600) / 60); - const s = elapsed % 60; - document.getElementById('stripSession').textContent = - `${h.toString().padStart(2, '0')}:${m.toString().padStart(2, '0')}:${s.toString().padStart(2, '0')}`; - } } function cleanupStaleVessels() { @@ -682,6 +687,77 @@ document.getElementById('utcTime').textContent = utc + ' UTC'; } + // Session timer functions + function startSessionTimer() { + if (!stats.sessionStart) { + stats.sessionStart = Date.now(); + } + if (sessionTimerInterval) clearInterval(sessionTimerInterval); + sessionTimerInterval = setInterval(updateSessionTimer, 1000); + + // Start message rate tracking + if (messageRateInterval) clearInterval(messageRateInterval); + lastMessageCount = stats.messagesReceived; + messageRateInterval = setInterval(updateMessageRate, 1000); + } + + function stopSessionTimer() { + if (sessionTimerInterval) { + clearInterval(sessionTimerInterval); + sessionTimerInterval = null; + } + if (messageRateInterval) { + clearInterval(messageRateInterval); + messageRateInterval = null; + } + } + + function updateSessionTimer() { + if (!stats.sessionStart) return; + const elapsed = Date.now() - stats.sessionStart; + const hours = Math.floor(elapsed / 3600000); + const mins = Math.floor((elapsed % 3600000) / 60000); + const secs = Math.floor((elapsed % 60000) / 1000); + document.getElementById('stripSession').textContent = + `${hours.toString().padStart(2,'0')}:${mins.toString().padStart(2,'0')}:${secs.toString().padStart(2,'0')}`; + } + + function updateMessageRate() { + const currentCount = stats.messagesReceived; + stats.messagesPerSecond = currentCount - lastMessageCount; + lastMessageCount = currentCount; + updateSignalQuality(); + } + + // Signal quality display + function updateSignalQuality() { + const msgRate = stats.messagesPerSecond; + const el = document.getElementById('stripSignal'); + const stat = el.closest('.strip-stat'); + + if (!isTracking || msgRate === 0) { + el.textContent = '--'; + stat.classList.remove('good', 'warning', 'poor'); + return; + } + + // Signal quality based on message rate + // Good: >5 msg/s, Warning: 1-5, Poor: <1 + if (msgRate >= 5) { + el.textContent = '●●●'; + stat.classList.remove('warning', 'poor'); + stat.classList.add('good'); + } else if (msgRate >= 1) { + el.textContent = '●●○'; + stat.classList.remove('good', 'poor'); + stat.classList.add('warning'); + } else { + el.textContent = '●○○'; + stat.classList.remove('good', 'warning'); + stat.classList.add('poor'); + } + } + // Initialize document.addEventListener('DOMContentLoaded', initMap); diff --git a/templates/partials/modes/spy-stations.html b/templates/partials/modes/spy-stations.html index ce9e315..666c68c 100644 --- a/templates/partials/modes/spy-stations.html +++ b/templates/partials/modes/spy-stations.html @@ -1,5 +1,5 @@ -