diff --git a/config.py b/config.py index 89d383b..3001fc3 100644 --- a/config.py +++ b/config.py @@ -337,6 +337,12 @@ 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) + # 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/routes/__init__.py b/routes/__init__.py index 9f2075f..cf197b9 100644 --- a/routes/__init__.py +++ b/routes/__init__.py @@ -14,7 +14,7 @@ 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 .offline import offline_bp from .pager import pager_bp @@ -33,6 +33,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 +55,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 +69,10 @@ 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 # Initialize TSCM state with queue and lock from app import app as app_module diff --git a/routes/wefax.py b/routes/wefax.py new file mode 100644 index 0000000..4afcd98 --- /dev/null +++ b/routes/wefax.py @@ -0,0 +1,285 @@ +"""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.sse import sse_stream_fanout +from utils.validation import validate_frequency +from utils.wefax import get_wefax_decoder +from utils.wefax_stations import get_current_broadcasts, get_station, load_stations + +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.""" + try: + _wefax_queue.put_nowait(data) + except queue.Full: + try: + _wefax_queue.get_nowait() + _wefax_queue.put_nowait(data) + except queue.Empty: + pass + + +@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 + } + """ + 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)) + + # 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=frequency_khz, + station=station, + device_index=device_int, + gain=gain, + ioc=ioc, + lpm=lpm, + direct_sampling=direct_sampling, + ) + + if success: + wefax_active_device = device_int + return jsonify({ + 'status': 'started', + 'frequency_khz': frequency_khz, + '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}) + + +@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/modes/wefax.css b/static/css/modes/wefax.css new file mode 100644 index 0000000..2945594 --- /dev/null +++ b/static/css/modes/wefax.css @@ -0,0 +1,422 @@ +/* ============================================ + WeFax (Weather Fax) Mode Styles + Amber/gold theme (#ffaa00) for HF + ============================================ */ + +/* --- 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.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; +} + +/* --- 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; } + +/* --- Responsive --- */ +@media (max-width: 768px) { + .wefax-main-row { + grid-template-columns: 1fr; + } +} diff --git a/static/js/modes/wefax.js b/static/js/modes/wefax.js new file mode 100644 index 0000000..c93e41a --- /dev/null +++ b/static/js/modes/wefax.js @@ -0,0 +1,671 @@ +/** + * 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, + }; + + // ---- 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(); + } + + function destroy() { + disconnectSSE(); + stopScope(); + 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([]); + 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 || []); + } + } + + 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 start() { + if (state.running) return; + + var freqSel = document.getElementById('wefaxFrequency'); + var freqKhz = freqSel ? parseFloat(freqSel.value) : 0; + if (!freqKhz || isNaN(freqKhz)) { + setStatus('Select a station and frequency first'); + 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 deviceSel = document.getElementById('rtlDevice'); + var device = deviceSel ? parseInt(deviceSel.value, 10) || 0 : 0; + + var body = { + frequency_khz: freqKhz, + station: station, + 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, + }; + + 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') { + state.running = true; + updateButtons(true); + setStatus('Scanning ' + freqKhz + ' kHz...'); + setStripFreq(freqKhz); + connectSSE(); + } else { + setStatus('Error: ' + (data.message || 'unknown')); + } + }) + .catch(function (err) { + setStatus('Error: ' + err.message); + }); + } + + function stop() { + fetch('/wefax/stop', { method: 'POST' }) + .then(function (r) { return r.json(); }) + .then(function () { + state.running = false; + updateButtons(false); + setStatus('Stopped'); + disconnectSSE(); + loadImages(); + }) + .catch(function (err) { + 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) { + 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 === 'error') { + state.running = false; + updateButtons(false); + } + + 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) { + fetch('/wefax/images/' + encodeURIComponent(filename), { method: 'DELETE' }) + .then(function () { loadImages(); }) + .catch(function (err) { console.error('WeFax delete error:', err); }); + } + + function deleteAllImages() { + if (!confirm('Delete all WeFax images?')) return; + fetch('/wefax/images', { method: 'DELETE' }) + .then(function () { loadImages(); }) + .catch(function (err) { console.error('WeFax delete all error:', err); }); + } + + function viewImage(url) { + // Open image in modal or new tab + window.open(url, '_blank'); + } + + // ---- 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); + } + + // ---- Public API ---- + + return { + init: init, + destroy: destroy, + start: start, + stop: stop, + onStationChange: onStationChange, + loadImages: loadImages, + deleteImage: deleteImage, + deleteAllImages: deleteAllImages, + viewImage: viewImage, + }; +})(); diff --git a/templates/index.html b/templates/index.html index c41c28b..7bffae6 100644 --- a/templates/index.html +++ b/templates/index.html @@ -79,7 +79,8 @@ 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') }}" }; window.INTERCEPT_MODE_STYLE_LOADED = {}; window.ensureModeStyles = function(mode) { @@ -264,6 +265,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

