mirror of
https://github.com/smittix/intercept.git
synced 2026-06-08 06:01:56 -07:00
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:
@@ -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)
|
||||
|
||||
@@ -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
@@ -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
@@ -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,
|
||||
})
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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">⭳</a>';
|
||||
html += '<button class="wefax-gallery-action delete" onclick="WeFax.deleteImage(\'' + img.filename + '\')" title="Delete">×</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
@@ -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') {
|
||||
|
||||
@@ -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–30 MHz) — 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 — 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;">±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>
|
||||
@@ -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 #}
|
||||
|
||||
@@ -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
@@ -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
|
||||
@@ -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]
|
||||
Reference in New Issue
Block a user