mirror of
https://github.com/smittix/intercept.git
synced 2026-04-25 07:10:00 -07:00
Add .gitignore entry for data/subghz/captures/ to prevent large IQ recording files from being committed. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
425 lines
14 KiB
Python
425 lines
14 KiB
Python
"""SubGHz transceiver routes.
|
|
|
|
Provides endpoints for HackRF-based SubGHz signal capture, protocol decoding,
|
|
signal replay/transmit, and wideband spectrum analysis.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import queue
|
|
|
|
from flask import Blueprint, jsonify, request, Response, send_file
|
|
|
|
from utils.logging import get_logger
|
|
from utils.sse import sse_stream
|
|
from utils.subghz import get_subghz_manager
|
|
from utils.constants import (
|
|
SUBGHZ_FREQ_MIN_MHZ,
|
|
SUBGHZ_FREQ_MAX_MHZ,
|
|
SUBGHZ_LNA_GAIN_MAX,
|
|
SUBGHZ_VGA_GAIN_MAX,
|
|
SUBGHZ_TX_VGA_GAIN_MAX,
|
|
SUBGHZ_TX_MAX_DURATION,
|
|
SUBGHZ_SAMPLE_RATES,
|
|
SUBGHZ_PRESETS,
|
|
)
|
|
|
|
logger = get_logger('intercept.subghz')
|
|
|
|
subghz_bp = Blueprint('subghz', __name__, url_prefix='/subghz')
|
|
|
|
# SSE queue for streaming events to frontend
|
|
_subghz_queue: queue.Queue = queue.Queue(maxsize=200)
|
|
|
|
|
|
def _event_callback(event: dict) -> None:
|
|
"""Forward SubGhzManager events to the SSE queue."""
|
|
try:
|
|
_subghz_queue.put_nowait(event)
|
|
except queue.Full:
|
|
try:
|
|
_subghz_queue.get_nowait()
|
|
_subghz_queue.put_nowait(event)
|
|
except queue.Empty:
|
|
pass
|
|
|
|
|
|
def _validate_frequency_hz(data: dict, key: str = 'frequency_hz') -> tuple[int | None, str | None]:
|
|
"""Validate frequency in Hz from request data. Returns (freq_hz, error_msg)."""
|
|
raw = data.get(key)
|
|
if raw is None:
|
|
return None, f'{key} is required'
|
|
try:
|
|
freq_hz = int(raw)
|
|
freq_mhz = freq_hz / 1_000_000
|
|
if not (SUBGHZ_FREQ_MIN_MHZ <= freq_mhz <= SUBGHZ_FREQ_MAX_MHZ):
|
|
return None, f'Frequency must be between {SUBGHZ_FREQ_MIN_MHZ}-{SUBGHZ_FREQ_MAX_MHZ} MHz'
|
|
return freq_hz, None
|
|
except (ValueError, TypeError):
|
|
return None, f'Invalid {key}'
|
|
|
|
|
|
def _validate_serial(data: dict) -> str | None:
|
|
"""Extract and validate optional HackRF device serial."""
|
|
serial = data.get('device_serial', '')
|
|
if not serial or not isinstance(serial, str):
|
|
return None
|
|
# HackRF serials are hex strings
|
|
serial = serial.strip()
|
|
if serial and all(c in '0123456789abcdefABCDEF' for c in serial):
|
|
return serial
|
|
return None
|
|
|
|
|
|
def _validate_int(data: dict, key: str, default: int, min_val: int, max_val: int) -> int:
|
|
"""Validate integer parameter with bounds clamping."""
|
|
try:
|
|
val = int(data.get(key, default))
|
|
return max(min_val, min(max_val, val))
|
|
except (ValueError, TypeError):
|
|
return default
|
|
|
|
|
|
def _validate_decode_profile(data: dict, default: str = 'weather') -> str:
|
|
profile = data.get('decode_profile', default)
|
|
if not isinstance(profile, str):
|
|
return default
|
|
profile = profile.strip().lower()
|
|
if profile in {'weather', 'all'}:
|
|
return profile
|
|
return default
|
|
|
|
|
|
def _validate_optional_float(data: dict, key: str) -> tuple[float | None, str | None]:
|
|
raw = data.get(key)
|
|
if raw is None or raw == '':
|
|
return None, None
|
|
try:
|
|
return float(raw), None
|
|
except (ValueError, TypeError):
|
|
return None, f'Invalid {key}'
|
|
|
|
|
|
def _validate_bool(data: dict, key: str, default: bool = False) -> bool:
|
|
raw = data.get(key, default)
|
|
if isinstance(raw, bool):
|
|
return raw
|
|
if isinstance(raw, (int, float)):
|
|
return bool(raw)
|
|
if isinstance(raw, str):
|
|
return raw.strip().lower() in {'1', 'true', 'yes', 'on', 'enabled'}
|
|
return default
|
|
|
|
|
|
# ------------------------------------------------------------------
|
|
# STATUS
|
|
# ------------------------------------------------------------------
|
|
|
|
@subghz_bp.route('/status')
|
|
def get_status():
|
|
manager = get_subghz_manager()
|
|
return jsonify(manager.get_status())
|
|
|
|
|
|
@subghz_bp.route('/presets')
|
|
def get_presets():
|
|
return jsonify({'presets': SUBGHZ_PRESETS, 'sample_rates': SUBGHZ_SAMPLE_RATES})
|
|
|
|
|
|
# ------------------------------------------------------------------
|
|
# RECEIVE
|
|
# ------------------------------------------------------------------
|
|
|
|
@subghz_bp.route('/receive/start', methods=['POST'])
|
|
def start_receive():
|
|
data = request.get_json(silent=True) or {}
|
|
|
|
freq_hz, err = _validate_frequency_hz(data)
|
|
if err:
|
|
return jsonify({'status': 'error', 'message': err}), 400
|
|
|
|
sample_rate = _validate_int(data, 'sample_rate', 2000000, 2000000, 20000000)
|
|
lna_gain = _validate_int(data, 'lna_gain', 32, 0, SUBGHZ_LNA_GAIN_MAX)
|
|
vga_gain = _validate_int(data, 'vga_gain', 20, 0, SUBGHZ_VGA_GAIN_MAX)
|
|
trigger_enabled = _validate_bool(data, 'trigger_enabled', False)
|
|
trigger_pre_ms = _validate_int(data, 'trigger_pre_ms', 350, 50, 5000)
|
|
trigger_post_ms = _validate_int(data, 'trigger_post_ms', 700, 100, 10000)
|
|
device_serial = _validate_serial(data)
|
|
|
|
manager = get_subghz_manager()
|
|
manager.set_callback(_event_callback)
|
|
|
|
result = manager.start_receive(
|
|
frequency_hz=freq_hz,
|
|
sample_rate=sample_rate,
|
|
lna_gain=lna_gain,
|
|
vga_gain=vga_gain,
|
|
trigger_enabled=trigger_enabled,
|
|
trigger_pre_ms=trigger_pre_ms,
|
|
trigger_post_ms=trigger_post_ms,
|
|
device_serial=device_serial,
|
|
)
|
|
|
|
status_code = 200 if result.get('status') != 'error' else 409
|
|
return jsonify(result), status_code
|
|
|
|
|
|
@subghz_bp.route('/receive/stop', methods=['POST'])
|
|
def stop_receive():
|
|
manager = get_subghz_manager()
|
|
result = manager.stop_receive()
|
|
return jsonify(result)
|
|
|
|
|
|
# ------------------------------------------------------------------
|
|
# DECODE
|
|
# ------------------------------------------------------------------
|
|
|
|
@subghz_bp.route('/decode/start', methods=['POST'])
|
|
def start_decode():
|
|
data = request.get_json(silent=True) or {}
|
|
|
|
freq_hz, err = _validate_frequency_hz(data)
|
|
if err:
|
|
return jsonify({'status': 'error', 'message': err}), 400
|
|
|
|
sample_rate = _validate_int(data, 'sample_rate', 2000000, 2000000, 20000000)
|
|
lna_gain = _validate_int(data, 'lna_gain', 32, 0, SUBGHZ_LNA_GAIN_MAX)
|
|
vga_gain = _validate_int(data, 'vga_gain', 20, 0, SUBGHZ_VGA_GAIN_MAX)
|
|
decode_profile = _validate_decode_profile(data)
|
|
device_serial = _validate_serial(data)
|
|
|
|
manager = get_subghz_manager()
|
|
manager.set_callback(_event_callback)
|
|
|
|
result = manager.start_decode(
|
|
frequency_hz=freq_hz,
|
|
sample_rate=sample_rate,
|
|
lna_gain=lna_gain,
|
|
vga_gain=vga_gain,
|
|
decode_profile=decode_profile,
|
|
device_serial=device_serial,
|
|
)
|
|
|
|
status_code = 200 if result.get('status') != 'error' else 409
|
|
return jsonify(result), status_code
|
|
|
|
|
|
@subghz_bp.route('/decode/stop', methods=['POST'])
|
|
def stop_decode():
|
|
manager = get_subghz_manager()
|
|
result = manager.stop_decode()
|
|
return jsonify(result)
|
|
|
|
|
|
# ------------------------------------------------------------------
|
|
# TRANSMIT
|
|
# ------------------------------------------------------------------
|
|
|
|
@subghz_bp.route('/transmit', methods=['POST'])
|
|
def start_transmit():
|
|
data = request.get_json(silent=True) or {}
|
|
|
|
capture_id = data.get('capture_id')
|
|
if not capture_id or not isinstance(capture_id, str):
|
|
return jsonify({'status': 'error', 'message': 'capture_id is required'}), 400
|
|
|
|
# Sanitize capture_id
|
|
if not capture_id.isalnum():
|
|
return jsonify({'status': 'error', 'message': 'Invalid capture_id'}), 400
|
|
|
|
tx_gain = _validate_int(data, 'tx_gain', 20, 0, SUBGHZ_TX_VGA_GAIN_MAX)
|
|
max_duration = _validate_int(data, 'max_duration', 10, 1, SUBGHZ_TX_MAX_DURATION)
|
|
start_seconds, start_err = _validate_optional_float(data, 'start_seconds')
|
|
if start_err:
|
|
return jsonify({'status': 'error', 'message': start_err}), 400
|
|
duration_seconds, duration_err = _validate_optional_float(data, 'duration_seconds')
|
|
if duration_err:
|
|
return jsonify({'status': 'error', 'message': duration_err}), 400
|
|
device_serial = _validate_serial(data)
|
|
|
|
manager = get_subghz_manager()
|
|
manager.set_callback(_event_callback)
|
|
|
|
result = manager.transmit(
|
|
capture_id=capture_id,
|
|
tx_gain=tx_gain,
|
|
max_duration=max_duration,
|
|
start_seconds=start_seconds,
|
|
duration_seconds=duration_seconds,
|
|
device_serial=device_serial,
|
|
)
|
|
|
|
status_code = 200 if result.get('status') != 'error' else 400
|
|
return jsonify(result), status_code
|
|
|
|
|
|
@subghz_bp.route('/transmit/stop', methods=['POST'])
|
|
def stop_transmit():
|
|
manager = get_subghz_manager()
|
|
result = manager.stop_transmit()
|
|
return jsonify(result)
|
|
|
|
|
|
# ------------------------------------------------------------------
|
|
# SWEEP
|
|
# ------------------------------------------------------------------
|
|
|
|
@subghz_bp.route('/sweep/start', methods=['POST'])
|
|
def start_sweep():
|
|
data = request.get_json(silent=True) or {}
|
|
|
|
try:
|
|
freq_start = float(data.get('freq_start_mhz', 300))
|
|
freq_end = float(data.get('freq_end_mhz', 928))
|
|
if freq_start >= freq_end:
|
|
return jsonify({'status': 'error', 'message': 'freq_start must be less than freq_end'}), 400
|
|
if freq_start < SUBGHZ_FREQ_MIN_MHZ or freq_end > SUBGHZ_FREQ_MAX_MHZ:
|
|
return jsonify({'status': 'error', 'message': f'Frequency range: {SUBGHZ_FREQ_MIN_MHZ}-{SUBGHZ_FREQ_MAX_MHZ} MHz'}), 400
|
|
except (ValueError, TypeError):
|
|
return jsonify({'status': 'error', 'message': 'Invalid frequency range'}), 400
|
|
|
|
bin_width = _validate_int(data, 'bin_width', 100000, 10000, 5000000)
|
|
device_serial = _validate_serial(data)
|
|
|
|
manager = get_subghz_manager()
|
|
manager.set_callback(_event_callback)
|
|
|
|
result = manager.start_sweep(
|
|
freq_start_mhz=freq_start,
|
|
freq_end_mhz=freq_end,
|
|
bin_width=bin_width,
|
|
device_serial=device_serial,
|
|
)
|
|
|
|
status_code = 200 if result.get('status') != 'error' else 409
|
|
return jsonify(result), status_code
|
|
|
|
|
|
@subghz_bp.route('/sweep/stop', methods=['POST'])
|
|
def stop_sweep():
|
|
manager = get_subghz_manager()
|
|
result = manager.stop_sweep()
|
|
return jsonify(result)
|
|
|
|
|
|
# ------------------------------------------------------------------
|
|
# CAPTURES LIBRARY
|
|
# ------------------------------------------------------------------
|
|
|
|
@subghz_bp.route('/captures')
|
|
def list_captures():
|
|
manager = get_subghz_manager()
|
|
captures = manager.list_captures()
|
|
return jsonify({
|
|
'status': 'ok',
|
|
'captures': [c.to_dict() for c in captures],
|
|
'count': len(captures),
|
|
})
|
|
|
|
|
|
@subghz_bp.route('/captures/<capture_id>')
|
|
def get_capture(capture_id: str):
|
|
if not capture_id.isalnum():
|
|
return jsonify({'status': 'error', 'message': 'Invalid capture_id'}), 400
|
|
|
|
manager = get_subghz_manager()
|
|
capture = manager.get_capture(capture_id)
|
|
if not capture:
|
|
return jsonify({'status': 'error', 'message': 'Capture not found'}), 404
|
|
|
|
return jsonify({'status': 'ok', 'capture': capture.to_dict()})
|
|
|
|
|
|
@subghz_bp.route('/captures/<capture_id>/download')
|
|
def download_capture(capture_id: str):
|
|
if not capture_id.isalnum():
|
|
return jsonify({'status': 'error', 'message': 'Invalid capture_id'}), 400
|
|
|
|
manager = get_subghz_manager()
|
|
path = manager.get_capture_path(capture_id)
|
|
if not path:
|
|
return jsonify({'status': 'error', 'message': 'Capture not found'}), 404
|
|
|
|
return send_file(
|
|
path,
|
|
mimetype='application/octet-stream',
|
|
as_attachment=True,
|
|
download_name=path.name,
|
|
)
|
|
|
|
|
|
@subghz_bp.route('/captures/<capture_id>/trim', methods=['POST'])
|
|
def trim_capture(capture_id: str):
|
|
if not capture_id.isalnum():
|
|
return jsonify({'status': 'error', 'message': 'Invalid capture_id'}), 400
|
|
|
|
data = request.get_json(silent=True) or {}
|
|
start_seconds, start_err = _validate_optional_float(data, 'start_seconds')
|
|
if start_err:
|
|
return jsonify({'status': 'error', 'message': start_err}), 400
|
|
duration_seconds, duration_err = _validate_optional_float(data, 'duration_seconds')
|
|
if duration_err:
|
|
return jsonify({'status': 'error', 'message': duration_err}), 400
|
|
|
|
label = data.get('label', '')
|
|
if label is None:
|
|
label = ''
|
|
if not isinstance(label, str) or len(label) > 100:
|
|
return jsonify({'status': 'error', 'message': 'Label must be a string (max 100 chars)'}), 400
|
|
|
|
manager = get_subghz_manager()
|
|
result = manager.trim_capture(
|
|
capture_id=capture_id,
|
|
start_seconds=start_seconds,
|
|
duration_seconds=duration_seconds,
|
|
label=label,
|
|
)
|
|
|
|
if result.get('status') == 'ok':
|
|
return jsonify(result), 200
|
|
message = str(result.get('message') or 'Trim failed')
|
|
status_code = 404 if 'not found' in message.lower() else 400
|
|
return jsonify(result), status_code
|
|
|
|
|
|
@subghz_bp.route('/captures/<capture_id>', methods=['DELETE'])
|
|
def delete_capture(capture_id: str):
|
|
if not capture_id.isalnum():
|
|
return jsonify({'status': 'error', 'message': 'Invalid capture_id'}), 400
|
|
|
|
manager = get_subghz_manager()
|
|
if manager.delete_capture(capture_id):
|
|
return jsonify({'status': 'deleted', 'id': capture_id})
|
|
return jsonify({'status': 'error', 'message': 'Capture not found'}), 404
|
|
|
|
|
|
@subghz_bp.route('/captures/<capture_id>', methods=['PATCH'])
|
|
def update_capture(capture_id: str):
|
|
if not capture_id.isalnum():
|
|
return jsonify({'status': 'error', 'message': 'Invalid capture_id'}), 400
|
|
|
|
data = request.get_json(silent=True) or {}
|
|
label = data.get('label', '')
|
|
|
|
if not isinstance(label, str) or len(label) > 100:
|
|
return jsonify({'status': 'error', 'message': 'Label must be a string (max 100 chars)'}), 400
|
|
|
|
manager = get_subghz_manager()
|
|
if manager.update_capture_label(capture_id, label):
|
|
return jsonify({'status': 'updated', 'id': capture_id, 'label': label})
|
|
return jsonify({'status': 'error', 'message': 'Capture not found'}), 404
|
|
|
|
|
|
# ------------------------------------------------------------------
|
|
# SSE STREAM
|
|
# ------------------------------------------------------------------
|
|
|
|
@subghz_bp.route('/stream')
|
|
def stream():
|
|
response = Response(sse_stream(_subghz_queue), mimetype='text/event-stream')
|
|
response.headers['Cache-Control'] = 'no-cache'
|
|
response.headers['X-Accel-Buffering'] = 'no'
|
|
response.headers['Connection'] = 'keep-alive'
|
|
return response
|