+
+ +
+
+ + +
+ + @@ -213,6 +214,7 @@ {% endif %} {{ mobile_item('sstv', 'SSTV', '') }} {{ mobile_item('weathersat', 'WxSat', '') }} + {{ mobile_item('wefax', 'WeFax', '') }} {{ mobile_item('sstv_general', 'HF SSTV', '') }} {{ mobile_item('spaceweather', 'SpaceWx', '') }} {# Wireless #} diff --git a/tests/test_wefax.py b/tests/test_wefax.py new file mode 100644 index 0000000..4ac3b72 --- /dev/null +++ b/tests/test_wefax.py @@ -0,0 +1,430 @@ +"""Tests for WeFax (Weather Fax) routes, decoder, and station loader.""" + +from __future__ import annotations + +import json +import math +from pathlib import Path +from unittest.mock import MagicMock, patch + +import numpy as np + + +def _login_session(client) -> None: + """Mark the Flask test session as authenticated.""" + with client.session_transaction() as sess: + sess['logged_in'] = True + sess['username'] = 'test' + sess['role'] = 'admin' + + +# --------------------------------------------------------------------------- +# Station database tests +# --------------------------------------------------------------------------- + +class TestWeFaxStations: + """WeFax station database tests.""" + + def test_load_stations_returns_list(self): + """load_stations() should return a non-empty list.""" + from utils.wefax_stations import load_stations + stations = load_stations() + assert isinstance(stations, list) + assert len(stations) >= 10 + + def test_station_has_required_fields(self): + """Each station must have required fields.""" + from utils.wefax_stations import load_stations + required = {'name', 'callsign', 'country', 'city', 'coordinates', + 'frequencies', 'ioc', 'lpm', 'schedule'} + for station in load_stations(): + missing = required - set(station.keys()) + assert not missing, f"Station {station.get('callsign', '?')} missing: {missing}" + + def test_get_station_by_callsign(self): + """get_station() should return correct station.""" + from utils.wefax_stations import get_station + station = get_station('NOJ') + assert station is not None + assert station['callsign'] == 'NOJ' + assert station['country'] == 'US' + + def test_get_station_case_insensitive(self): + """get_station() should be case-insensitive.""" + from utils.wefax_stations import get_station + assert get_station('noj') is not None + + def test_get_station_not_found(self): + """get_station() should return None for unknown callsign.""" + from utils.wefax_stations import get_station + assert get_station('XXXXX') is None + + def test_station_frequencies_have_khz(self): + """Each frequency entry must have 'khz' and 'description'.""" + from utils.wefax_stations import load_stations + for station in load_stations(): + for freq in station['frequencies']: + assert 'khz' in freq, f"{station['callsign']} missing khz" + assert 'description' in freq, f"{station['callsign']} missing description" + assert isinstance(freq['khz'], (int, float)) + assert freq['khz'] > 0 + + def test_schedule_format(self): + """Schedule entries must have utc, duration_min, content.""" + from utils.wefax_stations import load_stations + for station in load_stations(): + for entry in station['schedule']: + assert 'utc' in entry + assert 'duration_min' in entry + assert 'content' in entry + # UTC format: HH:MM + parts = entry['utc'].split(':') + assert len(parts) == 2 + assert 0 <= int(parts[0]) <= 23 + assert 0 <= int(parts[1]) <= 59 + + def test_get_current_broadcasts(self): + """get_current_broadcasts() should return up to 3 entries.""" + from utils.wefax_stations import get_current_broadcasts + broadcasts = get_current_broadcasts('NOJ') + assert isinstance(broadcasts, list) + assert len(broadcasts) <= 3 + for b in broadcasts: + assert 'utc' in b + assert 'content' in b + + +# --------------------------------------------------------------------------- +# Decoder unit tests +# --------------------------------------------------------------------------- + +class TestWeFaxDecoder: + """WeFax decoder DSP and data class tests.""" + + def test_freq_to_pixel_black(self): + """1500 Hz should map to 0 (black).""" + from utils.wefax import _freq_to_pixel + assert _freq_to_pixel(1500.0) == 0 + + def test_freq_to_pixel_white(self): + """2300 Hz should map to 255 (white).""" + from utils.wefax import _freq_to_pixel + assert _freq_to_pixel(2300.0) == 255 + + def test_freq_to_pixel_mid(self): + """1900 Hz (carrier) should map to ~128.""" + from utils.wefax import _freq_to_pixel + val = _freq_to_pixel(1900.0) + assert 120 <= val <= 135 + + def test_freq_to_pixel_clamp_low(self): + """Below 1500 Hz should clamp to 0.""" + from utils.wefax import _freq_to_pixel + assert _freq_to_pixel(1000.0) == 0 + + def test_freq_to_pixel_clamp_high(self): + """Above 2300 Hz should clamp to 255.""" + from utils.wefax import _freq_to_pixel + assert _freq_to_pixel(3000.0) == 255 + + def test_ioc_576_pixel_count(self): + """IOC 576 should give pi*576 ≈ 1809 pixels per line.""" + pixels = int(math.pi * 576) + assert pixels == 1809 + + def test_ioc_288_pixel_count(self): + """IOC 288 should give pi*288 ≈ 904 pixels per line.""" + pixels = int(math.pi * 288) + assert pixels == 904 + + def test_goertzel_mag_detects_tone(self): + """Goertzel should detect a pure tone.""" + from utils.wefax import _goertzel_mag + sr = 22050 + freq = 1900.0 + t = np.arange(sr) / sr + samples = np.sin(2 * np.pi * freq * t) + mag = _goertzel_mag(samples[:2205], freq, sr) + # Should be significantly non-zero for a matching tone + assert mag > 1.0 + + def test_goertzel_mag_rejects_wrong_freq(self): + """Goertzel should be much weaker for non-matching frequency.""" + from utils.wefax import _goertzel_mag + sr = 22050 + t = np.arange(sr) / sr + samples = np.sin(2 * np.pi * 1900.0 * t) + mag_match = _goertzel_mag(samples[:2205], 1900.0, sr) + mag_off = _goertzel_mag(samples[:2205], 300.0, sr) + assert mag_match > mag_off * 5 + + def test_detect_tone_start(self): + """detect_tone should identify a 300 Hz start tone.""" + from utils.wefax import _detect_tone + sr = 22050 + t = np.arange(sr) / sr + samples = np.sin(2 * np.pi * 300.0 * t) + assert _detect_tone(samples[:2205], 300.0, sr, threshold=2.0) + + def test_wefax_image_to_dict(self): + """WeFaxImage.to_dict() should produce expected format.""" + from datetime import datetime, timezone + + from utils.wefax import WeFaxImage + img = WeFaxImage( + filename='test.png', + path=Path('/tmp/test.png'), + station='NOJ', + frequency_khz=4298, + timestamp=datetime(2026, 1, 1, tzinfo=timezone.utc), + ioc=576, + lpm=120, + size_bytes=1234, + ) + d = img.to_dict() + assert d['filename'] == 'test.png' + assert d['station'] == 'NOJ' + assert d['frequency_khz'] == 4298 + assert d['ioc'] == 576 + assert d['url'] == '/wefax/images/test.png' + + def test_wefax_progress_to_dict(self): + """WeFaxProgress.to_dict() should produce expected format.""" + from utils.wefax import WeFaxProgress + p = WeFaxProgress( + status='receiving', + station='NOJ', + message='Receiving: 100 lines', + progress_percent=50, + line_count=100, + ) + d = p.to_dict() + assert d['type'] == 'wefax_progress' + assert d['status'] == 'receiving' + assert d['progress'] == 50 + assert d['station'] == 'NOJ' + assert d['line_count'] == 100 + + def test_singleton_returns_same_instance(self, tmp_path): + """get_wefax_decoder() should return a singleton.""" + from utils.wefax import WeFaxDecoder + # Use __new__ to avoid __init__ creating dirs + d1 = WeFaxDecoder.__new__(WeFaxDecoder) + # Test the module-level singleton pattern + import utils.wefax as wefax_mod + original = wefax_mod._decoder + try: + wefax_mod._decoder = d1 + assert wefax_mod.get_wefax_decoder() is d1 + assert wefax_mod.get_wefax_decoder() is d1 + finally: + wefax_mod._decoder = original + + +# --------------------------------------------------------------------------- +# Route tests +# --------------------------------------------------------------------------- + +class TestWeFaxRoutes: + """WeFax route endpoint tests.""" + + def test_status(self, client): + """GET /wefax/status should return decoder status.""" + _login_session(client) + mock_decoder = MagicMock() + mock_decoder.is_running = False + mock_decoder.get_images.return_value = [] + + with patch('routes.wefax.get_wefax_decoder', return_value=mock_decoder): + response = client.get('/wefax/status') + + assert response.status_code == 200 + data = response.get_json() + assert data['available'] is True + assert data['running'] is False + + def test_stations_list(self, client): + """GET /wefax/stations should return station list.""" + _login_session(client) + response = client.get('/wefax/stations') + assert response.status_code == 200 + data = response.get_json() + assert data['status'] == 'ok' + assert data['count'] >= 10 + + def test_station_detail(self, client): + """GET /wefax/stations/NOJ should return station detail.""" + _login_session(client) + response = client.get('/wefax/stations/NOJ') + assert response.status_code == 200 + data = response.get_json() + assert data['status'] == 'ok' + assert data['station']['callsign'] == 'NOJ' + assert 'current_broadcasts' in data + + def test_station_not_found(self, client): + """GET /wefax/stations/XXXXX should return 404.""" + _login_session(client) + response = client.get('/wefax/stations/XXXXX') + assert response.status_code == 404 + + def test_start_requires_frequency(self, client): + """POST /wefax/start without frequency should fail.""" + _login_session(client) + mock_decoder = MagicMock() + mock_decoder.is_running = False + + with patch('routes.wefax.get_wefax_decoder', return_value=mock_decoder): + response = client.post( + '/wefax/start', + data=json.dumps({}), + content_type='application/json', + ) + + assert response.status_code == 400 + data = response.get_json() + assert data['status'] == 'error' + + def test_start_validates_frequency_range(self, client): + """POST /wefax/start with out-of-range frequency should fail.""" + _login_session(client) + mock_decoder = MagicMock() + mock_decoder.is_running = False + + with patch('routes.wefax.get_wefax_decoder', return_value=mock_decoder): + response = client.post( + '/wefax/start', + data=json.dumps({'frequency_khz': 100}), # 0.1 MHz - too low + content_type='application/json', + ) + + assert response.status_code == 400 + + def test_start_validates_ioc(self, client): + """POST /wefax/start with invalid IOC should fail.""" + _login_session(client) + mock_decoder = MagicMock() + mock_decoder.is_running = False + + with patch('routes.wefax.get_wefax_decoder', return_value=mock_decoder): + response = client.post( + '/wefax/start', + data=json.dumps({'frequency_khz': 4298, 'ioc': 999}), + content_type='application/json', + ) + + assert response.status_code == 400 + data = response.get_json() + assert 'IOC' in data['message'] + + def test_start_validates_lpm(self, client): + """POST /wefax/start with invalid LPM should fail.""" + _login_session(client) + mock_decoder = MagicMock() + mock_decoder.is_running = False + + with patch('routes.wefax.get_wefax_decoder', return_value=mock_decoder): + response = client.post( + '/wefax/start', + data=json.dumps({'frequency_khz': 4298, 'lpm': 999}), + content_type='application/json', + ) + + assert response.status_code == 400 + data = response.get_json() + assert 'LPM' in data['message'] + + def test_start_success(self, client): + """POST /wefax/start with valid params should succeed.""" + _login_session(client) + mock_decoder = MagicMock() + mock_decoder.is_running = False + mock_decoder.start.return_value = True + + with patch('routes.wefax.get_wefax_decoder', return_value=mock_decoder), \ + patch('routes.wefax.app_module.claim_sdr_device', return_value=None): + response = client.post( + '/wefax/start', + data=json.dumps({ + 'frequency_khz': 4298, + 'station': 'NOJ', + 'device': 0, + 'ioc': 576, + 'lpm': 120, + }), + content_type='application/json', + ) + + assert response.status_code == 200 + data = response.get_json() + assert data['status'] == 'started' + assert data['frequency_khz'] == 4298 + assert data['station'] == 'NOJ' + mock_decoder.start.assert_called_once() + + def test_start_device_busy(self, client): + """POST /wefax/start should return 409 when device is busy.""" + _login_session(client) + mock_decoder = MagicMock() + mock_decoder.is_running = False + + with patch('routes.wefax.get_wefax_decoder', return_value=mock_decoder), \ + patch('routes.wefax.app_module.claim_sdr_device', + return_value='Device 0 in use by pager'): + response = client.post( + '/wefax/start', + data=json.dumps({'frequency_khz': 4298}), + content_type='application/json', + ) + + assert response.status_code == 409 + data = response.get_json() + assert data['error_type'] == 'DEVICE_BUSY' + + def test_stop(self, client): + """POST /wefax/stop should stop the decoder.""" + _login_session(client) + mock_decoder = MagicMock() + + with patch('routes.wefax.get_wefax_decoder', return_value=mock_decoder): + response = client.post('/wefax/stop') + + assert response.status_code == 200 + data = response.get_json() + assert data['status'] == 'stopped' + mock_decoder.stop.assert_called_once() + + def test_images_list(self, client): + """GET /wefax/images should return image list.""" + _login_session(client) + mock_decoder = MagicMock() + mock_decoder.get_images.return_value = [] + + with patch('routes.wefax.get_wefax_decoder', return_value=mock_decoder): + response = client.get('/wefax/images') + + assert response.status_code == 200 + data = response.get_json() + assert data['status'] == 'ok' + assert data['count'] == 0 + + def test_delete_image_invalid_filename(self, client): + """DELETE /wefax/images/ should reject invalid filenames.""" + _login_session(client) + mock_decoder = MagicMock() + + with patch('routes.wefax.get_wefax_decoder', return_value=mock_decoder): + # Use a filename with special chars that won't be split by Flask routing + response = client.delete('/wefax/images/te$t!file.png') + + assert response.status_code == 400 + + def test_delete_image_wrong_extension(self, client): + """DELETE /wefax/images/ should reject non-PNG.""" + _login_session(client) + mock_decoder = MagicMock() + + with patch('routes.wefax.get_wefax_decoder', return_value=mock_decoder): + response = client.delete('/wefax/images/test.jpg') + + assert response.status_code == 400 diff --git a/utils/wefax.py b/utils/wefax.py new file mode 100644 index 0000000..d825b42 --- /dev/null +++ b/utils/wefax.py @@ -0,0 +1,754 @@ +"""WeFax (Weather Fax) decoder. + +Decodes HF radiofax (weather fax) transmissions using RTL-SDR direct +sampling mode. The decoder implements the standard WeFax AM protocol: +carrier 1900 Hz, deviation +/-400 Hz (black=1500, white=2300). + +Pipeline: rtl_fm -M usb -E direct2 -> stdout PCM -> Python DSP state machine + +State machine: SCANNING -> PHASING -> RECEIVING -> COMPLETE +""" + +from __future__ import annotations + +import base64 +import contextlib +import io +import math +import subprocess +import threading +import time +from dataclasses import dataclass +from datetime import datetime, timezone +from enum import Enum +from pathlib import Path +from typing import Callable + +import numpy as np + +from utils.logging import get_logger + +logger = get_logger('intercept.wefax') + +try: + from PIL import Image as PILImage +except ImportError: + PILImage = None # type: ignore[assignment,misc] + +# --------------------------------------------------------------------------- +# WeFax protocol constants +# --------------------------------------------------------------------------- +CARRIER_FREQ = 1900.0 # Hz - center/carrier +BLACK_FREQ = 1500.0 # Hz - black level +WHITE_FREQ = 2300.0 # Hz - white level +START_TONE_FREQ = 300.0 # Hz - start tone +STOP_TONE_FREQ = 450.0 # Hz - stop tone +PHASING_FREQ = WHITE_FREQ # White pulse during phasing + +START_TONE_DURATION = 3.0 # Minimum seconds of start tone to detect +STOP_TONE_DURATION = 3.0 # Minimum seconds of stop tone to detect +PHASING_MIN_LINES = 5 # Minimum phasing lines before image + +DEFAULT_SAMPLE_RATE = 22050 +DEFAULT_IOC = 576 +DEFAULT_LPM = 120 + + +class DecoderState(Enum): + """WeFax decoder state machine states.""" + SCANNING = 'scanning' + START_DETECTED = 'start_detected' + PHASING = 'phasing' + RECEIVING = 'receiving' + COMPLETE = 'complete' + + +# --------------------------------------------------------------------------- +# Dataclasses +# --------------------------------------------------------------------------- + +@dataclass +class WeFaxImage: + """Decoded WeFax image metadata.""" + filename: str + path: Path + station: str + frequency_khz: float + timestamp: datetime + ioc: int + lpm: int + size_bytes: int = 0 + + def to_dict(self) -> dict: + return { + 'filename': self.filename, + 'path': str(self.path), + 'station': self.station, + 'frequency_khz': self.frequency_khz, + 'timestamp': self.timestamp.isoformat(), + 'ioc': self.ioc, + 'lpm': self.lpm, + 'size_bytes': self.size_bytes, + 'url': f'/wefax/images/{self.filename}', + } + + +@dataclass +class WeFaxProgress: + """WeFax decode progress update for SSE streaming.""" + status: str # 'scanning', 'phasing', 'receiving', 'complete', 'error', 'stopped' + station: str = '' + message: str = '' + progress_percent: int = 0 + line_count: int = 0 + image: WeFaxImage | None = None + partial_image: str | None = None + + def to_dict(self) -> dict: + result: dict = { + 'type': 'wefax_progress', + 'status': self.status, + 'progress': self.progress_percent, + } + if self.station: + result['station'] = self.station + if self.message: + result['message'] = self.message + if self.line_count: + result['line_count'] = self.line_count + if self.image: + result['image'] = self.image.to_dict() + if self.partial_image: + result['partial_image'] = self.partial_image + return result + + +# --------------------------------------------------------------------------- +# DSP helpers (reuse Goertzel from SSTV where sensible) +# --------------------------------------------------------------------------- + +def _goertzel_mag(samples: np.ndarray, target_freq: float, + sample_rate: int) -> float: + """Compute Goertzel magnitude at a single frequency.""" + n = len(samples) + if n == 0: + return 0.0 + w = 2.0 * math.pi * target_freq / sample_rate + coeff = 2.0 * math.cos(w) + s1 = 0.0 + s2 = 0.0 + for sample in samples: + s0 = float(sample) + coeff * s1 - s2 + s2 = s1 + s1 = s0 + energy = s1 * s1 + s2 * s2 - coeff * s1 * s2 + return math.sqrt(max(0.0, energy)) + + +def _freq_to_pixel(frequency: float) -> int: + """Map WeFax audio frequency to pixel value (0=black, 255=white). + + Linear mapping: 1500 Hz -> 0 (black), 2300 Hz -> 255 (white). + """ + normalized = (frequency - BLACK_FREQ) / (WHITE_FREQ - BLACK_FREQ) + return max(0, min(255, int(normalized * 255 + 0.5))) + + +def _estimate_frequency(samples: np.ndarray, sample_rate: int, + freq_low: float = 1200.0, + freq_high: float = 2500.0) -> float: + """Estimate dominant frequency using coarse+fine Goertzel sweep.""" + if len(samples) == 0: + return 0.0 + + best_freq = freq_low + best_energy = 0.0 + + # Coarse sweep (25 Hz steps) + freq = freq_low + while freq <= freq_high: + energy = _goertzel_mag(samples, freq, sample_rate) ** 2 + if energy > best_energy: + best_energy = energy + best_freq = freq + freq += 25.0 + + # Fine sweep around peak (+/- 25 Hz, 5 Hz steps) + fine_low = max(freq_low, best_freq - 25.0) + fine_high = min(freq_high, best_freq + 25.0) + freq = fine_low + while freq <= fine_high: + energy = _goertzel_mag(samples, freq, sample_rate) ** 2 + if energy > best_energy: + best_energy = energy + best_freq = freq + freq += 5.0 + + return best_freq + + +def _detect_tone(samples: np.ndarray, target_freq: float, + sample_rate: int, threshold: float = 3.0) -> bool: + """Detect if a specific tone dominates the signal.""" + target_mag = _goertzel_mag(samples, target_freq, sample_rate) + # Check against a few reference frequencies + refs = [1000.0, 1500.0, 1900.0, 2300.0] + refs = [f for f in refs if abs(f - target_freq) > 100] + if not refs: + return target_mag > 0.01 + avg_ref = sum(_goertzel_mag(samples, f, sample_rate) for f in refs) / len(refs) + if avg_ref <= 0: + return target_mag > 0.01 + return target_mag / avg_ref >= threshold + + +# --------------------------------------------------------------------------- +# WeFaxDecoder +# --------------------------------------------------------------------------- + +class WeFaxDecoder: + """WeFax decoder singleton. + + Manages rtl_fm subprocess and decodes WeFax images using a state + machine that detects start/stop tones, phasing signals, and + demodulates image lines. + """ + + def __init__(self) -> None: + self._rtl_process: subprocess.Popen | None = None + self._running = False + self._lock = threading.Lock() + self._callback: Callable[[dict], None] | None = None + self._last_scope_time: float = 0.0 + self._output_dir = Path('instance/wefax_images') + self._images: list[WeFaxImage] = [] + self._decode_thread: threading.Thread | None = None + + # Current session parameters + self._station = '' + self._frequency_khz = 0.0 + self._ioc = DEFAULT_IOC + self._lpm = DEFAULT_LPM + self._sample_rate = DEFAULT_SAMPLE_RATE + self._device_index = 0 + self._gain = 40.0 + self._direct_sampling = True + + self._output_dir.mkdir(parents=True, exist_ok=True) + + @property + def is_running(self) -> bool: + return self._running + + def set_callback(self, callback: Callable[[dict], None]) -> None: + """Set callback for progress updates (fed to SSE queue).""" + self._callback = callback + + def start( + self, + frequency_khz: float, + station: str = '', + device_index: int = 0, + gain: float = 40.0, + ioc: int = DEFAULT_IOC, + lpm: int = DEFAULT_LPM, + direct_sampling: bool = True, + ) -> bool: + """Start WeFax decoder. + + Args: + frequency_khz: Frequency in kHz (e.g. 4298 for NOJ). + station: Station callsign for metadata. + device_index: RTL-SDR device index. + gain: Receiver gain in dB. + ioc: Index of Cooperation (576 or 288). + lpm: Lines per minute (120 or 60). + direct_sampling: Enable RTL-SDR direct sampling for HF. + + Returns: + True if started successfully. + """ + with self._lock: + if self._running: + return True + + self._station = station + self._frequency_khz = frequency_khz + self._ioc = ioc + self._lpm = lpm + self._device_index = device_index + self._gain = gain + self._direct_sampling = direct_sampling + self._sample_rate = DEFAULT_SAMPLE_RATE + + try: + self._running = True + self._start_pipeline() + + logger.info( + f"WeFax decoder started: {frequency_khz} kHz, " + f"station={station}, IOC={ioc}, LPM={lpm}" + ) + self._emit_progress(WeFaxProgress( + status='scanning', + station=station, + message=f'Scanning {frequency_khz} kHz for WeFax start tone...', + )) + return True + + except Exception as e: + self._running = False + logger.error(f"Failed to start WeFax decoder: {e}") + self._emit_progress(WeFaxProgress( + status='error', + message=str(e), + )) + return False + + def _start_pipeline(self) -> None: + """Start rtl_fm subprocess in USB mode for WeFax.""" + freq_hz = int(self._frequency_khz * 1000) + + rtl_cmd = [ + 'rtl_fm', + '-d', str(self._device_index), + '-f', str(freq_hz), + '-M', 'usb', + '-s', str(self._sample_rate), + '-r', str(self._sample_rate), + '-g', str(self._gain), + ] + + if self._direct_sampling: + rtl_cmd.extend(['-E', 'direct2']) + + rtl_cmd.append('-') + + logger.info(f"Starting rtl_fm: {' '.join(rtl_cmd)}") + + self._rtl_process = subprocess.Popen( + rtl_cmd, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + ) + + self._decode_thread = threading.Thread( + target=self._decode_audio_stream, daemon=True) + self._decode_thread.start() + + def _decode_audio_stream(self) -> None: + """Read audio from rtl_fm and decode WeFax images. + + Runs in a background thread. Processes 100ms chunks through + the start-tone / phasing / image state machine. + """ + sr = self._sample_rate + chunk_samples = sr // 10 # 100ms + chunk_bytes = chunk_samples * 2 # int16 + + state = DecoderState.SCANNING + start_tone_count = 0 + stop_tone_count = 0 + phasing_line_count = 0 + + # Image parameters + pixels_per_line = int(math.pi * self._ioc) + line_duration_s = 60.0 / self._lpm + samples_per_line = int(line_duration_s * sr) + + # Image buffer + image_lines: list[np.ndarray] = [] + line_buffer = np.zeros(0, dtype=np.float64) + max_lines = 2000 # Safety limit + + rtl_fm_error = '' + last_partial_line = -1 + + logger.info( + f"WeFax decode thread started: IOC={self._ioc}, " + f"LPM={self._lpm}, pixels/line={pixels_per_line}, " + f"samples/line={samples_per_line}" + ) + + while self._running and self._rtl_process: + try: + raw_data = self._rtl_process.stdout.read(chunk_bytes) + if not raw_data: + if self._running: + stderr_msg = '' + if self._rtl_process and self._rtl_process.stderr: + with contextlib.suppress(Exception): + stderr_msg = self._rtl_process.stderr.read().decode( + errors='replace').strip() + rc = self._rtl_process.poll() if self._rtl_process else None + logger.warning(f"rtl_fm stream ended (exit code: {rc})") + if stderr_msg: + logger.warning(f"rtl_fm stderr: {stderr_msg}") + rtl_fm_error = stderr_msg + break + + n_samples = len(raw_data) // 2 + if n_samples == 0: + continue + + raw_int16 = np.frombuffer(raw_data[:n_samples * 2], dtype=np.int16) + samples = raw_int16.astype(np.float64) / 32768.0 + + # Emit scope waveform for frontend visualisation + self._emit_scope(raw_int16) + + if state == DecoderState.SCANNING: + # Look for 300 Hz start tone + if _detect_tone(samples, START_TONE_FREQ, sr, threshold=2.5): + start_tone_count += 1 + # Need sustained detection (>= START_TONE_DURATION seconds) + needed = int(START_TONE_DURATION / 0.1) + if start_tone_count >= needed: + state = DecoderState.PHASING + phasing_line_count = 0 + logger.info("WeFax start tone detected, entering phasing") + self._emit_progress(WeFaxProgress( + status='phasing', + station=self._station, + message='Start tone detected, synchronising...', + )) + else: + start_tone_count = max(0, start_tone_count - 1) + + elif state == DecoderState.PHASING: + # Count phasing lines (alternating black/white pulses) + phasing_line_count += 1 + needed_phasing = max(PHASING_MIN_LINES, int(2.0 / 0.1)) + if phasing_line_count >= needed_phasing: + state = DecoderState.RECEIVING + image_lines = [] + line_buffer = np.zeros(0, dtype=np.float64) + last_partial_line = -1 + logger.info("Phasing complete, receiving image") + self._emit_progress(WeFaxProgress( + status='receiving', + station=self._station, + message='Receiving image...', + )) + + elif state == DecoderState.RECEIVING: + # Check for stop tone + if _detect_tone(samples, STOP_TONE_FREQ, sr, threshold=2.5): + stop_tone_count += 1 + needed_stop = int(STOP_TONE_DURATION / 0.1) + if stop_tone_count >= needed_stop: + # Process any remaining line buffer + if len(line_buffer) >= samples_per_line * 0.5: + line_pixels = self._decode_line( + line_buffer, pixels_per_line, sr) + image_lines.append(line_pixels) + + state = DecoderState.COMPLETE + logger.info( + f"Stop tone detected, image complete: " + f"{len(image_lines)} lines" + ) + break + else: + stop_tone_count = max(0, stop_tone_count - 1) + + # Accumulate samples into line buffer + line_buffer = np.concatenate([line_buffer, samples]) + + # Extract complete lines + while len(line_buffer) >= samples_per_line: + line_samples = line_buffer[:samples_per_line] + line_buffer = line_buffer[samples_per_line:] + + line_pixels = self._decode_line( + line_samples, pixels_per_line, sr) + image_lines.append(line_pixels) + + # Safety limit + if len(image_lines) >= max_lines: + logger.warning("WeFax max lines reached, saving image") + state = DecoderState.COMPLETE + break + + # Emit progress periodically + current_lines = len(image_lines) + if current_lines > 0 and current_lines != last_partial_line and current_lines % 20 == 0: + last_partial_line = current_lines + # Rough progress estimate (typical chart ~800 lines) + pct = min(95, int(current_lines / 8)) + partial_url = self._encode_partial( + image_lines, pixels_per_line) + self._emit_progress(WeFaxProgress( + status='receiving', + station=self._station, + message=f'Receiving: {current_lines} lines', + progress_percent=pct, + line_count=current_lines, + partial_image=partial_url, + )) + + except Exception as e: + logger.error(f"Error in WeFax decode thread: {e}") + if not self._running: + break + time.sleep(0.1) + + # Save image if we got data + if state == DecoderState.COMPLETE and image_lines: + self._save_image(image_lines, pixels_per_line) + elif state == DecoderState.RECEIVING and len(image_lines) > 20: + # Save partial image if we had significant data + logger.info(f"Saving partial WeFax image: {len(image_lines)} lines") + self._save_image(image_lines, pixels_per_line) + + # Clean up + with self._lock: + was_running = self._running + self._running = False + if self._rtl_process: + with contextlib.suppress(Exception): + self._rtl_process.terminate() + self._rtl_process.wait(timeout=2) + self._rtl_process = None + + if was_running: + err_detail = rtl_fm_error.split('\n')[-1] if rtl_fm_error else '' + if state != DecoderState.COMPLETE: + msg = f'rtl_fm failed: {err_detail}' if err_detail else 'Decode stopped unexpectedly' + self._emit_progress(WeFaxProgress( + status='error', message=msg)) + else: + self._emit_progress(WeFaxProgress( + status='stopped', message='Decoder stopped')) + + logger.info("WeFax decode thread ended") + + def _decode_line(self, line_samples: np.ndarray, + pixels_per_line: int, sample_rate: int) -> np.ndarray: + """Decode one scan line from audio samples to pixel values. + + Uses instantaneous frequency estimation via the analytic signal + (Hilbert transform), then maps frequency to grayscale. + """ + n = len(line_samples) + pixels = np.zeros(pixels_per_line, dtype=np.uint8) + + if n < pixels_per_line: + return pixels + + samples_per_pixel = n / pixels_per_line + + # Use Hilbert transform for instantaneous frequency + try: + analytic = np.fft.ifft( + np.fft.fft(line_samples) * 2 * (np.arange(n) < n // 2)) + inst_phase = np.unwrap(np.angle(analytic)) + inst_freq = np.diff(inst_phase) / (2.0 * math.pi) * sample_rate + inst_freq = np.clip(inst_freq, BLACK_FREQ - 200, WHITE_FREQ + 200) + + # Average frequency per pixel + for px in range(pixels_per_line): + start_idx = int(px * samples_per_pixel) + end_idx = int((px + 1) * samples_per_pixel) + end_idx = min(end_idx, len(inst_freq)) + if start_idx >= end_idx: + continue + avg_freq = float(np.mean(inst_freq[start_idx:end_idx])) + pixels[px] = _freq_to_pixel(avg_freq) + + except Exception: + # Fallback: simple Goertzel per pixel window + for px in range(pixels_per_line): + start_idx = int(px * samples_per_pixel) + end_idx = int((px + 1) * samples_per_pixel) + if start_idx >= len(line_samples) or start_idx >= end_idx: + break + window = line_samples[start_idx:end_idx] + freq = _estimate_frequency(window, sample_rate, + BLACK_FREQ - 200, WHITE_FREQ + 200) + pixels[px] = _freq_to_pixel(freq) + + return pixels + + def _encode_partial(self, image_lines: list[np.ndarray], + width: int) -> str | None: + """Encode current image lines as a JPEG data URL for live preview.""" + if PILImage is None or not image_lines: + return None + try: + height = len(image_lines) + img_array = np.zeros((height, width), dtype=np.uint8) + for i, line in enumerate(image_lines): + img_array[i, :len(line)] = line[:width] + img = PILImage.fromarray(img_array, mode='L') + buf = io.BytesIO() + img.save(buf, format='JPEG', quality=40) + b64 = base64.b64encode(buf.getvalue()).decode('ascii') + return f'data:image/jpeg;base64,{b64}' + except Exception: + return None + + def _save_image(self, image_lines: list[np.ndarray], + width: int) -> None: + """Save completed image to disk.""" + if PILImage is None: + logger.error("Cannot save image: Pillow not installed") + self._emit_progress(WeFaxProgress( + status='error', + message='Cannot save image - Pillow not installed', + )) + return + + try: + height = len(image_lines) + img_array = np.zeros((height, width), dtype=np.uint8) + for i, line in enumerate(image_lines): + img_array[i, :len(line)] = line[:width] + + img = PILImage.fromarray(img_array, mode='L') + timestamp = datetime.now(timezone.utc) + station_tag = self._station or 'unknown' + filename = f"wefax_{timestamp.strftime('%Y%m%d_%H%M%S')}_{station_tag}.png" + filepath = self._output_dir / filename + img.save(filepath, 'PNG') + + wefax_image = WeFaxImage( + filename=filename, + path=filepath, + station=self._station, + frequency_khz=self._frequency_khz, + timestamp=timestamp, + ioc=self._ioc, + lpm=self._lpm, + size_bytes=filepath.stat().st_size, + ) + self._images.append(wefax_image) + + logger.info(f"WeFax image saved: {filename} ({wefax_image.size_bytes} bytes)") + self._emit_progress(WeFaxProgress( + status='complete', + station=self._station, + message=f'Image decoded: {height} lines', + progress_percent=100, + line_count=height, + image=wefax_image, + )) + + except Exception as e: + logger.error(f"Error saving WeFax image: {e}") + self._emit_progress(WeFaxProgress( + status='error', + message=f'Error saving image: {e}', + )) + + def stop(self) -> None: + """Stop WeFax decoder.""" + with self._lock: + self._running = False + + if self._rtl_process: + try: + self._rtl_process.terminate() + self._rtl_process.wait(timeout=5) + except Exception: + with contextlib.suppress(Exception): + self._rtl_process.kill() + self._rtl_process = None + + logger.info("WeFax decoder stopped") + + def get_images(self) -> list[WeFaxImage]: + """Get list of decoded images.""" + self._scan_images() + return list(self._images) + + def delete_image(self, filename: str) -> bool: + """Delete a single decoded image.""" + filepath = self._output_dir / filename + if not filepath.exists(): + return False + filepath.unlink() + self._images = [img for img in self._images if img.filename != filename] + logger.info(f"Deleted WeFax image: {filename}") + return True + + def delete_all_images(self) -> int: + """Delete all decoded images. Returns count deleted.""" + count = 0 + for filepath in self._output_dir.glob('*.png'): + filepath.unlink() + count += 1 + self._images.clear() + logger.info(f"Deleted all WeFax images ({count} files)") + return count + + def _scan_images(self) -> None: + """Scan output directory for images not yet tracked.""" + known = {img.filename for img in self._images} + for filepath in self._output_dir.glob('*.png'): + if filepath.name not in known: + try: + stat = filepath.stat() + image = WeFaxImage( + filename=filepath.name, + path=filepath, + station='', + frequency_khz=0, + timestamp=datetime.fromtimestamp(stat.st_mtime, tz=timezone.utc), + ioc=self._ioc, + lpm=self._lpm, + size_bytes=stat.st_size, + ) + self._images.append(image) + except Exception as e: + logger.warning(f"Error scanning image {filepath}: {e}") + + def _emit_progress(self, progress: WeFaxProgress) -> None: + """Emit progress update to callback.""" + if self._callback: + try: + self._callback(progress.to_dict()) + except Exception as e: + logger.error(f"Error in progress callback: {e}") + + def _emit_scope(self, raw_int16: np.ndarray) -> None: + """Emit scope waveform data for frontend visualisation.""" + if not self._callback: + return + + now = time.monotonic() + if now - self._last_scope_time < 0.1: + return + self._last_scope_time = now + + try: + peak = int(np.max(np.abs(raw_int16))) + rms = int(np.sqrt(np.mean(raw_int16.astype(np.float64) ** 2))) + + # Downsample to 256 signed int8 values for lightweight transport + window = raw_int16[-256:] if len(raw_int16) > 256 else raw_int16 + waveform = np.clip(window // 256, -127, 127).astype(np.int8).tolist() + + self._callback({ + 'type': 'scope', + 'rms': rms, + 'peak': peak, + 'waveform': waveform, + }) + except Exception: + pass + + +# --------------------------------------------------------------------------- +# Module-level singleton +# --------------------------------------------------------------------------- + +_decoder: WeFaxDecoder | None = None + + +def get_wefax_decoder() -> WeFaxDecoder: + """Get or create the global WeFax decoder instance.""" + global _decoder + if _decoder is None: + _decoder = WeFaxDecoder() + return _decoder diff --git a/utils/wefax_stations.py b/utils/wefax_stations.py new file mode 100644 index 0000000..d80289c --- /dev/null +++ b/utils/wefax_stations.py @@ -0,0 +1,89 @@ +"""WeFax station database loader. + +Loads and caches station data from data/wefax_stations.json. Provides +lookup by callsign and current-broadcast filtering based on UTC time. +""" + +from __future__ import annotations + +import json +from datetime import datetime, timezone +from pathlib import Path + +_stations_cache: list[dict] | None = None +_stations_by_callsign: dict[str, dict] = {} + +_STATIONS_PATH = Path(__file__).resolve().parent.parent / 'data' / 'wefax_stations.json' + + +def load_stations() -> list[dict]: + """Load all WeFax stations from JSON, caching on first call.""" + global _stations_cache, _stations_by_callsign + + if _stations_cache is not None: + return _stations_cache + + with open(_STATIONS_PATH) as f: + data = json.load(f) + + _stations_cache = data.get('stations', []) + _stations_by_callsign = {s['callsign']: s for s in _stations_cache} + return _stations_cache + + +def get_station(callsign: str) -> dict | None: + """Get a single station by callsign.""" + load_stations() + return _stations_by_callsign.get(callsign.upper()) + + +def get_current_broadcasts(callsign: str) -> list[dict]: + """Return schedule entries closest to the current UTC time. + + Returns up to 3 entries: the most recent past broadcast and the + next two upcoming ones, annotated with ``minutes_until`` or + ``minutes_ago`` relative to now. + """ + station = get_station(callsign) + if not station: + return [] + + now = datetime.now(timezone.utc) + current_minutes = now.hour * 60 + now.minute + + schedule = station.get('schedule', []) + if not schedule: + return [] + + # Convert schedule times to minutes-since-midnight for comparison + entries: list[tuple[int, dict]] = [] + for entry in schedule: + parts = entry['utc'].split(':') + mins = int(parts[0]) * 60 + int(parts[1]) + entries.append((mins, entry)) + entries.sort(key=lambda x: x[0]) + + # Find closest entries relative to now + results = [] + for mins, entry in entries: + diff = mins - current_minutes + # Wrap around midnight + if diff < -720: + diff += 1440 + elif diff > 720: + diff -= 1440 + + annotated = dict(entry) + if diff >= 0: + annotated['minutes_until'] = diff + else: + annotated['minutes_ago'] = abs(diff) + annotated['_sort_key'] = abs(diff) + results.append(annotated) + + results.sort(key=lambda x: x['_sort_key']) + + # Return 3 nearest entries, clean up sort key + for r in results: + r.pop('_sort_key', None) + return results[:3]