diff --git a/.claude/CLAUDE.md b/.claude/CLAUDE.md new file mode 100644 index 0000000..6e58a0f --- /dev/null +++ b/.claude/CLAUDE.md @@ -0,0 +1,42 @@ +## Workflow Orchestration +### 1. Plan Node Default +- Enter plan mode for ANY non-trivial task (3+ steps or architectural decisions) +- If something goes sideways, STOP and re-plan immediately - don't keep pushing +- Use plan mode for verification steps, not just building +- Write detailed specs upfront to reduce ambiguity +### 2. Subagent Strategy +- Use subagents liberally to keep main context window clean +- Offload research, exploration, and parallel analysis to subagents +- For complex problems, throw more compute at it via subagents +- One tack per subagent for focused execution +### 3. Self-Improvement Loop +- After ANY correction from the user: update 'tasks/lessons.md" with the pattern +- Write rules for yourself that prevent the same mistake +- Ruthlessly iterate on these lessons until mistake rate drops +- Review lessons at session start for relevant project +### 4. Verification Before Done +- Never mark a task complete without proving it works +- Diff behavior between main and your changes when relevant +- Ask yourself: "Would a staff engineer approve this?" +- Run tests, check logs, demonstrate correctness +### 5. Demand Elegance (Balanced) +- For non-trivial changes: pause and ask "is there a more elegant way?" +- If a fix feels hacky: "Knowing everything I know now, implement the elegant solution" +- Skip this for simple, obvious fixes - don't over-engineer +-Challenge your own work before presenting it +### 6. Autonomous Bug Fizing +- When given a bug report: just fix it. Don't ask for hand-holding +- Point at logs, errors, failing tests - then resolve them +- Zero context switching required from the user +- Go fix failing CI tests without being told how +## Task Management +1. **Plan First**: Write plan to "tasks/todo.md" with checkable items +2. **Verify Plan**: Check in before starting implementation +3. **Track Progress**: Mark items complete as you go +4. **Explain Changes**: High-level summary at each step +5. **Document Results**: Add review section to 'tasks/todo.md" +6. **Capture Lessons**: Update 'tasks/lessons.md' after corrections +## Core Principles +- **Simplicity First**: Make every change as simple as possible. Impact minimal code. +- **No Laziness**: Find root causes. No temporary fixes. Senior developer standards. +- **Minimat Impact**: Changes should only touch what's necessary. Avoid introducing bugs. diff --git a/app.py b/app.py index 0754fff..1584e83 100644 --- a/app.py +++ b/app.py @@ -198,6 +198,11 @@ tscm_lock = threading.Lock() subghz_queue = queue.Queue(maxsize=QUEUE_MAX_SIZE) subghz_lock = threading.Lock() +# CW/Morse code decoder +morse_process = None +morse_queue = queue.Queue(maxsize=QUEUE_MAX_SIZE) +morse_lock = threading.Lock() + # Deauth Attack Detection deauth_detector = None deauth_detector_queue = queue.Queue(maxsize=QUEUE_MAX_SIZE) @@ -755,6 +760,7 @@ def health_check() -> Response: 'wifi': wifi_active, 'bluetooth': bt_active, 'dsc': dsc_process is not None and (dsc_process.poll() is None if dsc_process else False), + 'morse': morse_process is not None and (morse_process.poll() is None if morse_process else False), 'subghz': _get_subghz_active(), }, 'data': { @@ -772,7 +778,7 @@ def health_check() -> Response: def kill_all() -> Response: """Kill all decoder, WiFi, and Bluetooth processes.""" global current_process, sensor_process, wifi_process, adsb_process, ais_process, acars_process - global vdl2_process + global vdl2_process, morse_process global aprs_process, aprs_rtl_process, dsc_process, dsc_rtl_process, bt_process # Import adsb and ais modules to reset their state @@ -825,6 +831,10 @@ def kill_all() -> Response: with vdl2_lock: vdl2_process = None + # Reset Morse state + with morse_lock: + morse_process = None + # Reset APRS state with aprs_lock: aprs_process = None diff --git a/config.py b/config.py index 89d383b..76635b8 100644 --- a/config.py +++ b/config.py @@ -331,12 +331,20 @@ SATELLITE_ORBIT_MINUTES = _get_env_int('SATELLITE_ORBIT_MINUTES', 45) # Weather satellite settings WEATHER_SAT_DEFAULT_GAIN = _get_env_float('WEATHER_SAT_GAIN', 40.0) -WEATHER_SAT_SAMPLE_RATE = _get_env_int('WEATHER_SAT_SAMPLE_RATE', 1000000) +WEATHER_SAT_SAMPLE_RATE = _get_env_int('WEATHER_SAT_SAMPLE_RATE', 2400000) WEATHER_SAT_MIN_ELEVATION = _get_env_float('WEATHER_SAT_MIN_ELEVATION', 15.0) WEATHER_SAT_PREDICTION_HOURS = _get_env_int('WEATHER_SAT_PREDICTION_HOURS', 24) WEATHER_SAT_SCHEDULE_REFRESH_MINUTES = _get_env_int('WEATHER_SAT_SCHEDULE_REFRESH_MINUTES', 30) WEATHER_SAT_CAPTURE_BUFFER_SECONDS = _get_env_int('WEATHER_SAT_CAPTURE_BUFFER_SECONDS', 30) +# WeFax (Weather Fax) settings +WEFAX_DEFAULT_GAIN = _get_env_float('WEFAX_GAIN', 40.0) +WEFAX_SAMPLE_RATE = _get_env_int('WEFAX_SAMPLE_RATE', 22050) +WEFAX_DEFAULT_IOC = _get_env_int('WEFAX_IOC', 576) +WEFAX_DEFAULT_LPM = _get_env_int('WEFAX_LPM', 120) +WEFAX_SCHEDULE_REFRESH_MINUTES = _get_env_int('WEFAX_SCHEDULE_REFRESH_MINUTES', 30) +WEFAX_CAPTURE_BUFFER_SECONDS = _get_env_int('WEFAX_CAPTURE_BUFFER_SECONDS', 30) + # SubGHz transceiver settings (HackRF) SUBGHZ_DEFAULT_FREQUENCY = _get_env_float('SUBGHZ_FREQUENCY', 433.92) SUBGHZ_DEFAULT_SAMPLE_RATE = _get_env_int('SUBGHZ_SAMPLE_RATE', 2000000) diff --git a/data/wefax_stations.json b/data/wefax_stations.json new file mode 100644 index 0000000..9c72f98 --- /dev/null +++ b/data/wefax_stations.json @@ -0,0 +1,733 @@ +{ + "stations": [ + { + "name": "USCG Kodiak", + "callsign": "NOJ", + "country": "US", + "city": "Kodiak, AK", + "coordinates": [57.78, -152.50], + "frequencies": [ + {"khz": 2054, "description": "Night"}, + {"khz": 4298, "description": "Primary"}, + {"khz": 8459, "description": "Day"}, + {"khz": 12412.5, "description": "Extended"} + ], + "ioc": 576, + "lpm": 120, + "schedule": [ + {"utc": "03:40", "duration_min": 148, "content": "Chart Series 1"}, + {"utc": "09:50", "duration_min": 138, "content": "Chart Series 2"}, + {"utc": "15:40", "duration_min": 148, "content": "Chart Series 3"}, + {"utc": "21:50", "duration_min": 98, "content": "Chart Series 4"} + ] + }, + { + "name": "USCG Boston", + "callsign": "NMF", + "country": "US", + "city": "Boston, MA", + "coordinates": [42.36, -71.04], + "frequencies": [ + {"khz": 4235, "description": "Night"}, + {"khz": 6340.5, "description": "Primary"}, + {"khz": 9110, "description": "Day"}, + {"khz": 12750, "description": "Extended"} + ], + "ioc": 576, + "lpm": 120, + "schedule": [ + {"utc": "00:00", "duration_min": 20, "content": "Surface Analysis"}, + {"utc": "02:30", "duration_min": 20, "content": "Wind/Wave Analysis"}, + {"utc": "04:38", "duration_min": 20, "content": "Sea State Analysis"}, + {"utc": "06:00", "duration_min": 20, "content": "Surface Analysis"}, + {"utc": "09:30", "duration_min": 20, "content": "48-Hour Surface Prog"}, + {"utc": "12:00", "duration_min": 20, "content": "Surface Analysis"}, + {"utc": "14:00", "duration_min": 20, "content": "24-Hour Surface Prog"}, + {"utc": "16:00", "duration_min": 20, "content": "Sea State Analysis"}, + {"utc": "18:10", "duration_min": 20, "content": "Surface Analysis"}, + {"utc": "22:00", "duration_min": 20, "content": "Satellite Image"} + ] + }, + { + "name": "USCG New Orleans", + "callsign": "NMG", + "country": "US", + "city": "New Orleans, LA", + "coordinates": [29.95, -90.07], + "frequencies": [ + {"khz": 4317.9, "description": "Night"}, + {"khz": 8503.9, "description": "Primary"}, + {"khz": 12789.9, "description": "Day"}, + {"khz": 17146.4, "description": "Extended"} + ], + "ioc": 576, + "lpm": 120, + "schedule": [ + {"utc": "00:00", "duration_min": 20, "content": "Surface Analysis"}, + {"utc": "03:00", "duration_min": 20, "content": "24-Hour Surface Prog"}, + {"utc": "06:00", "duration_min": 20, "content": "Surface Analysis"}, + {"utc": "09:00", "duration_min": 20, "content": "Sea State Analysis"}, + {"utc": "12:00", "duration_min": 20, "content": "Surface Analysis"}, + {"utc": "15:00", "duration_min": 20, "content": "48-Hour Surface Prog"}, + {"utc": "18:00", "duration_min": 20, "content": "Surface Analysis"}, + {"utc": "21:00", "duration_min": 20, "content": "Tropical Analysis"} + ] + }, + { + "name": "USCG Pt. Reyes", + "callsign": "NMC", + "country": "US", + "city": "Pt. Reyes, CA", + "coordinates": [38.07, -122.97], + "frequencies": [ + {"khz": 4346, "description": "Night"}, + {"khz": 8682, "description": "Primary"}, + {"khz": 12786, "description": "Day"}, + {"khz": 17151.2, "description": "Extended"}, + {"khz": 22527, "description": "DX"} + ], + "ioc": 576, + "lpm": 120, + "schedule": [ + {"utc": "00:00", "duration_min": 20, "content": "Surface Analysis"}, + {"utc": "01:40", "duration_min": 20, "content": "Wind/Wave Analysis"}, + {"utc": "06:55", "duration_min": 20, "content": "Surface Analysis"}, + {"utc": "11:20", "duration_min": 20, "content": "48-Hour Surface Prog"}, + {"utc": "14:00", "duration_min": 20, "content": "Surface Analysis"}, + {"utc": "18:40", "duration_min": 20, "content": "Sea State Analysis"}, + {"utc": "23:20", "duration_min": 20, "content": "Satellite Image"} + ] + }, + { + "name": "USCG Honolulu", + "callsign": "KVM70", + "country": "US", + "city": "Honolulu, HI", + "coordinates": [21.31, -157.86], + "frequencies": [ + {"khz": 9982.5, "description": "Primary"}, + {"khz": 11090, "description": "Day"}, + {"khz": 16135, "description": "Extended"} + ], + "ioc": 576, + "lpm": 120, + "schedule": [ + {"utc": "00:00", "duration_min": 20, "content": "Surface Analysis"}, + {"utc": "05:19", "duration_min": 20, "content": "Surface Prog"}, + {"utc": "12:00", "duration_min": 20, "content": "Surface Analysis"}, + {"utc": "17:19", "duration_min": 20, "content": "Sea State Analysis"} + ] + }, + { + "name": "RN Northwood", + "callsign": "GYA", + "country": "GB", + "city": "Northwood, London", + "coordinates": [51.63, -0.42], + "frequencies": [ + {"khz": 2618.5, "description": "Night"}, + {"khz": 3280.5, "description": "Night Alt"}, + {"khz": 4610, "description": "Primary"}, + {"khz": 6834, "description": "Day Alt"}, + {"khz": 8040, "description": "Day"}, + {"khz": 11086.5, "description": "Extended"}, + {"khz": 12390, "description": "Persian Gulf"}, + {"khz": 18261, "description": "DX"} + ], + "ioc": 576, + "lpm": 120, + "schedule": [ + {"utc": "00:00", "duration_min": 20, "content": "Surface Analysis"}, + {"utc": "03:30", "duration_min": 20, "content": "24-Hour Surface Prog"}, + {"utc": "06:00", "duration_min": 20, "content": "Surface Analysis"}, + {"utc": "08:00", "duration_min": 20, "content": "Sea State Forecast"}, + {"utc": "09:30", "duration_min": 20, "content": "Extended Forecast"}, + {"utc": "12:00", "duration_min": 20, "content": "Surface Analysis"}, + {"utc": "15:30", "duration_min": 20, "content": "48-Hour Surface Prog"}, + {"utc": "18:00", "duration_min": 20, "content": "Surface Analysis"}, + {"utc": "19:00", "duration_min": 20, "content": "Wave Period Forecast"}, + {"utc": "21:30", "duration_min": 20, "content": "Extended Forecast"} + ] + }, + { + "name": "DWD Hamburg/Pinneberg", + "callsign": "DDH", + "country": "DE", + "city": "Pinneberg", + "coordinates": [53.66, 9.80], + "frequencies": [ + {"khz": 3855, "description": "Night (DDH3, 10kW)"}, + {"khz": 7880, "description": "Primary (DDK3, 20kW)"}, + {"khz": 13882.5, "description": "Day (DDK6, 20kW)"} + ], + "ioc": 576, + "lpm": 120, + "schedule": [ + {"utc": "04:30", "duration_min": 20, "content": "Surface Analysis N. Atlantic"}, + {"utc": "07:15", "duration_min": 20, "content": "Surface Prog"}, + {"utc": "09:30", "duration_min": 20, "content": "Surface Analysis Europe"}, + {"utc": "10:07", "duration_min": 20, "content": "Sea State North Sea"}, + {"utc": "13:00", "duration_min": 20, "content": "Surface Analysis"}, + {"utc": "15:20", "duration_min": 20, "content": "Extended Prog"}, + {"utc": "15:40", "duration_min": 20, "content": "Sea Ice Chart"}, + {"utc": "16:30", "duration_min": 20, "content": "Surface Analysis"}, + {"utc": "21:00", "duration_min": 20, "content": "Surface Analysis"}, + {"utc": "21:15", "duration_min": 20, "content": "Surface Prog"} + ] + }, + { + "name": "JMA Tokyo", + "callsign": "JMH", + "country": "JP", + "city": "Tokyo", + "coordinates": [35.69, 139.69], + "frequencies": [ + {"khz": 3622.5, "description": "Night"}, + {"khz": 7795, "description": "Primary"}, + {"khz": 13988.5, "description": "Day"} + ], + "ioc": 576, + "lpm": 120, + "schedule": [ + {"utc": "00:00", "duration_min": 20, "content": "Surface Analysis"}, + {"utc": "01:30", "duration_min": 20, "content": "24-Hour Prog"}, + {"utc": "03:00", "duration_min": 20, "content": "Satellite Image"}, + {"utc": "06:00", "duration_min": 20, "content": "Surface Analysis"}, + {"utc": "07:30", "duration_min": 20, "content": "Wave Analysis"}, + {"utc": "09:00", "duration_min": 20, "content": "Satellite Image"}, + {"utc": "10:19", "duration_min": 20, "content": "Tropical Cyclone Info"}, + {"utc": "12:00", "duration_min": 20, "content": "Surface Analysis"}, + {"utc": "15:00", "duration_min": 20, "content": "Satellite Image"}, + {"utc": "18:00", "duration_min": 20, "content": "Surface Analysis"}, + {"utc": "21:00", "duration_min": 20, "content": "48-Hour Prog"} + ] + }, + { + "name": "Kyodo News Tokyo", + "callsign": "JJC", + "country": "JP", + "city": "Tokyo", + "coordinates": [35.69, 139.69], + "frequencies": [ + {"khz": 4316, "description": "Night"}, + {"khz": 8467.5, "description": "Primary"}, + {"khz": 12745.5, "description": "Day"}, + {"khz": 16971, "description": "Extended"}, + {"khz": 17069.6, "description": "DX"}, + {"khz": 22542, "description": "DX 2"} + ], + "ioc": 576, + "lpm": 120, + "schedule": [ + {"utc": "00:00", "duration_min": 20, "content": "Press Photo/News Fax"}, + {"utc": "04:00", "duration_min": 20, "content": "Press Photo/News Fax"}, + {"utc": "08:00", "duration_min": 20, "content": "Press Photo/News Fax"}, + {"utc": "12:00", "duration_min": 20, "content": "Press Photo/News Fax"}, + {"utc": "16:00", "duration_min": 20, "content": "Press Photo/News Fax"}, + {"utc": "20:00", "duration_min": 20, "content": "Press Photo/News Fax"} + ] + }, + { + "name": "Kagoshima Fisheries", + "callsign": "JFX", + "country": "JP", + "city": "Kagoshima", + "coordinates": [31.60, 130.56], + "frequencies": [ + {"khz": 4274, "description": "Night"}, + {"khz": 8658, "description": "Primary"}, + {"khz": 13074, "description": "Day"}, + {"khz": 16907.5, "description": "Extended"}, + {"khz": 22559.6, "description": "DX"} + ], + "ioc": 576, + "lpm": 120, + "schedule": [ + {"utc": "00:00", "duration_min": 20, "content": "Sea Surface Temp"}, + {"utc": "04:00", "duration_min": 20, "content": "Fishing Forecast"}, + {"utc": "08:00", "duration_min": 20, "content": "Sea Surface Temp"}, + {"utc": "12:00", "duration_min": 20, "content": "Current Chart"}, + {"utc": "16:00", "duration_min": 20, "content": "Fishing Forecast"}, + {"utc": "20:00", "duration_min": 20, "content": "Sea Surface Temp"} + ] + }, + { + "name": "KMA Seoul", + "callsign": "HLL2", + "country": "KR", + "city": "Seoul", + "coordinates": [37.57, 126.98], + "frequencies": [ + {"khz": 3585, "description": "Night"}, + {"khz": 5857.5, "description": "Primary"}, + {"khz": 7433.5, "description": "Day"}, + {"khz": 9165, "description": "Extended"}, + {"khz": 13570, "description": "DX"} + ], + "ioc": 576, + "lpm": 120, + "schedule": [ + {"utc": "00:00", "duration_min": 20, "content": "Surface Analysis"}, + {"utc": "03:00", "duration_min": 20, "content": "24-Hour Prog"}, + {"utc": "06:00", "duration_min": 20, "content": "Surface Analysis"}, + {"utc": "09:00", "duration_min": 20, "content": "Satellite Image"}, + {"utc": "12:00", "duration_min": 20, "content": "Surface Analysis"}, + {"utc": "15:00", "duration_min": 20, "content": "Sea State Analysis"}, + {"utc": "18:00", "duration_min": 20, "content": "Surface Analysis"}, + {"utc": "21:00", "duration_min": 20, "content": "48-Hour Prog"} + ] + }, + { + "name": "Taipei Met", + "callsign": "BMF", + "country": "TW", + "city": "Taipei", + "coordinates": [25.03, 121.57], + "frequencies": [ + {"khz": 4616, "description": "Primary"}, + {"khz": 8140, "description": "Day"}, + {"khz": 13900, "description": "Extended"} + ], + "ioc": 576, + "lpm": 120, + "schedule": [ + {"utc": "00:00", "duration_min": 20, "content": "Surface Analysis"}, + {"utc": "06:00", "duration_min": 20, "content": "Surface Analysis"}, + {"utc": "12:00", "duration_min": 20, "content": "Surface Analysis"}, + {"utc": "18:00", "duration_min": 20, "content": "Surface Analysis"} + ] + }, + { + "name": "Bangkok Met", + "callsign": "HSW64", + "country": "TH", + "city": "Bangkok", + "coordinates": [13.76, 100.50], + "frequencies": [ + {"khz": 7396.8, "description": "Primary"} + ], + "ioc": 576, + "lpm": 120, + "schedule": [ + {"utc": "00:00", "duration_min": 20, "content": "Surface Analysis"}, + {"utc": "06:00", "duration_min": 20, "content": "Surface Analysis"}, + {"utc": "12:00", "duration_min": 20, "content": "Surface Analysis"}, + {"utc": "18:00", "duration_min": 20, "content": "Surface Analysis"} + ] + }, + { + "name": "Shanghai Met", + "callsign": "XSG", + "country": "CN", + "city": "Shanghai", + "coordinates": [31.23, 121.47], + "frequencies": [ + {"khz": 4170, "description": "Night"}, + {"khz": 8302, "description": "Primary"}, + {"khz": 12382, "description": "Day"}, + {"khz": 16559, "description": "Extended"} + ], + "ioc": 576, + "lpm": 120, + "schedule": [ + {"utc": "00:00", "duration_min": 20, "content": "Surface Analysis"}, + {"utc": "06:00", "duration_min": 20, "content": "Surface Analysis"}, + {"utc": "12:00", "duration_min": 20, "content": "Surface Analysis"}, + {"utc": "18:00", "duration_min": 20, "content": "Surface Analysis"} + ] + }, + { + "name": "Guangzhou Radio", + "callsign": "XSQ", + "country": "CN", + "city": "Guangzhou", + "coordinates": [23.13, 113.26], + "frequencies": [ + {"khz": 4199.8, "description": "Night"}, + {"khz": 8412.5, "description": "Primary"}, + {"khz": 12629.3, "description": "Day"}, + {"khz": 16826.3, "description": "Extended"} + ], + "ioc": 576, + "lpm": 120, + "schedule": [ + {"utc": "00:00", "duration_min": 20, "content": "Surface Analysis"}, + {"utc": "06:00", "duration_min": 20, "content": "Surface Analysis"}, + {"utc": "12:00", "duration_min": 20, "content": "Surface Analysis"}, + {"utc": "18:00", "duration_min": 20, "content": "Surface Analysis"} + ] + }, + { + "name": "Singapore Met", + "callsign": "9VF", + "country": "SG", + "city": "Singapore", + "coordinates": [1.35, 103.82], + "frequencies": [ + {"khz": 16035, "description": "Primary"}, + {"khz": 17430, "description": "Alternate"} + ], + "ioc": 576, + "lpm": 120, + "schedule": [ + {"utc": "00:00", "duration_min": 20, "content": "Surface Analysis"}, + {"utc": "06:00", "duration_min": 20, "content": "Surface Analysis"}, + {"utc": "12:00", "duration_min": 20, "content": "Surface Analysis"}, + {"utc": "18:00", "duration_min": 20, "content": "Surface Analysis"} + ] + }, + { + "name": "New Delhi Met", + "callsign": "ATP", + "country": "IN", + "city": "New Delhi", + "coordinates": [28.61, 77.21], + "frequencies": [ + {"khz": 7405, "description": "Night"}, + {"khz": 14842, "description": "Day"} + ], + "ioc": 576, + "lpm": 120, + "schedule": [ + {"utc": "00:00", "duration_min": 20, "content": "Surface Analysis"}, + {"utc": "06:00", "duration_min": 20, "content": "Surface Analysis"}, + {"utc": "12:00", "duration_min": 20, "content": "Surface Analysis"}, + {"utc": "18:00", "duration_min": 20, "content": "Surface Analysis"} + ] + }, + { + "name": "Murmansk Met", + "callsign": "RBW", + "country": "RU", + "city": "Murmansk", + "coordinates": [68.97, 33.09], + "frequencies": [ + {"khz": 6445.5, "description": "Night"}, + {"khz": 7907, "description": "Primary"}, + {"khz": 8444, "description": "Day"} + ], + "ioc": 576, + "lpm": 120, + "schedule": [ + {"utc": "07:00", "duration_min": 20, "content": "Surface Analysis"}, + {"utc": "08:00", "duration_min": 20, "content": "Ice Chart"}, + {"utc": "14:00", "duration_min": 20, "content": "Surface Analysis"}, + {"utc": "14:30", "duration_min": 20, "content": "Surface Prog"}, + {"utc": "20:00", "duration_min": 20, "content": "Surface Analysis"} + ] + }, + { + "name": "St. Petersburg Met", + "callsign": "RDD78", + "country": "RU", + "city": "St. Petersburg", + "coordinates": [59.93, 30.32], + "frequencies": [ + {"khz": 2640, "description": "Night"}, + {"khz": 4212, "description": "Primary"} + ], + "ioc": 576, + "lpm": 120, + "schedule": [ + {"utc": "06:00", "duration_min": 20, "content": "Surface Analysis"}, + {"utc": "12:00", "duration_min": 20, "content": "Surface Analysis"}, + {"utc": "18:00", "duration_min": 20, "content": "Surface Analysis"} + ] + }, + { + "name": "Athens Met", + "callsign": "SVJ4", + "country": "GR", + "city": "Athens", + "coordinates": [37.97, 23.73], + "frequencies": [ + {"khz": 4482.9, "description": "Night"}, + {"khz": 8106.9, "description": "Primary"} + ], + "ioc": 576, + "lpm": 120, + "schedule": [ + {"utc": "06:00", "duration_min": 20, "content": "Surface Analysis Med"}, + {"utc": "09:00", "duration_min": 20, "content": "Surface Prog"}, + {"utc": "12:00", "duration_min": 20, "content": "Surface Analysis Med"}, + {"utc": "18:00", "duration_min": 20, "content": "Surface Analysis Med"} + ] + }, + { + "name": "Charleville Met", + "callsign": "VMC", + "country": "AU", + "city": "Charleville, QLD", + "coordinates": [-26.41, 146.24], + "frequencies": [ + {"khz": 2628, "description": "Night"}, + {"khz": 5100, "description": "Primary"}, + {"khz": 11030, "description": "Day"}, + {"khz": 13920, "description": "Extended"}, + {"khz": 20469, "description": "DX"} + ], + "ioc": 576, + "lpm": 120, + "schedule": [ + {"utc": "00:00", "duration_min": 20, "content": "MSLP Analysis"}, + {"utc": "03:00", "duration_min": 20, "content": "Prognosis"}, + {"utc": "06:00", "duration_min": 20, "content": "MSLP Analysis"}, + {"utc": "09:00", "duration_min": 20, "content": "Sea/Swell Chart"}, + {"utc": "12:00", "duration_min": 20, "content": "MSLP Analysis"}, + {"utc": "18:00", "duration_min": 20, "content": "MSLP Analysis"}, + {"utc": "19:00", "duration_min": 20, "content": "Prognosis"} + ] + }, + { + "name": "Wiluna Met", + "callsign": "VMW", + "country": "AU", + "city": "Wiluna, WA", + "coordinates": [-26.59, 120.23], + "frequencies": [ + {"khz": 5755, "description": "Night"}, + {"khz": 7535, "description": "Primary"}, + {"khz": 10555, "description": "Day"}, + {"khz": 15615, "description": "Extended"}, + {"khz": 18060, "description": "DX"} + ], + "ioc": 576, + "lpm": 120, + "schedule": [ + {"utc": "00:00", "duration_min": 20, "content": "MSLP Analysis"}, + {"utc": "06:00", "duration_min": 20, "content": "MSLP Analysis"}, + {"utc": "11:00", "duration_min": 20, "content": "Prognosis"}, + {"utc": "18:00", "duration_min": 20, "content": "MSLP Analysis"}, + {"utc": "21:00", "duration_min": 20, "content": "Sea/Swell Chart"} + ] + }, + { + "name": "NZ MetService", + "callsign": "ZKLF", + "country": "NZ", + "city": "Auckland", + "coordinates": [-36.85, 174.76], + "frequencies": [ + {"khz": 3247.4, "description": "Night"}, + {"khz": 5807, "description": "Primary"}, + {"khz": 9459, "description": "Day"}, + {"khz": 13550.5, "description": "Extended"}, + {"khz": 16340.1, "description": "DX"} + ], + "ioc": 576, + "lpm": 120, + "schedule": [ + {"utc": "00:00", "duration_min": 20, "content": "Surface Analysis"}, + {"utc": "06:00", "duration_min": 20, "content": "Surface Analysis"}, + {"utc": "12:00", "duration_min": 20, "content": "Surface Analysis"}, + {"utc": "18:00", "duration_min": 20, "content": "Surface Analysis"} + ] + }, + { + "name": "CFH Halifax", + "callsign": "CFH", + "country": "CA", + "city": "Halifax, NS", + "coordinates": [44.65, -63.57], + "frequencies": [ + {"khz": 4271, "description": "Night"}, + {"khz": 6496.4, "description": "Primary"}, + {"khz": 10536, "description": "Day"}, + {"khz": 13510, "description": "Extended"} + ], + "ioc": 576, + "lpm": 120, + "schedule": [ + {"utc": "00:00", "duration_min": 20, "content": "Surface Analysis"}, + {"utc": "03:00", "duration_min": 20, "content": "Surface Prog"}, + {"utc": "06:00", "duration_min": 20, "content": "Surface Analysis"}, + {"utc": "12:00", "duration_min": 20, "content": "Surface Analysis"}, + {"utc": "18:00", "duration_min": 20, "content": "Surface Analysis"}, + {"utc": "22:22", "duration_min": 20, "content": "Ice Chart"}, + {"utc": "23:01", "duration_min": 20, "content": "Surface Analysis"} + ] + }, + { + "name": "CCG Iqaluit", + "callsign": "VFF", + "country": "CA", + "city": "Iqaluit, NU", + "coordinates": [63.75, -68.52], + "frequencies": [ + {"khz": 3253, "description": "Night"}, + {"khz": 7710, "description": "Day"} + ], + "ioc": 576, + "lpm": 120, + "schedule": [ + {"utc": "00:10", "duration_min": 20, "content": "Ice Chart"}, + {"utc": "05:00", "duration_min": 20, "content": "Surface Analysis"}, + {"utc": "07:00", "duration_min": 20, "content": "Ice Chart"}, + {"utc": "10:00", "duration_min": 20, "content": "Surface Analysis"}, + {"utc": "11:00", "duration_min": 20, "content": "Ice Chart"}, + {"utc": "21:00", "duration_min": 20, "content": "Surface Analysis"}, + {"utc": "23:30", "duration_min": 20, "content": "Ice Chart"} + ] + }, + { + "name": "CCG Inuvik", + "callsign": "VFA", + "country": "CA", + "city": "Inuvik, NT", + "coordinates": [68.36, -133.72], + "frequencies": [ + {"khz": 4292, "description": "Night"}, + {"khz": 8457.8, "description": "Primary"} + ], + "ioc": 576, + "lpm": 120, + "schedule": [ + {"utc": "02:00", "duration_min": 20, "content": "Ice Chart"}, + {"utc": "16:30", "duration_min": 20, "content": "Ice Chart"} + ] + }, + { + "name": "CCG Sydney", + "callsign": "VCO", + "country": "CA", + "city": "Sydney, NS", + "coordinates": [46.14, -60.19], + "frequencies": [ + {"khz": 4416, "description": "Night"}, + {"khz": 6915.1, "description": "Primary"} + ], + "ioc": 576, + "lpm": 120, + "schedule": [ + {"utc": "11:21", "duration_min": 20, "content": "Surface Analysis"}, + {"utc": "11:42", "duration_min": 20, "content": "Surface Prog"}, + {"utc": "17:41", "duration_min": 20, "content": "Surface Analysis"}, + {"utc": "22:00", "duration_min": 20, "content": "Surface Analysis"}, + {"utc": "23:31", "duration_min": 20, "content": "Surface Prog"} + ] + }, + { + "name": "Cape Naval", + "callsign": "ZSJ", + "country": "ZA", + "city": "Cape Town", + "coordinates": [-33.92, 18.42], + "frequencies": [ + {"khz": 4014, "description": "Night"}, + {"khz": 7508, "description": "Primary"}, + {"khz": 13538, "description": "Day"}, + {"khz": 18238, "description": "Extended"} + ], + "ioc": 576, + "lpm": 120, + "schedule": [ + {"utc": "04:30", "duration_min": 20, "content": "Surface Analysis"}, + {"utc": "05:00", "duration_min": 20, "content": "Sea State"}, + {"utc": "06:30", "duration_min": 20, "content": "Surface Prog"}, + {"utc": "07:30", "duration_min": 20, "content": "Surface Analysis"}, + {"utc": "08:00", "duration_min": 20, "content": "Satellite Image"}, + {"utc": "10:30", "duration_min": 20, "content": "Surface Analysis"}, + {"utc": "11:00", "duration_min": 20, "content": "Sea State"}, + {"utc": "15:30", "duration_min": 20, "content": "Surface Analysis"}, + {"utc": "15:40", "duration_min": 20, "content": "Surface Prog"}, + {"utc": "22:30", "duration_min": 20, "content": "Surface Analysis"} + ] + }, + { + "name": "Valparaiso Naval", + "callsign": "CBV", + "country": "CL", + "city": "Valparaiso", + "coordinates": [-33.05, -71.62], + "frequencies": [ + {"khz": 4228, "description": "Night"}, + {"khz": 8677, "description": "Primary"}, + {"khz": 17146.4, "description": "Day"} + ], + "ioc": 576, + "lpm": 120, + "schedule": [ + {"utc": "11:15", "duration_min": 20, "content": "Surface Analysis"}, + {"utc": "11:30", "duration_min": 20, "content": "Surface Prog"}, + {"utc": "16:30", "duration_min": 20, "content": "Surface Analysis"}, + {"utc": "16:45", "duration_min": 20, "content": "Sea State"}, + {"utc": "19:15", "duration_min": 20, "content": "Surface Analysis"}, + {"utc": "19:30", "duration_min": 20, "content": "Surface Prog"}, + {"utc": "22:00", "duration_min": 20, "content": "Surface Analysis"}, + {"utc": "23:10", "duration_min": 20, "content": "Surface Analysis"}, + {"utc": "23:25", "duration_min": 20, "content": "Sea State"} + ] + }, + { + "name": "Magallanes Naval", + "callsign": "CBM", + "country": "CL", + "city": "Punta Arenas", + "coordinates": [-53.16, -70.91], + "frequencies": [ + {"khz": 4322, "description": "Night"}, + {"khz": 8696, "description": "Primary"} + ], + "ioc": 576, + "lpm": 120, + "schedule": [ + {"utc": "01:00", "duration_min": 20, "content": "Surface Analysis"}, + {"utc": "13:00", "duration_min": 20, "content": "Surface Analysis"} + ] + }, + { + "name": "Rio de Janeiro Naval", + "callsign": "PWZ33", + "country": "BR", + "city": "Rio de Janeiro", + "coordinates": [-22.91, -43.17], + "frequencies": [ + {"khz": 12665, "description": "Primary"}, + {"khz": 16978, "description": "Day"} + ], + "ioc": 576, + "lpm": 120, + "schedule": [ + {"utc": "07:45", "duration_min": 20, "content": "Surface Analysis"}, + {"utc": "16:30", "duration_min": 20, "content": "Surface Analysis"} + ] + }, + { + "name": "Dakar Met", + "callsign": "6VU", + "country": "SN", + "city": "Dakar", + "coordinates": [14.69, -17.44], + "frequencies": [ + {"khz": 13667.5, "description": "Primary"}, + {"khz": 19750, "description": "Day"} + ], + "ioc": 576, + "lpm": 120, + "schedule": [ + {"utc": "00:00", "duration_min": 20, "content": "Surface Analysis"}, + {"utc": "12:00", "duration_min": 20, "content": "Surface Analysis"} + ] + }, + { + "name": "Misaki Fisheries", + "callsign": "JFC", + "country": "JP", + "city": "Miura", + "coordinates": [35.14, 139.62], + "frequencies": [ + {"khz": 8616, "description": "Primary"}, + {"khz": 13074, "description": "Day"}, + {"khz": 17231, "description": "Extended"} + ], + "ioc": 576, + "lpm": 120, + "schedule": [ + {"utc": "00:00", "duration_min": 20, "content": "Sea Surface Temp"}, + {"utc": "06:00", "duration_min": 20, "content": "Current Chart"}, + {"utc": "12:00", "duration_min": 20, "content": "Fishing Forecast"}, + {"utc": "18:00", "duration_min": 20, "content": "Sea Surface Temp"} + ] + } + ] +} diff --git a/intercept_agent.py b/intercept_agent.py index 5c3ed41..07cda74 100644 --- a/intercept_agent.py +++ b/intercept_agent.py @@ -2860,11 +2860,22 @@ class ModeManager: pass logger.info("APRS reader stopped") - def _parse_aprs_packet(self, line: str) -> dict | None: - """Parse APRS packet from direwolf or multimon-ng.""" - match = re.match(r'([A-Z0-9-]+)>([^:]+):(.+)', line) - if not match: - return None + def _parse_aprs_packet(self, line: str) -> dict | None: + """Parse APRS packet from direwolf or multimon-ng.""" + if not line: + return None + + # Normalize common decoder prefixes before parsing. + # multimon-ng: "AFSK1200: ..." + # direwolf: "[0.4] ...", "[0L] ..." + line = line.strip() + if line.startswith('AFSK1200:'): + line = line[9:].strip() + line = re.sub(r'^(?:\[[^\]]+\]\s*)+', '', line) + + match = re.match(r'([A-Z0-9-]+)>([^:]+):(.+)', line) + if not match: + return None callsign = match.group(1) path = match.group(2) diff --git a/routes/__init__.py b/routes/__init__.py index 9f2075f..e1e1319 100644 --- a/routes/__init__.py +++ b/routes/__init__.py @@ -14,8 +14,9 @@ def register_blueprints(app): from .correlation import correlation_bp from .dsc import dsc_bp from .gps import gps_bp - from .listening_post import receiver_bp + from .listening_post import receiver_bp from .meshtastic import meshtastic_bp + from .morse import morse_bp from .offline import offline_bp from .pager import pager_bp from .recordings import recordings_bp @@ -33,6 +34,7 @@ def register_blueprints(app): from .updater import updater_bp from .vdl2 import vdl2_bp from .weather_sat import weather_sat_bp + from .wefax import wefax_bp from .websdr import websdr_bp from .wifi import wifi_bp from .wifi_v2 import wifi_v2_bp @@ -54,7 +56,7 @@ def register_blueprints(app): app.register_blueprint(gps_bp) app.register_blueprint(settings_bp) app.register_blueprint(correlation_bp) - app.register_blueprint(receiver_bp) + app.register_blueprint(receiver_bp) app.register_blueprint(meshtastic_bp) app.register_blueprint(tscm_bp) app.register_blueprint(spy_stations_bp) @@ -68,9 +70,11 @@ def register_blueprints(app): app.register_blueprint(alerts_bp) # Cross-mode alerts app.register_blueprint(recordings_bp) # Session recordings app.register_blueprint(subghz_bp) # SubGHz transceiver (HackRF) - app.register_blueprint(bt_locate_bp) # BT Locate SAR device tracking - app.register_blueprint(space_weather_bp) # Space weather monitoring - app.register_blueprint(signalid_bp) # External signal ID enrichment + app.register_blueprint(bt_locate_bp) # BT Locate SAR device tracking + app.register_blueprint(space_weather_bp) # Space weather monitoring + app.register_blueprint(signalid_bp) # External signal ID enrichment + app.register_blueprint(wefax_bp) # WeFax HF weather fax decoder + app.register_blueprint(morse_bp) # CW/Morse code decoder # Initialize TSCM state with queue and lock from app import app as app_module diff --git a/routes/adsb.py b/routes/adsb.py index d938bc5..5c662cb 100644 --- a/routes/adsb.py +++ b/routes/adsb.py @@ -685,7 +685,7 @@ def start_adsb(): 'session': session }), 409 - data = request.json or {} + data = request.get_json(silent=True) or {} start_source = data.get('source') started_by = request.remote_addr @@ -899,7 +899,7 @@ def start_adsb(): def stop_adsb(): """Stop ADS-B tracking.""" global adsb_using_service, adsb_active_device - data = request.json or {} + data = request.get_json(silent=True) or {} stop_source = data.get('source') stopped_by = request.remote_addr diff --git a/routes/aprs.py b/routes/aprs.py index b8fa86e..a6e29d6 100644 --- a/routes/aprs.py +++ b/routes/aprs.py @@ -14,14 +14,14 @@ import threading import time from datetime import datetime from subprocess import PIPE, STDOUT -from typing import Generator, Optional +from typing import Any, Generator, Optional from flask import Blueprint, jsonify, request, Response import app as app_module from utils.logging import sensor_logger as logger from utils.validation import validate_device_index, validate_gain, validate_ppm -from utils.sse import sse_stream_fanout +from utils.sse import sse_stream_fanout from utils.event_pipeline import process_event from utils.sdr import SDRFactory, SDRType from utils.constants import ( @@ -109,6 +109,26 @@ MODEM 1200 return DIREWOLF_CONFIG_PATH +def normalize_aprs_output_line(line: str) -> str: + """Normalize a decoder output line to raw APRS packet format. + + Handles common decoder prefixes: + - multimon-ng: ``AFSK1200: ...`` + - direwolf tags: ``[0.4] ...``, ``[0L] ...``, etc. + """ + if not line: + return '' + + normalized = line.strip() + if normalized.startswith('AFSK1200:'): + normalized = normalized[9:].strip() + + # Strip one or more leading bracket tags emitted by decoders. + # Examples: [0.4], [0L], [NONE] + normalized = re.sub(r'^(?:\[[^\]]+\]\s*)+', '', normalized) + return normalized + + def parse_aprs_packet(raw_packet: str) -> Optional[dict]: """Parse APRS packet into structured data. @@ -125,10 +145,15 @@ def parse_aprs_packet(raw_packet: str) -> Optional[dict]: - User-defined formats """ try: + raw_packet = normalize_aprs_output_line(raw_packet) + if not raw_packet: + return None + # Basic APRS packet format: CALLSIGN>PATH:DATA # Example: N0CALL-9>APRS,TCPIP*:@092345z4903.50N/07201.75W_090/000g005t077 - match = re.match(r'^([A-Z0-9-]+)>([^:]+):(.+)$', raw_packet, re.IGNORECASE) + # Source callsigns can include tactical suffixes like "/1" on some stations. + match = re.match(r'^([A-Z0-9/\-]+)>([^:]+):(.+)$', raw_packet, re.IGNORECASE) if not match: return None @@ -444,6 +469,109 @@ def parse_position(data: str) -> Optional[dict]: return result + # Legacy/no-decimal variant occasionally seen in degraded decodes: + # DDMMN/DDDMMW (symbol chars still present between/after coords). + nodot_match = re.match( + r'^(\d{2})(\d{2})([NS])(.)(\d{3})(\d{2})([EW])(.)?', + data + ) + if nodot_match: + lat_deg = int(nodot_match.group(1)) + lat_min = float(nodot_match.group(2)) + lat_dir = nodot_match.group(3) + symbol_table = nodot_match.group(4) + lon_deg = int(nodot_match.group(5)) + lon_min = float(nodot_match.group(6)) + lon_dir = nodot_match.group(7) + symbol_code = nodot_match.group(8) or '' + + lat = lat_deg + lat_min / 60.0 + if lat_dir == 'S': + lat = -lat + + lon = lon_deg + lon_min / 60.0 + if lon_dir == 'W': + lon = -lon + + result = { + 'lat': round(lat, 6), + 'lon': round(lon, 6), + 'symbol': symbol_table + symbol_code, + } + + remaining = data[13:] if len(data) > 13 else '' + + cs_match = re.search(r'(\d{3})/(\d{3})', remaining) + if cs_match: + result['course'] = int(cs_match.group(1)) + result['speed'] = int(cs_match.group(2)) + + alt_match = re.search(r'/A=(-?\d+)', remaining) + if alt_match: + result['altitude'] = int(alt_match.group(1)) + + return result + + # Fallback: tolerate APRS ambiguity spaces in minute fields. + # Example: 4903. N/07201. W + if len(data) >= 18: + lat_field = data[0:7] + lat_dir = data[7] + symbol_table = data[8] if len(data) > 8 else '' + lon_field = data[9:17] if len(data) >= 17 else '' + lon_dir = data[17] if len(data) > 17 else '' + symbol_code = data[18] if len(data) > 18 else '' + + if ( + len(lat_field) == 7 + and len(lon_field) == 8 + and lat_dir in ('N', 'S') + and lon_dir in ('E', 'W') + ): + lat_deg_txt = lat_field[:2] + lat_min_txt = lat_field[2:].replace(' ', '0') + lon_deg_txt = lon_field[:3] + lon_min_txt = lon_field[3:].replace(' ', '0') + + if ( + lat_deg_txt.isdigit() + and lon_deg_txt.isdigit() + and re.match(r'^\d{2}\.\d+$', lat_min_txt) + and re.match(r'^\d{2}\.\d+$', lon_min_txt) + ): + lat_deg = int(lat_deg_txt) + lon_deg = int(lon_deg_txt) + lat_min = float(lat_min_txt) + lon_min = float(lon_min_txt) + + lat = lat_deg + lat_min / 60.0 + if lat_dir == 'S': + lat = -lat + + lon = lon_deg + lon_min / 60.0 + if lon_dir == 'W': + lon = -lon + + result = { + 'lat': round(lat, 6), + 'lon': round(lon, 6), + 'symbol': symbol_table + symbol_code, + } + + # Keep same extension parsing behavior as primary branch. + remaining = data[19:] if len(data) > 19 else '' + + cs_match = re.search(r'(\d{3})/(\d{3})', remaining) + if cs_match: + result['course'] = int(cs_match.group(1)) + result['speed'] = int(cs_match.group(2)) + + alt_match = re.search(r'/A=(-?\d+)', remaining) + if alt_match: + result['altitude'] = int(alt_match.group(1)) + + return result + except Exception as e: logger.debug(f"Failed to parse position: {e}") @@ -1321,7 +1449,11 @@ def stream_aprs_output(rtl_process: subprocess.Popen, decoder_process: subproces - type='meter': Audio level meter readings (rate-limited) """ global aprs_packet_count, aprs_station_count, aprs_last_packet_time, aprs_stations - global _last_meter_time, _last_meter_level + global _last_meter_time, _last_meter_level, aprs_active_device + + # Capture the device claimed by THIS session so the finally block only + # releases our own device, not one claimed by a subsequent start. + my_device = aprs_active_device # Reset meter state _last_meter_time = 0.0 @@ -1348,13 +1480,8 @@ def stream_aprs_output(rtl_process: subprocess.Popen, decoder_process: subproces app_module.aprs_queue.put(meter_msg) continue # Audio level lines are not packets - # multimon-ng prefixes decoded packets with "AFSK1200: " - if line.startswith('AFSK1200:'): - line = line[9:].strip() - - # direwolf often prefixes packets with "[0.4] " or similar audio level indicator - # Strip any leading bracket prefix like "[0.4] " before parsing - line = re.sub(r'^\[\d+\.\d+\]\s*', '', line) + # Normalize decoder prefixes (multimon/direwolf) before parsing. + line = normalize_aprs_output_line(line) # Skip non-packet lines (APRS format: CALL>PATH:DATA) if '>' not in line or ':' not in line: @@ -1370,20 +1497,24 @@ def stream_aprs_output(rtl_process: subprocess.Popen, decoder_process: subproces if callsign and callsign not in aprs_stations: aprs_station_count += 1 - # Update station data + # Update station data, preserving last known coordinates when + # packets do not contain position fields. if callsign: + existing = aprs_stations.get(callsign, {}) + packet_lat = packet.get('lat') + packet_lon = packet.get('lon') aprs_stations[callsign] = { 'callsign': callsign, - 'lat': packet.get('lat'), - 'lon': packet.get('lon'), - 'symbol': packet.get('symbol'), + 'lat': packet_lat if packet_lat is not None else existing.get('lat'), + 'lon': packet_lon if packet_lon is not None else existing.get('lon'), + 'symbol': packet.get('symbol') or existing.get('symbol'), 'last_seen': packet.get('timestamp'), 'packet_type': packet.get('packet_type'), } # Geofence check - _aprs_lat = packet.get('lat') - _aprs_lon = packet.get('lon') - if _aprs_lat and _aprs_lon: + _aprs_lat = packet_lat + _aprs_lon = packet_lon + if _aprs_lat is not None and _aprs_lon is not None: try: from utils.geofence import get_geofence_manager for _gf_evt in get_geofence_manager().check_position( @@ -1416,7 +1547,6 @@ def stream_aprs_output(rtl_process: subprocess.Popen, decoder_process: subproces logger.error(f"APRS stream error: {e}") app_module.aprs_queue.put({'type': 'error', 'message': str(e)}) finally: - global aprs_active_device app_module.aprs_queue.put({'type': 'status', 'status': 'stopped'}) # Cleanup processes for proc in [rtl_process, decoder_process]: @@ -1428,9 +1558,9 @@ def stream_aprs_output(rtl_process: subprocess.Popen, decoder_process: subproces proc.kill() except Exception: pass - # Release SDR device - if aprs_active_device is not None: - app_module.release_sdr_device(aprs_active_device) + # Release SDR device — only if it's still ours (not reclaimed by a new start) + if my_device is not None and aprs_active_device == my_device: + app_module.release_sdr_device(my_device) aprs_active_device = None @@ -1478,6 +1608,24 @@ def get_stations() -> Response: }) +@aprs_bp.route('/data') +def aprs_data() -> Response: + """Get APRS data snapshot for remote controller polling compatibility.""" + running = False + if app_module.aprs_process: + running = app_module.aprs_process.poll() is None + + return jsonify({ + 'status': 'success', + 'running': running, + 'stations': list(aprs_stations.values()), + 'count': len(aprs_stations), + 'packet_count': aprs_packet_count, + 'station_count': aprs_station_count, + 'last_packet_time': aprs_last_packet_time, + }) + + @aprs_bp.route('/start', methods=['POST']) def start_aprs() -> Response: """Start APRS decoder.""" @@ -1763,25 +1911,25 @@ def stop_aprs() -> Response: return jsonify({'status': 'stopped'}) -@aprs_bp.route('/stream') -def stream_aprs() -> Response: - """SSE stream for APRS packets.""" - def _on_msg(msg: dict[str, Any]) -> None: - process_event('aprs', msg, msg.get('type')) - - response = Response( - sse_stream_fanout( - source_queue=app_module.aprs_queue, - channel_key='aprs', - timeout=SSE_QUEUE_TIMEOUT, - keepalive_interval=SSE_KEEPALIVE_INTERVAL, - on_message=_on_msg, - ), - mimetype='text/event-stream', - ) - response.headers['Cache-Control'] = 'no-cache' - response.headers['X-Accel-Buffering'] = 'no' - return response +@aprs_bp.route('/stream') +def stream_aprs() -> Response: + """SSE stream for APRS packets.""" + def _on_msg(msg: dict[str, Any]) -> None: + process_event('aprs', msg, msg.get('type')) + + response = Response( + sse_stream_fanout( + source_queue=app_module.aprs_queue, + channel_key='aprs', + timeout=SSE_QUEUE_TIMEOUT, + keepalive_interval=SSE_KEEPALIVE_INTERVAL, + on_message=_on_msg, + ), + mimetype='text/event-stream', + ) + response.headers['Cache-Control'] = 'no-cache' + response.headers['X-Accel-Buffering'] = 'no' + return response @aprs_bp.route('/frequencies') diff --git a/routes/gps.py b/routes/gps.py index 4d85e0b..aab2b9e 100644 --- a/routes/gps.py +++ b/routes/gps.py @@ -65,14 +65,17 @@ def auto_connect_gps(): If gpsd is not running, attempts to detect GPS devices and start gpsd. Returns current status if already connected. """ - # Check if already running - reader = get_gps_reader() - if reader and reader.is_running: - position = reader.position - sky = reader.sky - return jsonify({ - 'status': 'connected', - 'source': 'gpsd', + # Check if already running + reader = get_gps_reader() + if reader and reader.is_running: + # Ensure stream callbacks are attached for this process. + reader.add_callback(_position_callback) + reader.add_sky_callback(_sky_callback) + position = reader.position + sky = reader.sky + return jsonify({ + 'status': 'connected', + 'source': 'gpsd', 'has_fix': position is not None, 'position': position.to_dict() if position else None, 'sky': sky.to_dict() if sky else None, @@ -204,21 +207,22 @@ def get_position(): }) -@gps_bp.route('/satellites') -def get_satellites(): - """Get current satellite sky view data.""" - reader = get_gps_reader() - - if not reader or not reader.is_running: - return jsonify({ - 'status': 'error', - 'message': 'GPS client not running' - }), 400 - - sky = reader.sky - if sky: - return jsonify({ - 'status': 'ok', +@gps_bp.route('/satellites') +def get_satellites(): + """Get current satellite sky view data.""" + reader = get_gps_reader() + + if not reader or not reader.is_running: + return jsonify({ + 'status': 'waiting', + 'running': False, + 'message': 'GPS client not running' + }) + + sky = reader.sky + if sky: + return jsonify({ + 'status': 'ok', 'sky': sky.to_dict() }) else: diff --git a/routes/listening_post.py b/routes/listening_post.py index 17cdd22..1ae8428 100644 --- a/routes/listening_post.py +++ b/routes/listening_post.py @@ -38,15 +38,15 @@ receiver_bp = Blueprint('receiver', __name__, url_prefix='/receiver') # ============================================ # Audio demodulation state -audio_process = None -audio_rtl_process = None -audio_lock = threading.Lock() -audio_start_lock = threading.Lock() -audio_running = False -audio_frequency = 0.0 -audio_modulation = 'fm' -audio_source = 'process' -audio_start_token = 0 +audio_process = None +audio_rtl_process = None +audio_lock = threading.Lock() +audio_start_lock = threading.Lock() +audio_running = False +audio_frequency = 0.0 +audio_modulation = 'fm' +audio_source = 'process' +audio_start_token = 0 # Scanner state scanner_thread: Optional[threading.Thread] = None @@ -665,12 +665,21 @@ def scanner_loop_power(): logger.info("Power sweep scanner thread stopped") -def _start_audio_stream(frequency: float, modulation: str): +def _start_audio_stream( + frequency: float, + modulation: str, + *, + device: int | None = None, + sdr_type: str | None = None, + gain: int | None = None, + squelch: int | None = None, + bias_t: bool | None = None, +): """Start audio streaming at given frequency.""" global audio_process, audio_rtl_process, audio_running, audio_frequency, audio_modulation + # Stop existing stream and snapshot config under lock with audio_lock: - # Stop any existing stream _stop_audio_stream_internal() ffmpeg_path = find_ffmpeg() @@ -678,209 +687,222 @@ def _start_audio_stream(frequency: float, modulation: str): logger.error("ffmpeg not found") return - # Determine SDR type and build appropriate command - sdr_type_str = scanner_config.get('sdr_type', 'rtlsdr') - try: - sdr_type = SDRType(sdr_type_str) - except ValueError: - sdr_type = SDRType.RTL_SDR + # Snapshot runtime tuning config so the spawned demod command cannot + # drift if shared scanner_config changes while startup is in-flight. + device_index = int(device if device is not None else scanner_config.get('device', 0)) + gain_value = int(gain if gain is not None else scanner_config.get('gain', 40)) + squelch_value = int(squelch if squelch is not None else scanner_config.get('squelch', 0)) + bias_t_enabled = bool(scanner_config.get('bias_t', False) if bias_t is None else bias_t) + sdr_type_str = str(sdr_type if sdr_type is not None else scanner_config.get('sdr_type', 'rtlsdr')).lower() - # Set sample rates based on modulation - if modulation == 'wfm': - sample_rate = 170000 - resample_rate = 32000 - elif modulation in ['usb', 'lsb']: - sample_rate = 12000 - resample_rate = 12000 - else: - sample_rate = 24000 - resample_rate = 24000 + # Build commands outside lock (no blocking I/O, just command construction) + try: + resolved_sdr_type = SDRType(sdr_type_str) + except ValueError: + resolved_sdr_type = SDRType.RTL_SDR - # Build the SDR command based on device type - if sdr_type == SDRType.RTL_SDR: - # Use rtl_fm for RTL-SDR devices - rtl_fm_path = find_rtl_fm() - if not rtl_fm_path: - logger.error("rtl_fm not found") - return + # Set sample rates based on modulation + if modulation == 'wfm': + sample_rate = 170000 + resample_rate = 32000 + elif modulation in ['usb', 'lsb']: + sample_rate = 12000 + resample_rate = 12000 + else: + sample_rate = 24000 + resample_rate = 24000 - freq_hz = int(frequency * 1e6) - sdr_cmd = [ - rtl_fm_path, - '-M', _rtl_fm_demod_mode(modulation), - '-f', str(freq_hz), - '-s', str(sample_rate), - '-r', str(resample_rate), - '-g', str(scanner_config['gain']), - '-d', str(scanner_config['device']), - '-l', str(scanner_config['squelch']), - ] - if scanner_config.get('bias_t', False): - sdr_cmd.append('-T') - # Omit explicit filename: rtl_fm defaults to stdout. - # (Some builds intermittently stall when '-' is passed explicitly.) - else: - # Use SDR abstraction layer for HackRF, Airspy, LimeSDR, SDRPlay - rx_fm_path = find_rx_fm() - if not rx_fm_path: - logger.error(f"rx_fm not found - required for {sdr_type.value}. Install SoapySDR utilities.") - return + # Build the SDR command based on device type + if resolved_sdr_type == SDRType.RTL_SDR: + rtl_fm_path = find_rtl_fm() + if not rtl_fm_path: + logger.error("rtl_fm not found") + return - # Create device and get command builder - device = SDRFactory.create_default_device(sdr_type, index=scanner_config['device']) - builder = SDRFactory.get_builder(sdr_type) - - # Build FM demod command - sdr_cmd = builder.build_fm_demod_command( - device=device, - frequency_mhz=frequency, - sample_rate=resample_rate, - gain=float(scanner_config['gain']), - modulation=modulation, - squelch=scanner_config['squelch'], - bias_t=scanner_config.get('bias_t', False) - ) - # Ensure we use the found rx_fm path - sdr_cmd[0] = rx_fm_path - - encoder_cmd = [ - ffmpeg_path, - '-hide_banner', - '-loglevel', 'error', - '-fflags', 'nobuffer', - '-flags', 'low_delay', - '-probesize', '32', - '-analyzeduration', '0', - '-f', 's16le', - '-ar', str(resample_rate), - '-ac', '1', - '-i', 'pipe:0', - '-acodec', 'pcm_s16le', - '-ar', '44100', - '-f', 'wav', - 'pipe:1' + freq_hz = int(frequency * 1e6) + sdr_cmd = [ + rtl_fm_path, + '-M', _rtl_fm_demod_mode(modulation), + '-f', str(freq_hz), + '-s', str(sample_rate), + '-r', str(resample_rate), + '-g', str(gain_value), + '-d', str(device_index), + '-l', str(squelch_value), ] + if bias_t_enabled: + sdr_cmd.append('-T') + else: + rx_fm_path = find_rx_fm() + if not rx_fm_path: + logger.error(f"rx_fm not found - required for {resolved_sdr_type.value}. Install SoapySDR utilities.") + return - try: - # Use subprocess piping for reliable streaming. - # Log stderr to temp files for error diagnosis. - rtl_stderr_log = '/tmp/rtl_fm_stderr.log' - ffmpeg_stderr_log = '/tmp/ffmpeg_stderr.log' - logger.info(f"Starting audio: {frequency} MHz, mod={modulation}, device={scanner_config['device']}") + sdr_device = SDRFactory.create_default_device(resolved_sdr_type, index=device_index) + builder = SDRFactory.get_builder(resolved_sdr_type) + sdr_cmd = builder.build_fm_demod_command( + device=sdr_device, + frequency_mhz=frequency, + sample_rate=resample_rate, + gain=float(gain_value), + modulation=modulation, + squelch=squelch_value, + bias_t=bias_t_enabled, + ) + sdr_cmd[0] = rx_fm_path - # Retry loop for USB device contention (device may not be - # released immediately after a previous process exits) - max_attempts = 3 - for attempt in range(max_attempts): - audio_rtl_process = None - audio_process = None - rtl_err_handle = None - ffmpeg_err_handle = None - try: - rtl_err_handle = open(rtl_stderr_log, 'w') - ffmpeg_err_handle = open(ffmpeg_stderr_log, 'w') - audio_rtl_process = subprocess.Popen( - sdr_cmd, - stdout=subprocess.PIPE, - stderr=rtl_err_handle, - bufsize=0, - start_new_session=True # Create new process group for clean shutdown - ) - audio_process = subprocess.Popen( - encoder_cmd, - stdin=audio_rtl_process.stdout, - stdout=subprocess.PIPE, - stderr=ffmpeg_err_handle, - bufsize=0, - start_new_session=True # Create new process group for clean shutdown - ) - if audio_rtl_process.stdout: - audio_rtl_process.stdout.close() - finally: - if rtl_err_handle: - rtl_err_handle.close() - if ffmpeg_err_handle: - ffmpeg_err_handle.close() + encoder_cmd = [ + ffmpeg_path, + '-hide_banner', + '-loglevel', 'error', + '-fflags', 'nobuffer', + '-flags', 'low_delay', + '-probesize', '32', + '-analyzeduration', '0', + '-f', 's16le', + '-ar', str(resample_rate), + '-ac', '1', + '-i', 'pipe:0', + '-acodec', 'pcm_s16le', + '-ar', '44100', + '-f', 'wav', + 'pipe:1' + ] - # Brief delay to check if process started successfully - time.sleep(0.3) + # Retry loop outside lock — spawning + health check sleeps don't block + # other operations. audio_start_lock already serializes callers. + try: + rtl_stderr_log = '/tmp/rtl_fm_stderr.log' + ffmpeg_stderr_log = '/tmp/ffmpeg_stderr.log' + logger.info(f"Starting audio: {frequency} MHz, mod={modulation}, device={device_index}") - if (audio_rtl_process and audio_rtl_process.poll() is not None) or ( - audio_process and audio_process.poll() is not None - ): - # Read stderr from temp files - rtl_stderr = '' - ffmpeg_stderr = '' - try: - with open(rtl_stderr_log, 'r') as f: - rtl_stderr = f.read().strip() - except Exception: - pass - try: - with open(ffmpeg_stderr_log, 'r') as f: - ffmpeg_stderr = f.read().strip() - except Exception: - pass + new_rtl_proc = None + new_audio_proc = None + max_attempts = 3 + for attempt in range(max_attempts): + new_rtl_proc = None + new_audio_proc = None + rtl_err_handle = None + ffmpeg_err_handle = None + try: + rtl_err_handle = open(rtl_stderr_log, 'w') + ffmpeg_err_handle = open(ffmpeg_stderr_log, 'w') + new_rtl_proc = subprocess.Popen( + sdr_cmd, + stdout=subprocess.PIPE, + stderr=rtl_err_handle, + bufsize=0, + start_new_session=True + ) + new_audio_proc = subprocess.Popen( + encoder_cmd, + stdin=new_rtl_proc.stdout, + stdout=subprocess.PIPE, + stderr=ffmpeg_err_handle, + bufsize=0, + start_new_session=True + ) + if new_rtl_proc.stdout: + new_rtl_proc.stdout.close() + finally: + if rtl_err_handle: + rtl_err_handle.close() + if ffmpeg_err_handle: + ffmpeg_err_handle.close() - if 'usb_claim_interface' in rtl_stderr and attempt < max_attempts - 1: - logger.warning(f"USB device busy (attempt {attempt + 1}/{max_attempts}), waiting for release...") - if audio_process: - try: - audio_process.terminate() - audio_process.wait(timeout=0.5) - except Exception: - pass - if audio_rtl_process: - try: - audio_rtl_process.terminate() - audio_rtl_process.wait(timeout=0.5) - except Exception: - pass - time.sleep(1.0) - continue + # Brief delay to check if process started successfully + time.sleep(0.3) - if audio_process and audio_process.poll() is None: - try: - audio_process.terminate() - audio_process.wait(timeout=0.5) - except Exception: - pass - if audio_rtl_process and audio_rtl_process.poll() is None: - try: - audio_rtl_process.terminate() - audio_rtl_process.wait(timeout=0.5) - except Exception: - pass - audio_process = None - audio_rtl_process = None - - logger.error( - f"Audio pipeline exited immediately. rtl_fm stderr: {rtl_stderr}, ffmpeg stderr: {ffmpeg_stderr}" - ) - return - - # Pipeline started successfully - break - - # Keep monitor startup tolerant: some demod chains can take - # several seconds before producing stream bytes. - if ( - not audio_process - or not audio_rtl_process - or audio_process.poll() is not None - or audio_rtl_process.poll() is not None + if (new_rtl_proc and new_rtl_proc.poll() is not None) or ( + new_audio_proc and new_audio_proc.poll() is not None ): - logger.warning("Audio pipeline did not remain alive after startup") - _stop_audio_stream_internal() + rtl_stderr = '' + ffmpeg_stderr = '' + try: + with open(rtl_stderr_log, 'r') as f: + rtl_stderr = f.read().strip() + except Exception: + pass + try: + with open(ffmpeg_stderr_log, 'r') as f: + ffmpeg_stderr = f.read().strip() + except Exception: + pass + + if 'usb_claim_interface' in rtl_stderr and attempt < max_attempts - 1: + logger.warning(f"USB device busy (attempt {attempt + 1}/{max_attempts}), waiting for release...") + if new_audio_proc: + try: + new_audio_proc.terminate() + new_audio_proc.wait(timeout=0.5) + except Exception: + pass + if new_rtl_proc: + try: + new_rtl_proc.terminate() + new_rtl_proc.wait(timeout=0.5) + except Exception: + pass + time.sleep(1.0) + continue + + if new_audio_proc and new_audio_proc.poll() is None: + try: + new_audio_proc.terminate() + new_audio_proc.wait(timeout=0.5) + except Exception: + pass + if new_rtl_proc and new_rtl_proc.poll() is None: + try: + new_rtl_proc.terminate() + new_rtl_proc.wait(timeout=0.5) + except Exception: + pass + new_audio_proc = None + new_rtl_proc = None + + logger.error( + f"Audio pipeline exited immediately. rtl_fm stderr: {rtl_stderr}, ffmpeg stderr: {ffmpeg_stderr}" + ) return + # Pipeline started successfully + break + + # Verify pipeline is still alive, then install under lock + if ( + not new_audio_proc + or not new_rtl_proc + or new_audio_proc.poll() is not None + or new_rtl_proc.poll() is not None + ): + logger.warning("Audio pipeline did not remain alive after startup") + # Clean up failed processes + if new_audio_proc: + try: + new_audio_proc.terminate() + new_audio_proc.wait(timeout=0.5) + except Exception: + pass + if new_rtl_proc: + try: + new_rtl_proc.terminate() + new_rtl_proc.wait(timeout=0.5) + except Exception: + pass + return + + # Install processes under lock + with audio_lock: + audio_rtl_process = new_rtl_proc + audio_process = new_audio_proc audio_running = True audio_frequency = frequency audio_modulation = modulation - logger.info(f"Audio stream started: {frequency} MHz ({modulation}) via {sdr_type.value}") + logger.info(f"Audio stream started: {frequency} MHz ({modulation}) via {resolved_sdr_type.value}") - except Exception as e: - logger.error(f"Failed to start audio stream: {e}") + except Exception as e: + logger.error(f"Failed to start audio stream: {e}") def _stop_audio_stream(): @@ -1271,202 +1293,223 @@ def get_presets() -> Response: # MANUAL AUDIO ENDPOINTS (for direct listening) # ============================================ -@receiver_bp.route('/audio/start', methods=['POST']) -def start_audio() -> Response: - """Start audio at specific frequency (manual mode).""" - global scanner_running, scanner_active_device, receiver_active_device, scanner_power_process, scanner_thread - global audio_running, audio_frequency, audio_modulation, audio_source, audio_start_token - - data = request.json or {} - - try: - frequency = float(data.get('frequency', 0)) - modulation = normalize_modulation(data.get('modulation', 'wfm')) - squelch = int(data.get('squelch', 0)) - gain = int(data.get('gain', 40)) - device = int(data.get('device', 0)) - sdr_type = str(data.get('sdr_type', 'rtlsdr')).lower() - request_token_raw = data.get('request_token') - request_token = int(request_token_raw) if request_token_raw is not None else None - bias_t_raw = data.get('bias_t', scanner_config.get('bias_t', False)) - if isinstance(bias_t_raw, str): - bias_t = bias_t_raw.strip().lower() in {'1', 'true', 'yes', 'on'} - else: - bias_t = bool(bias_t_raw) - except (ValueError, TypeError) as e: - return jsonify({ - 'status': 'error', - 'message': f'Invalid parameter: {e}' - }), 400 - - if frequency <= 0: - return jsonify({ - 'status': 'error', - 'message': 'frequency is required' - }), 400 - - valid_sdr_types = ['rtlsdr', 'hackrf', 'airspy', 'limesdr', 'sdrplay'] - if sdr_type not in valid_sdr_types: - return jsonify({ - 'status': 'error', - 'message': f'Invalid sdr_type. Use: {", ".join(valid_sdr_types)}' - }), 400 - - with audio_start_lock: - if request_token is not None: - if request_token < audio_start_token: - return jsonify({ - 'status': 'stale', - 'message': 'Superseded audio start request', - 'source': audio_source, - 'superseded': True, - }), 409 - audio_start_token = request_token - else: - audio_start_token += 1 - request_token = audio_start_token - - # Stop scanner if running - if scanner_running: - scanner_running = False - if scanner_active_device is not None: - app_module.release_sdr_device(scanner_active_device) - scanner_active_device = None - if scanner_thread and scanner_thread.is_alive(): - try: - scanner_thread.join(timeout=2.0) - except Exception: - pass - if scanner_power_process and scanner_power_process.poll() is None: - try: - scanner_power_process.terminate() - scanner_power_process.wait(timeout=1) - except Exception: - try: - scanner_power_process.kill() - except Exception: - pass - scanner_power_process = None - try: - subprocess.run(['pkill', '-9', 'rtl_power'], capture_output=True, timeout=0.5) - except Exception: - pass - time.sleep(0.5) - - # Update config for audio - scanner_config['squelch'] = squelch - scanner_config['gain'] = gain - scanner_config['device'] = device - scanner_config['sdr_type'] = sdr_type - scanner_config['bias_t'] = bias_t - - # Preferred path: when waterfall WebSocket is active on the same SDR, - # derive monitor audio from that IQ stream instead of spawning rtl_fm. - try: - from routes.waterfall_websocket import ( - get_shared_capture_status, - start_shared_monitor_from_capture, - ) - - shared = get_shared_capture_status() - if shared.get('running') and shared.get('device') == device: - _stop_audio_stream() - ok, msg = start_shared_monitor_from_capture( - device=device, - frequency_mhz=frequency, - modulation=modulation, - squelch=squelch, - ) - if ok: - audio_running = True - audio_frequency = frequency - audio_modulation = modulation - audio_source = 'waterfall' - # Shared monitor uses the waterfall's existing SDR claim. - if receiver_active_device is not None: - app_module.release_sdr_device(receiver_active_device) - receiver_active_device = None - return jsonify({ - 'status': 'started', - 'frequency': frequency, - 'modulation': modulation, - 'source': 'waterfall', - 'request_token': request_token, - }) - logger.warning(f"Shared waterfall monitor unavailable: {msg}") - except Exception as e: - logger.debug(f"Shared waterfall monitor probe failed: {e}") - - # Stop waterfall if it's using the same SDR (SSE path) - if waterfall_running and waterfall_active_device == device: - _stop_waterfall_internal() - time.sleep(0.2) - - # Claim device for listening audio. The WebSocket waterfall handler - # may still be tearing down its IQ capture process (thread join + - # safe_terminate can take several seconds), so we retry with back-off - # to give the USB device time to be fully released. - if receiver_active_device is None or receiver_active_device != device: - if receiver_active_device is not None: - app_module.release_sdr_device(receiver_active_device) - receiver_active_device = None - - error = None - max_claim_attempts = 6 - for attempt in range(max_claim_attempts): - error = app_module.claim_sdr_device(device, 'receiver') - if not error: - break - if attempt < max_claim_attempts - 1: - logger.debug( - f"Device claim attempt {attempt + 1}/{max_claim_attempts} " - f"failed, retrying in 0.5s: {error}" - ) - time.sleep(0.5) - - if error: - return jsonify({ - 'status': 'error', - 'error_type': 'DEVICE_BUSY', - 'message': error - }), 409 - receiver_active_device = device - - _start_audio_stream(frequency, modulation) - - if audio_running: - audio_source = 'process' - return jsonify({ - 'status': 'started', - 'frequency': audio_frequency, - 'modulation': audio_modulation, - 'source': 'process', - 'request_token': request_token, - }) - - # Avoid leaving a stale device claim after startup failure. - if receiver_active_device is not None: - app_module.release_sdr_device(receiver_active_device) - receiver_active_device = None - - start_error = '' - for log_path in ('/tmp/rtl_fm_stderr.log', '/tmp/ffmpeg_stderr.log'): - try: - with open(log_path, 'r') as handle: - content = handle.read().strip() - if content: - start_error = content.splitlines()[-1] - break - except Exception: - continue - - message = 'Failed to start audio. Check SDR device.' - if start_error: - message = f'Failed to start audio: {start_error}' - return jsonify({ - 'status': 'error', - 'message': message - }), 500 +@receiver_bp.route('/audio/start', methods=['POST']) +def start_audio() -> Response: + """Start audio at specific frequency (manual mode).""" + global scanner_running, scanner_active_device, receiver_active_device, scanner_power_process, scanner_thread + global audio_running, audio_frequency, audio_modulation, audio_source, audio_start_token + + data = request.json or {} + + try: + frequency = float(data.get('frequency', 0)) + modulation = normalize_modulation(data.get('modulation', 'wfm')) + squelch = int(data.get('squelch', 0)) + gain = int(data.get('gain', 40)) + device = int(data.get('device', 0)) + sdr_type = str(data.get('sdr_type', 'rtlsdr')).lower() + request_token_raw = data.get('request_token') + request_token = int(request_token_raw) if request_token_raw is not None else None + bias_t_raw = data.get('bias_t', scanner_config.get('bias_t', False)) + if isinstance(bias_t_raw, str): + bias_t = bias_t_raw.strip().lower() in {'1', 'true', 'yes', 'on'} + else: + bias_t = bool(bias_t_raw) + except (ValueError, TypeError) as e: + return jsonify({ + 'status': 'error', + 'message': f'Invalid parameter: {e}' + }), 400 + + if frequency <= 0: + return jsonify({ + 'status': 'error', + 'message': 'frequency is required' + }), 400 + + valid_sdr_types = ['rtlsdr', 'hackrf', 'airspy', 'limesdr', 'sdrplay'] + if sdr_type not in valid_sdr_types: + return jsonify({ + 'status': 'error', + 'message': f'Invalid sdr_type. Use: {", ".join(valid_sdr_types)}' + }), 400 + + with audio_start_lock: + if request_token is not None: + if request_token < audio_start_token: + return jsonify({ + 'status': 'stale', + 'message': 'Superseded audio start request', + 'source': audio_source, + 'superseded': True, + 'current_token': audio_start_token, + }), 409 + audio_start_token = request_token + else: + audio_start_token += 1 + request_token = audio_start_token + + # Grab scanner refs inside lock, signal stop, clear state + need_scanner_teardown = False + scanner_thread_ref = None + scanner_proc_ref = None + if scanner_running: + scanner_running = False + if scanner_active_device is not None: + app_module.release_sdr_device(scanner_active_device) + scanner_active_device = None + scanner_thread_ref = scanner_thread + scanner_proc_ref = scanner_power_process + scanner_power_process = None + need_scanner_teardown = True + + # Update config for audio + scanner_config['squelch'] = squelch + scanner_config['gain'] = gain + scanner_config['device'] = device + scanner_config['sdr_type'] = sdr_type + scanner_config['bias_t'] = bias_t + + # Scanner teardown outside lock (blocking: thread join, process wait, pkill, sleep) + if need_scanner_teardown: + if scanner_thread_ref and scanner_thread_ref.is_alive(): + try: + scanner_thread_ref.join(timeout=2.0) + except Exception: + pass + if scanner_proc_ref and scanner_proc_ref.poll() is None: + try: + scanner_proc_ref.terminate() + scanner_proc_ref.wait(timeout=1) + except Exception: + try: + scanner_proc_ref.kill() + except Exception: + pass + try: + subprocess.run(['pkill', '-9', 'rtl_power'], capture_output=True, timeout=0.5) + except Exception: + pass + time.sleep(0.5) + + # Re-acquire lock for waterfall check and device claim + with audio_start_lock: + + # Preferred path: when waterfall WebSocket is active on the same SDR, + # derive monitor audio from that IQ stream instead of spawning rtl_fm. + try: + from routes.waterfall_websocket import ( + get_shared_capture_status, + start_shared_monitor_from_capture, + ) + + shared = get_shared_capture_status() + if shared.get('running') and shared.get('device') == device: + _stop_audio_stream() + ok, msg = start_shared_monitor_from_capture( + device=device, + frequency_mhz=frequency, + modulation=modulation, + squelch=squelch, + ) + if ok: + audio_running = True + audio_frequency = frequency + audio_modulation = modulation + audio_source = 'waterfall' + # Shared monitor uses the waterfall's existing SDR claim. + if receiver_active_device is not None: + app_module.release_sdr_device(receiver_active_device) + receiver_active_device = None + return jsonify({ + 'status': 'started', + 'frequency': frequency, + 'modulation': modulation, + 'source': 'waterfall', + 'request_token': request_token, + }) + logger.warning(f"Shared waterfall monitor unavailable: {msg}") + except Exception as e: + logger.debug(f"Shared waterfall monitor probe failed: {e}") + + # Stop waterfall if it's using the same SDR (SSE path) + if waterfall_running and waterfall_active_device == device: + _stop_waterfall_internal() + time.sleep(0.2) + + # Claim device for listening audio. The WebSocket waterfall handler + # may still be tearing down its IQ capture process (thread join + + # safe_terminate can take several seconds), so we retry with back-off + # to give the USB device time to be fully released. + if receiver_active_device is None or receiver_active_device != device: + if receiver_active_device is not None: + app_module.release_sdr_device(receiver_active_device) + receiver_active_device = None + + error = None + max_claim_attempts = 6 + for attempt in range(max_claim_attempts): + error = app_module.claim_sdr_device(device, 'receiver') + if not error: + break + if attempt < max_claim_attempts - 1: + logger.debug( + f"Device claim attempt {attempt + 1}/{max_claim_attempts} " + f"failed, retrying in 0.5s: {error}" + ) + time.sleep(0.5) + + if error: + return jsonify({ + 'status': 'error', + 'error_type': 'DEVICE_BUSY', + 'message': error + }), 409 + receiver_active_device = device + + _start_audio_stream( + frequency, + modulation, + device=device, + sdr_type=sdr_type, + gain=gain, + squelch=squelch, + bias_t=bias_t, + ) + + if audio_running: + audio_source = 'process' + return jsonify({ + 'status': 'started', + 'frequency': audio_frequency, + 'modulation': audio_modulation, + 'source': 'process', + 'request_token': request_token, + }) + + # Avoid leaving a stale device claim after startup failure. + if receiver_active_device is not None: + app_module.release_sdr_device(receiver_active_device) + receiver_active_device = None + + start_error = '' + for log_path in ('/tmp/rtl_fm_stderr.log', '/tmp/ffmpeg_stderr.log'): + try: + with open(log_path, 'r') as handle: + content = handle.read().strip() + if content: + start_error = content.splitlines()[-1] + break + except Exception: + continue + + message = 'Failed to start audio. Check SDR device.' + if start_error: + message = f'Failed to start audio: {start_error}' + return jsonify({ + 'status': 'error', + 'message': message + }), 500 @receiver_bp.route('/audio/stop', methods=['POST']) @@ -1584,6 +1627,17 @@ def audio_probe() -> Response: @receiver_bp.route('/audio/stream') def stream_audio() -> Response: """Stream WAV audio.""" + request_token_raw = request.args.get('request_token') + request_token = None + if request_token_raw is not None: + try: + request_token = int(request_token_raw) + except (ValueError, TypeError): + request_token = None + + if request_token is not None and request_token < audio_start_token: + return Response(b'', mimetype='audio/wav', status=204) + if audio_source == 'waterfall': for _ in range(40): if audio_running: @@ -1593,39 +1647,41 @@ def stream_audio() -> Response: if not audio_running: return Response(b'', mimetype='audio/wav', status=204) - def generate_shared(): - global audio_running, audio_source - try: - from routes.waterfall_websocket import ( - get_shared_capture_status, + def generate_shared(): + global audio_running, audio_source + try: + from routes.waterfall_websocket import ( + get_shared_capture_status, read_shared_monitor_audio_chunk, ) except Exception: return - # Browser expects an immediate WAV header. - yield _wav_header(sample_rate=48000) - inactive_since: float | None = None - - while audio_running and audio_source == 'waterfall': - chunk = read_shared_monitor_audio_chunk(timeout=1.0) - if chunk: - inactive_since = None - yield chunk - continue - shared = get_shared_capture_status() - if shared.get('running') and shared.get('monitor_enabled'): - inactive_since = None - continue - if inactive_since is None: - inactive_since = time.monotonic() - continue - if (time.monotonic() - inactive_since) < 4.0: - continue - if not shared.get('running') or not shared.get('monitor_enabled'): - audio_running = False - audio_source = 'process' - break + # Browser expects an immediate WAV header. + yield _wav_header(sample_rate=48000) + inactive_since: float | None = None + + while audio_running and audio_source == 'waterfall': + if request_token is not None and request_token < audio_start_token: + break + chunk = read_shared_monitor_audio_chunk(timeout=1.0) + if chunk: + inactive_since = None + yield chunk + continue + shared = get_shared_capture_status() + if shared.get('running') and shared.get('monitor_enabled'): + inactive_since = None + continue + if inactive_since is None: + inactive_since = time.monotonic() + continue + if (time.monotonic() - inactive_since) < 4.0: + continue + if not shared.get('running') or not shared.get('monitor_enabled'): + audio_running = False + audio_source = 'process' + break return Response( generate_shared(), @@ -1674,6 +1730,8 @@ def stream_audio() -> Response: first_chunk_deadline = time.time() + 20.0 warned_wait = False while audio_running and proc.poll() is None: + if request_token is not None and request_token < audio_start_token: + break # Use select to avoid blocking forever ready, _, _ = select.select([proc.stdout], [], [], 2.0) if ready: diff --git a/routes/morse.py b/routes/morse.py new file mode 100644 index 0000000..56800a2 --- /dev/null +++ b/routes/morse.py @@ -0,0 +1,251 @@ +"""CW/Morse code decoder routes.""" + +from __future__ import annotations + +import contextlib +import queue +import subprocess +import threading +from typing import Any + +from flask import Blueprint, Response, jsonify, request + +import app as app_module +from utils.event_pipeline import process_event +from utils.logging import sensor_logger as logger +from utils.morse import morse_decoder_thread +from utils.process import register_process, safe_terminate, unregister_process +from utils.sdr import SDRFactory, SDRType +from utils.sse import sse_stream_fanout +from utils.validation import ( + validate_device_index, + validate_frequency, + validate_gain, + validate_ppm, +) + +morse_bp = Blueprint('morse', __name__) + +# Track which device is being used +morse_active_device: int | None = None + + +def _validate_tone_freq(value: Any) -> float: + """Validate CW tone frequency (300-1200 Hz).""" + try: + freq = float(value) + if not 300 <= freq <= 1200: + raise ValueError("Tone frequency must be between 300 and 1200 Hz") + return freq + except (ValueError, TypeError) as e: + raise ValueError(f"Invalid tone frequency: {value}") from e + + +def _validate_wpm(value: Any) -> int: + """Validate words per minute (5-50).""" + try: + wpm = int(value) + if not 5 <= wpm <= 50: + raise ValueError("WPM must be between 5 and 50") + return wpm + except (ValueError, TypeError) as e: + raise ValueError(f"Invalid WPM: {value}") from e + + +@morse_bp.route('/morse/start', methods=['POST']) +def start_morse() -> Response: + global morse_active_device + + with app_module.morse_lock: + if app_module.morse_process: + return jsonify({'status': 'error', 'message': 'Morse decoder already running'}), 409 + + data = request.json or {} + + # Validate standard SDR inputs + try: + freq = validate_frequency(data.get('frequency', '14.060')) + gain = validate_gain(data.get('gain', '0')) + ppm = validate_ppm(data.get('ppm', '0')) + device = validate_device_index(data.get('device', '0')) + except ValueError as e: + return jsonify({'status': 'error', 'message': str(e)}), 400 + + # Validate Morse-specific inputs + try: + tone_freq = _validate_tone_freq(data.get('tone_freq', '700')) + except ValueError as e: + return jsonify({'status': 'error', 'message': str(e)}), 400 + + try: + wpm = _validate_wpm(data.get('wpm', '15')) + except ValueError as e: + return jsonify({'status': 'error', 'message': str(e)}), 400 + + # Claim SDR device + device_int = int(device) + error = app_module.claim_sdr_device(device_int, 'morse') + if error: + return jsonify({ + 'status': 'error', + 'error_type': 'DEVICE_BUSY', + 'message': error, + }), 409 + morse_active_device = device_int + + # Clear queue + while not app_module.morse_queue.empty(): + try: + app_module.morse_queue.get_nowait() + except queue.Empty: + break + + # Build rtl_fm USB demodulation command + sdr_type_str = data.get('sdr_type', 'rtlsdr') + try: + sdr_type = SDRType(sdr_type_str) + except ValueError: + sdr_type = SDRType.RTL_SDR + + sdr_device = SDRFactory.create_default_device(sdr_type, index=device) + builder = SDRFactory.get_builder(sdr_device.sdr_type) + + sample_rate = 8000 + bias_t = data.get('bias_t', False) + + rtl_cmd = builder.build_fm_demod_command( + device=sdr_device, + frequency_mhz=freq, + sample_rate=sample_rate, + gain=float(gain) if gain and gain != '0' else None, + ppm=int(ppm) if ppm and ppm != '0' else None, + modulation='usb', + bias_t=bias_t, + ) + + full_cmd = ' '.join(rtl_cmd) + logger.info(f"Morse decoder running: {full_cmd}") + + try: + rtl_process = subprocess.Popen( + rtl_cmd, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + ) + register_process(rtl_process) + + # Monitor rtl_fm stderr + def monitor_stderr(): + for line in rtl_process.stderr: + err_text = line.decode('utf-8', errors='replace').strip() + if err_text: + logger.debug(f"[rtl_fm/morse] {err_text}") + + stderr_thread = threading.Thread(target=monitor_stderr) + stderr_thread.daemon = True + stderr_thread.start() + + # Start Morse decoder thread + stop_event = threading.Event() + decoder_thread = threading.Thread( + target=morse_decoder_thread, + args=( + rtl_process.stdout, + app_module.morse_queue, + stop_event, + sample_rate, + tone_freq, + wpm, + ), + ) + decoder_thread.daemon = True + decoder_thread.start() + + app_module.morse_process = rtl_process + app_module.morse_process._stop_decoder = stop_event + app_module.morse_process._decoder_thread = decoder_thread + + app_module.morse_queue.put({'type': 'status', 'status': 'started'}) + + return jsonify({ + 'status': 'started', + 'command': full_cmd, + 'tone_freq': tone_freq, + 'wpm': wpm, + }) + + except FileNotFoundError as e: + if morse_active_device is not None: + app_module.release_sdr_device(morse_active_device) + morse_active_device = None + return jsonify({'status': 'error', 'message': f'Tool not found: {e.filename}'}), 400 + + except Exception as e: + # Clean up rtl_fm if it was started + try: + rtl_process.terminate() + rtl_process.wait(timeout=2) + except Exception: + with contextlib.suppress(Exception): + rtl_process.kill() + unregister_process(rtl_process) + if morse_active_device is not None: + app_module.release_sdr_device(morse_active_device) + morse_active_device = None + return jsonify({'status': 'error', 'message': str(e)}), 500 + + +@morse_bp.route('/morse/stop', methods=['POST']) +def stop_morse() -> Response: + global morse_active_device + + with app_module.morse_lock: + if app_module.morse_process: + # Signal decoder thread to stop + stop_event = getattr(app_module.morse_process, '_stop_decoder', None) + if stop_event: + stop_event.set() + + safe_terminate(app_module.morse_process) + unregister_process(app_module.morse_process) + app_module.morse_process = None + + if morse_active_device is not None: + app_module.release_sdr_device(morse_active_device) + morse_active_device = None + + app_module.morse_queue.put({'type': 'status', 'status': 'stopped'}) + return jsonify({'status': 'stopped'}) + + return jsonify({'status': 'not_running'}) + + +@morse_bp.route('/morse/status') +def morse_status() -> Response: + with app_module.morse_lock: + running = ( + app_module.morse_process is not None + and app_module.morse_process.poll() is None + ) + return jsonify({'running': running}) + + +@morse_bp.route('/morse/stream') +def morse_stream() -> Response: + def _on_msg(msg: dict[str, Any]) -> None: + process_event('morse', msg, msg.get('type')) + + response = Response( + sse_stream_fanout( + source_queue=app_module.morse_queue, + channel_key='morse', + timeout=1.0, + keepalive_interval=30.0, + on_message=_on_msg, + ), + mimetype='text/event-stream', + ) + response.headers['Cache-Control'] = 'no-cache' + response.headers['X-Accel-Buffering'] = 'no' + response.headers['Connection'] = 'keep-alive' + return response diff --git a/routes/rtlamr.py b/routes/rtlamr.py index 96bdc44..3acb291 100644 --- a/routes/rtlamr.py +++ b/routes/rtlamr.py @@ -138,36 +138,34 @@ def start_rtlamr() -> Response: output_format = data.get('format', 'json') # Start rtl_tcp first + rtl_tcp_just_started = False + rtl_tcp_cmd_str = '' with rtl_tcp_lock: if not rtl_tcp_process: logger.info("Starting rtl_tcp server...") try: rtl_tcp_cmd = ['rtl_tcp', '-a', '0.0.0.0'] - + # Add device index if not 0 if device and device != '0': rtl_tcp_cmd.extend(['-d', str(device)]) - + # Add gain if not auto if gain and gain != '0': rtl_tcp_cmd.extend(['-g', str(gain)]) - + # Add PPM correction if not 0 if ppm and ppm != '0': rtl_tcp_cmd.extend(['-p', str(ppm)]) - + rtl_tcp_process = subprocess.Popen( rtl_tcp_cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE ) register_process(rtl_tcp_process) - - # Wait a moment for rtl_tcp to start - time.sleep(3) - - logger.info(f"rtl_tcp started: {' '.join(rtl_tcp_cmd)}") - app_module.rtlamr_queue.put({'type': 'info', 'text': f'rtl_tcp: {" ".join(rtl_tcp_cmd)}'}) + rtl_tcp_just_started = True + rtl_tcp_cmd_str = ' '.join(rtl_tcp_cmd) except Exception as e: logger.error(f"Failed to start rtl_tcp: {e}") # Release SDR device on rtl_tcp failure @@ -176,6 +174,12 @@ def start_rtlamr() -> Response: rtlamr_active_device = None return jsonify({'status': 'error', 'message': f'Failed to start rtl_tcp: {e}'}), 500 + # Wait for rtl_tcp to start outside lock + if rtl_tcp_just_started: + time.sleep(3) + logger.info(f"rtl_tcp started: {rtl_tcp_cmd_str}") + app_module.rtlamr_queue.put({'type': 'info', 'text': f'rtl_tcp: {rtl_tcp_cmd_str}'}) + # Build rtlamr command cmd = [ 'rtlamr', @@ -258,25 +262,34 @@ def start_rtlamr() -> Response: def stop_rtlamr() -> Response: global rtl_tcp_process, rtlamr_active_device + # Grab process refs inside locks, clear state, then terminate outside + rtlamr_proc = None with app_module.rtlamr_lock: if app_module.rtlamr_process: - app_module.rtlamr_process.terminate() - try: - app_module.rtlamr_process.wait(timeout=2) - except subprocess.TimeoutExpired: - app_module.rtlamr_process.kill() + rtlamr_proc = app_module.rtlamr_process app_module.rtlamr_process = None + if rtlamr_proc: + rtlamr_proc.terminate() + try: + rtlamr_proc.wait(timeout=2) + except subprocess.TimeoutExpired: + rtlamr_proc.kill() + # Also stop rtl_tcp + tcp_proc = None with rtl_tcp_lock: if rtl_tcp_process: - rtl_tcp_process.terminate() - try: - rtl_tcp_process.wait(timeout=2) - except subprocess.TimeoutExpired: - rtl_tcp_process.kill() + tcp_proc = rtl_tcp_process rtl_tcp_process = None - logger.info("rtl_tcp stopped") + + if tcp_proc: + tcp_proc.terminate() + try: + tcp_proc.wait(timeout=2) + except subprocess.TimeoutExpired: + tcp_proc.kill() + logger.info("rtl_tcp stopped") # Release device from registry if rtlamr_active_device is not None: diff --git a/routes/tscm.py b/routes/tscm.py index 4f60b42..dd3a862 100644 --- a/routes/tscm.py +++ b/routes/tscm.py @@ -1345,7 +1345,7 @@ def _scan_rf_signals( sweep_ranges: list[dict] | None = None ) -> list[dict]: """ - Scan for RF signals using SDR (rtl_power). + Scan for RF signals using SDR (rtl_power or hackrf_sweep). Scans common surveillance frequency bands: - 88-108 MHz: FM broadcast (potential FM bugs) @@ -1375,39 +1375,50 @@ def _scan_rf_signals( logger.info(f"Starting RF scan (device={sdr_device})") + # Detect available SDR devices and sweep tools rtl_power_path = shutil.which('rtl_power') - if not rtl_power_path: - logger.warning("rtl_power not found in PATH, RF scanning unavailable") + hackrf_sweep_path = shutil.which('hackrf_sweep') + + sdr_type = None + sweep_tool_path = None + + try: + from utils.sdr import SDRFactory + from utils.sdr.base import SDRType + devices = SDRFactory.detect_devices() + rtlsdr_available = any(d.sdr_type == SDRType.RTL_SDR for d in devices) + hackrf_available = any(d.sdr_type == SDRType.HACKRF for d in devices) + except ImportError: + rtlsdr_available = False + hackrf_available = False + + # Pick the best available SDR + sweep tool combo + if rtlsdr_available and rtl_power_path: + sdr_type = 'rtlsdr' + sweep_tool_path = rtl_power_path + logger.info(f"Using RTL-SDR with rtl_power at: {rtl_power_path}") + elif hackrf_available and hackrf_sweep_path: + sdr_type = 'hackrf' + sweep_tool_path = hackrf_sweep_path + logger.info(f"Using HackRF with hackrf_sweep at: {hackrf_sweep_path}") + elif rtl_power_path: + # Tool exists but no device detected — try anyway (detection may have failed) + sdr_type = 'rtlsdr' + sweep_tool_path = rtl_power_path + logger.info(f"No SDR detected but rtl_power found, attempting RTL-SDR scan") + elif hackrf_sweep_path: + sdr_type = 'hackrf' + sweep_tool_path = hackrf_sweep_path + logger.info(f"No SDR detected but hackrf_sweep found, attempting HackRF scan") + + if not sweep_tool_path: + logger.warning("No supported sweep tool found (rtl_power or hackrf_sweep)") _emit_event('rf_status', { 'status': 'error', - 'message': 'rtl_power not installed. Install rtl-sdr package for RF scanning.', + 'message': 'No SDR sweep tool installed. Install rtl-sdr (rtl_power) or HackRF (hackrf_sweep) for RF scanning.', }) return signals - logger.info(f"Found rtl_power at: {rtl_power_path}") - - # Test if RTL-SDR device is accessible - rtl_test_path = shutil.which('rtl_test') - if rtl_test_path: - try: - test_result = subprocess.run( - [rtl_test_path, '-t'], - capture_output=True, - text=True, - timeout=5 - ) - if 'No supported devices found' in test_result.stderr or test_result.returncode != 0: - logger.warning("No RTL-SDR device found") - _emit_event('rf_status', { - 'status': 'error', - 'message': 'No RTL-SDR device connected. Connect an RTL-SDR dongle for RF scanning.', - }) - return signals - except subprocess.TimeoutExpired: - pass # Device might be busy, continue anyway - except Exception as e: - logger.debug(f"rtl_test check failed: {e}") - # Define frequency bands to scan (in Hz) # Format: (start_freq, end_freq, bin_size, description) scan_bands: list[tuple[int, int, int, str]] = [] @@ -1448,7 +1459,7 @@ def _scan_rf_signals( try: # Build device argument - device_arg = ['-d', str(sdr_device if sdr_device is not None else 0)] + device_idx = sdr_device if sdr_device is not None else 0 # Scan each band and look for strong signals for start_freq, end_freq, bin_size, band_name in scan_bands: @@ -1458,15 +1469,27 @@ def _scan_rf_signals( logger.info(f"Scanning {band_name} ({start_freq/1e6:.1f}-{end_freq/1e6:.1f} MHz)") try: - # Run rtl_power for a quick sweep of this band - cmd = [ - rtl_power_path, - '-f', f'{start_freq}:{end_freq}:{bin_size}', - '-g', '40', # Gain - '-i', '1', # Integration interval (1 second) - '-1', # Single shot mode - '-c', '20%', # Crop 20% of edges - ] + device_arg + [tmp_path] + # Build sweep command based on SDR type + if sdr_type == 'hackrf': + cmd = [ + sweep_tool_path, + '-f', f'{int(start_freq / 1e6)}:{int(end_freq / 1e6)}', + '-w', str(bin_size), + '-1', # Single sweep + ] + output_mode = 'stdout' + else: + cmd = [ + sweep_tool_path, + '-f', f'{start_freq}:{end_freq}:{bin_size}', + '-g', '40', # Gain + '-i', '1', # Integration interval (1 second) + '-1', # Single shot mode + '-c', '20%', # Crop 20% of edges + '-d', str(device_idx), + tmp_path, + ] + output_mode = 'file' logger.debug(f"Running: {' '.join(cmd)}") @@ -1478,9 +1501,14 @@ def _scan_rf_signals( ) if result.returncode != 0: - logger.warning(f"rtl_power returned {result.returncode}: {result.stderr}") + logger.warning(f"{os.path.basename(sweep_tool_path)} returned {result.returncode}: {result.stderr}") - # Parse the CSV output + # For HackRF, write stdout CSV data to temp file for unified parsing + if output_mode == 'stdout' and result.stdout: + with open(tmp_path, 'w') as f: + f.write(result.stdout) + + # Parse the CSV output (same format for both rtl_power and hackrf_sweep) if os.path.exists(tmp_path) and os.path.getsize(tmp_path) > 0: with open(tmp_path, 'r') as f: for line in f: @@ -1488,13 +1516,12 @@ def _scan_rf_signals( if len(parts) >= 7: try: # CSV format: date, time, hz_low, hz_high, hz_step, samples, db_values... - hz_low = int(parts[2]) - hz_high = int(parts[3]) - hz_step = float(parts[4]) + hz_low = int(parts[2].strip()) + hz_high = int(parts[3].strip()) + hz_step = float(parts[4].strip()) db_values = [float(x) for x in parts[6:] if x.strip()] # Find peaks above noise floor - # RTL-SDR dongles have higher noise figures, so use permissive thresholds noise_floor = sum(db_values) / len(db_values) if db_values else -100 threshold = noise_floor + 6 # Signal must be 6dB above noise diff --git a/routes/waterfall_websocket.py b/routes/waterfall_websocket.py index 15d14c9..de31227 100644 --- a/routes/waterfall_websocket.py +++ b/routes/waterfall_websocket.py @@ -1,7 +1,10 @@ """WebSocket-based waterfall streaming with I/Q capture and server-side FFT.""" +from __future__ import annotations + import json import queue +import shutil import socket import subprocess import threading @@ -544,6 +547,16 @@ def init_waterfall_websocket(app: Flask): })) continue + # Pre-flight: check the capture binary exists + if not shutil.which(iq_cmd[0]): + app_module.release_sdr_device(device_index) + claimed_device = None + ws.send(json.dumps({ + 'status': 'error', + 'message': f'Required tool "{iq_cmd[0]}" not found. Install SoapySDR tools (rx_sdr).', + })) + continue + # Spawn I/Q capture process (retry to handle USB release lag) max_attempts = 3 if was_restarting else 1 try: @@ -556,7 +569,7 @@ def init_waterfall_websocket(app: Flask): iq_process = subprocess.Popen( iq_cmd, stdout=subprocess.PIPE, - stderr=subprocess.DEVNULL, + stderr=subprocess.PIPE, bufsize=0, ) register_process(iq_process) @@ -564,17 +577,23 @@ def init_waterfall_websocket(app: Flask): # Brief check that process started time.sleep(0.3) if iq_process.poll() is not None: + stderr_out = '' + if iq_process.stderr: + with suppress(Exception): + stderr_out = iq_process.stderr.read().decode('utf-8', errors='replace').strip() unregister_process(iq_process) iq_process = None if attempt < max_attempts - 1: logger.info( f"I/Q process exited immediately, " f"retrying ({attempt + 1}/{max_attempts})..." + + (f" stderr: {stderr_out}" if stderr_out else "") ) time.sleep(0.5) continue + detail = f": {stderr_out}" if stderr_out else "" raise RuntimeError( - "I/Q capture process exited immediately" + f"I/Q capture process exited immediately{detail}" ) break # Process started successfully except Exception as e: diff --git a/routes/weather_sat.py b/routes/weather_sat.py index 6222b52..71fa96c 100644 --- a/routes/weather_sat.py +++ b/routes/weather_sat.py @@ -18,6 +18,7 @@ from utils.weather_sat import ( is_weather_sat_available, CaptureProgress, WEATHER_SATELLITES, + DEFAULT_SAMPLE_RATE, ) logger = get_logger('intercept.weather_sat') @@ -40,6 +41,35 @@ def _progress_callback(progress: CaptureProgress) -> None: pass +def _release_weather_sat_device(device_index: int) -> None: + """Release an SDR device only if weather-sat currently owns it.""" + if device_index < 0: + return + + try: + import app as app_module + except ImportError: + return + + owner = None + get_status = getattr(app_module, 'get_sdr_device_status', None) + if callable(get_status): + try: + owner = get_status().get(device_index) + except Exception: + owner = None + + if owner and owner != 'weather_sat': + logger.debug( + 'Skipping SDR release for device %s owned by %s', + device_index, + owner, + ) + return + + app_module.release_sdr_device(device_index) + + @weather_sat_bp.route('/status') def get_status(): """Get weather satellite decoder status. @@ -152,18 +182,15 @@ def start_capture(): decoder.set_callback(_progress_callback) def _release_device(): - try: - import app as app_module - app_module.release_sdr_device(device_index) - except ImportError: - pass + _release_weather_sat_device(device_index) decoder.set_on_complete(_release_device) - success = decoder.start( + success, error_msg = decoder.start( satellite=satellite, device_index=device_index, gain=gain, + sample_rate=DEFAULT_SAMPLE_RATE, bias_t=bias_t, ) @@ -181,7 +208,7 @@ def start_capture(): _release_device() return jsonify({ 'status': 'error', - 'message': 'Failed to start capture' + 'message': error_msg or 'Failed to start capture' }), 500 @@ -283,7 +310,7 @@ def test_decode(): decoder.set_callback(_progress_callback) decoder.set_on_complete(None) - success = decoder.start_from_file( + success, error_msg = decoder.start_from_file( satellite=satellite, input_file=input_file, sample_rate=sample_rate, @@ -302,7 +329,7 @@ def test_decode(): else: return jsonify({ 'status': 'error', - 'message': 'Failed to start file decode' + 'message': error_msg or 'Failed to start file decode' }), 500 @@ -318,12 +345,7 @@ def stop_capture(): decoder.stop() - # Release SDR device - try: - import app as app_module - app_module.release_sdr_device(device_index) - except ImportError: - pass + _release_weather_sat_device(device_index) return jsonify({'status': 'stopped'}) @@ -563,26 +585,26 @@ def enable_schedule(): 'message': 'Invalid parameter value' }), 400 - scheduler = get_weather_sat_scheduler() - scheduler.set_callbacks(_progress_callback, _scheduler_event_callback) - - try: - result = scheduler.enable( - lat=lat, - lon=lon, - min_elevation=min_elev, - device=device, - gain=gain_val, - bias_t=bool(data.get('bias_t', False)), - ) - except Exception as e: - logger.exception("Failed to enable weather sat scheduler") - return jsonify({ - 'status': 'error', - 'message': 'Failed to enable scheduler' - }), 500 - - return jsonify({'status': 'ok', **result}) + scheduler = get_weather_sat_scheduler() + scheduler.set_callbacks(_progress_callback, _scheduler_event_callback) + + try: + result = scheduler.enable( + lat=lat, + lon=lon, + min_elevation=min_elev, + device=device, + gain=gain_val, + bias_t=bool(data.get('bias_t', False)), + ) + except Exception as e: + logger.exception("Failed to enable weather sat scheduler") + return jsonify({ + 'status': 'error', + 'message': 'Failed to enable scheduler' + }), 500 + + return jsonify({'status': 'ok', **result}) @weather_sat_bp.route('/schedule/disable', methods=['POST']) diff --git a/routes/wefax.py b/routes/wefax.py new file mode 100644 index 0000000..497cdf0 --- /dev/null +++ b/routes/wefax.py @@ -0,0 +1,514 @@ +"""WeFax (Weather Fax) decoder routes. + +Provides endpoints for decoding HF weather fax transmissions from +maritime/aviation weather services worldwide. +""" + +from __future__ import annotations + +import queue + +from flask import Blueprint, Response, jsonify, request, send_file + +import app as app_module +from utils.logging import get_logger +from utils.sdr import SDRType +from utils.sse import sse_stream_fanout +from utils.validation import validate_frequency +from utils.wefax import get_wefax_decoder +from utils.wefax_stations import ( + WEFAX_USB_ALIGNMENT_OFFSET_KHZ, + get_current_broadcasts, + get_station, + load_stations, + resolve_tuning_frequency_khz, +) + +logger = get_logger('intercept.wefax') + +wefax_bp = Blueprint('wefax', __name__, url_prefix='/wefax') + +# SSE progress queue +_wefax_queue: queue.Queue = queue.Queue(maxsize=100) + +# Track active SDR device +wefax_active_device: int | None = None + + +def _progress_callback(data: dict) -> None: + """Callback to queue progress updates for SSE stream.""" + global wefax_active_device + + try: + _wefax_queue.put_nowait(data) + except queue.Full: + try: + _wefax_queue.get_nowait() + _wefax_queue.put_nowait(data) + except queue.Empty: + pass + + # Ensure manually claimed SDR devices are always released when a + # decode session ends on its own (complete/error/stopped). + if ( + isinstance(data, dict) + and data.get('type') == 'wefax_progress' + and data.get('status') in ('complete', 'error', 'stopped') + and wefax_active_device is not None + ): + app_module.release_sdr_device(wefax_active_device) + wefax_active_device = None + + +@wefax_bp.route('/status') +def get_status(): + """Get WeFax decoder status.""" + decoder = get_wefax_decoder() + return jsonify({ + 'available': True, + 'running': decoder.is_running, + 'image_count': len(decoder.get_images()), + }) + + +@wefax_bp.route('/start', methods=['POST']) +def start_decoder(): + """Start WeFax decoder. + + JSON body: + { + "frequency_khz": 4298, + "station": "NOJ", + "device": 0, + "gain": 40, + "ioc": 576, + "lpm": 120, + "direct_sampling": true, + "frequency_reference": "auto" // auto, carrier, or dial + } + """ + decoder = get_wefax_decoder() + + if decoder.is_running: + return jsonify({ + 'status': 'already_running', + 'message': 'WeFax decoder is already running', + }) + + # Clear queue + while not _wefax_queue.empty(): + try: + _wefax_queue.get_nowait() + except queue.Empty: + break + + data = request.get_json(silent=True) or {} + + # Validate frequency (required) + frequency_khz = data.get('frequency_khz') + if frequency_khz is None: + return jsonify({ + 'status': 'error', + 'message': 'frequency_khz is required', + }), 400 + + try: + frequency_khz = float(frequency_khz) + # WeFax operates on HF: 2-30 MHz (2000-30000 kHz) + freq_mhz = frequency_khz / 1000.0 + validate_frequency(freq_mhz, min_mhz=2.0, max_mhz=30.0) + except (TypeError, ValueError) as e: + return jsonify({ + 'status': 'error', + 'message': f'Invalid frequency: {e}', + }), 400 + + station = str(data.get('station', '')).strip() + device_index = data.get('device', 0) + gain = float(data.get('gain', 40.0)) + ioc = int(data.get('ioc', 576)) + lpm = int(data.get('lpm', 120)) + direct_sampling = bool(data.get('direct_sampling', True)) + frequency_reference = str(data.get('frequency_reference', 'auto')).strip().lower() + + sdr_type_str = str(data.get('sdr_type', 'rtlsdr')).lower() + try: + sdr_type = SDRType(sdr_type_str) + except ValueError: + sdr_type = SDRType.RTL_SDR + if not frequency_reference: + frequency_reference = 'auto' + + try: + tuned_frequency_khz, resolved_reference, usb_offset_applied = ( + resolve_tuning_frequency_khz( + listed_frequency_khz=frequency_khz, + station_callsign=station, + frequency_reference=frequency_reference, + ) + ) + tuned_mhz = tuned_frequency_khz / 1000.0 + validate_frequency(tuned_mhz, min_mhz=2.0, max_mhz=30.0) + except ValueError as e: + return jsonify({ + 'status': 'error', + 'message': f'Invalid frequency settings: {e}', + }), 400 + + # Validate IOC and LPM + if ioc not in (288, 576): + return jsonify({ + 'status': 'error', + 'message': 'IOC must be 288 or 576', + }), 400 + + if lpm not in (60, 120): + return jsonify({ + 'status': 'error', + 'message': 'LPM must be 60 or 120', + }), 400 + + # Claim SDR device + global wefax_active_device + device_int = int(device_index) + error = app_module.claim_sdr_device(device_int, 'wefax') + if error: + return jsonify({ + 'status': 'error', + 'error_type': 'DEVICE_BUSY', + 'message': error, + }), 409 + + # Set callback and start + decoder.set_callback(_progress_callback) + success = decoder.start( + frequency_khz=tuned_frequency_khz, + station=station, + device_index=device_int, + gain=gain, + ioc=ioc, + lpm=lpm, + direct_sampling=direct_sampling, + sdr_type=sdr_type_str, + ) + + if success: + wefax_active_device = device_int + return jsonify({ + 'status': 'started', + 'frequency_khz': frequency_khz, + 'tuned_frequency_khz': tuned_frequency_khz, + 'frequency_reference': resolved_reference, + 'usb_offset_applied': usb_offset_applied, + 'usb_offset_khz': ( + WEFAX_USB_ALIGNMENT_OFFSET_KHZ if usb_offset_applied else 0.0 + ), + 'station': station, + 'ioc': ioc, + 'lpm': lpm, + 'device': device_int, + }) + else: + app_module.release_sdr_device(device_int) + return jsonify({ + 'status': 'error', + 'message': 'Failed to start decoder', + }), 500 + + +@wefax_bp.route('/stop', methods=['POST']) +def stop_decoder(): + """Stop WeFax decoder.""" + global wefax_active_device + decoder = get_wefax_decoder() + decoder.stop() + + if wefax_active_device is not None: + app_module.release_sdr_device(wefax_active_device) + wefax_active_device = None + + return jsonify({'status': 'stopped'}) + + +@wefax_bp.route('/stream') +def stream_progress(): + """SSE stream of WeFax decode progress.""" + response = Response( + sse_stream_fanout( + source_queue=_wefax_queue, + channel_key='wefax', + timeout=1.0, + keepalive_interval=30.0, + ), + mimetype='text/event-stream', + ) + response.headers['Cache-Control'] = 'no-cache' + response.headers['X-Accel-Buffering'] = 'no' + response.headers['Connection'] = 'keep-alive' + return response + + +@wefax_bp.route('/images') +def list_images(): + """Get list of decoded WeFax images.""" + decoder = get_wefax_decoder() + images = decoder.get_images() + + limit = request.args.get('limit', type=int) + if limit and limit > 0: + images = images[-limit:] + + return jsonify({ + 'status': 'ok', + 'images': [img.to_dict() for img in images], + 'count': len(images), + }) + + +@wefax_bp.route('/images/') +def get_image(filename: str): + """Get a decoded WeFax image file.""" + decoder = get_wefax_decoder() + + if not filename.replace('_', '').replace('-', '').replace('.', '').isalnum(): + return jsonify({'status': 'error', 'message': 'Invalid filename'}), 400 + + if not filename.endswith('.png'): + return jsonify({'status': 'error', 'message': 'Only PNG files supported'}), 400 + + image_path = decoder._output_dir / filename + if not image_path.exists(): + return jsonify({'status': 'error', 'message': 'Image not found'}), 404 + + return send_file(image_path, mimetype='image/png') + + +@wefax_bp.route('/images/', methods=['DELETE']) +def delete_image(filename: str): + """Delete a decoded WeFax image.""" + decoder = get_wefax_decoder() + + if not filename.replace('_', '').replace('-', '').replace('.', '').isalnum(): + return jsonify({'status': 'error', 'message': 'Invalid filename'}), 400 + + if not filename.endswith('.png'): + return jsonify({'status': 'error', 'message': 'Only PNG files supported'}), 400 + + if decoder.delete_image(filename): + return jsonify({'status': 'ok'}) + else: + return jsonify({'status': 'error', 'message': 'Image not found'}), 404 + + +@wefax_bp.route('/images', methods=['DELETE']) +def delete_all_images(): + """Delete all decoded WeFax images.""" + decoder = get_wefax_decoder() + count = decoder.delete_all_images() + return jsonify({'status': 'ok', 'deleted': count}) + + +# ======================== +# Auto-Scheduler Endpoints +# ======================== + + +def _scheduler_event_callback(event: dict) -> None: + """Forward scheduler events to the SSE queue.""" + try: + _wefax_queue.put_nowait(event) + except queue.Full: + try: + _wefax_queue.get_nowait() + _wefax_queue.put_nowait(event) + except queue.Empty: + pass + + +@wefax_bp.route('/schedule/enable', methods=['POST']) +def enable_schedule(): + """Enable auto-scheduling of WeFax broadcast captures. + + JSON body: + { + "station": "NOJ", + "frequency_khz": 4298, + "device": 0, + "gain": 40, + "ioc": 576, + "lpm": 120, + "direct_sampling": true, + "frequency_reference": "auto" // auto, carrier, or dial + } + + Returns: + JSON with scheduler status. + """ + from utils.wefax_scheduler import get_wefax_scheduler + + data = request.get_json(silent=True) or {} + + station = str(data.get('station', '')).strip() + if not station: + return jsonify({ + 'status': 'error', + 'message': 'station is required', + }), 400 + + frequency_khz = data.get('frequency_khz') + if frequency_khz is None: + return jsonify({ + 'status': 'error', + 'message': 'frequency_khz is required', + }), 400 + + try: + frequency_khz = float(frequency_khz) + freq_mhz = frequency_khz / 1000.0 + validate_frequency(freq_mhz, min_mhz=2.0, max_mhz=30.0) + except (TypeError, ValueError) as e: + return jsonify({ + 'status': 'error', + 'message': f'Invalid frequency: {e}', + }), 400 + + device = int(data.get('device', 0)) + gain = float(data.get('gain', 40.0)) + ioc = int(data.get('ioc', 576)) + lpm = int(data.get('lpm', 120)) + direct_sampling = bool(data.get('direct_sampling', True)) + frequency_reference = str(data.get('frequency_reference', 'auto')).strip().lower() + if not frequency_reference: + frequency_reference = 'auto' + + try: + tuned_frequency_khz, resolved_reference, usb_offset_applied = ( + resolve_tuning_frequency_khz( + listed_frequency_khz=frequency_khz, + station_callsign=station, + frequency_reference=frequency_reference, + ) + ) + tuned_mhz = tuned_frequency_khz / 1000.0 + validate_frequency(tuned_mhz, min_mhz=2.0, max_mhz=30.0) + except ValueError as e: + return jsonify({ + 'status': 'error', + 'message': f'Invalid frequency settings: {e}', + }), 400 + + scheduler = get_wefax_scheduler() + scheduler.set_callbacks(_progress_callback, _scheduler_event_callback) + + try: + result = scheduler.enable( + station=station, + frequency_khz=tuned_frequency_khz, + device=device, + gain=gain, + ioc=ioc, + lpm=lpm, + direct_sampling=direct_sampling, + ) + except Exception: + logger.exception("Failed to enable WeFax scheduler") + return jsonify({ + 'status': 'error', + 'message': 'Failed to enable scheduler', + }), 500 + + return jsonify({ + 'status': 'ok', + **result, + 'frequency_khz': frequency_khz, + 'tuned_frequency_khz': tuned_frequency_khz, + 'frequency_reference': resolved_reference, + 'usb_offset_applied': usb_offset_applied, + 'usb_offset_khz': ( + WEFAX_USB_ALIGNMENT_OFFSET_KHZ if usb_offset_applied else 0.0 + ), + }) + + +@wefax_bp.route('/schedule/disable', methods=['POST']) +def disable_schedule(): + """Disable auto-scheduling.""" + from utils.wefax_scheduler import get_wefax_scheduler + + scheduler = get_wefax_scheduler() + result = scheduler.disable() + return jsonify(result) + + +@wefax_bp.route('/schedule/status') +def schedule_status(): + """Get current scheduler state.""" + from utils.wefax_scheduler import get_wefax_scheduler + + scheduler = get_wefax_scheduler() + return jsonify(scheduler.get_status()) + + +@wefax_bp.route('/schedule/broadcasts') +def schedule_broadcasts(): + """List scheduled broadcasts.""" + from utils.wefax_scheduler import get_wefax_scheduler + + scheduler = get_wefax_scheduler() + broadcasts = scheduler.get_broadcasts() + return jsonify({ + 'status': 'ok', + 'broadcasts': broadcasts, + 'count': len(broadcasts), + }) + + +@wefax_bp.route('/schedule/skip/', methods=['POST']) +def skip_broadcast(broadcast_id: str): + """Skip a scheduled broadcast.""" + from utils.wefax_scheduler import get_wefax_scheduler + + if not broadcast_id.replace('_', '').replace('-', '').isalnum(): + return jsonify({ + 'status': 'error', + 'message': 'Invalid broadcast ID', + }), 400 + + scheduler = get_wefax_scheduler() + if scheduler.skip_broadcast(broadcast_id): + return jsonify({'status': 'skipped', 'broadcast_id': broadcast_id}) + else: + return jsonify({ + 'status': 'error', + 'message': 'Broadcast not found or already processed', + }), 404 + + +@wefax_bp.route('/stations') +def list_stations(): + """Get all WeFax stations from the database.""" + stations = load_stations() + return jsonify({ + 'status': 'ok', + 'stations': stations, + 'count': len(stations), + }) + + +@wefax_bp.route('/stations/') +def station_detail(callsign: str): + """Get station detail including current schedule info.""" + station = get_station(callsign) + if not station: + return jsonify({ + 'status': 'error', + 'message': f'Station {callsign} not found', + }), 404 + + current = get_current_broadcasts(callsign) + + return jsonify({ + 'status': 'ok', + 'station': station, + 'current_broadcasts': current, + }) diff --git a/static/css/index.css b/static/css/index.css index 84987cc..9224adb 100644 --- a/static/css/index.css +++ b/static/css/index.css @@ -202,10 +202,38 @@ body { } .welcome-container { + position: relative; width: 90%; max-width: 900px; z-index: 1; animation: welcomeFadeIn 0.8s ease-out; + max-height: calc(100vh - 40px); + overflow: hidden; +} + +.welcome-settings-btn { + position: absolute; + top: 12px; + right: 12px; + z-index: 2; + background: none; + border: none; + cursor: pointer; + padding: 6px; + border-radius: 6px; + color: var(--text-dim, rgba(255, 255, 255, 0.3)); + transition: color 0.2s, background 0.2s; +} + +.welcome-settings-btn:hover { + color: var(--accent-cyan, #00d4ff); + background: rgba(255, 255, 255, 0.05); +} + +.welcome-settings-btn svg { + width: 20px; + height: 20px; + display: block; } @keyframes welcomeFadeIn { @@ -232,6 +260,7 @@ body { .welcome-logo { animation: logoPulse 3s ease-in-out infinite; + will-change: filter; } @keyframes logoPulse { @@ -332,6 +361,7 @@ body { padding: 20px; max-height: calc(100vh - 300px); overflow-y: auto; + scrollbar-gutter: stable; } .changelog-release { @@ -1559,6 +1589,7 @@ header h1 .tagline { overflow: hidden; padding: 12px; position: relative; + flex-shrink: 0; } .section h3 { @@ -1744,6 +1775,7 @@ header h1 .tagline { } .run-btn { + flex-shrink: 0; width: 100%; padding: 12px; background: var(--accent-green); @@ -1781,6 +1813,7 @@ header h1 .tagline { } .stop-btn { + flex-shrink: 0; width: 100%; padding: 12px; background: var(--accent-red); diff --git a/static/css/modes/gps.css b/static/css/modes/gps.css index 22f40d9..cd7449f 100644 --- a/static/css/modes/gps.css +++ b/static/css/modes/gps.css @@ -151,8 +151,17 @@ overflow: hidden; } +.gps-sky-globe { + position: absolute; + inset: 0; + width: 100%; + height: 100%; +} + #gpsSkyCanvas { - display: block; + position: absolute; + inset: 0; + display: none; width: 100%; height: 100%; cursor: grab; @@ -166,10 +175,50 @@ .gps-sky-overlay { position: absolute; inset: 0; + display: none; pointer-events: none; font-family: var(--font-mono); } +.gps-skyview-canvas-wrap.gps-sky-fallback .gps-sky-globe { + display: none; +} + +.gps-skyview-canvas-wrap.gps-sky-fallback #gpsSkyCanvas, +.gps-skyview-canvas-wrap.gps-sky-fallback .gps-sky-overlay { + display: block; +} + +.gps-globe-sat-icon { + --sat-size: 18px; + --sat-color: #8ea6bd; + width: var(--sat-size); + height: var(--sat-size); + transform: translate(-50%, -50%); + display: inline-flex; + align-items: center; + justify-content: center; + border-radius: 50%; + border: 1px solid var(--sat-color); + background: radial-gradient(circle at 35% 30%, rgba(255, 255, 255, 0.22), rgba(7, 14, 23, 0.82) 72%); + box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.35), 0 0 12px var(--sat-color); +} + +.gps-globe-sat-icon img { + width: 76%; + height: 76%; + object-fit: contain; +} + +.gps-globe-sat-icon.used { + opacity: 0.98; +} + +.gps-globe-sat-icon.unused { + opacity: 0.72; + box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.35), 0 0 6px var(--sat-color); +} + .gps-sky-label { position: absolute; transform: translate(-50%, -50%); diff --git a/static/css/modes/morse.css b/static/css/modes/morse.css new file mode 100644 index 0000000..844bf49 --- /dev/null +++ b/static/css/modes/morse.css @@ -0,0 +1,118 @@ +/* Morse Code / CW Decoder Styles */ + +/* Scope canvas container */ +.morse-scope-container { + background: var(--bg-primary); + border: 1px solid var(--border-color); + border-radius: 6px; + padding: 8px; + margin-bottom: 12px; +} + +.morse-scope-container canvas { + width: 100%; + height: 80px; + display: block; + border-radius: 4px; +} + +/* Decoded text panel */ +.morse-decoded-panel { + background: var(--bg-primary); + border: 1px solid var(--border-color); + border-radius: 6px; + padding: 12px; + min-height: 120px; + max-height: 400px; + overflow-y: auto; + font-family: var(--font-mono); + font-size: 18px; + line-height: 1.6; + color: var(--text-primary); + word-wrap: break-word; +} + +.morse-decoded-panel:empty::before { + content: 'Decoded text will appear here...'; + color: var(--text-dim); + font-size: 14px; + font-style: italic; +} + +/* Individual decoded character with fade-in */ +.morse-char { + display: inline; + animation: morseFadeIn 0.3s ease-out; + position: relative; +} + +@keyframes morseFadeIn { + from { + opacity: 0; + color: var(--accent-cyan); + } + to { + opacity: 1; + color: var(--text-primary); + } +} + +/* Small Morse notation above character */ +.morse-char-morse { + font-size: 9px; + color: var(--text-dim); + letter-spacing: 1px; + display: block; + line-height: 1; + margin-bottom: -2px; +} + +/* Reference grid */ +.morse-ref-grid { + transition: max-height 0.3s ease, opacity 0.3s ease; + max-height: 500px; + opacity: 1; + overflow: hidden; +} + +.morse-ref-grid.collapsed { + max-height: 0; + opacity: 0; +} + +/* Toolbar: export/copy/clear */ +.morse-toolbar { + display: flex; + gap: 6px; + margin-bottom: 8px; + flex-wrap: wrap; +} + +.morse-toolbar .btn { + font-size: 11px; + padding: 4px 10px; +} + +/* Status bar at bottom */ +.morse-status-bar { + display: flex; + justify-content: space-between; + align-items: center; + font-size: 11px; + color: var(--text-dim); + padding: 6px 0; + border-top: 1px solid var(--border-color); + margin-top: 8px; +} + +.morse-status-bar .status-item { + display: flex; + align-items: center; + gap: 4px; +} + +/* Word space styling */ +.morse-word-space { + display: inline; + width: 0.5em; +} diff --git a/static/css/modes/wefax.css b/static/css/modes/wefax.css new file mode 100644 index 0000000..14462f7 --- /dev/null +++ b/static/css/modes/wefax.css @@ -0,0 +1,687 @@ +/* ============================================ + WeFax (Weather Fax) Mode Styles + Amber/gold theme (#ffaa00) for HF + ============================================ */ + +/* Place WeFax sidebar panel above the shared SDR Device section + while keeping the collapse button at the very top. */ +#wefaxMode.active { + order: -1; +} +.sidebar:has(#wefaxMode.active) > .sidebar-collapse-btn { + order: -2; +} + +/* --- Stats Strip --- */ +.wefax-stats-strip { + display: flex; + align-items: center; + gap: 12px; + padding: 8px 14px; + background: var(--bg-card, #0e1117); + border: 1px solid var(--border-color, #1e2a3a); + border-radius: 6px; + margin-bottom: 12px; + flex-wrap: wrap; +} + +.wefax-strip-group { + display: flex; + align-items: center; + gap: 10px; +} + +.wefax-strip-status { + display: flex; + align-items: center; + gap: 6px; +} + +.wefax-strip-dot { + width: 8px; + height: 8px; + border-radius: 50%; + background: #444; + flex-shrink: 0; +} + +.wefax-strip-dot.scanning { background: #ffaa00; animation: wefax-pulse 1.5s ease-in-out infinite; } +.wefax-strip-dot.phasing { background: #ffcc44; animation: wefax-pulse 0.8s ease-in-out infinite; } +.wefax-strip-dot.receiving { background: #00cc66; animation: wefax-pulse 1s ease-in-out infinite; } +.wefax-strip-dot.complete { background: #00cc66; } +.wefax-strip-dot.error { background: #f44; } + +@keyframes wefax-pulse { + 0%, 100% { opacity: 1; } + 50% { opacity: 0.4; } +} + +.wefax-strip-status-text { + font-family: var(--font-mono, 'JetBrains Mono', monospace); + font-size: 11px; + color: var(--text-primary, #e0e0e0); + text-transform: uppercase; + letter-spacing: 0.5px; +} + +.wefax-strip-btn { + padding: 4px 12px; + border: 1px solid var(--border-color, #1e2a3a); + border-radius: 4px; + font-family: var(--font-mono, monospace); + font-size: 10px; + text-transform: uppercase; + letter-spacing: 0.5px; + cursor: pointer; + background: var(--bg-primary, #161b22); + color: var(--text-primary, #e0e0e0); + display: inline-flex; + align-items: center; + gap: 4px; + transition: all 0.15s ease; +} + +.wefax-strip-btn.start { color: #ffaa00; border-color: #ffaa0044; } +.wefax-strip-btn.start:hover { background: #ffaa0015; border-color: #ffaa00; } +.wefax-strip-btn.start.wefax-strip-btn-error { + border-color: #ffaa00; + color: #ffaa00; + box-shadow: 0 0 8px rgba(255, 170, 0, 0.3); + animation: wefax-pulse 0.6s ease-in-out 3; +} + +.wefax-strip-btn.stop { color: #f44; border-color: #f4444444; } +.wefax-strip-btn.stop:hover { background: #f4441a; border-color: #f44; } + +.wefax-strip-divider { + width: 1px; + height: 20px; + background: var(--border-color, #1e2a3a); +} + +.wefax-strip-stat { + display: flex; + flex-direction: column; + align-items: center; + gap: 1px; +} + +.wefax-strip-value { + font-family: var(--font-mono, monospace); + font-size: 13px; + font-weight: 600; + color: var(--text-primary, #e0e0e0); + font-variant-numeric: tabular-nums; +} + +.wefax-strip-value.accent-amber { color: #ffaa00; } + +.wefax-strip-label { + font-family: var(--font-mono, monospace); + font-size: 8px; + color: var(--text-dim, #555); + text-transform: uppercase; + letter-spacing: 1px; +} + +/* --- Schedule Toggle --- */ +.wefax-schedule-toggle { + display: flex; + align-items: center; + gap: 6px; + cursor: pointer; + font-size: 10px; + font-family: var(--font-mono, 'JetBrains Mono', monospace); + color: var(--text-dim, #666); + text-transform: uppercase; + letter-spacing: 0.5px; +} + +.wefax-schedule-toggle input[type="checkbox"] { + width: 14px; + height: 14px; + cursor: pointer; + accent-color: #ffaa00; +} + +.wefax-schedule-toggle input:checked + span { + color: #ffaa00; +} + +/* --- Visuals Container --- */ +.wefax-visuals-container { + display: flex; + flex-direction: column; + gap: 0; + width: 100%; +} + +/* --- Main Row --- */ +.wefax-main-row { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 12px; +} + +/* --- Schedule Timeline --- */ +.wefax-schedule-panel { + background: var(--bg-card, #0e1117); + border: 1px solid var(--border-color, #1e2a3a); + border-radius: 6px; + margin-bottom: 12px; + overflow: hidden; +} + +.wefax-schedule-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 8px 12px; + border-bottom: 1px solid var(--border-color, #1e2a3a); +} + +.wefax-schedule-title { + font-family: var(--font-mono, monospace); + font-size: 11px; + text-transform: uppercase; + letter-spacing: 1px; + color: #ffaa00; +} + +.wefax-schedule-list { + display: flex; + flex-direction: column; + max-height: 200px; + overflow-y: auto; +} + +.wefax-schedule-entry { + display: flex; + align-items: center; + gap: 10px; + padding: 6px 12px; + border-bottom: 1px solid var(--border-color, #1e2a3a)11; + font-family: var(--font-mono, monospace); + font-size: 11px; +} + +.wefax-schedule-entry:last-child { border-bottom: none; } + +.wefax-schedule-entry.active { + background: #ffaa0010; + border-left: 3px solid #ffaa00; +} + +.wefax-schedule-entry.upcoming { + background: #ffaa0008; +} + +.wefax-schedule-entry.past { + opacity: 0.4; +} + +.wefax-schedule-time { + color: #ffaa00; + min-width: 45px; + font-variant-numeric: tabular-nums; +} + +.wefax-schedule-content { + color: var(--text-primary, #e0e0e0); + flex: 1; +} + +.wefax-schedule-badge { + font-size: 9px; + padding: 1px 6px; + border-radius: 3px; + background: var(--border-color, #1e2a3a); + color: var(--text-dim, #555); +} + +.wefax-schedule-badge.live { + background: #ffaa0030; + color: #ffaa00; + font-weight: 600; +} + +.wefax-schedule-badge.soon { + background: #ffaa0015; + color: #ffcc66; +} + +.wefax-schedule-empty { + padding: 16px; + text-align: center; + color: var(--text-dim, #555); + font-size: 11px; + font-family: var(--font-mono, monospace); +} + +/* --- Live Section --- */ +.wefax-live-section { + background: var(--bg-card, #0e1117); + border: 1px solid var(--border-color, #1e2a3a); + border-radius: 6px; + overflow: hidden; +} + +.wefax-live-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 8px 12px; + border-bottom: 1px solid var(--border-color, #1e2a3a); +} + +.wefax-live-title { + font-family: var(--font-mono, monospace); + font-size: 11px; + text-transform: uppercase; + letter-spacing: 1px; + color: #ffaa00; +} + +.wefax-live-content { + padding: 12px; + min-height: 200px; + display: flex; + align-items: center; + justify-content: center; +} + +.wefax-idle-state { + text-align: center; + color: var(--text-dim, #555); +} + +.wefax-idle-state svg { + width: 48px; + height: 48px; + color: #ffaa0033; + margin-bottom: 12px; +} + +.wefax-idle-state h4 { + margin: 0 0 4px; + color: var(--text-primary, #e0e0e0); + font-size: 13px; +} + +.wefax-idle-state p { + margin: 0; + font-size: 11px; +} + +.wefax-live-preview { + max-width: 100%; + max-height: 400px; + border-radius: 4px; + image-rendering: pixelated; +} + +/* --- Gallery Section --- */ +.wefax-gallery-section { + background: var(--bg-card, #0e1117); + border: 1px solid var(--border-color, #1e2a3a); + border-radius: 6px; + overflow: hidden; +} + +.wefax-gallery-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 8px 12px; + border-bottom: 1px solid var(--border-color, #1e2a3a); +} + +.wefax-gallery-title { + font-family: var(--font-mono, monospace); + font-size: 11px; + text-transform: uppercase; + letter-spacing: 1px; + color: #ffaa00; +} + +.wefax-gallery-controls { + display: flex; + align-items: center; + gap: 8px; +} + +.wefax-gallery-count { + font-family: var(--font-mono, monospace); + font-size: 11px; + color: var(--text-dim, #555); +} + +.wefax-gallery-clear-btn { + font-family: var(--font-mono, monospace); + font-size: 9px; + text-transform: uppercase; + letter-spacing: 0.5px; + background: none; + border: 1px solid var(--border-color, #1e2a3a); + color: var(--text-dim, #555); + padding: 2px 8px; + border-radius: 3px; + cursor: pointer; +} + +.wefax-gallery-clear-btn:hover { + border-color: #f44; + color: #f44; +} + +.wefax-gallery-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(180px, 1fr)); + gap: 8px; + padding: 10px; + max-height: 500px; + overflow-y: auto; +} + +.wefax-gallery-empty { + padding: 24px; + text-align: center; + color: var(--text-dim, #555); + font-size: 11px; + font-family: var(--font-mono, monospace); + grid-column: 1 / -1; +} + +.wefax-gallery-item { + position: relative; + background: var(--bg-primary, #161b22); + border: 1px solid var(--border-color, #1e2a3a); + border-radius: 4px; + overflow: hidden; +} + +.wefax-gallery-item img { + width: 100%; + aspect-ratio: 4/3; + object-fit: cover; + cursor: pointer; + display: block; +} + +.wefax-gallery-item img:hover { + opacity: 0.85; +} + +.wefax-gallery-meta { + padding: 4px 6px; + display: flex; + flex-direction: column; + gap: 1px; + font-family: var(--font-mono, monospace); + font-size: 9px; + color: var(--text-dim, #555); +} + +.wefax-gallery-actions { + position: absolute; + top: 4px; + right: 4px; + display: flex; + gap: 4px; + opacity: 0; + transition: opacity 0.15s; +} + +.wefax-gallery-item:hover .wefax-gallery-actions { + opacity: 1; +} + +.wefax-gallery-action { + width: 22px; + height: 22px; + border-radius: 3px; + border: none; + background: rgba(0, 0, 0, 0.7); + color: #ccc; + font-size: 14px; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + text-decoration: none; +} + +.wefax-gallery-action:hover { color: #fff; } +.wefax-gallery-action.delete:hover { color: #f44; } + +/* --- Countdown Bar + Timeline --- */ +.wefax-countdown-bar { + display: flex; + align-items: center; + gap: 16px; + padding: 10px 16px; + background: var(--bg-secondary, #141820); + border: 1px solid var(--border-color, #1e2a3a); + border-radius: 6px; + margin-bottom: 12px; +} + +.wefax-countdown-next { + display: flex; + align-items: center; + gap: 12px; + flex-shrink: 0; +} + +.wefax-countdown-boxes { + display: flex; + gap: 4px; +} + +.wefax-countdown-box { + display: flex; + flex-direction: column; + align-items: center; + padding: 4px 8px; + background: var(--bg-primary, #0d1117); + border: 1px solid var(--border-color, #2a3040); + border-radius: 4px; + min-width: 40px; +} + +.wefax-countdown-box.imminent { + border-color: #ffaa00; + box-shadow: 0 0 8px rgba(255, 170, 0, 0.2); +} + +.wefax-countdown-box.active { + border-color: #ffaa00; + box-shadow: 0 0 8px rgba(255, 170, 0, 0.3); + animation: wefax-glow 1.5s ease-in-out infinite; +} + +@keyframes wefax-glow { + 0%, 100% { box-shadow: 0 0 8px rgba(255, 170, 0, 0.3); } + 50% { box-shadow: 0 0 16px rgba(255, 170, 0, 0.5); } +} + +.wefax-cd-value { + font-size: 16px; + font-weight: 700; + font-family: 'Roboto Condensed', 'Arial Narrow', sans-serif; + color: var(--text-primary, #e0e0e0); + line-height: 1; +} + +.wefax-cd-unit { + font-size: 8px; + color: var(--text-dim, #666); + text-transform: uppercase; + letter-spacing: 0.5px; + margin-top: 2px; +} + +.wefax-countdown-info { + display: flex; + flex-direction: column; + gap: 2px; +} + +.wefax-countdown-content { + font-size: 12px; + font-weight: 600; + color: #ffaa00; + font-family: 'Roboto Condensed', 'Arial Narrow', sans-serif; +} + +.wefax-countdown-detail { + font-size: 10px; + color: var(--text-dim, #666); + font-family: 'Roboto Condensed', 'Arial Narrow', sans-serif; +} + +.wefax-timeline { + flex: 1; + position: relative; + height: 36px; + min-width: 200px; +} + +.wefax-timeline-track { + position: absolute; + top: 4px; + left: 0; + right: 0; + height: 16px; + background: var(--bg-primary, #0d1117); + border: 1px solid var(--border-color, #2a3040); + border-radius: 3px; + overflow: hidden; +} + +.wefax-timeline-broadcast { + position: absolute; + top: 0; + height: 100%; + background: rgba(255, 170, 0, 0.5); + border-radius: 2px; + cursor: default; + opacity: 0.8; + min-width: 2px; +} + +.wefax-timeline-broadcast:hover { + opacity: 1; +} + +.wefax-timeline-broadcast.active { + background: rgba(255, 170, 0, 0.85); + border: 1px solid #ffaa00; +} + +.wefax-timeline-cursor { + position: absolute; + top: 2px; + width: 2px; + height: 20px; + background: #ff4444; + border-radius: 1px; + z-index: 2; +} + +.wefax-timeline-labels { + position: absolute; + bottom: 0; + left: 0; + right: 0; + display: flex; + justify-content: space-between; + font-size: 8px; + color: var(--text-dim, #666); + font-family: 'Roboto Condensed', 'Arial Narrow', sans-serif; +} + +/* --- Image Modal --- */ +.wefax-image-modal { + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: rgba(0, 0, 0, 0.9); + display: none; + align-items: center; + justify-content: center; + z-index: 10000; + padding: 40px; +} + +.wefax-image-modal.show { + display: flex; +} + +.wefax-image-modal img { + max-width: 100%; + max-height: 100%; + border: 1px solid var(--border-color); + border-radius: 4px; +} + +.wefax-modal-toolbar { + position: absolute; + top: 20px; + right: 60px; + display: flex; + gap: 8px; + z-index: 1; +} + +.wefax-modal-btn { + display: flex; + align-items: center; + gap: 6px; + font-family: var(--font-mono); + font-size: 10px; + padding: 6px 12px; + background: rgba(255, 255, 255, 0.1); + border: 1px solid rgba(255, 255, 255, 0.2); + border-radius: 4px; + color: white; + cursor: pointer; + transition: all 0.15s; + text-transform: uppercase; +} + +.wefax-modal-btn:hover { + background: rgba(255, 255, 255, 0.2); +} + +.wefax-modal-btn.delete:hover { + background: var(--accent-red, #ff3366); + border-color: var(--accent-red, #ff3366); +} + +.wefax-modal-close { + position: absolute; + top: 20px; + right: 20px; + background: none; + border: none; + color: white; + font-size: 32px; + cursor: pointer; + opacity: 0.7; + transition: opacity 0.15s; + z-index: 1; +} + +.wefax-modal-close:hover { + opacity: 1; +} + +/* --- Responsive --- */ +@media (max-width: 768px) { + .wefax-main-row { + grid-template-columns: 1fr; + } +} diff --git a/static/images/globe/satellite-icon.svg b/static/images/globe/satellite-icon.svg new file mode 100644 index 0000000..3245e9b --- /dev/null +++ b/static/images/globe/satellite-icon.svg @@ -0,0 +1,50 @@ + + Satellite + Professional satellite icon with solar panels and body + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/static/js/core/alerts.js b/static/js/core/alerts.js index 4c3f8a1..a0ebbb3 100644 --- a/static/js/core/alerts.js +++ b/static/js/core/alerts.js @@ -7,6 +7,7 @@ const AlertCenter = (function() { let rules = []; let eventSource = null; let reconnectTimer = null; + let lastConnectionWarningAt = 0; function init() { loadRules(); @@ -31,7 +32,14 @@ const AlertCenter = (function() { }; eventSource.onerror = function() { - console.warn('[Alerts] SSE connection error'); + const now = Date.now(); + const offline = (typeof window.isOffline === 'function' && window.isOffline()) || + (typeof navigator !== 'undefined' && navigator.onLine === false); + const shouldLog = !offline && !document.hidden && (now - lastConnectionWarningAt) > 15000; + if (shouldLog) { + lastConnectionWarningAt = now; + console.warn('[Alerts] SSE connection error; retrying'); + } if (reconnectTimer) clearTimeout(reconnectTimer); reconnectTimer = setTimeout(connect, 2500); }; diff --git a/static/js/core/run-state.js b/static/js/core/run-state.js index b9ea250..c481934 100644 --- a/static/js/core/run-state.js +++ b/static/js/core/run-state.js @@ -92,8 +92,9 @@ const RunState = (function() { renderHealth(data); } catch (err) { renderHealth(null, err); + const transient = isTransientFailure(err); const now = Date.now(); - if (typeof reportActionableError === 'function' && (now - lastErrorToastAt) > 30000) { + if (!transient && typeof reportActionableError === 'function' && (now - lastErrorToastAt) > 30000) { lastErrorToastAt = now; reportActionableError('Run State', err, { persistent: false }); } @@ -214,6 +215,17 @@ const RunState = (function() { return String(err); } + function isTransientFailure(err) { + if (typeof window.isTransientOrOffline === 'function' && window.isTransientOrOffline(err)) { + return true; + } + if (typeof navigator !== 'undefined' && navigator.onLine === false) { + return true; + } + const text = extractMessage(err).toLowerCase(); + return text.includes('failed to fetch') || text.includes('network') || text.includes('timeout'); + } + function getLastHealth() { return lastHealth; } diff --git a/static/js/core/settings-manager.js b/static/js/core/settings-manager.js index 48edca2..c3faebe 100644 --- a/static/js/core/settings-manager.js +++ b/static/js/core/settings-manager.js @@ -1281,6 +1281,7 @@ function loadVoiceAlertConfig() { const pager = document.getElementById('voiceCfgPager'); const tscm = document.getElementById('voiceCfgTscm'); const tracker = document.getElementById('voiceCfgTracker'); + const military = document.getElementById('voiceCfgAdsbMilitary'); const squawk = document.getElementById('voiceCfgSquawk'); const rate = document.getElementById('voiceCfgRate'); const pitch = document.getElementById('voiceCfgPitch'); @@ -1290,6 +1291,7 @@ function loadVoiceAlertConfig() { if (pager) pager.checked = cfg.streams.pager !== false; if (tscm) tscm.checked = cfg.streams.tscm !== false; if (tracker) tracker.checked = cfg.streams.bluetooth !== false; + if (military) military.checked = cfg.streams.adsb_military !== false; if (squawk) squawk.checked = cfg.streams.squawks !== false; if (rate) rate.value = cfg.rate; if (pitch) pitch.value = cfg.pitch; @@ -1314,10 +1316,11 @@ function saveVoiceAlertConfig() { pitch: parseFloat(document.getElementById('voiceCfgPitch')?.value) || 0.9, voiceName: document.getElementById('voiceCfgVoice')?.value || '', streams: { - pager: !!document.getElementById('voiceCfgPager')?.checked, - tscm: !!document.getElementById('voiceCfgTscm')?.checked, - bluetooth: !!document.getElementById('voiceCfgTracker')?.checked, - squawks: !!document.getElementById('voiceCfgSquawk')?.checked, + pager: !!document.getElementById('voiceCfgPager')?.checked, + tscm: !!document.getElementById('voiceCfgTscm')?.checked, + bluetooth: !!document.getElementById('voiceCfgTracker')?.checked, + adsb_military: !!document.getElementById('voiceCfgAdsbMilitary')?.checked, + squawks: !!document.getElementById('voiceCfgSquawk')?.checked, }, }); } diff --git a/static/js/core/ui-feedback.js b/static/js/core/ui-feedback.js index c39d53f..7b8c12f 100644 --- a/static/js/core/ui-feedback.js +++ b/static/js/core/ui-feedback.js @@ -208,9 +208,31 @@ const AppFeedback = (function() { return state; } + function isOffline() { + return typeof navigator !== 'undefined' && navigator.onLine === false; + } + + function isTransientNetworkError(error) { + const text = String(extractMessage(error) || '').toLowerCase(); + if (!text) return false; + + return text.includes('networkerror') || + text.includes('failed to fetch') || + text.includes('network request failed') || + text.includes('load failed') || + text.includes('err_network_io_suspended') || + text.includes('network io suspended') || + text.includes('the network connection was lost') || + text.includes('connection reset') || + text.includes('timeout'); + } + + function isTransientOrOffline(error) { + return isOffline() || isTransientNetworkError(error); + } + function isNetworkError(message) { - const text = String(message || '').toLowerCase(); - return text.includes('networkerror') || text.includes('failed to fetch') || text.includes('timeout'); + return isTransientNetworkError(message); } function isSettingsError(message) { @@ -224,6 +246,9 @@ const AppFeedback = (function() { reportError, removeToast, renderCollectionState, + isOffline, + isTransientNetworkError, + isTransientOrOffline, }; })(); @@ -243,6 +268,18 @@ window.renderCollectionState = function(container, options) { return AppFeedback.renderCollectionState(container, options); }; +window.isOffline = function() { + return AppFeedback.isOffline(); +}; + +window.isTransientNetworkError = function(error) { + return AppFeedback.isTransientNetworkError(error); +}; + +window.isTransientOrOffline = function(error) { + return AppFeedback.isTransientOrOffline(error); +}; + document.addEventListener('DOMContentLoaded', () => { AppFeedback.init(); }); diff --git a/static/js/core/voice-alerts.js b/static/js/core/voice-alerts.js index 883cefd..bf60f36 100644 --- a/static/js/core/voice-alerts.js +++ b/static/js/core/voice-alerts.js @@ -20,7 +20,13 @@ const VoiceAlerts = (function () { rate: 1.1, pitch: 0.9, voiceName: '', - streams: { pager: true, tscm: true, bluetooth: true }, + streams: { + pager: true, + tscm: true, + bluetooth: true, + adsb_military: true, + squawks: true, + }, }; function _toNumberInRange(value, fallback, min, max) { diff --git a/static/js/modes/gps.js b/static/js/modes/gps.js index 0af071b..f7f7d6e 100644 --- a/static/js/modes/gps.js +++ b/static/js/modes/gps.js @@ -9,22 +9,45 @@ const GPS = (function() { let lastPosition = null; let lastSky = null; let skyPollTimer = null; + let statusPollTimer = null; let themeObserver = null; let skyRenderer = null; let skyRendererInitAttempted = false; - - // Constellation color map - const CONST_COLORS = { - 'GPS': '#00d4ff', - 'GLONASS': '#00ff88', + let skyRendererInitPromise = null; + + // Constellation color map + const CONST_COLORS = { + 'GPS': '#00d4ff', + 'GLONASS': '#00ff88', 'Galileo': '#ff8800', 'BeiDou': '#ff4466', - 'SBAS': '#ffdd00', - 'QZSS': '#cc66ff', - }; - + 'SBAS': '#ffdd00', + 'QZSS': '#cc66ff', + }; + + const CONST_ALTITUDES = { + 'GPS': 0.28, + 'GLONASS': 0.27, + 'Galileo': 0.29, + 'BeiDou': 0.30, + 'SBAS': 0.34, + 'QZSS': 0.31, + }; + + const GPS_GLOBE_SCRIPT_URLS = [ + 'https://cdn.jsdelivr.net/npm/globe.gl@2.33.1/dist/globe.gl.min.js', + ]; + const GPS_GLOBE_TEXTURE_URL = '/static/images/globe/earth-dark.jpg'; + const GPS_SATELLITE_ICON_URL = '/static/images/globe/satellite-icon.svg'; + function init() { - initSkyRenderer(); + const initPromise = initSkyRenderer(); + if (initPromise && typeof initPromise.then === 'function') { + initPromise.then(() => { + if (lastSky) drawSkyView(lastSky.satellites || []); + else drawEmptySkyView(); + }).catch(() => {}); + } drawEmptySkyView(); if (!connected) connect(); @@ -48,26 +71,397 @@ const GPS = (function() { } function initSkyRenderer() { - if (skyRendererInitAttempted) return; + if (skyRendererInitPromise) return skyRendererInitPromise; skyRendererInitAttempted = true; - const canvas = document.getElementById('gpsSkyCanvas'); - if (!canvas) return; + let fallbackRenderer = null; + const fallbackCanvas = document.getElementById('gpsSkyCanvas'); + const fallbackOverlay = document.getElementById('gpsSkyOverlay'); - const overlay = document.getElementById('gpsSkyOverlay'); - try { - skyRenderer = createWebGlSkyRenderer(canvas, overlay); - } catch (err) { - skyRenderer = null; - console.warn('GPS sky WebGL renderer failed, falling back to 2D', err); + // Show an immediate fallback while the globe library loads. + setSkyCanvasFallbackMode(true); + if (fallbackCanvas) { + try { + fallbackRenderer = createWebGlSkyRenderer(fallbackCanvas, fallbackOverlay); + skyRenderer = fallbackRenderer; + } catch (err) { + fallbackRenderer = null; + skyRenderer = null; + console.warn('GPS sky WebGL renderer failed, falling back to 2D', err); + } + } + + skyRendererInitPromise = (async function() { + const globeContainer = document.getElementById('gpsSkyGlobe'); + if (globeContainer) { + try { + const globeRenderer = await createGlobeSkyRenderer(globeContainer); + if (globeRenderer) { + if (fallbackRenderer && fallbackRenderer !== globeRenderer && typeof fallbackRenderer.destroy === 'function') { + fallbackRenderer.destroy(); + } + setSkyCanvasFallbackMode(false); + skyRenderer = globeRenderer; + return skyRenderer; + } + } catch (err) { + console.warn('GPS globe renderer failed, falling back to canvas renderer', err); + } + } + + setSkyCanvasFallbackMode(true); + if (!fallbackRenderer && fallbackCanvas) { + try { + fallbackRenderer = createWebGlSkyRenderer(fallbackCanvas, fallbackOverlay); + } catch (err) { + fallbackRenderer = null; + console.warn('GPS sky WebGL renderer failed, falling back to 2D', err); + } + } + + skyRenderer = fallbackRenderer; + return skyRenderer; + })(); + + return skyRendererInitPromise; + } + + function setSkyCanvasFallbackMode(enabled) { + const wrap = document.getElementById('gpsSkyViewWrap'); + if (wrap) { + wrap.classList.toggle('gps-sky-fallback', !!enabled); } } - - function connect() { - updateConnectionUI(false, false, 'connecting'); - fetch('/gps/auto-connect', { method: 'POST' }) - .then(r => r.json()) - .then(data => { + + function isSkyCanvasFallbackEnabled() { + const wrap = document.getElementById('gpsSkyViewWrap'); + return !wrap || wrap.classList.contains('gps-sky-fallback'); + } + + function getObserverCoords() { + const posLat = Number(lastPosition && lastPosition.latitude); + const posLon = Number(lastPosition && lastPosition.longitude); + if (Number.isFinite(posLat) && Number.isFinite(posLon)) { + return { lat: posLat, lon: normalizeLon(posLon) }; + } + + if (typeof observerLocation === 'object' && observerLocation) { + const obsLat = Number(observerLocation.lat); + const obsLon = Number(observerLocation.lon); + if (Number.isFinite(obsLat) && Number.isFinite(obsLon)) { + return { lat: obsLat, lon: normalizeLon(obsLon) }; + } + } + + return null; + } + + async function ensureGpsGlobeLibrary() { + if (typeof window.Globe === 'function') return true; + + const webglSupportFn = (typeof isWebglSupported === 'function') ? isWebglSupported : localWebglSupportCheck; + if (!webglSupportFn()) return false; + + if (typeof ensureWebsdrGlobeLibrary === 'function') { + try { + const ready = await ensureWebsdrGlobeLibrary(); + if (ready && typeof window.Globe === 'function') return true; + } catch (_) {} + } + + for (const src of GPS_GLOBE_SCRIPT_URLS) { + await loadGpsGlobeScript(src); + } + return typeof window.Globe === 'function'; + } + + function loadGpsGlobeScript(src) { + const state = getSharedGlobeScriptState(); + if (!state.promises[src]) { + state.promises[src] = loadSharedGlobeScript(src); + } + return state.promises[src].catch((error) => { + delete state.promises[src]; + throw error; + }); + } + + function getSharedGlobeScriptState() { + const key = '__interceptGlobeScriptState'; + if (!window[key]) { + window[key] = { + promises: Object.create(null), + }; + } + return window[key]; + } + + function loadSharedGlobeScript(src) { + return new Promise((resolve, reject) => { + const selector = [ + `script[data-intercept-globe-src="${src}"]`, + `script[data-websdr-src="${src}"]`, + `script[data-gps-globe-src="${src}"]`, + `script[src="${src}"]`, + ].join(', '); + const existing = document.querySelector(selector); + + if (existing) { + if (existing.dataset.loaded === 'true') { + resolve(); + return; + } + if (existing.dataset.failed === 'true') { + existing.remove(); + } else { + existing.addEventListener('load', () => resolve(), { once: true }); + existing.addEventListener('error', () => reject(new Error(`Failed to load ${src}`)), { once: true }); + return; + } + } + + const script = document.createElement('script'); + script.src = src; + script.async = true; + script.crossOrigin = 'anonymous'; + script.dataset.interceptGlobeSrc = src; + script.dataset.gpsGlobeSrc = src; + script.onload = () => { + script.dataset.loaded = 'true'; + resolve(); + }; + script.onerror = () => { + script.dataset.failed = 'true'; + reject(new Error(`Failed to load ${src}`)); + }; + document.head.appendChild(script); + }); + } + + function localWebglSupportCheck() { + try { + const canvas = document.createElement('canvas'); + return !!(canvas.getContext('webgl') || canvas.getContext('experimental-webgl')); + } catch (_) { + return false; + } + } + + async function createGlobeSkyRenderer(container) { + const ready = await ensureGpsGlobeLibrary(); + if (!ready || typeof window.Globe !== 'function') return null; + + let layoutAttempts = 0; + while ((!container.clientWidth || !container.clientHeight) && layoutAttempts < 4) { + await new Promise(resolve => requestAnimationFrame(resolve)); + layoutAttempts += 1; + } + if (!container.clientWidth || !container.clientHeight) return null; + + container.innerHTML = ''; + container.style.background = 'radial-gradient(circle at 32% 18%, rgba(16, 45, 70, 0.92), rgba(4, 9, 16, 0.96) 58%, rgba(2, 4, 9, 0.99) 100%)'; + container.style.cursor = 'grab'; + + const globe = window.Globe()(container) + .backgroundColor('rgba(0,0,0,0)') + .globeImageUrl(GPS_GLOBE_TEXTURE_URL) + .showAtmosphere(true) + .atmosphereColor('#3bb9ff') + .atmosphereAltitude(0.17) + .pointRadius('radius') + .pointAltitude('altitude') + .pointColor('color') + .pointLabel(point => point.label || '') + .pointsTransitionDuration(0) + .htmlAltitude('altitude') + .htmlElementsData([]) + .htmlElement((sat) => createSatelliteIconElement(sat)); + + const controls = globe.controls(); + if (controls) { + controls.autoRotate = false; + controls.enablePan = false; + controls.minDistance = 130; + controls.maxDistance = 420; + controls.rotateSpeed = 0.8; + controls.zoomSpeed = 0.8; + } + + let destroyed = false; + let lastSatellites = []; + let hasInitialView = false; + const resizeObserver = (typeof ResizeObserver !== 'undefined') + ? new ResizeObserver(() => resizeGlobe()) + : null; + + if (resizeObserver) resizeObserver.observe(container); + + function resizeGlobe() { + if (destroyed) return; + const width = container.clientWidth; + const height = container.clientHeight; + if (!width || !height) return; + globe.width(width); + globe.height(height); + } + + function renderGlobe() { + if (destroyed) return; + resizeGlobe(); + + const observer = getObserverCoords(); + const points = []; + const satelliteIcons = []; + + if (observer) { + points.push({ + lat: observer.lat, + lng: observer.lon, + altitude: 0.012, + radius: 0.34, + color: '#ffffff', + label: '
Observer
', + }); + } + + lastSatellites.forEach((sat) => { + const azimuth = Number(sat.azimuth); + const elevation = Number(sat.elevation); + if (!observer || !Number.isFinite(azimuth) || !Number.isFinite(elevation)) return; + + const color = CONST_COLORS[sat.constellation] || CONST_COLORS.GPS; + const shellAltitude = getSatelliteShellAltitude(sat.constellation, elevation); + const footprint = projectSkyTrackToEarth(observer.lat, observer.lon, azimuth, elevation); + satelliteIcons.push({ + lat: footprint.lat, + lng: footprint.lon, + altitude: shellAltitude, + color: color, + used: !!sat.used, + sizePx: sat.used ? 20 : 17, + title: buildSatelliteTitle(sat), + iconUrl: GPS_SATELLITE_ICON_URL, + }); + }); + + globe.pointsData(points); + globe.htmlElementsData(satelliteIcons); + + if (observer && !hasInitialView) { + globe.pointOfView({ lat: observer.lat, lng: observer.lon, altitude: 1.6 }, 950); + hasInitialView = true; + } + } + + function createSatelliteIconElement(sat) { + const marker = document.createElement('div'); + marker.className = `gps-globe-sat-icon ${sat.used ? 'used' : 'unused'}`; + marker.style.setProperty('--sat-color', sat.color || '#9fb2c5'); + marker.style.setProperty('--sat-size', `${Math.max(12, Number(sat.sizePx) || 18)}px`); + marker.title = sat.title || 'Satellite'; + + const img = document.createElement('img'); + img.src = sat.iconUrl || GPS_SATELLITE_ICON_URL; + img.alt = 'Satellite'; + img.decoding = 'async'; + img.draggable = false; + + marker.appendChild(img); + return marker; + } + + function setSatellites(satellites) { + lastSatellites = Array.isArray(satellites) ? satellites : []; + renderGlobe(); + } + + function requestRender() { + renderGlobe(); + } + + function destroy() { + destroyed = true; + if (resizeObserver) { + try { + resizeObserver.disconnect(); + } catch (_) {} + } + container.innerHTML = ''; + } + + setSatellites([]); + + return { + setSatellites: setSatellites, + requestRender: requestRender, + destroy: destroy, + }; + } + + function buildSatelliteTitle(sat) { + const constellation = String(sat.constellation || 'GPS'); + const prn = String(sat.prn || '--'); + const elevation = Number.isFinite(Number(sat.elevation)) ? `${Number(sat.elevation).toFixed(1)}\u00b0` : '--'; + const azimuth = Number.isFinite(Number(sat.azimuth)) ? `${Number(sat.azimuth).toFixed(1)}\u00b0` : '--'; + const snr = Number.isFinite(Number(sat.snr)) ? `${Math.round(Number(sat.snr))} dB-Hz` : 'n/a'; + const used = sat.used ? 'USED IN FIX' : 'TRACKED'; + + return `${constellation} PRN ${prn} | El ${elevation} | Az ${azimuth} | SNR ${snr} | ${used}`; + } + + function getSatelliteShellAltitude(constellation, elevation) { + const base = CONST_ALTITUDES[constellation] || CONST_ALTITUDES.GPS; + const el = Math.max(0, Math.min(90, Number(elevation) || 0)); + const horizonFactor = 1 - (el / 90); + return base + (horizonFactor * 0.04); + } + + function projectSkyTrackToEarth(observerLat, observerLon, azimuth, elevation) { + const el = Math.max(0, Math.min(90, Number(elevation) || 0)); + const horizonFactor = 1 - (el / 90); + const angularDistance = 76 * Math.pow(horizonFactor, 1.08); + return destinationPoint(observerLat, observerLon, azimuth, angularDistance); + } + + function destinationPoint(latDeg, lonDeg, bearingDeg, distanceDeg) { + const lat1 = degToRad(latDeg); + const lon1 = degToRad(lonDeg); + const bearing = degToRad(bearingDeg); + const distance = degToRad(distanceDeg); + + const sinLat1 = Math.sin(lat1); + const cosLat1 = Math.cos(lat1); + const sinDist = Math.sin(distance); + const cosDist = Math.cos(distance); + + const sinLat2 = (sinLat1 * cosDist) + (cosLat1 * sinDist * Math.cos(bearing)); + const lat2 = Math.asin(Math.max(-1, Math.min(1, sinLat2))); + + const y = Math.sin(bearing) * sinDist * cosLat1; + const x = cosDist - (sinLat1 * Math.sin(lat2)); + const lon2 = lon1 + Math.atan2(y, x); + + return { + lat: radToDeg(lat2), + lon: normalizeLon(radToDeg(lon2)), + }; + } + + function normalizeLon(lon) { + let normalized = (lon + 540) % 360; + normalized = normalized < 0 ? normalized + 360 : normalized; + return normalized - 180; + } + + function radToDeg(rad) { + return rad * 180 / Math.PI; + } + + function connect() { + updateConnectionUI(false, false, 'connecting'); + fetch('/gps/auto-connect', { method: 'POST' }) + .then(r => r.json()) + .then(data => { if (data.status === 'connected') { connected = true; updateConnectionUI(true, data.has_fix); @@ -78,16 +472,18 @@ const GPS = (function() { if (data.sky) { lastSky = data.sky; updateSkyUI(data.sky); - } - subscribeToStream(); - startSkyPolling(); - // Ensure the global GPS stream is running - if (typeof startGpsStream === 'function' && !gpsEventSource) { - startGpsStream(); - } - } else { - connected = false; - updateConnectionUI(false, false, 'error', data.message || 'gpsd not available'); + } + subscribeToStream(); + startSkyPolling(); + startStatusPolling(); + // Ensure the global GPS stream is running + const hasGlobalGpsStream = typeof gpsEventSource !== 'undefined' && !!gpsEventSource; + if (typeof startGpsStream === 'function' && !hasGlobalGpsStream) { + startGpsStream(); + } + } else { + connected = false; + updateConnectionUI(false, false, 'error', data.message || 'gpsd not available'); } }) .catch(() => { @@ -96,36 +492,40 @@ const GPS = (function() { }); } - function disconnect() { - unsubscribeFromStream(); - stopSkyPolling(); - fetch('/gps/stop', { method: 'POST' }) - .then(() => { - connected = false; - updateConnectionUI(false); - }); + function disconnect() { + unsubscribeFromStream(); + stopSkyPolling(); + stopStatusPolling(); + fetch('/gps/stop', { method: 'POST' }) + .then(() => { + connected = false; + updateConnectionUI(false); + }); } function onGpsStreamData(data) { if (!connected) return; - if (data.type === 'position') { - lastPosition = data; - updatePositionUI(data); - updateConnectionUI(true, true); - } else if (data.type === 'sky') { - lastSky = data; - updateSkyUI(data); - } - } - - function startSkyPolling() { - stopSkyPolling(); - // Poll satellite data every 5 seconds as a reliable fallback - // SSE stream may miss sky updates due to queue contention with position messages - pollSatellites(); - skyPollTimer = setInterval(pollSatellites, 5000); - } + if (data.type === 'position') { + lastPosition = data; + updatePositionUI(data); + updateConnectionUI(true, true); + if (lastSky && skyRenderer) { + drawSkyView(lastSky.satellites || []); + } + } else if (data.type === 'sky') { + lastSky = data; + updateSkyUI(data); + } + } + function startSkyPolling() { + stopSkyPolling(); + // Poll satellite data every 5 seconds as a reliable fallback + // SSE stream may miss sky updates due to queue contention with position messages + pollSatellites(); + skyPollTimer = setInterval(pollSatellites, 5000); + } + function stopSkyPolling() { if (skyPollTimer) { clearInterval(skyPollTimer); @@ -133,18 +533,62 @@ const GPS = (function() { } } - function pollSatellites() { - if (!connected) return; - fetch('/gps/satellites') - .then(r => r.json()) - .then(data => { + function pollSatellites() { + if (!connected) return; + fetch('/gps/satellites') + .then(r => r.json()) + .then(data => { if (data.status === 'ok' && data.sky) { lastSky = data.sky; updateSkyUI(data.sky); } - }) - .catch(() => {}); - } + }) + .catch(() => {}); + } + + function startStatusPolling() { + stopStatusPolling(); + // Poll full status as a fallback when SSE is unavailable or blocked. + pollStatus(); + statusPollTimer = setInterval(pollStatus, 2000); + } + + function stopStatusPolling() { + if (statusPollTimer) { + clearInterval(statusPollTimer); + statusPollTimer = null; + } + } + + function pollStatus() { + if (!connected) return; + fetch('/gps/status') + .then(r => r.json()) + .then(data => { + if (!connected || !data) return; + if (data.running !== true) { + connected = false; + stopSkyPolling(); + stopStatusPolling(); + updateConnectionUI(false, false, 'error', data.message || 'GPS disconnected'); + return; + } + + if (data.position) { + lastPosition = data.position; + updatePositionUI(data.position); + updateConnectionUI(true, true); + } else { + updateConnectionUI(true, false); + } + + if (data.sky) { + lastSky = data.sky; + updateSkyUI(data.sky); + } + }) + .catch(() => {}); + } function subscribeToStream() { // Subscribe to the global GPS stream instead of opening a separate SSE connection @@ -294,8 +738,11 @@ const GPS = (function() { return; } + if (!isSkyCanvasFallbackEnabled()) return; + const canvas = document.getElementById('gpsSkyCanvas'); if (!canvas) return; + resize2DFallbackCanvas(canvas); drawSkyViewBase2D(canvas); } @@ -311,9 +758,12 @@ const GPS = (function() { return; } + if (!isSkyCanvasFallbackEnabled()) return; + const canvas = document.getElementById('gpsSkyCanvas'); if (!canvas) return; + resize2DFallbackCanvas(canvas); drawSkyViewBase2D(canvas); const ctx = canvas.getContext('2d'); @@ -428,6 +878,15 @@ const GPS = (function() { ctx.fill(); } + function resize2DFallbackCanvas(canvas) { + const cssWidth = Math.max(1, Math.floor(canvas.clientWidth || 400)); + const cssHeight = Math.max(1, Math.floor(canvas.clientHeight || 400)); + if (canvas.width !== cssWidth || canvas.height !== cssHeight) { + canvas.width = cssWidth; + canvas.height = cssHeight; + } + } + function createWebGlSkyRenderer(canvas, overlay) { const gl = canvas.getContext('webgl', { antialias: true, alpha: false, depth: true }); if (!gl) return null; @@ -1076,6 +1535,7 @@ const GPS = (function() { function destroy() { unsubscribeFromStream(); stopSkyPolling(); + stopStatusPolling(); if (themeObserver) { themeObserver.disconnect(); themeObserver = null; @@ -1085,6 +1545,8 @@ const GPS = (function() { skyRenderer = null; } skyRendererInitAttempted = false; + skyRendererInitPromise = null; + setSkyCanvasFallbackMode(false); } return { diff --git a/static/js/modes/morse.js b/static/js/modes/morse.js new file mode 100644 index 0000000..b594bf6 --- /dev/null +++ b/static/js/modes/morse.js @@ -0,0 +1,400 @@ +/** + * Morse Code (CW) decoder module. + * + * IIFE providing start/stop controls, SSE streaming, scope canvas, + * decoded text display, and export capabilities. + */ +var MorseMode = (function () { + 'use strict'; + + var state = { + running: false, + initialized: false, + eventSource: null, + charCount: 0, + decodedLog: [], // { timestamp, morse, char } + }; + + // Scope state + var scopeCtx = null; + var scopeAnim = null; + var scopeHistory = []; + var SCOPE_HISTORY_LEN = 300; + var scopeThreshold = 0; + var scopeToneOn = false; + + // ---- Initialization ---- + + function init() { + if (state.initialized) { + checkStatus(); + return; + } + state.initialized = true; + checkStatus(); + } + + function destroy() { + disconnectSSE(); + stopScope(); + } + + // ---- Status ---- + + function checkStatus() { + fetch('/morse/status') + .then(function (r) { return r.json(); }) + .then(function (data) { + if (data.running) { + state.running = true; + updateUI(true); + connectSSE(); + startScope(); + } else { + state.running = false; + updateUI(false); + } + }) + .catch(function () {}); + } + + // ---- Start / Stop ---- + + function start() { + if (state.running) return; + + var payload = { + frequency: document.getElementById('morseFrequency').value || '14.060', + gain: document.getElementById('morseGain').value || '0', + ppm: document.getElementById('morsePPM').value || '0', + device: document.getElementById('deviceSelect')?.value || '0', + sdr_type: document.getElementById('sdrTypeSelect')?.value || 'rtlsdr', + tone_freq: document.getElementById('morseToneFreq').value || '700', + wpm: document.getElementById('morseWpm').value || '15', + bias_t: typeof getBiasTEnabled === 'function' ? getBiasTEnabled() : false, + }; + + fetch('/morse/start', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload), + }) + .then(function (r) { return r.json(); }) + .then(function (data) { + if (data.status === 'started') { + state.running = true; + state.charCount = 0; + state.decodedLog = []; + updateUI(true); + connectSSE(); + startScope(); + clearDecodedText(); + } else { + alert('Error: ' + (data.message || 'Unknown error')); + } + }) + .catch(function (err) { + alert('Failed to start Morse decoder: ' + err); + }); + } + + function stop() { + fetch('/morse/stop', { method: 'POST' }) + .then(function (r) { return r.json(); }) + .then(function () { + state.running = false; + updateUI(false); + disconnectSSE(); + stopScope(); + }) + .catch(function () {}); + } + + // ---- SSE ---- + + function connectSSE() { + disconnectSSE(); + var es = new EventSource('/morse/stream'); + + es.onmessage = function (e) { + try { + var msg = JSON.parse(e.data); + handleMessage(msg); + } catch (_) {} + }; + + es.onerror = function () { + // Reconnect handled by browser + }; + + state.eventSource = es; + } + + function disconnectSSE() { + if (state.eventSource) { + state.eventSource.close(); + state.eventSource = null; + } + } + + function handleMessage(msg) { + var type = msg.type; + + if (type === 'scope') { + // Update scope data + var amps = msg.amplitudes || []; + for (var i = 0; i < amps.length; i++) { + scopeHistory.push(amps[i]); + if (scopeHistory.length > SCOPE_HISTORY_LEN) { + scopeHistory.shift(); + } + } + scopeThreshold = msg.threshold || 0; + scopeToneOn = msg.tone_on || false; + + } else if (type === 'morse_char') { + appendChar(msg.char, msg.morse, msg.timestamp); + + } else if (type === 'morse_space') { + appendSpace(); + + } else if (type === 'status') { + if (msg.status === 'stopped') { + state.running = false; + updateUI(false); + disconnectSSE(); + stopScope(); + } + } else if (type === 'error') { + console.error('Morse error:', msg.text); + } + } + + // ---- Decoded text ---- + + function appendChar(ch, morse, timestamp) { + state.charCount++; + state.decodedLog.push({ timestamp: timestamp, morse: morse, char: ch }); + + var panel = document.getElementById('morseDecodedText'); + if (!panel) return; + + var span = document.createElement('span'); + span.className = 'morse-char'; + span.textContent = ch; + span.title = morse + ' (' + timestamp + ')'; + panel.appendChild(span); + + // Auto-scroll + panel.scrollTop = panel.scrollHeight; + + // Update count + var countEl = document.getElementById('morseCharCount'); + if (countEl) countEl.textContent = state.charCount + ' chars'; + var barChars = document.getElementById('morseStatusBarChars'); + if (barChars) barChars.textContent = state.charCount + ' chars decoded'; + } + + function appendSpace() { + var panel = document.getElementById('morseDecodedText'); + if (!panel) return; + + var span = document.createElement('span'); + span.className = 'morse-word-space'; + span.textContent = ' '; + panel.appendChild(span); + } + + function clearDecodedText() { + var panel = document.getElementById('morseDecodedText'); + if (panel) panel.innerHTML = ''; + state.charCount = 0; + state.decodedLog = []; + var countEl = document.getElementById('morseCharCount'); + if (countEl) countEl.textContent = '0 chars'; + var barChars = document.getElementById('morseStatusBarChars'); + if (barChars) barChars.textContent = '0 chars decoded'; + } + + // ---- Scope canvas ---- + + function startScope() { + var canvas = document.getElementById('morseScopeCanvas'); + if (!canvas) return; + + var dpr = window.devicePixelRatio || 1; + var rect = canvas.getBoundingClientRect(); + canvas.width = rect.width * dpr; + canvas.height = 80 * dpr; + canvas.style.height = '80px'; + + scopeCtx = canvas.getContext('2d'); + scopeCtx.scale(dpr, dpr); + scopeHistory = []; + + var toneLabel = document.getElementById('morseScopeToneLabel'); + var threshLabel = document.getElementById('morseScopeThreshLabel'); + + function draw() { + if (!scopeCtx) return; + var w = rect.width; + var h = 80; + + scopeCtx.fillStyle = '#050510'; + scopeCtx.fillRect(0, 0, w, h); + + // Update header labels + if (toneLabel) toneLabel.textContent = scopeToneOn ? 'ON' : '--'; + if (threshLabel) threshLabel.textContent = scopeThreshold > 0 ? Math.round(scopeThreshold) : '--'; + + if (scopeHistory.length === 0) { + scopeAnim = requestAnimationFrame(draw); + return; + } + + // Find max for normalization + var maxVal = 0; + for (var i = 0; i < scopeHistory.length; i++) { + if (scopeHistory[i] > maxVal) maxVal = scopeHistory[i]; + } + if (maxVal === 0) maxVal = 1; + + var barW = w / SCOPE_HISTORY_LEN; + var threshNorm = scopeThreshold / maxVal; + + // Draw amplitude bars + for (var j = 0; j < scopeHistory.length; j++) { + var norm = scopeHistory[j] / maxVal; + var barH = norm * (h - 10); + var x = j * barW; + var y = h - barH; + + // Green if above threshold, gray if below + if (scopeHistory[j] > scopeThreshold) { + scopeCtx.fillStyle = '#00ff88'; + } else { + scopeCtx.fillStyle = '#334455'; + } + scopeCtx.fillRect(x, y, Math.max(barW - 1, 1), barH); + } + + // Draw threshold line + if (scopeThreshold > 0) { + var threshY = h - (threshNorm * (h - 10)); + scopeCtx.strokeStyle = '#ff4444'; + scopeCtx.lineWidth = 1; + scopeCtx.setLineDash([4, 4]); + scopeCtx.beginPath(); + scopeCtx.moveTo(0, threshY); + scopeCtx.lineTo(w, threshY); + scopeCtx.stroke(); + scopeCtx.setLineDash([]); + } + + // Tone indicator + if (scopeToneOn) { + scopeCtx.fillStyle = '#00ff88'; + scopeCtx.beginPath(); + scopeCtx.arc(w - 12, 12, 5, 0, Math.PI * 2); + scopeCtx.fill(); + } + + scopeAnim = requestAnimationFrame(draw); + } + + draw(); + } + + function stopScope() { + if (scopeAnim) { + cancelAnimationFrame(scopeAnim); + scopeAnim = null; + } + scopeCtx = null; + } + + // ---- Export ---- + + function exportTxt() { + var text = state.decodedLog.map(function (e) { return e.char; }).join(''); + downloadFile('morse_decoded.txt', text, 'text/plain'); + } + + function exportCsv() { + var lines = ['timestamp,morse,character']; + state.decodedLog.forEach(function (e) { + lines.push(e.timestamp + ',"' + e.morse + '",' + e.char); + }); + downloadFile('morse_decoded.csv', lines.join('\n'), 'text/csv'); + } + + function copyToClipboard() { + var text = state.decodedLog.map(function (e) { return e.char; }).join(''); + navigator.clipboard.writeText(text).then(function () { + var btn = document.getElementById('morseCopyBtn'); + if (btn) { + var orig = btn.textContent; + btn.textContent = 'Copied!'; + setTimeout(function () { btn.textContent = orig; }, 1500); + } + }); + } + + function downloadFile(filename, content, type) { + var blob = new Blob([content], { type: type }); + var url = URL.createObjectURL(blob); + var a = document.createElement('a'); + a.href = url; + a.download = filename; + a.click(); + URL.revokeObjectURL(url); + } + + // ---- UI ---- + + function updateUI(running) { + var startBtn = document.getElementById('morseStartBtn'); + var stopBtn = document.getElementById('morseStopBtn'); + var indicator = document.getElementById('morseStatusIndicator'); + var statusText = document.getElementById('morseStatusText'); + + if (startBtn) startBtn.style.display = running ? 'none' : ''; + if (stopBtn) stopBtn.style.display = running ? '' : 'none'; + + if (indicator) { + indicator.style.background = running ? '#00ff88' : 'var(--text-dim)'; + } + if (statusText) { + statusText.textContent = running ? 'Listening' : 'Standby'; + } + + // Toggle scope and output panels (pager/sensor pattern) + var scopePanel = document.getElementById('morseScopePanel'); + var outputPanel = document.getElementById('morseOutputPanel'); + if (scopePanel) scopePanel.style.display = running ? 'block' : 'none'; + if (outputPanel) outputPanel.style.display = running ? 'block' : 'none'; + + var scopeStatus = document.getElementById('morseScopeStatusLabel'); + if (scopeStatus) scopeStatus.textContent = running ? 'ACTIVE' : 'IDLE'; + if (scopeStatus) scopeStatus.style.color = running ? '#0f0' : '#444'; + } + + function setFreq(mhz) { + var el = document.getElementById('morseFrequency'); + if (el) el.value = mhz; + } + + // ---- Public API ---- + + return { + init: init, + destroy: destroy, + start: start, + stop: stop, + setFreq: setFreq, + exportTxt: exportTxt, + exportCsv: exportCsv, + copyToClipboard: copyToClipboard, + clearText: clearDecodedText, + }; +})(); diff --git a/static/js/modes/waterfall.js b/static/js/modes/waterfall.js index 7851144..df30094 100644 --- a/static/js/modes/waterfall.js +++ b/static/js/modes/waterfall.js @@ -36,6 +36,7 @@ const Waterfall = (function () { let _startMhz = 98.8; let _endMhz = 101.2; + let _lastEffectiveSpan = 2.4; let _monitorFreqMhz = 100.0; let _monitoring = false; @@ -2515,6 +2516,11 @@ const Waterfall = (function () { _endMhz = msg.end_freq; _drawFreqAxis(); } + if (Number.isFinite(msg.effective_span_mhz)) { + _lastEffectiveSpan = msg.effective_span_mhz; + const spanEl = document.getElementById('wfSpanMhz'); + if (spanEl) spanEl.value = msg.effective_span_mhz; + } _setStatus(`Streaming ${_startMhz.toFixed(4)} - ${_endMhz.toFixed(4)} MHz`); _setVisualStatus('RUNNING'); if (_monitoring) { @@ -2535,6 +2541,12 @@ const Waterfall = (function () { } _updateFreqDisplay(); _setStatus(`Tuned ${_monitorFreqMhz.toFixed(4)} MHz`); + if (_monitoring && _monitorSource === 'waterfall') { + const mode = _getMonitorMode().toUpperCase(); + _setMonitorState(`Monitoring ${_monitorFreqMhz.toFixed(4)} MHz ${mode} via shared IQ`); + _setStatus(`Audio monitor active on ${_monitorFreqMhz.toFixed(4)} MHz (${mode})`); + _setVisualStatus('MONITOR'); + } if (!_monitoring) _setVisualStatus('RUNNING'); } else if (_onRetuneRequired(msg)) { return; @@ -2557,6 +2569,10 @@ const Waterfall = (function () { _pendingMonitorTuneMhz = null; _scanStartPending = false; _pendingSharedMonitorRearm = false; + // Reset span input to last known good value so an + // invalid span doesn't persist across restart (#150). + const spanEl = document.getElementById('wfSpanMhz'); + if (spanEl) spanEl.value = _lastEffectiveSpan; // If the monitor was using the shared IQ stream that // just failed, tear down the stale monitor state so // the button becomes clickable again after restart. @@ -2603,7 +2619,7 @@ const Waterfall = (function () { player.load(); } - async function _attachMonitorAudio(nonce) { + async function _attachMonitorAudio(nonce, streamToken = null) { const player = document.getElementById('wfAudioPlayer'); if (!player) { return { ok: false, reason: 'player_missing', message: 'Audio player is unavailable.' }; @@ -2622,7 +2638,10 @@ const Waterfall = (function () { } await _pauseMonitorAudioElement(); - player.src = `/receiver/audio/stream?fresh=1&t=${Date.now()}-${attempt}`; + const tokenQuery = (streamToken !== null && streamToken !== undefined && String(streamToken).length > 0) + ? `&request_token=${encodeURIComponent(String(streamToken))}` + : ''; + player.src = `/receiver/audio/stream?fresh=1&t=${Date.now()}-${attempt}${tokenQuery}`; player.load(); try { @@ -2678,25 +2697,6 @@ const Waterfall = (function () { }; } - function _deviceKey(device) { - if (!device) return ''; - return `${device.sdrType || ''}:${device.deviceIndex || 0}`; - } - - function _findAlternateDevice(currentDevice) { - const currentKey = _deviceKey(currentDevice); - for (const d of _devices) { - const candidate = { - sdrType: String(d.sdr_type || 'rtlsdr'), - deviceIndex: parseInt(d.index, 10) || 0, - }; - if (_deviceKey(candidate) !== currentKey) { - return candidate; - } - } - return null; - } - async function _requestAudioStart({ frequency, modulation, @@ -2760,6 +2760,7 @@ const Waterfall = (function () { _resumeWaterfallAfterMonitor = !!wasRunningWaterfall; } + const liveCenterMhz = _currentCenter(); // Keep an explicit pending tune target so retunes cannot fall // back to a stale frequency during capture restart churn. const requestedTuneMhz = Number.isFinite(_pendingMonitorTuneMhz) @@ -2767,11 +2768,11 @@ const Waterfall = (function () { : ( Number.isFinite(_pendingCaptureVfoMhz) ? _pendingCaptureVfoMhz - : (Number.isFinite(_monitorFreqMhz) ? _monitorFreqMhz : _currentCenter()) + : (Number.isFinite(_monitorFreqMhz) ? _monitorFreqMhz : liveCenterMhz) ); const centerMhz = retuneOnly - ? (Number.isFinite(requestedTuneMhz) ? requestedTuneMhz : _currentCenter()) - : _currentCenter(); + ? (Number.isFinite(liveCenterMhz) ? liveCenterMhz : requestedTuneMhz) + : liveCenterMhz; const mode = document.getElementById('wfMonitorMode')?.value || 'wfm'; const squelch = parseInt(document.getElementById('wfMonitorSquelch')?.value, 10) || 0; const sliderGain = parseInt(document.getElementById('wfMonitorGain')?.value, 10); @@ -2780,69 +2781,98 @@ const Waterfall = (function () { ? sliderGain : (Number.isFinite(fallbackGain) ? Math.round(fallbackGain) : 40); const selectedDevice = _selectedDevice(); - const altDevice = _running ? _findAlternateDevice(selectedDevice) : null; - let monitorDevice = altDevice || selectedDevice; + // Always target the currently selected SDR for monitor start/retune. + // This keeps waterfall-shared monitor tuning deterministic and avoids + // retuning a different receiver than the one driving the display. + let monitorDevice = selectedDevice; const biasT = !!document.getElementById('wfBiasT')?.checked; - const usingSecondaryDevice = !!altDevice; + // Use a high monotonic token so backend start ordering remains + // valid across page reloads (local nonces reset to small values). + const requestToken = Math.trunc((Date.now() * 4096) + (nonce & 0x0fff)); if (!retuneOnly) { _monitorFreqMhz = centerMhz; } else if (Number.isFinite(centerMhz)) { _monitorFreqMhz = centerMhz; + _pendingMonitorTuneMhz = centerMhz; + _pendingCaptureVfoMhz = centerMhz; } _drawFreqAxis(); _stopSmeter(); _setUnlockVisible(false); _audioUnlockRequired = false; - if (usingSecondaryDevice) { + if (retuneOnly && _monitoring) { + _setMonitorState(`Retuning ${centerMhz.toFixed(4)} MHz ${mode.toUpperCase()}...`); + } else { _setMonitorState( `Starting ${centerMhz.toFixed(4)} MHz ${mode.toUpperCase()} on ` + `${monitorDevice.sdrType.toUpperCase()} #${monitorDevice.deviceIndex}...` ); - } else { - _setMonitorState(`Starting ${centerMhz.toFixed(4)} MHz ${mode.toUpperCase()}...`); } // Use live _monitorFreqMhz for retunes so that any user // clicks that changed the VFO during the async setup are // picked up rather than overridden. - let { response, payload } = await _requestAudioStart({ - frequency: centerMhz, - modulation: mode, - squelch, - gain, - device: monitorDevice, - biasT, - requestToken: nonce, - }); + const requestAudioStartResynced = async (deviceForRequest) => { + let startResult = await _requestAudioStart({ + frequency: centerMhz, + modulation: mode, + squelch, + gain, + device: deviceForRequest, + biasT, + requestToken, + }); + const startPayload = startResult?.payload || {}; + const isStale = startPayload.superseded === true || startPayload.status === 'stale'; + if (isStale) { + const currentToken = Number(startPayload.current_token); + if (Number.isFinite(currentToken) && currentToken >= 0) { + startResult = await _requestAudioStart({ + frequency: centerMhz, + modulation: mode, + squelch, + gain, + device: deviceForRequest, + biasT, + requestToken: currentToken + 1, + }); + } + } + return startResult; + }; + + let { response, payload } = await requestAudioStartResynced(monitorDevice); if (nonce !== _audioConnectNonce) return; const staleStart = payload?.superseded === true || payload?.status === 'stale'; - if (staleStart) return; + if (staleStart) { + // If the backend still reports stale after token resync, + // schedule a fresh retune so monitor audio does not stay on + // an older station indefinitely. + if (_monitoring) { + const liveMode = _getMonitorMode().toUpperCase(); + _setMonitorState(`Monitoring ${_monitorFreqMhz.toFixed(4)} MHz ${liveMode}`); + _setStatus(`Audio monitor active on ${_monitorFreqMhz.toFixed(4)} MHz (${liveMode})`); + _setVisualStatus('MONITOR'); + _queueMonitorRetune(90); + } + return; + } const busy = payload?.error_type === 'DEVICE_BUSY' || (response.status === 409 && !staleStart); - if ( - busy - && _running - && !usingSecondaryDevice - && !retuneOnly - ) { + if (busy && _running && !retuneOnly) { _setMonitorState('Audio device busy, pausing waterfall and retrying monitor...'); await stop({ keepStatus: true }); _resumeWaterfallAfterMonitor = true; await _wait(220); monitorDevice = selectedDevice; - ({ response, payload } = await _requestAudioStart({ - frequency: centerMhz, - modulation: mode, - squelch, - gain, - device: monitorDevice, - biasT, - requestToken: nonce, - })); + ({ response, payload } = await requestAudioStartResynced(monitorDevice)); if (nonce !== _audioConnectNonce) return; - if (payload?.superseded === true || payload?.status === 'stale') return; + if (payload?.superseded === true || payload?.status === 'stale') { + if (_monitoring) _queueMonitorRetune(90); + return; + } } if (!response.ok || payload.status !== 'started') { @@ -2861,13 +2891,14 @@ const Waterfall = (function () { return; } - const attach = await _attachMonitorAudio(nonce); + const attach = await _attachMonitorAudio(nonce, payload?.request_token); if (nonce !== _audioConnectNonce) return; _monitorSource = payload?.source === 'waterfall' ? 'waterfall' : 'process'; - if ( + const pendingTuneMismatch = ( Number.isFinite(_pendingMonitorTuneMhz) - && Math.abs(_pendingMonitorTuneMhz - centerMhz) < 1e-6 - ) { + && Math.abs(_pendingMonitorTuneMhz - centerMhz) >= 1e-6 + ); + if (!pendingTuneMismatch) { _pendingMonitorTuneMhz = null; } @@ -2878,6 +2909,7 @@ const Waterfall = (function () { _setMonitorState(`Monitoring ${centerMhz.toFixed(4)} MHz ${mode.toUpperCase()} (audio locked)`); _setStatus('Monitor started but browser blocked playback. Click Unlock Audio.'); _setVisualStatus('MONITOR'); + if (pendingTuneMismatch) _queueMonitorRetune(45); return; } @@ -2911,20 +2943,27 @@ const Waterfall = (function () { _setMonitorState( `Monitoring ${displayMhz.toFixed(4)} MHz ${mode.toUpperCase()} via shared IQ` ); - } else if (usingSecondaryDevice) { + } else { _setMonitorState( `Monitoring ${displayMhz.toFixed(4)} MHz ${mode.toUpperCase()} ` + `via ${monitorDevice.sdrType.toUpperCase()} #${monitorDevice.deviceIndex}` ); - } else { - _setMonitorState(`Monitoring ${displayMhz.toFixed(4)} MHz ${mode.toUpperCase()}`); } _setStatus(`Audio monitor active on ${displayMhz.toFixed(4)} MHz (${mode.toUpperCase()})`); _setVisualStatus('MONITOR'); + if (pendingTuneMismatch) { + _queueMonitorRetune(45); + } // After a retune reconnect, sync the backend to the latest // VFO in case the user clicked a new frequency while the // audio stream was reconnecting. - if (retuneOnly && _monitorSource === 'waterfall' && _ws && _ws.readyState === WebSocket.OPEN) { + if ( + !pendingTuneMismatch + && retuneOnly + && _monitorSource === 'waterfall' + && _ws + && _ws.readyState === WebSocket.OPEN + ) { _sendWsTuneCmd(); } } catch (err) { @@ -3233,7 +3272,8 @@ const Waterfall = (function () { function stepFreq(multiplier) { const step = _getNumber('wfStepSize', 0.1); - _setAndTune(_currentCenter() + multiplier * step, true); + // Coalesce rapid step-button presses into one final retune. + _setAndTune(_currentCenter() + multiplier * step, false); } function zoomBy(factor) { diff --git a/static/js/modes/weather-satellite.js b/static/js/modes/weather-satellite.js index c09ad8a..b528b71 100644 --- a/static/js/modes/weather-satellite.js +++ b/static/js/modes/weather-satellite.js @@ -265,10 +265,13 @@ const WeatherSat = (function() { * Stop capture */ async function stop() { + // Optimistically update UI immediately so stop feels responsive, + // even if the server takes time to terminate the process. + isRunning = false; + stopStream(); + updateStatusUI('idle', 'Stopping...'); try { await fetch('/weather-sat/stop', { method: 'POST' }); - isRunning = false; - stopStream(); updateStatusUI('idle', 'Stopped'); showNotification('Weather Sat', 'Capture stopped'); } catch (err) { diff --git a/static/js/modes/websdr.js b/static/js/modes/websdr.js index de4bcfe..b2a60fe 100644 --- a/static/js/modes/websdr.js +++ b/static/js/modes/websdr.js @@ -19,7 +19,6 @@ let websdrResizeHooked = false; let websdrGlobeFallbackNotified = false; const WEBSDR_GLOBE_SCRIPT_URLS = [ - 'https://cdn.jsdelivr.net/npm/three@0.160.0/build/three.min.js', 'https://cdn.jsdelivr.net/npm/globe.gl@2.33.1/dist/globe.gl.min.js', ]; const WEBSDR_GLOBE_TEXTURE_URL = '/static/images/globe/earth-dark.jpg'; @@ -186,8 +185,34 @@ async function ensureWebsdrGlobeLibrary() { } function loadWebsdrScript(src) { + const state = getSharedGlobeScriptState(); + if (!state.promises[src]) { + state.promises[src] = loadSharedGlobeScript(src); + } + return state.promises[src].catch((error) => { + delete state.promises[src]; + throw error; + }); +} + +function getSharedGlobeScriptState() { + const key = '__interceptGlobeScriptState'; + if (!window[key]) { + window[key] = { + promises: Object.create(null), + }; + } + return window[key]; +} + +function loadSharedGlobeScript(src) { return new Promise((resolve, reject) => { - const selector = `script[data-websdr-src="${src}"]`; + const selector = [ + `script[data-intercept-globe-src="${src}"]`, + `script[data-websdr-src="${src}"]`, + `script[data-gps-globe-src="${src}"]`, + `script[src="${src}"]`, + ].join(', '); const existing = document.querySelector(selector); if (existing) { @@ -208,6 +233,7 @@ function loadWebsdrScript(src) { script.src = src; script.async = true; script.crossOrigin = 'anonymous'; + script.dataset.interceptGlobeSrc = src; script.dataset.websdrSrc = src; script.onload = () => { script.dataset.loaded = 'true'; diff --git a/static/js/modes/wefax.js b/static/js/modes/wefax.js new file mode 100644 index 0000000..853bc72 --- /dev/null +++ b/static/js/modes/wefax.js @@ -0,0 +1,1171 @@ +/** + * WeFax (Weather Fax) decoder module. + * + * IIFE providing start/stop controls, station selector, broadcast + * schedule timeline, live image preview, decoded image gallery, + * and audio waveform scope. + */ +var WeFax = (function () { + 'use strict'; + + var state = { + running: false, + initialized: false, + eventSource: null, + stations: [], + images: [], + selectedStation: null, + pollTimer: null, + countdownInterval: null, + schedulerPollTimer: null, + schedulerEnabled: false, + }; + + // ---- Scope state ---- + + var scopeCtx = null; + var scopeAnim = null; + var scopeHistory = []; + var scopeWaveBuffer = []; + var scopeDisplayWave = []; + var SCOPE_HISTORY_LEN = 200; + var SCOPE_WAVE_BUFFER_LEN = 2048; + var SCOPE_WAVE_INPUT_SMOOTH = 0.55; + var SCOPE_WAVE_DISPLAY_SMOOTH = 0.22; + var SCOPE_WAVE_IDLE_DECAY = 0.96; + var scopeRms = 0; + var scopePeak = 0; + var scopeTargetRms = 0; + var scopeTargetPeak = 0; + var scopeLastWaveAt = 0; + var scopeLastInputSample = 0; + var scopeImageBurst = 0; + + // ---- Initialisation ---- + + function init() { + if (state.initialized) { + // Re-render cached data immediately so UI isn't empty + if (state.stations.length) renderStationDropdown(); + loadImages(); + return; + } + state.initialized = true; + loadStations(); + loadImages(); + checkSchedulerStatus(); + } + + function destroy() { + closeImage(); + disconnectSSE(); + stopScope(); + stopCountdownTimer(); + stopSchedulerPoll(); + if (state.pollTimer) { + clearInterval(state.pollTimer); + state.pollTimer = null; + } + } + + // ---- Stations ---- + + function loadStations() { + fetch('/wefax/stations') + .then(function (r) { return r.json(); }) + .then(function (data) { + if (data.status === 'ok' && data.stations) { + state.stations = data.stations; + renderStationDropdown(); + } + }) + .catch(function (err) { + console.error('WeFax: failed to load stations', err); + }); + } + + function renderStationDropdown() { + var sel = document.getElementById('wefaxStation'); + if (!sel) return; + + // Keep the placeholder + sel.innerHTML = ''; + + state.stations.forEach(function (s) { + var opt = document.createElement('option'); + opt.value = s.callsign; + opt.textContent = s.callsign + ' — ' + s.name + ' (' + s.country + ')'; + sel.appendChild(opt); + }); + } + + function onStationChange() { + var sel = document.getElementById('wefaxStation'); + var callsign = sel ? sel.value : ''; + + if (!callsign) { + state.selectedStation = null; + renderFrequencyDropdown([]); + renderScheduleTimeline([]); + renderBroadcastTimeline([]); + stopCountdownTimer(); + return; + } + + var station = state.stations.find(function (s) { return s.callsign === callsign; }); + state.selectedStation = station || null; + + if (station) { + renderFrequencyDropdown(station.frequencies || []); + // Set IOC/LPM from station defaults + var iocSel = document.getElementById('wefaxIOC'); + var lpmSel = document.getElementById('wefaxLPM'); + if (iocSel && station.ioc) iocSel.value = String(station.ioc); + if (lpmSel && station.lpm) lpmSel.value = String(station.lpm); + renderScheduleTimeline(station.schedule || []); + renderBroadcastTimeline(station.schedule || []); + startCountdownTimer(); + } + } + + function renderFrequencyDropdown(frequencies) { + var sel = document.getElementById('wefaxFrequency'); + if (!sel) return; + + sel.innerHTML = ''; + + if (frequencies.length === 0) { + var opt = document.createElement('option'); + opt.value = ''; + opt.textContent = 'Select station first'; + sel.appendChild(opt); + return; + } + + frequencies.forEach(function (f) { + var opt = document.createElement('option'); + opt.value = String(f.khz); + opt.textContent = f.khz + ' kHz — ' + f.description; + sel.appendChild(opt); + }); + } + + // ---- Start / Stop ---- + + function selectedFrequencyReference() { + var alignCheckbox = document.getElementById('wefaxAutoUsbAlign'); + if (alignCheckbox && !alignCheckbox.checked) { + return 'dial'; + } + return 'auto'; + } + + function start() { + if (state.running) return; + + var freqSel = document.getElementById('wefaxFrequency'); + var freqKhz = freqSel ? parseFloat(freqSel.value) : 0; + if (!freqKhz || isNaN(freqKhz)) { + flashStartError(); + return; + } + + var stationSel = document.getElementById('wefaxStation'); + var station = stationSel ? stationSel.value : ''; + var iocSel = document.getElementById('wefaxIOC'); + var lpmSel = document.getElementById('wefaxLPM'); + var gainInput = document.getElementById('wefaxGain'); + var dsCheckbox = document.getElementById('wefaxDirectSampling'); + + var device = (typeof getSelectedDevice === 'function') + ? parseInt(getSelectedDevice(), 10) || 0 : 0; + + var body = { + frequency_khz: freqKhz, + station: station, + device: device, + sdr_type: (typeof getSelectedSDRType === 'function') ? getSelectedSDRType() : 'rtlsdr', + gain: gainInput ? parseFloat(gainInput.value) || 40 : 40, + ioc: iocSel ? parseInt(iocSel.value, 10) : 576, + lpm: lpmSel ? parseInt(lpmSel.value, 10) : 120, + direct_sampling: dsCheckbox ? dsCheckbox.checked : true, + frequency_reference: selectedFrequencyReference(), + }; + + fetch('/wefax/start', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }) + .then(function (r) { return r.json(); }) + .then(function (data) { + if (data.status === 'started' || data.status === 'already_running') { + var tunedKhz = Number(data.tuned_frequency_khz); + if (isNaN(tunedKhz) || tunedKhz <= 0) tunedKhz = freqKhz; + state.running = true; + updateButtons(true); + if (data.usb_offset_applied) { + setStatus('Scanning ' + tunedKhz + ' kHz (USB aligned from ' + freqKhz + ' kHz)...'); + } else { + setStatus('Scanning ' + tunedKhz + ' kHz...'); + } + setStripFreq(tunedKhz); + connectSSE(); + } else { + var errMsg = data.message || 'unknown error'; + setStatus('Error: ' + errMsg); + showStripError(errMsg); + } + }) + .catch(function (err) { + var errMsg = err.message || 'Network error'; + setStatus('Error: ' + errMsg); + showStripError(errMsg); + }); + } + + function stop() { + // Immediate UI feedback before waiting for backend response + state.running = false; + updateButtons(false); + setStatus('Stopping...'); + if (!state.schedulerEnabled) { + disconnectSSE(); + } + + fetch('/wefax/stop', { method: 'POST' }) + .then(function (r) { return r.json(); }) + .then(function () { + setStatus('Stopped'); + loadImages(); + }) + .catch(function (err) { + setStatus('Stopped'); + console.error('WeFax stop error:', err); + }); + } + + // ---- SSE ---- + + function connectSSE() { + disconnectSSE(); + + var es = new EventSource('/wefax/stream'); + state.eventSource = es; + + es.onmessage = function (evt) { + try { + var data = JSON.parse(evt.data); + if (data.type === 'scope') { + applyScopeData(data); + } else { + handleProgress(data); + } + } catch (e) { /* ignore keepalives */ } + }; + + es.onerror = function () { + // EventSource will auto-reconnect + }; + + // Show scope and start animation + var panel = document.getElementById('wefaxScopePanel'); + if (panel) panel.style.display = 'block'; + initScope(); + } + + function disconnectSSE() { + if (state.eventSource) { + state.eventSource.close(); + state.eventSource = null; + } + stopScope(); + var panel = document.getElementById('wefaxScopePanel'); + if (panel) panel.style.display = 'none'; + } + + function handleProgress(data) { + // Handle scheduler events + if (data.type === 'schedule_capture_start') { + setStatus('Auto-capture started: ' + (data.broadcast ? data.broadcast.content : '')); + state.running = true; + updateButtons(true); + connectSSE(); + return; + } + if (data.type === 'schedule_capture_complete') { + setStatus('Auto-capture complete'); + loadImages(); + return; + } + if (data.type === 'schedule_capture_skipped') { + setStatus('Broadcast skipped: ' + (data.reason || '')); + return; + } + + if (data.type !== 'wefax_progress') return; + + var statusText = data.message || data.status || ''; + setStatus(statusText); + + var dot = document.getElementById('wefaxStripDot'); + if (dot) { + dot.className = 'wefax-strip-dot ' + (data.status || 'idle'); + } + + var statusEl = document.getElementById('wefaxStripStatus'); + if (statusEl) { + var labels = { + scanning: 'Scanning', + phasing: 'Phasing', + receiving: 'Receiving', + complete: 'Complete', + error: 'Error', + stopped: 'Idle', + }; + statusEl.textContent = labels[data.status] || data.status || 'Idle'; + } + + // Update line count + if (data.line_count) { + var lineEl = document.getElementById('wefaxStripLines'); + if (lineEl) lineEl.textContent = String(data.line_count); + } + + // Live preview + if (data.partial_image) { + var previewEl = document.getElementById('wefaxLivePreview'); + if (previewEl) { + previewEl.src = data.partial_image; + previewEl.style.display = 'block'; + } + var idleEl = document.getElementById('wefaxIdleState'); + if (idleEl) idleEl.style.display = 'none'; + } + + // Image complete + if (data.status === 'complete' && data.image) { + scopeImageBurst = 1.0; + loadImages(); + setStatus('Image decoded: ' + (data.line_count || '?') + ' lines'); + } + + if (data.status === 'complete') { + state.running = false; + updateButtons(false); + if (!state.schedulerEnabled) { + disconnectSSE(); + } + } + + if (data.status === 'error') { + state.running = false; + updateButtons(false); + showStripError(data.message || 'Decode error'); + } + + if (data.status === 'stopped') { + state.running = false; + updateButtons(false); + } + } + + // ---- Audio Waveform Scope ---- + + function initScope() { + var canvas = document.getElementById('wefaxScopeCanvas'); + if (!canvas) return; + + if (scopeAnim) { cancelAnimationFrame(scopeAnim); scopeAnim = null; } + + resizeScopeCanvas(canvas); + scopeCtx = canvas.getContext('2d'); + scopeHistory = new Array(SCOPE_HISTORY_LEN).fill(0); + scopeWaveBuffer = []; + scopeDisplayWave = []; + scopeRms = scopePeak = scopeTargetRms = scopeTargetPeak = 0; + scopeImageBurst = scopeLastWaveAt = scopeLastInputSample = 0; + drawScope(); + } + + function stopScope() { + if (scopeAnim) { cancelAnimationFrame(scopeAnim); scopeAnim = null; } + scopeCtx = null; + scopeWaveBuffer = []; + scopeDisplayWave = []; + scopeHistory = []; + scopeLastWaveAt = 0; + scopeLastInputSample = 0; + } + + function resizeScopeCanvas(canvas) { + if (!canvas) return; + var rect = canvas.getBoundingClientRect(); + var dpr = window.devicePixelRatio || 1; + var width = Math.max(1, Math.floor(rect.width * dpr)); + var height = Math.max(1, Math.floor(rect.height * dpr)); + if (canvas.width !== width || canvas.height !== height) { + canvas.width = width; + canvas.height = height; + } + } + + function applyScopeData(scopeData) { + if (!scopeData || typeof scopeData !== 'object') return; + + scopeTargetRms = Number(scopeData.rms) || 0; + scopeTargetPeak = Number(scopeData.peak) || 0; + + if (Array.isArray(scopeData.waveform) && scopeData.waveform.length) { + for (var i = 0; i < scopeData.waveform.length; i++) { + var sample = Number(scopeData.waveform[i]); + if (!isFinite(sample)) continue; + var normalized = Math.max(-127, Math.min(127, sample)) / 127; + scopeLastInputSample += (normalized - scopeLastInputSample) * SCOPE_WAVE_INPUT_SMOOTH; + scopeWaveBuffer.push(scopeLastInputSample); + } + if (scopeWaveBuffer.length > SCOPE_WAVE_BUFFER_LEN) { + scopeWaveBuffer.splice(0, scopeWaveBuffer.length - SCOPE_WAVE_BUFFER_LEN); + } + scopeLastWaveAt = performance.now(); + } + } + + function drawScope() { + var ctx = scopeCtx; + if (!ctx) return; + + resizeScopeCanvas(ctx.canvas); + var W = ctx.canvas.width, H = ctx.canvas.height, midY = H / 2; + + // Phosphor persistence + ctx.fillStyle = 'rgba(5, 5, 16, 0.26)'; + ctx.fillRect(0, 0, W, H); + + // Smooth RMS/Peak + scopeRms += (scopeTargetRms - scopeRms) * 0.25; + scopePeak += (scopeTargetPeak - scopePeak) * 0.15; + + // Rolling envelope + scopeHistory.push(Math.min(scopeRms / 32768, 1.0)); + if (scopeHistory.length > SCOPE_HISTORY_LEN) scopeHistory.shift(); + + // Grid lines + ctx.strokeStyle = 'rgba(40, 40, 80, 0.4)'; + ctx.lineWidth = 0.8; + var gx, gy; + for (var i = 1; i < 8; i++) { + gx = (W / 8) * i; + ctx.beginPath(); ctx.moveTo(gx, 0); ctx.lineTo(gx, H); ctx.stroke(); + } + for (var g = 0.25; g < 1; g += 0.25) { + gy = midY - g * midY; + var gy2 = midY + g * midY; + ctx.beginPath(); ctx.moveTo(0, gy); ctx.lineTo(W, gy); + ctx.moveTo(0, gy2); ctx.lineTo(W, gy2); ctx.stroke(); + } + + // Center baseline + ctx.strokeStyle = 'rgba(60, 60, 100, 0.5)'; + ctx.lineWidth = 1; + ctx.beginPath(); ctx.moveTo(0, midY); ctx.lineTo(W, midY); ctx.stroke(); + + // Amplitude envelope (amber tint) + var envStepX = W / (SCOPE_HISTORY_LEN - 1); + ctx.strokeStyle = 'rgba(255, 170, 0, 0.35)'; + ctx.lineWidth = 1; + ctx.beginPath(); + for (var ei = 0; ei < scopeHistory.length; ei++) { + var ex = ei * envStepX, amp = scopeHistory[ei] * midY * 0.85; + if (ei === 0) ctx.moveTo(ex, midY - amp); else ctx.lineTo(ex, midY - amp); + } + ctx.stroke(); + ctx.beginPath(); + for (var ej = 0; ej < scopeHistory.length; ej++) { + var ex2 = ej * envStepX, amp2 = scopeHistory[ej] * midY * 0.85; + if (ej === 0) ctx.moveTo(ex2, midY + amp2); else ctx.lineTo(ex2, midY + amp2); + } + ctx.stroke(); + + // Waveform trace (amber) + var wavePoints = Math.min(Math.max(120, Math.floor(W / 3.2)), 420); + if (scopeWaveBuffer.length > 1) { + var waveIsFresh = (performance.now() - scopeLastWaveAt) < 700; + var srcLen = scopeWaveBuffer.length; + var srcWindow = Math.min(srcLen, 1536); + var srcStart = srcLen - srcWindow; + + if (scopeDisplayWave.length !== wavePoints) { + scopeDisplayWave = new Array(wavePoints).fill(0); + } + + for (var wi = 0; wi < wavePoints; wi++) { + var a = srcStart + Math.floor((wi / wavePoints) * srcWindow); + var b = srcStart + Math.floor(((wi + 1) / wavePoints) * srcWindow); + var start = Math.max(srcStart, Math.min(srcLen - 1, a)); + var end = Math.max(start + 1, Math.min(srcLen, b)); + var sum = 0, count = 0; + for (var j = start; j < end; j++) { sum += scopeWaveBuffer[j]; count++; } + var targetSample = count > 0 ? sum / count : 0; + scopeDisplayWave[wi] += (targetSample - scopeDisplayWave[wi]) * SCOPE_WAVE_DISPLAY_SMOOTH; + } + + ctx.strokeStyle = waveIsFresh ? '#ffaa00' : 'rgba(255, 170, 0, 0.45)'; + ctx.lineWidth = 1.7; + ctx.shadowColor = '#ffaa00'; + ctx.shadowBlur = waveIsFresh ? 6 : 2; + + var stepX = wavePoints > 1 ? W / (wavePoints - 1) : W; + ctx.beginPath(); + ctx.moveTo(0, midY - scopeDisplayWave[0] * midY * 0.9); + for (var qi = 1; qi < wavePoints - 1; qi++) { + var x = qi * stepX, y = midY - scopeDisplayWave[qi] * midY * 0.9; + var nx = (qi + 1) * stepX, ny = midY - scopeDisplayWave[qi + 1] * midY * 0.9; + ctx.quadraticCurveTo(x, y, (x + nx) / 2, (y + ny) / 2); + } + ctx.lineTo((wavePoints - 1) * stepX, + midY - scopeDisplayWave[wavePoints - 1] * midY * 0.9); + ctx.stroke(); + + if (!waveIsFresh) { + for (var di = 0; di < scopeDisplayWave.length; di++) { + scopeDisplayWave[di] *= SCOPE_WAVE_IDLE_DECAY; + } + } + } + ctx.shadowBlur = 0; + + // Peak indicator + var peakNorm = Math.min(scopePeak / 32768, 1.0); + if (peakNorm > 0.01) { + var peakY = midY - peakNorm * midY * 0.9; + ctx.strokeStyle = 'rgba(255, 68, 68, 0.6)'; + ctx.lineWidth = 1; + ctx.setLineDash([4, 4]); + ctx.beginPath(); ctx.moveTo(0, peakY); ctx.lineTo(W, peakY); ctx.stroke(); + ctx.setLineDash([]); + } + + // Image-decoded flash (amber overlay) + if (scopeImageBurst > 0.01) { + ctx.fillStyle = 'rgba(255, 170, 0, ' + (scopeImageBurst * 0.15) + ')'; + ctx.fillRect(0, 0, W, H); + scopeImageBurst *= 0.88; + } + + // Label updates + var rmsLabel = document.getElementById('wefaxScopeRmsLabel'); + var peakLabel = document.getElementById('wefaxScopePeakLabel'); + var statusLabel = document.getElementById('wefaxScopeStatusLabel'); + if (rmsLabel) rmsLabel.textContent = Math.round(scopeRms); + if (peakLabel) peakLabel.textContent = Math.round(scopePeak); + if (statusLabel) { + var fresh = (performance.now() - scopeLastWaveAt) < 700; + if (fresh && scopeRms > 1300) { + statusLabel.textContent = 'DEMODULATING'; + statusLabel.style.color = '#ffaa00'; + } else if (fresh && scopeRms > 500) { + statusLabel.textContent = 'CARRIER'; + statusLabel.style.color = '#cc8800'; + } else if (fresh) { + statusLabel.textContent = 'QUIET'; + statusLabel.style.color = '#666'; + } else { + statusLabel.textContent = 'IDLE'; + statusLabel.style.color = '#444'; + } + } + + scopeAnim = requestAnimationFrame(drawScope); + } + + // ---- Images ---- + + function loadImages() { + fetch('/wefax/images') + .then(function (r) { return r.json(); }) + .then(function (data) { + if (data.status === 'ok') { + state.images = data.images || []; + renderImageGallery(); + var countEl = document.getElementById('wefaxImageCount'); + if (countEl) countEl.textContent = String(state.images.length); + var stripCount = document.getElementById('wefaxStripImageCount'); + if (stripCount) stripCount.textContent = String(state.images.length); + } + }) + .catch(function (err) { + console.error('WeFax: failed to load images', err); + }); + } + + function renderImageGallery() { + var gallery = document.getElementById('wefaxGallery'); + if (!gallery) return; + + if (state.images.length === 0) { + gallery.innerHTML = ''; + return; + } + + var html = ''; + // Show newest first + var sorted = state.images.slice().reverse(); + sorted.forEach(function (img) { + var ts = img.timestamp ? new Date(img.timestamp).toLocaleString() : ''; + var station = img.station || ''; + var freq = img.frequency_khz ? (img.frequency_khz + ' kHz') : ''; + html += ''; + }); + gallery.innerHTML = html; + } + + function deleteImage(filename) { + if (!filename) return; + if (!confirm('Delete this image?')) return; + fetch('/wefax/images/' + encodeURIComponent(filename), { method: 'DELETE' }) + .then(function (r) { return r.json(); }) + .then(function (data) { + if (data.status === 'ok') { + closeImage(); + loadImages(); + } else { + setStatus('Delete failed: ' + (data.message || 'unknown error')); + } + }) + .catch(function (err) { + console.error('WeFax delete error:', err); + setStatus('Delete failed: ' + err.message); + }); + } + + function deleteAllImages() { + if (!confirm('Delete all WeFax images?')) return; + fetch('/wefax/images', { method: 'DELETE' }) + .then(function (r) { return r.json(); }) + .then(function (data) { + if (data.status === 'ok') { + loadImages(); + } + }) + .catch(function (err) { console.error('WeFax delete all error:', err); }); + } + + var currentModalUrl = null; + var currentModalFilename = null; + + function viewImage(url, filename) { + currentModalUrl = url; + currentModalFilename = filename || null; + + var modal = document.getElementById('wefaxImageModal'); + if (!modal) { + modal = document.createElement('div'); + modal.id = 'wefaxImageModal'; + modal.className = 'wefax-image-modal'; + modal.innerHTML = + '
' + + '' + + '' + + '
' + + '' + + 'WeFax Image'; + modal.addEventListener('click', function (e) { + if (e.target === modal) closeImage(); + }); + modal.querySelector('#wefaxModalDownload').addEventListener('click', function (e) { + e.stopPropagation(); + if (currentModalUrl && currentModalFilename) { + var a = document.createElement('a'); + a.href = currentModalUrl; + a.download = currentModalFilename; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + } + }); + modal.querySelector('#wefaxModalDelete').addEventListener('click', function (e) { + e.stopPropagation(); + if (currentModalFilename) { + deleteImage(currentModalFilename); + } + }); + document.body.appendChild(modal); + } + + modal.querySelector('img').src = url; + modal.classList.add('show'); + } + + function closeImage() { + var modal = document.getElementById('wefaxImageModal'); + if (modal) modal.classList.remove('show'); + } + + // ---- Schedule Timeline ---- + + function renderScheduleTimeline(schedule) { + var container = document.getElementById('wefaxScheduleTimeline'); + if (!container) return; + + if (!schedule || schedule.length === 0) { + container.innerHTML = '
Select a station to see broadcast schedule
'; + return; + } + + var now = new Date(); + var nowMin = now.getUTCHours() * 60 + now.getUTCMinutes(); + + var html = '
'; + schedule.forEach(function (entry) { + var parts = entry.utc.split(':'); + var entryMin = parseInt(parts[0], 10) * 60 + parseInt(parts[1], 10); + var diff = entryMin - nowMin; + if (diff < -720) diff += 1440; + if (diff > 720) diff -= 1440; + + var cls = 'wefax-schedule-entry'; + var badge = ''; + if (diff >= 0 && diff <= entry.duration_min) { + cls += ' active'; + badge = 'LIVE'; + } else if (diff > 0 && diff <= 60) { + cls += ' upcoming'; + badge = '' + diff + 'm'; + } else if (diff > 0) { + badge = '' + Math.floor(diff / 60) + 'h ' + (diff % 60) + 'm'; + } else { + cls += ' past'; + } + + html += '
'; + html += '' + entry.utc + ''; + html += '' + entry.content + ''; + html += badge; + html += '
'; + }); + html += '
'; + container.innerHTML = html; + } + + // ---- UI helpers ---- + + function updateButtons(running) { + var startBtn = document.getElementById('wefaxStartBtn'); + var stopBtn = document.getElementById('wefaxStopBtn'); + if (startBtn) startBtn.style.display = running ? 'none' : 'inline-flex'; + if (stopBtn) stopBtn.style.display = running ? 'inline-flex' : 'none'; + + var dot = document.getElementById('wefaxStripDot'); + if (dot) dot.className = 'wefax-strip-dot ' + (running ? 'scanning' : 'idle'); + + var statusEl = document.getElementById('wefaxStripStatus'); + if (statusEl && !running) statusEl.textContent = 'Idle'; + } + + function setStatus(msg) { + var el = document.getElementById('wefaxStatusText'); + if (el) el.textContent = msg; + } + + function setStripFreq(khz) { + var el = document.getElementById('wefaxStripFreq'); + if (el) el.textContent = String(khz); + } + + function showStripError(msg) { + var statusEl = document.getElementById('wefaxStripStatus'); + if (statusEl) { + statusEl.textContent = 'Error: ' + msg; + statusEl.style.color = '#ff4444'; + } + var dot = document.getElementById('wefaxStripDot'); + if (dot) dot.className = 'wefax-strip-dot error'; + } + + function flashStartError() { + setStatus('Select a station and frequency first'); + + // Flash the Start button itself (most visible feedback) + var startBtn = document.getElementById('wefaxStartBtn'); + if (startBtn) { + startBtn.classList.add('wefax-strip-btn-error'); + setTimeout(function () { + startBtn.classList.remove('wefax-strip-btn-error'); + }, 2500); + } + + // Show error in strip status text (right next to the button) + var stripStatus = document.getElementById('wefaxStripStatus'); + if (stripStatus) { + var prevText = stripStatus.textContent; + stripStatus.textContent = 'Select Station'; + stripStatus.style.color = '#ffaa00'; + setTimeout(function () { + stripStatus.textContent = prevText || 'Idle'; + stripStatus.style.color = ''; + }, 2500); + } + + // Also update the schedule panel status + var statusEl = document.getElementById('wefaxStatusText'); + if (statusEl) { + statusEl.style.color = '#ffaa00'; + statusEl.style.fontWeight = '600'; + setTimeout(function () { + statusEl.style.color = ''; + statusEl.style.fontWeight = ''; + }, 2500); + } + + // Flash station/frequency dropdowns + var stationSel = document.getElementById('wefaxStation'); + var freqSel = document.getElementById('wefaxFrequency'); + [stationSel, freqSel].forEach(function (el) { + if (!el) return; + el.style.borderColor = '#ffaa00'; + el.style.boxShadow = '0 0 4px #ffaa0066'; + setTimeout(function () { + el.style.borderColor = ''; + el.style.boxShadow = ''; + }, 2500); + }); + } + + // ---- Broadcast Timeline + Countdown ---- + + function renderBroadcastTimeline(schedule) { + var bar = document.getElementById('wefaxCountdownBar'); + var track = document.getElementById('wefaxTimelineTrack'); + if (!bar || !track) return; + + if (!schedule || schedule.length === 0) { + bar.style.display = 'none'; + return; + } + + bar.style.display = 'flex'; + + // Clear existing broadcast markers + var existing = track.querySelectorAll('.wefax-timeline-broadcast'); + for (var i = 0; i < existing.length; i++) { + existing[i].parentNode.removeChild(existing[i]); + } + + var now = new Date(); + var nowMin = now.getUTCHours() * 60 + now.getUTCMinutes(); + + schedule.forEach(function (entry) { + var parts = entry.utc.split(':'); + var startMin = parseInt(parts[0], 10) * 60 + parseInt(parts[1], 10); + var duration = entry.duration_min || 20; + var leftPct = (startMin / 1440) * 100; + var widthPct = (duration / 1440) * 100; + + var block = document.createElement('div'); + block.className = 'wefax-timeline-broadcast'; + block.title = entry.utc + ' — ' + entry.content; + + // Mark active broadcasts + var diff = nowMin - startMin; + if (diff >= 0 && diff < duration) { + block.classList.add('active'); + } + + block.style.left = leftPct + '%'; + block.style.width = Math.max(widthPct, 0.3) + '%'; + track.appendChild(block); + }); + + updateTimelineCursor(); + } + + function updateTimelineCursor() { + var cursor = document.getElementById('wefaxTimelineCursor'); + if (!cursor) return; + + var now = new Date(); + var nowMin = now.getUTCHours() * 60 + now.getUTCMinutes() + now.getUTCSeconds() / 60; + cursor.style.left = ((nowMin / 1440) * 100) + '%'; + } + + function startCountdownTimer() { + stopCountdownTimer(); + updateCountdown(); + state.countdownInterval = setInterval(function () { + updateCountdown(); + updateTimelineCursor(); + }, 1000); + } + + function updateCountdown() { + var station = state.selectedStation; + if (!station || !station.schedule || !station.schedule.length) return; + + var now = new Date(); + var nowMin = now.getUTCHours() * 60 + now.getUTCMinutes() + now.getUTCSeconds() / 60; + + // Find next upcoming or currently active broadcast + var bestDiff = Infinity; + var bestEntry = null; + var isActive = false; + + station.schedule.forEach(function (entry) { + var parts = entry.utc.split(':'); + var startMin = parseInt(parts[0], 10) * 60 + parseInt(parts[1], 10); + var duration = entry.duration_min || 20; + + // Check if currently active + var elapsed = nowMin - startMin; + if (elapsed < 0) elapsed += 1440; + if (elapsed >= 0 && elapsed < duration) { + bestEntry = entry; + bestDiff = 0; + isActive = true; + return; + } + + // Time until start + var diff = startMin - nowMin; + if (diff < 0) diff += 1440; + if (diff < bestDiff) { + bestDiff = diff; + bestEntry = entry; + } + }); + + if (!bestEntry) return; + + var hoursEl = document.getElementById('wefaxCdHours'); + var minsEl = document.getElementById('wefaxCdMins'); + var secsEl = document.getElementById('wefaxCdSecs'); + var contentEl = document.getElementById('wefaxCountdownContent'); + var detailEl = document.getElementById('wefaxCountdownDetail'); + var boxes = document.getElementById('wefaxCountdownBoxes'); + + if (isActive) { + // Show "LIVE" countdown + var parts = bestEntry.utc.split(':'); + var startMin2 = parseInt(parts[0], 10) * 60 + parseInt(parts[1], 10); + var duration2 = bestEntry.duration_min || 20; + var elapsed2 = nowMin - startMin2; + if (elapsed2 < 0) elapsed2 += 1440; + var remaining = duration2 - elapsed2; + var remTotalSec = Math.max(0, Math.floor(remaining * 60)); + var h = Math.floor(remTotalSec / 3600); + var m = Math.floor((remTotalSec % 3600) / 60); + var s = remTotalSec % 60; + + if (hoursEl) hoursEl.textContent = String(h).padStart(2, '0'); + if (minsEl) minsEl.textContent = String(m).padStart(2, '0'); + if (secsEl) secsEl.textContent = String(s).padStart(2, '0'); + if (contentEl) contentEl.textContent = bestEntry.content; + if (detailEl) detailEl.textContent = 'LIVE — ' + bestEntry.utc + ' UTC'; + + // Set active class on boxes + if (boxes) { + var boxEls = boxes.querySelectorAll('.wefax-countdown-box'); + for (var i = 0; i < boxEls.length; i++) { + boxEls[i].classList.remove('imminent'); + boxEls[i].classList.add('active'); + } + } + } else { + // Countdown to next + var totalSec = Math.max(0, Math.floor(bestDiff * 60)); + var h2 = Math.floor(totalSec / 3600); + var m2 = Math.floor((totalSec % 3600) / 60); + var s2 = totalSec % 60; + + if (hoursEl) hoursEl.textContent = String(h2).padStart(2, '0'); + if (minsEl) minsEl.textContent = String(m2).padStart(2, '0'); + if (secsEl) secsEl.textContent = String(s2).padStart(2, '0'); + if (contentEl) contentEl.textContent = bestEntry.content; + if (detailEl) detailEl.textContent = 'Next at ' + bestEntry.utc + ' UTC'; + + // Set imminent class when < 10 min + if (boxes) { + var boxEls2 = boxes.querySelectorAll('.wefax-countdown-box'); + var isImminent = bestDiff < 10; + for (var j = 0; j < boxEls2.length; j++) { + boxEls2[j].classList.remove('active'); + if (isImminent) { + boxEls2[j].classList.add('imminent'); + } else { + boxEls2[j].classList.remove('imminent'); + } + } + } + } + } + + function stopCountdownTimer() { + if (state.countdownInterval) { + clearInterval(state.countdownInterval); + state.countdownInterval = null; + } + } + + // ---- Auto-Capture Scheduler ---- + + function checkSchedulerStatus() { + fetch('/wefax/schedule/status') + .then(function (r) { return r.json(); }) + .then(function (data) { + var strip = document.getElementById('wefaxStripAutoSchedule'); + var sidebar = document.getElementById('wefaxSidebarAutoSchedule'); + if (strip) strip.checked = !!data.enabled; + if (sidebar) sidebar.checked = !!data.enabled; + state.schedulerEnabled = !!data.enabled; + if (data.enabled) { + connectSSE(); + startSchedulerPoll(); + } + }) + .catch(function () { /* ignore */ }); + } + + function enableScheduler() { + var stationSel = document.getElementById('wefaxStation'); + var station = stationSel ? stationSel.value : ''; + var freqSel = document.getElementById('wefaxFrequency'); + var freqKhz = freqSel ? parseFloat(freqSel.value) : 0; + + if (!station || !freqKhz || isNaN(freqKhz)) { + flashStartError(); + syncSchedulerCheckboxes(false); + return; + } + + var deviceSel = document.getElementById('rtlDevice'); + var device = deviceSel ? parseInt(deviceSel.value, 10) || 0 : 0; + var gainInput = document.getElementById('wefaxGain'); + var iocSel = document.getElementById('wefaxIOC'); + var lpmSel = document.getElementById('wefaxLPM'); + var dsCheckbox = document.getElementById('wefaxDirectSampling'); + + fetch('/wefax/schedule/enable', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + station: station, + frequency_khz: freqKhz, + device: device, + gain: gainInput ? parseFloat(gainInput.value) || 40 : 40, + ioc: iocSel ? parseInt(iocSel.value, 10) : 576, + lpm: lpmSel ? parseInt(lpmSel.value, 10) : 120, + direct_sampling: dsCheckbox ? dsCheckbox.checked : true, + frequency_reference: selectedFrequencyReference(), + }), + }) + .then(function (r) { return r.json(); }) + .then(function (data) { + if (data.status === 'ok') { + var status = 'Auto-capture enabled — ' + (data.scheduled_count || 0) + ' broadcasts scheduled'; + if (data.usb_offset_applied && !isNaN(Number(data.tuned_frequency_khz))) { + status += ' (tuning ' + Number(data.tuned_frequency_khz) + ' kHz)'; + } + setStatus(status); + syncSchedulerCheckboxes(true); + state.schedulerEnabled = true; + connectSSE(); + startSchedulerPoll(); + } else { + setStatus('Scheduler error: ' + (data.message || 'unknown')); + syncSchedulerCheckboxes(false); + } + }) + .catch(function (err) { + setStatus('Scheduler error: ' + err.message); + syncSchedulerCheckboxes(false); + }); + } + + function disableScheduler() { + fetch('/wefax/schedule/disable', { method: 'POST' }) + .then(function (r) { return r.json(); }) + .then(function () { + setStatus('Auto-capture disabled'); + syncSchedulerCheckboxes(false); + state.schedulerEnabled = false; + stopSchedulerPoll(); + if (!state.running) { + disconnectSSE(); + } + }) + .catch(function (err) { + console.error('WeFax scheduler disable error:', err); + }); + } + + function toggleScheduler(checkbox) { + if (checkbox.checked) { + enableScheduler(); + } else { + disableScheduler(); + } + } + + function startSchedulerPoll() { + stopSchedulerPoll(); + state.schedulerPollTimer = setInterval(function () { + fetch('/wefax/status') + .then(function (r) { return r.json(); }) + .then(function (data) { + if (data.running && !state.running) { + state.running = true; + updateButtons(true); + setStatus('Auto-capture in progress...'); + connectSSE(); + } else if (!data.running && state.running) { + state.running = false; + updateButtons(false); + loadImages(); + } + }) + .catch(function () { /* ignore poll errors */ }); + }, 10000); + } + + function stopSchedulerPoll() { + if (state.schedulerPollTimer) { + clearInterval(state.schedulerPollTimer); + state.schedulerPollTimer = null; + } + } + + function syncSchedulerCheckboxes(enabled) { + var strip = document.getElementById('wefaxStripAutoSchedule'); + var sidebar = document.getElementById('wefaxSidebarAutoSchedule'); + if (strip) strip.checked = enabled; + if (sidebar) sidebar.checked = enabled; + } + + // ---- Public API ---- + + return { + init: init, + destroy: destroy, + start: start, + stop: stop, + onStationChange: onStationChange, + loadImages: loadImages, + deleteImage: deleteImage, + deleteAllImages: deleteAllImages, + viewImage: viewImage, + closeImage: closeImage, + toggleScheduler: toggleScheduler, + }; +})(); diff --git a/templates/adsb_dashboard.html b/templates/adsb_dashboard.html index 7634945..3d26ffa 100644 --- a/templates/adsb_dashboard.html +++ b/templates/adsb_dashboard.html @@ -429,6 +429,7 @@ let agentPollTimer = null; // Polling fallback for agent mode let isTracking = false; let currentFilter = 'all'; + // ICAO -> { emergency: bool, watchlist: bool, military: bool } let alertedAircraft = {}; let alertsEnabled = true; let detectionSoundEnabled = localStorage.getItem('adsb_detectionSound') !== 'false'; // Default on @@ -678,24 +679,64 @@ } } + function speakAircraftAlert(kind, icao, ac, detail) { + if (typeof VoiceAlerts === 'undefined' || typeof VoiceAlerts.speak !== 'function') return; + + const cfg = (typeof VoiceAlerts.getConfig === 'function') + ? VoiceAlerts.getConfig() + : { streams: {} }; + const streams = cfg && cfg.streams ? cfg.streams : {}; + const callsign = (ac && ac.callsign ? String(ac.callsign).trim() : '') || icao; + + if (kind === 'emergency') { + if (streams.squawks === false) return; + const squawk = detail && detail.squawk ? ` squawk ${detail.squawk}.` : '.'; + const meaning = detail && detail.name ? ` ${detail.name}.` : ''; + VoiceAlerts.speak(`Aircraft emergency: ${callsign}.${squawk}${meaning}`, VoiceAlerts.PRIORITY.HIGH); + return; + } + + if (kind === 'military') { + if (streams.adsb_military === false) return; + const country = detail && detail.country ? ` ${detail.country}.` : ''; + VoiceAlerts.speak(`Military aircraft detected: ${callsign}.${country}`, VoiceAlerts.PRIORITY.HIGH); + } + } + function checkAndAlertAircraft(icao, ac) { - if (alertedAircraft[icao]) return; + if (!alertedAircraft[icao]) { + alertedAircraft[icao] = { emergency: false, watchlist: false, military: false }; + } + + const alertState = alertedAircraft[icao]; const militaryInfo = isMilitaryAircraft(icao, ac.callsign); const squawkInfo = checkSquawkCode(ac); const onWatchlist = isOnWatchlist(ac); if (squawkInfo && squawkInfo.type === 'emergency') { - alertedAircraft[icao] = 'emergency'; - playAlertSound('emergency'); - showAlertBanner(`EMERGENCY: ${squawkInfo.name} - ${ac.callsign || icao}`, '#ff0000'); - } else if (onWatchlist) { - alertedAircraft[icao] = 'watchlist'; + if (!alertState.emergency) { + alertState.emergency = true; + playAlertSound('emergency'); + showAlertBanner(`EMERGENCY: ${squawkInfo.name} - ${ac.callsign || icao}`, '#ff0000'); + speakAircraftAlert('emergency', icao, ac, { + squawk: ac.squawk, + name: squawkInfo.name, + }); + } + return; + } + + if (onWatchlist && !alertState.watchlist) { + alertState.watchlist = true; playAlertSound('military'); // Use military sound for watchlist showAlertBanner(`WATCHLIST: ${ac.callsign || ac.registration || icao} detected!`, '#00d4ff'); - } else if (militaryInfo.military) { - alertedAircraft[icao] = 'military'; + } else if (militaryInfo.military && !alertState.military) { + alertState.military = true; playAlertSound('military'); showAlertBanner(`MILITARY: ${ac.callsign || icao}${militaryInfo.country ? ' (' + militaryInfo.country + ')' : ''}`, '#556b2f'); + speakAircraftAlert('military', icao, ac, { + country: militaryInfo.country || null, + }); } } @@ -2183,21 +2224,44 @@ sudo make install } } else { try { - // Route stop through agent proxy if using remote agent - const url = useAgent - ? `/controller/agents/${adsbCurrentAgent}/adsb/stop` + // Route stop through the source that actually started tracking. + const stopSource = adsbTrackingSource || (useAgent ? adsbCurrentAgent : 'local'); + const stopViaAgent = stopSource !== null && stopSource !== undefined && stopSource !== 'local'; + const url = stopViaAgent + ? `/controller/agents/${stopSource}/adsb/stop` : '/adsb/stop'; - await fetch(url, { + const response = await fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({}) + body: JSON.stringify({ source: 'adsb_dashboard' }) }); + const text = await response.text(); + let data = {}; + if (text) { + try { + data = JSON.parse(text); + } catch (e) { + throw new Error(`Invalid response: ${text}`); + } + } + const result = stopViaAgent && data.result ? data.result : data; + const stopped = response.ok && ( + result.status === 'stopped' || + result.status === 'success' || + data.status === 'success' + ); + if (!stopped) { + throw new Error(result.message || data.message || `HTTP ${response.status}`); + } // Update agent running modes tracking - if (useAgent && typeof agentRunningModes !== 'undefined') { + if (stopViaAgent && typeof agentRunningModes !== 'undefined') { agentRunningModes = agentRunningModes.filter(m => m !== 'adsb'); } - } catch (err) {} + } catch (err) { + alert('Failed to stop ADS-B: ' + err.message); + return; + } stopEventStream(); isTracking = false; @@ -2350,16 +2414,17 @@ sudo make install function startEventStream() { if (eventSource) eventSource.close(); - const useAgent = typeof adsbCurrentAgent !== 'undefined' && adsbCurrentAgent !== 'local'; + const activeSource = (isTracking && adsbTrackingSource) ? adsbTrackingSource : adsbCurrentAgent; + const useAgent = typeof activeSource !== 'undefined' && activeSource !== null && activeSource !== 'local'; const streamUrl = useAgent ? '/controller/stream/all' : '/adsb/stream'; - console.log(`[ADS-B] startEventStream called - adsbCurrentAgent=${adsbCurrentAgent}, useAgent=${useAgent}, streamUrl=${streamUrl}`); + console.log(`[ADS-B] startEventStream called - activeSource=${activeSource}, useAgent=${useAgent}, streamUrl=${streamUrl}`); eventSource = new EventSource(streamUrl); // Get agent name for filtering multi-agent stream let targetAgentName = null; if (useAgent && typeof agents !== 'undefined') { - const agent = agents.find(a => a.id == adsbCurrentAgent); + const agent = agents.find(a => a.id == activeSource); targetAgentName = agent ? agent.name : null; } @@ -4253,30 +4318,56 @@ sudo make install .catch(err => alert('VDL2 Error: ' + err)); } - function stopVdl2() { - const isAgentMode = vdl2CurrentAgent !== null; + async function stopVdl2() { + const sourceAgentId = vdl2CurrentAgent; + const isAgentMode = sourceAgentId !== null && sourceAgentId !== undefined; const endpoint = isAgentMode - ? `/controller/agents/${vdl2CurrentAgent}/vdl2/stop` + ? `/controller/agents/${sourceAgentId}/vdl2/stop` : '/vdl2/stop'; - fetch(endpoint, { method: 'POST' }) - .then(r => r.json()) - .then(() => { - isVdl2Running = false; - vdl2CurrentAgent = null; - document.getElementById('vdl2ToggleBtn').innerHTML = '▶ START VDL2'; - document.getElementById('vdl2ToggleBtn').classList.remove('active'); - document.getElementById('vdl2PanelIndicator').classList.remove('active'); - if (vdl2EventSource) { - vdl2EventSource.close(); - vdl2EventSource = null; - } - // Clear polling timer - if (vdl2PollTimer) { - clearInterval(vdl2PollTimer); - vdl2PollTimer = null; - } + try { + const response = await fetch(endpoint, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ source: 'adsb_dashboard' }) }); + const text = await response.text(); + let data = {}; + if (text) { + try { + data = JSON.parse(text); + } catch (e) { + throw new Error(`Invalid response: ${text}`); + } + } + + const result = isAgentMode && data.result ? data.result : data; + const stopped = response.ok && ( + result.status === 'stopped' || + result.status === 'success' || + data.status === 'success' + ); + if (!stopped) { + throw new Error(result.message || data.message || `HTTP ${response.status}`); + } + + isVdl2Running = false; + vdl2CurrentAgent = null; + document.getElementById('vdl2ToggleBtn').innerHTML = '▶ START VDL2'; + document.getElementById('vdl2ToggleBtn').classList.remove('active'); + document.getElementById('vdl2PanelIndicator').classList.remove('active'); + if (vdl2EventSource) { + vdl2EventSource.close(); + vdl2EventSource = null; + } + // Clear polling timer + if (vdl2PollTimer) { + clearInterval(vdl2PollTimer); + vdl2PollTimer = null; + } + } catch (err) { + alert('Failed to stop VDL2: ' + err.message); + } } // Sync VDL2 UI state (called by syncModeUI in agents.js) @@ -4294,6 +4385,7 @@ sudo make install startVdl2Stream(agentId !== null); } } else { + vdl2CurrentAgent = null; btn.innerHTML = '▶ START VDL2'; btn.classList.remove('active'); if (indicator) indicator.classList.remove('active'); @@ -5340,7 +5432,13 @@ sudo make install {% include 'partials/help-modal.html' %} + + @@ -5472,24 +5570,27 @@ sudo make install if (running) { isTracking = true; + const normalizedSource = source === null || source === undefined + ? (adsbTrackingSource || 'local') + : source; // If source is an agent ID (not 'local' and not null), update adsbCurrentAgent // This ensures startEventStream uses the correct routing - if (source && source !== 'local') { - adsbCurrentAgent = source; + if (normalizedSource !== 'local') { + adsbCurrentAgent = normalizedSource; // Also update the dropdown to match const agentSelect = document.getElementById('agentSelect'); if (agentSelect) { - agentSelect.value = source; + agentSelect.value = normalizedSource; } // Update global agent state too if (typeof currentAgent !== 'undefined') { - currentAgent = source; + currentAgent = normalizedSource; } - console.log(`[ADS-B] Updated adsbCurrentAgent to ${source}`); + console.log(`[ADS-B] Updated adsbCurrentAgent to ${normalizedSource}`); } - adsbTrackingSource = source || adsbCurrentAgent; // Track which source is active + adsbTrackingSource = normalizedSource; // Track which source is active // Update button if (btn) { @@ -5509,9 +5610,9 @@ sudo make install if (agentSelect) agentSelect.disabled = true; // Start data stream for the active source - const isAgentSource = adsbCurrentAgent !== 'local'; + const isAgentSource = normalizedSource !== 'local'; if (isAgentSource) { - console.log(`[ADS-B] Starting data stream from agent ${adsbCurrentAgent}`); + console.log(`[ADS-B] Starting data stream from agent ${normalizedSource}`); startEventStream(); drawRangeRings(); startSessionTimer(); diff --git a/templates/index.html b/templates/index.html index c41c28b..955e7eb 100644 --- a/templates/index.html +++ b/templates/index.html @@ -79,36 +79,90 @@ gps: "{{ url_for('static', filename='css/modes/gps.css') }}", subghz: "{{ url_for('static', filename='css/modes/subghz.css') }}?v={{ version }}&r=subghz_layout9", bt_locate: "{{ url_for('static', filename='css/modes/bt_locate.css') }}?v={{ version }}&r=btlocate4", - spaceweather: "{{ url_for('static', filename='css/modes/space-weather.css') }}" + spaceweather: "{{ url_for('static', filename='css/modes/space-weather.css') }}", + wefax: "{{ url_for('static', filename='css/modes/wefax.css') }}", + morse: "{{ url_for('static', filename='css/modes/morse.css') }}" }; window.INTERCEPT_MODE_STYLE_LOADED = {}; + window.INTERCEPT_MODE_STYLE_PROMISES = {}; window.ensureModeStyles = function(mode) { const href = window.INTERCEPT_MODE_STYLE_MAP ? window.INTERCEPT_MODE_STYLE_MAP[mode] : null; - if (!href) return; + if (!href) return Promise.resolve(); + if (window.INTERCEPT_MODE_STYLE_LOADED[href] === 'loaded') { + return Promise.resolve(); + } + if (window.INTERCEPT_MODE_STYLE_PROMISES[href]) { + return window.INTERCEPT_MODE_STYLE_PROMISES[href]; + } const absHref = new URL(href, window.location.href).href; const existing = Array.from(document.querySelectorAll('link[data-mode-style]')) .find((link) => link.href === absHref); - if (existing) { + if (existing && existing.sheet) { window.INTERCEPT_MODE_STYLE_LOADED[href] = 'loaded'; - return; + return Promise.resolve(); } - if (window.INTERCEPT_MODE_STYLE_LOADED[href] === 'loading') return; window.INTERCEPT_MODE_STYLE_LOADED[href] = 'loading'; - const link = document.createElement('link'); - link.rel = 'stylesheet'; - link.href = href; - link.dataset.modeStyle = mode; - link.onload = () => { - window.INTERCEPT_MODE_STYLE_LOADED[href] = 'loaded'; - }; - link.onerror = () => { - delete window.INTERCEPT_MODE_STYLE_LOADED[href]; - try { - link.remove(); - } catch (_) {} - }; - document.head.appendChild(link); + const link = existing || document.createElement('link'); + if (!existing) { + link.rel = 'stylesheet'; + link.href = href; + link.dataset.modeStyle = mode; + } + const promise = new Promise((resolve, reject) => { + const onLoad = () => { + window.INTERCEPT_MODE_STYLE_LOADED[href] = 'loaded'; + delete window.INTERCEPT_MODE_STYLE_PROMISES[href]; + resolve(); + }; + const onError = () => { + delete window.INTERCEPT_MODE_STYLE_LOADED[href]; + delete window.INTERCEPT_MODE_STYLE_PROMISES[href]; + try { + link.remove(); + } catch (_) {} + reject(new Error(`failed to load mode stylesheet: ${mode}`)); + }; + link.addEventListener('load', onLoad, { once: true }); + link.addEventListener('error', onError, { once: true }); + if (existing) { + // Existing links may have finished loading before listeners attached. + if (existing.sheet) onLoad(); + } else { + document.head.appendChild(link); + } + }); + window.INTERCEPT_MODE_STYLE_PROMISES[href] = promise; + return promise; }; + // Start loading a deep-linked mode stylesheet as early as possible. + (function preloadQueryModeStyles() { + const queryMode = new URLSearchParams(window.location.search).get('mode'); + const mode = queryMode === 'listening' ? 'waterfall' : queryMode; + if (!mode) return; + window.ensureModeStyles(mode).catch(() => {}); + })(); + // Warm remaining lazy mode styles in the background to avoid first-switch FOUC. + (function warmModeStylesInBackground() { + const modeMap = window.INTERCEPT_MODE_STYLE_MAP || {}; + const queryMode = new URLSearchParams(window.location.search).get('mode'); + const selectedMode = queryMode === 'listening' ? 'waterfall' : queryMode; + const modes = Object.keys(modeMap).filter((mode) => mode !== selectedMode); + if (!modes.length) return; + + const warm = function () { + modes.forEach(function (mode, index) { + setTimeout(function () { + window.ensureModeStyles(mode).catch(() => {}); + }, index * 40); + }); + }; + + if (typeof window.requestIdleCallback === 'function') { + window.requestIdleCallback(warm, { timeout: 2000 }); + } else { + setTimeout(warm, 600); + } + })(); @@ -141,6 +195,12 @@
+
@@ -264,6 +328,10 @@ HF SSTV + + + +
+
+
+
+ --- + KHZ +
+
+ 0 + LINES +
+
+ 0 + IMAGES +
+
+ + + + + + + + + +
+
+ Broadcast Schedule + +
+
+
Select a station to see broadcast schedule
+
+
+ + +
+
+
+
+ + + + + Live Decode +
+
+
+
+ + + + + + +

WeFax Decoder

+

Select a station and click Start to decode weather fax transmissions

+
+ +
+
+ + +
+ + +