Add WeFax (Weather Fax) decoder mode

Implement HF radiofax decoding with custom Python DSP pipeline
(rtl_fm USB → Goertzel/Hilbert demodulation), 33-station database
with broadcast schedules, audio waveform scope, live image preview,
and decoded image gallery. Amber/gold UI theme for HF distinction.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Smittix
2026-02-24 12:30:31 +00:00
parent 2a5f537381
commit 01abcac8f2
12 changed files with 3636 additions and 9 deletions
+6
View File
@@ -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)
+733
View File
@@ -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"}
]
}
]
}
+7 -5
View File
@@ -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
+285
View File
@@ -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/<filename>')
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/<filename>', 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/<callsign>')
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,
})
+422
View File
@@ -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;
}
}
+671
View File
@@ -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 = '<option value="">Select a station...</option>';
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 = '<div class="wefax-gallery-empty">No images decoded yet</div>';
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 += '<div class="wefax-gallery-item">';
html += '<img src="' + img.url + '" alt="WeFax" loading="lazy" onclick="WeFax.viewImage(\'' + img.url + '\')">';
html += '<div class="wefax-gallery-meta">';
html += '<span>' + station + (freq ? ' ' + freq : '') + '</span>';
html += '<span>' + ts + '</span>';
html += '</div>';
html += '<div class="wefax-gallery-actions">';
html += '<a href="' + img.url + '" download class="wefax-gallery-action" title="Download">&#x2B73;</a>';
html += '<button class="wefax-gallery-action delete" onclick="WeFax.deleteImage(\'' + img.filename + '\')" title="Delete">&times;</button>';
html += '</div>';
html += '</div>';
});
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 = '<div class="wefax-schedule-empty">Select a station to see broadcast schedule</div>';
return;
}
var now = new Date();
var nowMin = now.getUTCHours() * 60 + now.getUTCMinutes();
var html = '<div class="wefax-schedule-list">';
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 = '<span class="wefax-schedule-badge live">LIVE</span>';
} else if (diff > 0 && diff <= 60) {
cls += ' upcoming';
badge = '<span class="wefax-schedule-badge soon">' + diff + 'm</span>';
} else if (diff > 0) {
badge = '<span class="wefax-schedule-badge">' + Math.floor(diff / 60) + 'h ' + (diff % 60) + 'm</span>';
} else {
cls += ' past';
}
html += '<div class="' + cls + '">';
html += '<span class="wefax-schedule-time">' + entry.utc + '</span>';
html += '<span class="wefax-schedule-content">' + entry.content + '</span>';
html += badge;
html += '</div>';
});
html += '</div>';
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,
};
})();
+127 -4
View File
@@ -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 @@
<span class="mode-icon icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="18" height="18" rx="2"/><circle cx="12" cy="12" r="3"/><path d="M16.24 7.76a6 6 0 0 1 0 8.49m-8.48-.01a6 6 0 0 1 0-8.49"/></svg></span>
<span class="mode-name">HF SSTV</span>
</button>
<button class="mode-card mode-card-sm" onclick="selectMode('wefax')">
<span class="mode-icon icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="16" y1="13" x2="8" y2="13"/><line x1="16" y1="17" x2="8" y2="17"/><polyline points="10 9 9 9 8 9"/></svg></span>
<span class="mode-name">WeFax</span>
</button>
<button class="mode-card mode-card-sm" onclick="selectMode('spaceweather')">
<span class="mode-icon icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="5"/><line x1="12" y1="1" x2="12" y2="3"/><line x1="12" y1="21" x2="12" y2="23"/><line x1="4.22" y1="4.22" x2="5.64" y2="5.64"/><line x1="18.36" y1="18.36" x2="19.78" y2="19.78"/><line x1="1" y1="12" x2="3" y2="12"/><line x1="21" y1="12" x2="23" y2="12"/><line x1="4.22" y1="19.78" x2="5.64" y2="18.36"/><line x1="18.36" y1="5.64" x2="19.78" y2="4.22"/></svg></span>
<span class="mode-name">Space Wx</span>
@@ -616,6 +621,8 @@
{% include 'partials/modes/gps.html' %}
{% include 'partials/modes/wefax.html' %}
{% include 'partials/modes/space-weather.html' %}
{% include 'partials/modes/tscm.html' %}
@@ -2527,6 +2534,110 @@
</div>
</div>
<!-- WeFax Decoder Dashboard -->
<div id="wefaxVisuals" class="wefax-visuals-container" style="display: none;">
<!-- Stats Strip -->
<div class="wefax-stats-strip">
<div class="wefax-strip-group">
<div class="wefax-strip-status">
<span class="wefax-strip-dot idle" id="wefaxStripDot"></span>
<span class="wefax-strip-status-text" id="wefaxStripStatus">Idle</span>
</div>
<button class="wefax-strip-btn start" id="wefaxStartBtn" onclick="WeFax.start()">Start</button>
<button class="wefax-strip-btn stop" id="wefaxStopBtn" onclick="WeFax.stop()" style="display: none;">Stop</button>
</div>
<div class="wefax-strip-divider"></div>
<div class="wefax-strip-group">
<div class="wefax-strip-stat">
<span class="wefax-strip-value accent-amber" id="wefaxStripFreq">---</span>
<span class="wefax-strip-label">KHZ</span>
</div>
<div class="wefax-strip-stat">
<span class="wefax-strip-value" id="wefaxStripLines">0</span>
<span class="wefax-strip-label">LINES</span>
</div>
<div class="wefax-strip-stat">
<span class="wefax-strip-value" id="wefaxStripImageCount">0</span>
<span class="wefax-strip-label">IMAGES</span>
</div>
</div>
</div>
<!-- Audio Waveform Scope -->
<div id="wefaxScopePanel" style="display: none;">
<div style="background: #0a0a0a; border: 1px solid #2e2a1a; border-radius: 6px; padding: 8px 10px; font-family: 'Roboto Condensed', 'Arial Narrow', sans-serif;">
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 6px; font-size: 10px; color: #555; text-transform: uppercase; letter-spacing: 1px;">
<span>Audio Waveform</span>
<div style="display: flex; gap: 14px;">
<span>RMS: <span id="wefaxScopeRmsLabel" style="color: #ffaa00; font-variant-numeric: tabular-nums;">0</span></span>
<span>PEAK: <span id="wefaxScopePeakLabel" style="color: #f44; font-variant-numeric: tabular-nums;">0</span></span>
<span id="wefaxScopeStatusLabel" style="color: #444;">IDLE</span>
</div>
</div>
<canvas id="wefaxScopeCanvas" style="width: 100%; height: 80px; display: block; border-radius: 3px; background: #050510;"></canvas>
</div>
</div>
<!-- Schedule Timeline -->
<div class="wefax-schedule-panel">
<div class="wefax-schedule-header">
<span class="wefax-schedule-title">Broadcast Schedule</span>
<span id="wefaxStatusText" style="font-family: var(--font-mono); font-size: 10px; color: var(--text-dim);"></span>
</div>
<div id="wefaxScheduleTimeline">
<div class="wefax-schedule-empty">Select a station to see broadcast schedule</div>
</div>
</div>
<!-- Main Content: Live Preview + Gallery -->
<div class="wefax-main-row">
<div class="wefax-live-section">
<div class="wefax-live-header">
<div class="wefax-live-title">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="14" height="14" style="vertical-align: -2px; margin-right: 4px; color: #ffaa00;">
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/>
<polyline points="14 2 14 8 20 8"/>
</svg>
Live Decode
</div>
</div>
<div class="wefax-live-content" id="wefaxLiveContent">
<div class="wefax-idle-state" id="wefaxIdleState">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/>
<polyline points="14 2 14 8 20 8"/>
<line x1="16" y1="13" x2="8" y2="13"/>
<line x1="16" y1="17" x2="8" y2="17"/>
</svg>
<h4>WeFax Decoder</h4>
<p>Select a station and click Start to decode weather fax transmissions</p>
</div>
<img id="wefaxLivePreview" class="wefax-live-preview" style="display: none;" alt="WeFax decode in progress">
</div>
</div>
<div class="wefax-gallery-section">
<div class="wefax-gallery-header">
<div class="wefax-gallery-title">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="14" height="14" style="vertical-align: -2px; margin-right: 4px; color: #ffaa00;">
<rect x="3" y="3" width="18" height="18" rx="2"/>
<circle cx="8.5" cy="8.5" r="1.5"/>
<polyline points="21 15 16 10 5 21"/>
</svg>
Decoded Images
</div>
<div class="wefax-gallery-controls">
<span class="wefax-gallery-count" id="wefaxImageCount">0</span>
<button class="wefax-gallery-clear-btn" onclick="WeFax.deleteAllImages()" title="Delete all images">Clear All</button>
</div>
</div>
<div class="wefax-gallery-grid" id="wefaxGallery">
<div class="wefax-gallery-empty">No images decoded yet</div>
</div>
</div>
</div>
</div>
<!-- Space Weather Dashboard -->
<div id="spaceWeatherVisuals" class="sw-visuals-container" style="display: none;">
<!-- Header metrics strip -->
@@ -2932,6 +3043,7 @@
<script src="{{ url_for('static', filename='js/modes/websdr.js') }}"></script>
<script src="{{ url_for('static', filename='js/modes/subghz.js') }}?v={{ version }}&r=subghz_layout9"></script>
<script src="{{ url_for('static', filename='js/modes/bt_locate.js') }}?v={{ version }}&r=btlocate4"></script>
<script src="{{ url_for('static', filename='js/modes/wefax.js') }}"></script>
<script src="{{ url_for('static', filename='js/modes/space-weather.js') }}"></script>
<script src="{{ url_for('static', filename='js/core/voice-alerts.js') }}?v={{ version }}&r=voicefix2"></script>
<script src="{{ url_for('static', filename='js/core/keyboard-shortcuts.js') }}"></script>
@@ -3075,6 +3187,7 @@
sstv: { label: 'ISS SSTV', indicator: 'ISS SSTV', outputTitle: 'ISS SSTV Decoder', group: 'space' },
weathersat: { label: 'Weather Sat', indicator: 'WEATHER SAT', outputTitle: 'Weather Satellite Decoder', group: 'space' },
sstv_general: { label: 'HF SSTV', indicator: 'HF SSTV', outputTitle: 'HF SSTV Decoder', group: 'space' },
wefax: { label: 'WeFax', indicator: 'WEFAX', outputTitle: 'Weather Fax Decoder', group: 'space' },
spaceweather: { label: 'Space Weather', indicator: 'SPACE WX', outputTitle: 'Space Weather Monitor', group: 'space' },
wifi: { label: 'WiFi', indicator: 'WIFI', outputTitle: 'WiFi Scanner', group: 'wireless' },
bluetooth: { label: 'Bluetooth', indicator: 'BLUETOOTH', outputTitle: 'Bluetooth Scanner', group: 'wireless' },
@@ -3826,6 +3939,7 @@
document.getElementById('sstvMode')?.classList.toggle('active', mode === 'sstv');
document.getElementById('weatherSatMode')?.classList.toggle('active', mode === 'weathersat');
document.getElementById('sstvGeneralMode')?.classList.toggle('active', mode === 'sstv_general');
document.getElementById('wefaxMode')?.classList.toggle('active', mode === 'wefax');
document.getElementById('gpsMode')?.classList.toggle('active', mode === 'gps');
document.getElementById('wifiMode')?.classList.toggle('active', mode === 'wifi');
document.getElementById('bluetoothMode')?.classList.toggle('active', mode === 'bluetooth');
@@ -3877,6 +3991,7 @@
const websdrVisuals = document.getElementById('websdrVisuals');
const subghzVisuals = document.getElementById('subghzVisuals');
const btLocateVisuals = document.getElementById('btLocateVisuals');
const wefaxVisuals = document.getElementById('wefaxVisuals');
const spaceWeatherVisuals = document.getElementById('spaceWeatherVisuals');
const waterfallVisuals = document.getElementById('waterfallVisuals');
if (wifiLayoutContainer) wifiLayoutContainer.style.display = mode === 'wifi' ? 'flex' : 'none';
@@ -3893,6 +4008,7 @@
if (websdrVisuals) websdrVisuals.style.display = mode === 'websdr' ? 'flex' : 'none';
if (subghzVisuals) subghzVisuals.style.display = mode === 'subghz' ? 'flex' : 'none';
if (btLocateVisuals) btLocateVisuals.style.display = mode === 'bt_locate' ? 'flex' : 'none';
if (wefaxVisuals) wefaxVisuals.style.display = mode === 'wefax' ? 'flex' : 'none';
if (spaceWeatherVisuals) spaceWeatherVisuals.style.display = mode === 'spaceweather' ? 'flex' : 'none';
if (waterfallVisuals) waterfallVisuals.style.display = mode === 'waterfall' ? 'flex' : 'none';
@@ -3940,11 +4056,16 @@
if (typeof WeatherSat !== 'undefined' && WeatherSat.suspend) WeatherSat.suspend();
}
// Suspend WeFax background streams when leaving the mode
if (mode !== 'wefax') {
if (typeof WeFax !== 'undefined' && WeFax.destroy) WeFax.destroy();
}
// Show/hide Device Intelligence for modes that use it (not for satellite/aircraft/tscm)
const reconBtn = document.getElementById('reconBtn');
const intelBtn = document.querySelector('[onclick="exportDeviceDB()"]');
const reconPanel = document.getElementById('reconPanel');
if (mode === 'satellite' || mode === 'sstv' || mode === 'weathersat' || mode === 'sstv_general' || mode === 'gps' || mode === 'aprs' || mode === 'tscm' || mode === 'spystations' || mode === 'meshtastic' || mode === 'websdr' || mode === 'subghz' || mode === 'spaceweather' || mode === 'waterfall') {
if (mode === 'satellite' || mode === 'sstv' || mode === 'weathersat' || mode === 'sstv_general' || mode === 'wefax' || mode === 'gps' || mode === 'aprs' || mode === 'tscm' || mode === 'spystations' || mode === 'meshtastic' || mode === 'websdr' || mode === 'subghz' || mode === 'spaceweather' || mode === 'waterfall') {
if (reconPanel) reconPanel.style.display = 'none';
if (reconBtn) reconBtn.style.display = 'none';
if (intelBtn) intelBtn.style.display = 'none';
@@ -3964,7 +4085,7 @@
// Show RTL-SDR device section for modes that use it
const rtlDeviceSection = document.getElementById('rtlDeviceSection');
if (rtlDeviceSection) rtlDeviceSection.style.display = (mode === 'pager' || mode === 'sensor' || mode === 'rtlamr' || mode === 'aprs' || mode === 'sstv' || mode === 'weathersat' || mode === 'sstv_general') ? 'block' : 'none';
if (rtlDeviceSection) rtlDeviceSection.style.display = (mode === 'pager' || mode === 'sensor' || mode === 'rtlamr' || mode === 'aprs' || mode === 'sstv' || mode === 'weathersat' || mode === 'sstv_general' || mode === 'wefax') ? 'block' : 'none';
// Toggle mode-specific tool status displays
const toolStatusPager = document.getElementById('toolStatusPager');
@@ -3975,7 +4096,7 @@
// Hide output console for modes with their own visualizations
const outputEl = document.getElementById('output');
const statusBar = document.querySelector('.status-bar');
if (outputEl) outputEl.style.display = (mode === 'satellite' || mode === 'sstv' || mode === 'weathersat' || mode === 'sstv_general' || mode === 'aprs' || mode === 'wifi' || mode === 'bluetooth' || mode === 'tscm' || mode === 'spystations' || mode === 'meshtastic' || mode === 'websdr' || mode === 'subghz' || mode === 'spaceweather' || mode === 'bt_locate' || mode === 'waterfall') ? 'none' : 'block';
if (outputEl) outputEl.style.display = (mode === 'satellite' || mode === 'sstv' || mode === 'weathersat' || mode === 'sstv_general' || mode === 'wefax' || mode === 'aprs' || mode === 'wifi' || mode === 'bluetooth' || mode === 'tscm' || mode === 'spystations' || mode === 'meshtastic' || mode === 'websdr' || mode === 'subghz' || mode === 'spaceweather' || mode === 'bt_locate' || mode === 'waterfall') ? 'none' : 'block';
if (statusBar) statusBar.style.display = (mode === 'satellite' || mode === 'websdr' || mode === 'subghz' || mode === 'spaceweather' || mode === 'waterfall') ? 'none' : 'flex';
// Restore sidebar when leaving Meshtastic mode (user may have collapsed it)
@@ -4042,6 +4163,8 @@
setTimeout(() => {
if (typeof BtLocate !== 'undefined' && BtLocate.invalidateMap) BtLocate.invalidateMap();
}, 320);
} else if (mode === 'wefax') {
WeFax.init();
} else if (mode === 'spaceweather') {
SpaceWeather.init();
} else if (mode === 'waterfall') {
+110
View File
@@ -0,0 +1,110 @@
<!-- WEFAX MODE -->
<div id="wefaxMode" class="mode-content">
<div class="section">
<h3>WeFax Decoder</h3>
<p class="info-text" style="font-size: 11px; color: var(--text-dim); margin-bottom: 12px;">
Decode HF weather fax (radiofax) from maritime and aviation weather services.
Stations broadcast weather charts on fixed schedules via HF radio.
</p>
</div>
<div class="section">
<h3>Station</h3>
<div class="form-group">
<label>Station</label>
<select id="wefaxStation" onchange="WeFax.onStationChange()">
<option value="">Select a station...</option>
</select>
</div>
<div class="form-group">
<label>Frequency (kHz)</label>
<select id="wefaxFrequency">
<option value="">Select station first</option>
</select>
</div>
</div>
<div class="section">
<h3>Settings</h3>
<div class="form-group">
<label>IOC (Index of Cooperation)</label>
<select id="wefaxIOC">
<option value="576" selected>576 (Standard)</option>
<option value="288">288 (Half)</option>
</select>
</div>
<div class="form-group">
<label>LPM (Lines Per Minute)</label>
<select id="wefaxLPM">
<option value="120" selected>120 (Standard)</option>
<option value="60">60 (Slow)</option>
</select>
</div>
<div class="form-group">
<label>Gain (dB)</label>
<input type="number" id="wefaxGain" value="40" step="1" min="0" max="50">
</div>
<div class="form-group" style="display: flex; align-items: center; gap: 8px;">
<input type="checkbox" id="wefaxDirectSampling" checked>
<label for="wefaxDirectSampling" style="margin: 0; cursor: pointer;">Direct Sampling (Q-branch, required for HF)</label>
</div>
</div>
<!-- Antenna Guide -->
<div class="section">
<h3>HF Antenna Guide</h3>
<div style="font-size: 11px; color: var(--text-dim); line-height: 1.5;">
<p style="margin-bottom: 8px; color: #ffaa00; font-weight: 600;">
HF band (2&ndash;30 MHz) &mdash; requires HF antenna + direct sampling SDR
</p>
<div style="background: var(--bg-primary); border: 1px solid var(--border-color); border-radius: 4px; padding: 10px; margin-bottom: 10px;">
<strong style="color: #ffaa00; font-size: 12px;">Requirements</strong>
<ul style="margin: 6px 0 0 14px; padding: 0;">
<li><strong style="color: var(--text-primary);">SDR:</strong> RTL-SDR v3/v4 with direct sampling mode</li>
<li><strong style="color: var(--text-primary);">Antenna:</strong> Long wire (10m+), random wire, or dipole for target band</li>
<li><strong style="color: var(--text-primary);">Mode:</strong> USB (Upper Sideband) demodulation</li>
<li><strong style="color: var(--text-primary);">Signals:</strong> Moderate &mdash; HF propagation varies by time of day</li>
</ul>
</div>
<div style="background: var(--bg-primary); border: 1px solid var(--border-color); border-radius: 4px; padding: 10px;">
<strong style="color: #ffaa00; font-size: 12px;">Quick Reference</strong>
<table style="width: 100%; margin-top: 6px; font-size: 10px; border-collapse: collapse;">
<tr style="border-bottom: 1px solid var(--border-color);">
<td style="padding: 3px 4px; color: var(--text-dim);">Protocol</td>
<td style="padding: 3px 4px; color: var(--text-primary); text-align: right;">AM Facsimile (ITU-T T.4)</td>
</tr>
<tr style="border-bottom: 1px solid var(--border-color);">
<td style="padding: 3px 4px; color: var(--text-dim);">Carrier</td>
<td style="padding: 3px 4px; color: var(--text-primary); text-align: right;">1900 Hz</td>
</tr>
<tr style="border-bottom: 1px solid var(--border-color);">
<td style="padding: 3px 4px; color: var(--text-dim);">Deviation</td>
<td style="padding: 3px 4px; color: var(--text-primary); text-align: right;">&plusmn;400 Hz</td>
</tr>
<tr style="border-bottom: 1px solid var(--border-color);">
<td style="padding: 3px 4px; color: var(--text-dim);">Black / White</td>
<td style="padding: 3px 4px; color: var(--text-primary); text-align: right;">1500 / 2300 Hz</td>
</tr>
<tr>
<td style="padding: 3px 4px; color: var(--text-dim);">Start / Stop tone</td>
<td style="padding: 3px 4px; color: var(--text-primary); text-align: right;">300 / 450 Hz</td>
</tr>
</table>
</div>
</div>
</div>
<div class="section">
<h3>Resources</h3>
<div style="display: flex; flex-direction: column; gap: 6px;">
<a href="https://www.weather.gov/marine/radiofax_charts" target="_blank" rel="noopener" class="preset-btn" style="text-decoration: none; text-align: center;">
NWS Radiofax Charts
</a>
<a href="https://www.nws.noaa.gov/os/marine/rfax.pdf" target="_blank" rel="noopener" class="preset-btn" style="text-decoration: none; text-align: center;">
NOAA Radiofax Schedule (PDF)
</a>
</div>
</div>
</div>
+2
View File
@@ -102,6 +102,7 @@
{% endif %}
{{ mode_item('sstv', 'ISS SSTV', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="18" height="18" rx="2"/><circle cx="12" cy="12" r="3"/><path d="M3 9h2"/><path d="M19 9h2"/><path d="M3 15h2"/><path d="M19 15h2"/></svg>') }}
{{ mode_item('weathersat', 'Weather Sat', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><path d="M2 12h20"/><path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"/></svg>') }}
{{ mode_item('wefax', 'WeFax', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="16" y1="13" x2="8" y2="13"/><line x1="16" y1="17" x2="8" y2="17"/></svg>') }}
{{ mode_item('sstv_general', 'HF SSTV', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="18" height="18" rx="2"/><circle cx="12" cy="12" r="3"/><path d="M16.24 7.76a6 6 0 0 1 0 8.49m-8.48-.01a6 6 0 0 1 0-8.49"/></svg>') }}
{{ mode_item('spaceweather', 'Space Weather', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="5"/><line x1="12" y1="1" x2="12" y2="3"/><line x1="12" y1="21" x2="12" y2="23"/><line x1="4.22" y1="4.22" x2="5.64" y2="5.64"/><line x1="18.36" y1="18.36" x2="19.78" y2="19.78"/><line x1="1" y1="12" x2="3" y2="12"/><line x1="21" y1="12" x2="23" y2="12"/><line x1="4.22" y1="19.78" x2="5.64" y2="18.36"/><line x1="18.36" y1="5.64" x2="19.78" y2="4.22"/></svg>') }}
</div>
@@ -213,6 +214,7 @@
{% endif %}
{{ mobile_item('sstv', 'SSTV', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="3" width="18" height="18" rx="2"/><circle cx="12" cy="12" r="3"/></svg>') }}
{{ mobile_item('weathersat', 'WxSat', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><path d="M2 12h20"/><path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"/></svg>') }}
{{ mobile_item('wefax', 'WeFax', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="16" y1="13" x2="8" y2="13"/><line x1="16" y1="17" x2="8" y2="17"/></svg>') }}
{{ mobile_item('sstv_general', 'HF SSTV', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="3" width="18" height="18" rx="2"/><circle cx="12" cy="12" r="3"/></svg>') }}
{{ mobile_item('spaceweather', 'SpaceWx', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="5"/><line x1="12" y1="1" x2="12" y2="3"/><line x1="12" y1="21" x2="12" y2="23"/><line x1="1" y1="12" x2="3" y2="12"/><line x1="21" y1="12" x2="23" y2="12"/></svg>') }}
{# Wireless #}
+430
View File
@@ -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/<filename> 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/<filename> 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
+754
View File
@@ -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
+89
View File
@@ -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]