diff --git a/routes/wefax.py b/routes/wefax.py index d5c58e1..ccce86d 100644 --- a/routes/wefax.py +++ b/routes/wefax.py @@ -11,11 +11,17 @@ 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 +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 ( + WEFAX_USB_ALIGNMENT_OFFSET_KHZ, + get_current_broadcasts, + get_station, + load_stations, + resolve_tuning_frequency_khz, +) logger = get_logger('intercept.wefax') @@ -69,15 +75,16 @@ def start_decoder(): """Start WeFax decoder. JSON body: - { - "frequency_khz": 4298, - "station": "NOJ", - "device": 0, - "gain": 40, - "ioc": 576, - "lpm": 120, - "direct_sampling": true - } + { + "frequency_khz": 4298, + "station": "NOJ", + "device": 0, + "gain": 40, + "ioc": 576, + "lpm": 120, + "direct_sampling": true, + "frequency_reference": "auto" // auto, carrier, or dial + } """ decoder = get_wefax_decoder() @@ -115,17 +122,36 @@ def start_decoder(): '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', + station = str(data.get('station', '')).strip() + device_index = data.get('device', 0) + gain = float(data.get('gain', 40.0)) + ioc = int(data.get('ioc', 576)) + lpm = int(data.get('lpm', 120)) + direct_sampling = bool(data.get('direct_sampling', True)) + frequency_reference = str(data.get('frequency_reference', 'auto')).strip().lower() + if not frequency_reference: + frequency_reference = 'auto' + + try: + tuned_frequency_khz, resolved_reference, usb_offset_applied = ( + resolve_tuning_frequency_khz( + listed_frequency_khz=frequency_khz, + station_callsign=station, + frequency_reference=frequency_reference, + ) + ) + tuned_mhz = tuned_frequency_khz / 1000.0 + validate_frequency(tuned_mhz, min_mhz=2.0, max_mhz=30.0) + except ValueError as e: + return jsonify({ + 'status': 'error', + 'message': f'Invalid frequency settings: {e}', + }), 400 + + # Validate IOC and LPM + if ioc not in (288, 576): + return jsonify({ + 'status': 'error', 'message': 'IOC must be 288 or 576', }), 400 @@ -146,28 +172,34 @@ def start_decoder(): '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, + # Set callback and start + decoder.set_callback(_progress_callback) + success = decoder.start( + frequency_khz=tuned_frequency_khz, + station=station, + device_index=device_int, + gain=gain, + ioc=ioc, + lpm=lpm, direct_sampling=direct_sampling, ) if success: wefax_active_device = device_int - return jsonify({ - 'status': 'started', - 'frequency_khz': frequency_khz, - 'station': station, - 'ioc': ioc, - 'lpm': lpm, - 'device': device_int, - }) + return jsonify({ + 'status': 'started', + 'frequency_khz': frequency_khz, + 'tuned_frequency_khz': tuned_frequency_khz, + 'frequency_reference': resolved_reference, + 'usb_offset_applied': usb_offset_applied, + 'usb_offset_khz': ( + WEFAX_USB_ALIGNMENT_OFFSET_KHZ if usb_offset_applied else 0.0 + ), + 'station': station, + 'ioc': ioc, + 'lpm': lpm, + 'device': device_int, + }) else: app_module.release_sdr_device(device_int) return jsonify({ @@ -290,15 +322,16 @@ def enable_schedule(): """Enable auto-scheduling of WeFax broadcast captures. JSON body: - { - "station": "NOJ", - "frequency_khz": 4298, - "device": 0, - "gain": 40, - "ioc": 576, - "lpm": 120, - "direct_sampling": true - } + { + "station": "NOJ", + "frequency_khz": 4298, + "device": 0, + "gain": 40, + "ioc": 576, + "lpm": 120, + "direct_sampling": true, + "frequency_reference": "auto" // auto, carrier, or dial + } Returns: JSON with scheduler status. @@ -332,32 +365,61 @@ def enable_schedule(): }), 400 device = int(data.get('device', 0)) - gain = float(data.get('gain', 40.0)) - ioc = int(data.get('ioc', 576)) - lpm = int(data.get('lpm', 120)) - direct_sampling = bool(data.get('direct_sampling', True)) - - scheduler = get_wefax_scheduler() - scheduler.set_callbacks(_progress_callback, _scheduler_event_callback) - - try: - result = scheduler.enable( - station=station, - frequency_khz=frequency_khz, - device=device, - gain=gain, - ioc=ioc, - lpm=lpm, - direct_sampling=direct_sampling, + gain = float(data.get('gain', 40.0)) + ioc = int(data.get('ioc', 576)) + lpm = int(data.get('lpm', 120)) + direct_sampling = bool(data.get('direct_sampling', True)) + frequency_reference = str(data.get('frequency_reference', 'auto')).strip().lower() + if not frequency_reference: + frequency_reference = 'auto' + + try: + tuned_frequency_khz, resolved_reference, usb_offset_applied = ( + resolve_tuning_frequency_khz( + listed_frequency_khz=frequency_khz, + station_callsign=station, + frequency_reference=frequency_reference, + ) + ) + tuned_mhz = tuned_frequency_khz / 1000.0 + validate_frequency(tuned_mhz, min_mhz=2.0, max_mhz=30.0) + except ValueError as e: + return jsonify({ + 'status': 'error', + 'message': f'Invalid frequency settings: {e}', + }), 400 + + scheduler = get_wefax_scheduler() + scheduler.set_callbacks(_progress_callback, _scheduler_event_callback) + + try: + result = scheduler.enable( + station=station, + frequency_khz=tuned_frequency_khz, + device=device, + gain=gain, + ioc=ioc, + lpm=lpm, + direct_sampling=direct_sampling, ) except Exception: logger.exception("Failed to enable WeFax scheduler") - return jsonify({ - 'status': 'error', - 'message': 'Failed to enable scheduler', - }), 500 - - return jsonify({'status': 'ok', **result}) + return jsonify({ + 'status': 'error', + 'message': 'Failed to enable scheduler', + }), 500 + + return jsonify({ + 'status': 'ok', + **result, + 'frequency_khz': frequency_khz, + 'tuned_frequency_khz': tuned_frequency_khz, + 'frequency_reference': resolved_reference, + 'usb_offset_applied': usb_offset_applied, + 'usb_offset_khz': ( + WEFAX_USB_ALIGNMENT_OFFSET_KHZ if usb_offset_applied else 0.0 + ), + }) @wefax_bp.route('/schedule/disable', methods=['POST']) diff --git a/static/js/modes/wefax.js b/static/js/modes/wefax.js index a5a1902..3499af4 100644 --- a/static/js/modes/wefax.js +++ b/static/js/modes/wefax.js @@ -148,12 +148,20 @@ var WeFax = (function () { opt.textContent = f.khz + ' kHz — ' + f.description; sel.appendChild(opt); }); - } - - // ---- Start / Stop ---- - - function start() { - if (state.running) return; + } + + // ---- Start / Stop ---- + + function selectedFrequencyReference() { + var alignCheckbox = document.getElementById('wefaxAutoUsbAlign'); + if (alignCheckbox && !alignCheckbox.checked) { + return 'dial'; + } + return 'auto'; + } + + function start() { + if (state.running) return; var freqSel = document.getElementById('wefaxFrequency'); var freqKhz = freqSel ? parseFloat(freqSel.value) : 0; @@ -176,27 +184,34 @@ var WeFax = (function () { 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, - }; + gain: gainInput ? parseFloat(gainInput.value) || 40 : 40, + ioc: iocSel ? parseInt(iocSel.value, 10) : 576, + lpm: lpmSel ? parseInt(lpmSel.value, 10) : 120, + direct_sampling: dsCheckbox ? dsCheckbox.checked : true, + frequency_reference: selectedFrequencyReference(), + }; fetch('/wefax/start', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body), }) - .then(function (r) { return r.json(); }) - .then(function (data) { - if (data.status === 'started' || data.status === 'already_running') { - state.running = true; - updateButtons(true); - setStatus('Scanning ' + freqKhz + ' kHz...'); - setStripFreq(freqKhz); - connectSSE(); - } else { - var errMsg = data.message || 'unknown error'; + .then(function (r) { return r.json(); }) + .then(function (data) { + if (data.status === 'started' || data.status === 'already_running') { + var tunedKhz = Number(data.tuned_frequency_khz); + if (isNaN(tunedKhz) || tunedKhz <= 0) tunedKhz = freqKhz; + state.running = true; + updateButtons(true); + if (data.usb_offset_applied) { + setStatus('Scanning ' + tunedKhz + ' kHz (USB aligned from ' + freqKhz + ' kHz)...'); + } else { + setStatus('Scanning ' + tunedKhz + ' kHz...'); + } + setStripFreq(tunedKhz); + connectSSE(); + } else { + var errMsg = data.message || 'unknown error'; setStatus('Error: ' + errMsg); showStripError(errMsg); } @@ -1047,19 +1062,24 @@ var WeFax = (function () { station: station, frequency_khz: freqKhz, device: device, - gain: gainInput ? parseFloat(gainInput.value) || 40 : 40, - ioc: iocSel ? parseInt(iocSel.value, 10) : 576, - lpm: lpmSel ? parseInt(lpmSel.value, 10) : 120, - direct_sampling: dsCheckbox ? dsCheckbox.checked : true, - }), - }) - .then(function (r) { return r.json(); }) - .then(function (data) { - if (data.status === 'ok') { - setStatus('Auto-capture enabled — ' + (data.scheduled_count || 0) + ' broadcasts scheduled'); - syncSchedulerCheckboxes(true); - state.schedulerEnabled = true; - connectSSE(); + gain: gainInput ? parseFloat(gainInput.value) || 40 : 40, + ioc: iocSel ? parseInt(iocSel.value, 10) : 576, + lpm: lpmSel ? parseInt(lpmSel.value, 10) : 120, + direct_sampling: dsCheckbox ? dsCheckbox.checked : true, + frequency_reference: selectedFrequencyReference(), + }), + }) + .then(function (r) { return r.json(); }) + .then(function (data) { + if (data.status === 'ok') { + var status = 'Auto-capture enabled — ' + (data.scheduled_count || 0) + ' broadcasts scheduled'; + if (data.usb_offset_applied && !isNaN(Number(data.tuned_frequency_khz))) { + status += ' (tuning ' + Number(data.tuned_frequency_khz) + ' kHz)'; + } + setStatus(status); + syncSchedulerCheckboxes(true); + state.schedulerEnabled = true; + connectSSE(); startSchedulerPoll(); } else { setStatus('Scheduler error: ' + (data.message || 'unknown')); diff --git a/templates/partials/modes/wefax.html b/templates/partials/modes/wefax.html index 319e6a0..e0dd170 100644 --- a/templates/partials/modes/wefax.html +++ b/templates/partials/modes/wefax.html @@ -44,11 +44,18 @@ -
- - -
- +
+ + +
+
+ + +
+

+ Disable this if your source already provides USB dial frequencies. +

+

Auto Capture

diff --git a/tests/test_wefax.py b/tests/test_wefax.py index 2f23d1a..9a62192 100644 --- a/tests/test_wefax.py +++ b/tests/test_wefax.py @@ -54,16 +54,72 @@ class TestWeFaxStations: 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']: + 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_resolve_tuning_frequency_auto_uses_carrier_for_known_station(self): + """Known station frequencies default to carrier-list behavior in auto mode.""" + from utils.wefax_stations import resolve_tuning_frequency_khz + + tuned, reference, offset_applied = resolve_tuning_frequency_khz( + listed_frequency_khz=4298.0, + station_callsign='NOJ', + frequency_reference='auto', + ) + + assert math.isclose(tuned, 4296.1, abs_tol=1e-6) + assert reference == 'carrier' + assert offset_applied is True + + def test_resolve_tuning_frequency_auto_preserves_unknown_station_input(self): + """Ad-hoc frequencies (no station metadata) should be treated as dial.""" + from utils.wefax_stations import resolve_tuning_frequency_khz + + tuned, reference, offset_applied = resolve_tuning_frequency_khz( + listed_frequency_khz=4298.0, + station_callsign='', + frequency_reference='auto', + ) + + assert math.isclose(tuned, 4298.0, abs_tol=1e-6) + assert reference == 'dial' + assert offset_applied is False + + def test_resolve_tuning_frequency_dial_override(self): + """Explicit dial reference must bypass USB alignment.""" + from utils.wefax_stations import resolve_tuning_frequency_khz + + tuned, reference, offset_applied = resolve_tuning_frequency_khz( + listed_frequency_khz=4298.0, + station_callsign='NOJ', + frequency_reference='dial', + ) + + assert math.isclose(tuned, 4298.0, abs_tol=1e-6) + assert reference == 'dial' + assert offset_applied is False + + def test_resolve_tuning_frequency_rejects_invalid_reference(self): + """Invalid frequency reference values should raise a validation error.""" + from utils.wefax_stations import resolve_tuning_frequency_khz + + try: + resolve_tuning_frequency_khz( + listed_frequency_khz=4298.0, + station_callsign='NOJ', + frequency_reference='invalid', + ) + assert False, "Expected ValueError for invalid frequency_reference" + except ValueError as exc: + assert 'frequency_reference' in str(exc) + + 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)) @@ -334,11 +390,11 @@ class TestWeFaxRoutes: 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 + 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), \ @@ -355,12 +411,46 @@ class TestWeFaxRoutes: 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() + assert response.status_code == 200 + data = response.get_json() + assert data['status'] == 'started' + assert data['frequency_khz'] == 4298 + assert data['usb_offset_applied'] is True + assert math.isclose(data['tuned_frequency_khz'], 4296.1, abs_tol=1e-6) + assert data['frequency_reference'] == 'carrier' + assert data['station'] == 'NOJ' + mock_decoder.start.assert_called_once() + start_kwargs = mock_decoder.start.call_args.kwargs + assert math.isclose(start_kwargs['frequency_khz'], 4296.1, abs_tol=1e-6) + + def test_start_respects_dial_reference_override(self, client): + """POST /wefax/start with dial reference should not apply USB offset.""" + _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, + 'frequency_reference': 'dial', + }), + content_type='application/json', + ) + + assert response.status_code == 200 + data = response.get_json() + assert data['status'] == 'started' + assert data['usb_offset_applied'] is False + assert math.isclose(data['tuned_frequency_khz'], 4298.0, abs_tol=1e-6) + assert data['frequency_reference'] == 'dial' + start_kwargs = mock_decoder.start.call_args.kwargs + assert math.isclose(start_kwargs['frequency_khz'], 4298.0, abs_tol=1e-6) def test_start_device_busy(self, client): """POST /wefax/start should return 409 when device is busy.""" @@ -429,6 +519,35 @@ class TestWeFaxRoutes: assert response.status_code == 400 + def test_schedule_enable_applies_usb_alignment(self, client): + """Scheduler should receive tuned USB dial frequency in auto mode.""" + _login_session(client) + mock_scheduler = MagicMock() + mock_scheduler.enable.return_value = { + 'enabled': True, + 'scheduled_count': 2, + 'total_broadcasts': 2, + } + + with patch('utils.wefax_scheduler.get_wefax_scheduler', return_value=mock_scheduler): + response = client.post( + '/wefax/schedule/enable', + data=json.dumps({ + 'station': 'NOJ', + 'frequency_khz': 4298, + 'device': 0, + }), + content_type='application/json', + ) + + assert response.status_code == 200 + data = response.get_json() + assert data['status'] == 'ok' + assert data['usb_offset_applied'] is True + assert math.isclose(data['tuned_frequency_khz'], 4296.1, abs_tol=1e-6) + enable_kwargs = mock_scheduler.enable.call_args.kwargs + assert math.isclose(enable_kwargs['frequency_khz'], 4296.1, abs_tol=1e-6) + class TestWeFaxProgressCallback: """Regression tests for WeFax route-level progress callback behavior.""" diff --git a/utils/wefax_stations.py b/utils/wefax_stations.py index d80289c..15a3fdc 100644 --- a/utils/wefax_stations.py +++ b/utils/wefax_stations.py @@ -1,8 +1,8 @@ -"""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. -""" +"""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 @@ -10,10 +10,12 @@ 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' +_stations_cache: list[dict] | None = None +_stations_by_callsign: dict[str, dict] = {} +_VALID_FREQUENCY_REFERENCES = {'auto', 'carrier', 'dial'} +WEFAX_USB_ALIGNMENT_OFFSET_KHZ = 1.9 + +_STATIONS_PATH = Path(__file__).resolve().parent.parent / 'data' / 'wefax_stations.json' def load_stations() -> list[dict]: @@ -31,10 +33,79 @@ def load_stations() -> list[dict]: 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_station(callsign: str) -> dict | None: + """Get a single station by callsign.""" + load_stations() + return _stations_by_callsign.get(callsign.upper()) + + +def _normalize_frequency_reference(value: str | None) -> str: + """Normalize and validate frequency reference token.""" + reference = str(value or 'auto').strip().lower() + if reference not in _VALID_FREQUENCY_REFERENCES: + choices = ', '.join(sorted(_VALID_FREQUENCY_REFERENCES)) + raise ValueError(f'frequency_reference must be one of: {choices}') + return reference + + +def _station_frequency_reference(station: dict, listed_frequency_khz: float) -> str: + """Infer whether a station frequency entry is carrier or already USB dial.""" + for entry in station.get('frequencies', []): + try: + entry_khz = float(entry.get('khz')) + except (TypeError, ValueError): + continue + if abs(entry_khz - listed_frequency_khz) > 0.001: + continue + entry_ref = str(entry.get('reference', '')).strip().lower() + if entry_ref in ('carrier', 'dial'): + return entry_ref + + station_ref = str(station.get('frequency_reference', '')).strip().lower() + if station_ref in ('carrier', 'dial'): + return station_ref + + # Most published marine WeFax channel lists are carrier frequencies. + return 'carrier' + + +def resolve_tuning_frequency_khz( + listed_frequency_khz: float, + station_callsign: str = '', + frequency_reference: str = 'auto', +) -> tuple[float, str, bool]: + """Resolve listed frequency to the actual USB dial frequency. + + Args: + listed_frequency_khz: Frequency value provided by UI/API. + station_callsign: Station callsign used for metadata lookup. + frequency_reference: One of auto/carrier/dial. + + Returns: + (tuned_frequency_khz, resolved_reference, offset_applied) + """ + listed = float(listed_frequency_khz) + if listed <= 0: + raise ValueError('frequency_khz must be greater than zero') + + requested_ref = _normalize_frequency_reference(frequency_reference) + resolved_ref = requested_ref + + if requested_ref == 'auto': + station = get_station(station_callsign) if station_callsign else None + if station: + resolved_ref = _station_frequency_reference(station, listed) + else: + # For ad-hoc frequencies (no station metadata), treat input as dial. + resolved_ref = 'dial' + + if resolved_ref == 'carrier': + tuned = round(listed - WEFAX_USB_ALIGNMENT_OFFSET_KHZ, 3) + if tuned <= 0: + raise ValueError('frequency_khz too low after USB alignment offset') + return tuned, resolved_ref, True + + return listed, resolved_ref, False def get_current_broadcasts(callsign: str) -> list[dict]: