mirror of
https://github.com/smittix/intercept.git
synced 2026-07-06 16:48:12 -07:00
96172ca593
First-time run of ruff-format via pre-commit hook normalises quote style, trailing commas, and whitespace across 188 Python files. No logic changes. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
574 lines
22 KiB
Python
574 lines
22 KiB
Python
"""Pager decoding routes (POCSAG/FLEX)."""
|
||
|
||
from __future__ import annotations
|
||
|
||
import contextlib
|
||
import math
|
||
import os
|
||
import pathlib
|
||
import pty
|
||
import queue
|
||
import re
|
||
import select
|
||
import struct
|
||
import subprocess
|
||
import threading
|
||
import time
|
||
from datetime import datetime
|
||
from typing import Any
|
||
|
||
from flask import Blueprint, Response, jsonify, request
|
||
|
||
import app as app_module
|
||
from utils.dependencies import get_tool_path
|
||
from utils.event_pipeline import process_event
|
||
from utils.logging import pager_logger as logger
|
||
from utils.process import register_process, unregister_process
|
||
from utils.responses import api_error
|
||
from utils.sdr import SDRFactory, SDRType
|
||
from utils.sse import sse_stream_fanout
|
||
from utils.validation import (
|
||
validate_device_index,
|
||
validate_frequency,
|
||
validate_gain,
|
||
validate_ppm,
|
||
validate_rtl_tcp_host,
|
||
validate_rtl_tcp_port,
|
||
)
|
||
|
||
pager_bp = Blueprint("pager", __name__)
|
||
|
||
# Track which device is being used
|
||
pager_active_device: int | None = None
|
||
pager_active_sdr_type: str | None = None
|
||
|
||
|
||
def parse_multimon_output(line: str) -> dict[str, str] | None:
|
||
"""Parse multimon-ng output line."""
|
||
line = line.strip()
|
||
|
||
# POCSAG parsing - with message content
|
||
pocsag_match = re.match(r"(POCSAG\d+):\s*Address:\s*(\d+)\s+Function:\s*(\d+)\s+(Alpha|Numeric):\s*(.*)", line)
|
||
if pocsag_match:
|
||
return {
|
||
"protocol": pocsag_match.group(1),
|
||
"address": pocsag_match.group(2),
|
||
"function": pocsag_match.group(3),
|
||
"msg_type": pocsag_match.group(4),
|
||
"message": pocsag_match.group(5).strip() or "[No Message]",
|
||
}
|
||
|
||
# POCSAG parsing - other content types (catch-all for non-Alpha/Numeric labels)
|
||
pocsag_other_match = re.match(r"(POCSAG\d+):\s*Address:\s*(\d+)\s+Function:\s*(\d+)\s+(\w+):\s*(.*)", line)
|
||
if pocsag_other_match:
|
||
return {
|
||
"protocol": pocsag_other_match.group(1),
|
||
"address": pocsag_other_match.group(2),
|
||
"function": pocsag_other_match.group(3),
|
||
"msg_type": pocsag_other_match.group(4),
|
||
"message": pocsag_other_match.group(5).strip() or "[No Message]",
|
||
}
|
||
|
||
# POCSAG parsing - address only (no message content)
|
||
pocsag_addr_match = re.match(r"(POCSAG\d+):\s*Address:\s*(\d+)\s+Function:\s*(\d+)\s*$", line)
|
||
if pocsag_addr_match:
|
||
return {
|
||
"protocol": pocsag_addr_match.group(1),
|
||
"address": pocsag_addr_match.group(2),
|
||
"function": pocsag_addr_match.group(3),
|
||
"msg_type": "Tone",
|
||
"message": "[Tone Only]",
|
||
}
|
||
|
||
# FLEX parsing (standard format)
|
||
flex_match = re.match(
|
||
r"FLEX[:\|]\s*[\d\-]+[\s\|]+[\d:]+[\s\|]+([\d/A-Z]+)[\s\|]+([\d.]+)[\s\|]+\[?(\d+)\]?[\s\|]+(\w+)[\s\|]+(.*)",
|
||
line,
|
||
)
|
||
if flex_match:
|
||
return {
|
||
"protocol": "FLEX",
|
||
"address": flex_match.group(3),
|
||
"function": flex_match.group(1),
|
||
"msg_type": flex_match.group(4),
|
||
"message": flex_match.group(5).strip() or "[No Message]",
|
||
}
|
||
|
||
# Simple FLEX format
|
||
flex_simple = re.match(r"FLEX:\s*(.+)", line)
|
||
if flex_simple:
|
||
return {
|
||
"protocol": "FLEX",
|
||
"address": "Unknown",
|
||
"function": "",
|
||
"msg_type": "Unknown",
|
||
"message": flex_simple.group(1).strip(),
|
||
}
|
||
|
||
return None
|
||
|
||
|
||
def log_message(msg: dict[str, Any]) -> None:
|
||
"""Log a message to file if logging is enabled."""
|
||
if not app_module.logging_enabled:
|
||
return
|
||
try:
|
||
with open(app_module.log_file_path, "a") as f:
|
||
timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
||
f.write(
|
||
f"{timestamp} | {msg.get('protocol', 'UNKNOWN')} | {msg.get('address', '')} | {msg.get('message', '')}\n"
|
||
)
|
||
except Exception as e:
|
||
logger.error(f"Failed to log message: {e}")
|
||
|
||
|
||
def _encode_scope_waveform(samples: tuple[int, ...], window_size: int = 256) -> list[int]:
|
||
"""Compress recent PCM samples into a signed 8-bit waveform for SSE."""
|
||
if not samples:
|
||
return []
|
||
|
||
window = samples[-window_size:] if len(samples) > window_size else samples
|
||
waveform: list[int] = []
|
||
for sample in window:
|
||
# Convert int16 PCM to int8 range for lightweight transport.
|
||
packed = int(round(sample / 256))
|
||
waveform.append(max(-127, min(127, packed)))
|
||
return waveform
|
||
|
||
|
||
def audio_relay_thread(
|
||
rtl_stdout,
|
||
multimon_stdin,
|
||
output_queue: queue.Queue,
|
||
stop_event: threading.Event,
|
||
) -> None:
|
||
"""Relay audio from rtl_fm to multimon-ng while computing signal levels.
|
||
|
||
Reads raw 16-bit LE PCM from *rtl_stdout*, writes every chunk straight
|
||
through to *multimon_stdin*, and every ~100 ms pushes an RMS / peak scope
|
||
event plus a compact waveform sample onto *output_queue*.
|
||
"""
|
||
CHUNK = 4096 # bytes – 2048 samples at 16-bit mono
|
||
INTERVAL = 0.1 # seconds between scope updates
|
||
last_scope = time.monotonic()
|
||
|
||
try:
|
||
while not stop_event.is_set():
|
||
data = rtl_stdout.read(CHUNK)
|
||
if not data:
|
||
break
|
||
|
||
# Forward audio untouched
|
||
try:
|
||
multimon_stdin.write(data)
|
||
multimon_stdin.flush()
|
||
except (BrokenPipeError, OSError):
|
||
break
|
||
|
||
# Compute scope levels every ~100 ms
|
||
now = time.monotonic()
|
||
if now - last_scope >= INTERVAL:
|
||
last_scope = now
|
||
try:
|
||
n_samples = len(data) // 2
|
||
if n_samples == 0:
|
||
continue
|
||
samples = struct.unpack(f"<{n_samples}h", data[: n_samples * 2])
|
||
peak = max(abs(s) for s in samples)
|
||
rms = int(math.sqrt(sum(s * s for s in samples) / n_samples))
|
||
output_queue.put_nowait(
|
||
{
|
||
"type": "scope",
|
||
"rms": rms,
|
||
"peak": peak,
|
||
"waveform": _encode_scope_waveform(samples),
|
||
}
|
||
)
|
||
except (struct.error, ValueError, queue.Full):
|
||
pass
|
||
except Exception as e:
|
||
logger.debug(f"Audio relay error: {e}")
|
||
finally:
|
||
with contextlib.suppress(OSError):
|
||
multimon_stdin.close()
|
||
|
||
|
||
def stream_decoder(master_fd: int, process: subprocess.Popen[bytes]) -> None:
|
||
"""Stream decoder output to queue using PTY for unbuffered output."""
|
||
try:
|
||
app_module.output_queue.put({"type": "status", "text": "started"})
|
||
|
||
buffer = ""
|
||
while True:
|
||
try:
|
||
ready, _, _ = select.select([master_fd], [], [], 1.0)
|
||
except Exception:
|
||
break
|
||
|
||
if ready:
|
||
try:
|
||
data = os.read(master_fd, 1024)
|
||
if not data:
|
||
break
|
||
buffer += data.decode("utf-8", errors="replace")
|
||
|
||
while "\n" in buffer:
|
||
line, buffer = buffer.split("\n", 1)
|
||
line = line.strip()
|
||
if not line:
|
||
continue
|
||
|
||
parsed = parse_multimon_output(line)
|
||
if parsed:
|
||
parsed["timestamp"] = datetime.now().strftime("%H:%M:%S")
|
||
app_module.output_queue.put({"type": "message", **parsed})
|
||
log_message(parsed)
|
||
else:
|
||
app_module.output_queue.put({"type": "raw", "text": line})
|
||
except OSError:
|
||
break
|
||
|
||
if process.poll() is not None:
|
||
break
|
||
|
||
except Exception as e:
|
||
app_module.output_queue.put({"type": "error", "text": str(e)})
|
||
finally:
|
||
global pager_active_device, pager_active_sdr_type
|
||
with contextlib.suppress(OSError):
|
||
os.close(master_fd)
|
||
# Signal relay thread to stop
|
||
with app_module.process_lock:
|
||
stop_relay = getattr(app_module.current_process, "_stop_relay", None)
|
||
if stop_relay:
|
||
stop_relay.set()
|
||
# Cleanup companion rtl_fm process and decoder
|
||
with app_module.process_lock:
|
||
rtl_proc = getattr(app_module.current_process, "_rtl_process", None)
|
||
for proc in [rtl_proc, process]:
|
||
if proc:
|
||
try:
|
||
proc.terminate()
|
||
proc.wait(timeout=2)
|
||
except Exception:
|
||
with contextlib.suppress(Exception):
|
||
proc.kill()
|
||
unregister_process(proc)
|
||
app_module.output_queue.put({"type": "status", "text": "stopped"})
|
||
with app_module.process_lock:
|
||
app_module.current_process = None
|
||
# Release SDR device
|
||
if pager_active_device is not None:
|
||
app_module.release_sdr_device(pager_active_device, pager_active_sdr_type or "rtlsdr")
|
||
pager_active_device = None
|
||
pager_active_sdr_type = None
|
||
|
||
|
||
@pager_bp.route("/start", methods=["POST"])
|
||
def start_decoding() -> Response:
|
||
global pager_active_device, pager_active_sdr_type
|
||
|
||
with app_module.process_lock:
|
||
if app_module.current_process:
|
||
return api_error("Already running", 409)
|
||
|
||
data = request.json or {}
|
||
|
||
# Validate inputs
|
||
try:
|
||
freq = validate_frequency(data.get("frequency", "929.6125"))
|
||
gain = validate_gain(data.get("gain", "0"))
|
||
ppm = validate_ppm(data.get("ppm", "0"))
|
||
device = validate_device_index(data.get("device", "0"))
|
||
except ValueError as e:
|
||
return api_error(str(e), 400)
|
||
|
||
squelch = data.get("squelch", "0")
|
||
try:
|
||
squelch = int(squelch)
|
||
if not 0 <= squelch <= 1000:
|
||
raise ValueError("Squelch must be between 0 and 1000")
|
||
except (ValueError, TypeError):
|
||
return api_error("Invalid squelch value", 400)
|
||
|
||
# Check for rtl_tcp (remote SDR) connection
|
||
rtl_tcp_host = data.get("rtl_tcp_host")
|
||
rtl_tcp_port = data.get("rtl_tcp_port", 1234)
|
||
|
||
# Get SDR type early so we can pass it to claim/release
|
||
sdr_type_str = data.get("sdr_type", "rtlsdr")
|
||
|
||
# Claim local device if not using remote rtl_tcp
|
||
if not rtl_tcp_host:
|
||
device_int = int(device)
|
||
error = app_module.claim_sdr_device(device_int, "pager", sdr_type_str)
|
||
if error:
|
||
return api_error(error, 409, error_type="DEVICE_BUSY")
|
||
pager_active_device = device_int
|
||
pager_active_sdr_type = sdr_type_str
|
||
|
||
# Validate protocols
|
||
valid_protocols = ["POCSAG512", "POCSAG1200", "POCSAG2400", "FLEX"]
|
||
protocols = data.get("protocols", valid_protocols)
|
||
if not isinstance(protocols, list):
|
||
if pager_active_device is not None:
|
||
app_module.release_sdr_device(pager_active_device, pager_active_sdr_type or "rtlsdr")
|
||
pager_active_device = None
|
||
pager_active_sdr_type = None
|
||
return api_error("Protocols must be a list", 400)
|
||
protocols = [p for p in protocols if p in valid_protocols]
|
||
if not protocols:
|
||
protocols = valid_protocols
|
||
|
||
# Clear queue
|
||
while not app_module.output_queue.empty():
|
||
try:
|
||
app_module.output_queue.get_nowait()
|
||
except queue.Empty:
|
||
break
|
||
|
||
# Build multimon-ng decoder arguments
|
||
decoders = []
|
||
for proto in protocols:
|
||
if proto == "POCSAG512":
|
||
decoders.extend(["-a", "POCSAG512"])
|
||
elif proto == "POCSAG1200":
|
||
decoders.extend(["-a", "POCSAG1200"])
|
||
elif proto == "POCSAG2400":
|
||
decoders.extend(["-a", "POCSAG2400"])
|
||
elif proto == "FLEX":
|
||
decoders.extend(["-a", "FLEX"])
|
||
|
||
# Build command via SDR abstraction layer
|
||
try:
|
||
sdr_type = SDRType(sdr_type_str)
|
||
except ValueError:
|
||
sdr_type = SDRType.RTL_SDR
|
||
|
||
if rtl_tcp_host:
|
||
# Validate and create network device
|
||
try:
|
||
rtl_tcp_host = validate_rtl_tcp_host(rtl_tcp_host)
|
||
rtl_tcp_port = validate_rtl_tcp_port(rtl_tcp_port)
|
||
except ValueError as e:
|
||
return api_error(str(e), 400)
|
||
|
||
sdr_device = SDRFactory.create_network_device(rtl_tcp_host, rtl_tcp_port)
|
||
logger.info(f"Using remote SDR: rtl_tcp://{rtl_tcp_host}:{rtl_tcp_port}")
|
||
else:
|
||
# Create local device object
|
||
sdr_device = SDRFactory.create_default_device(sdr_type, index=device)
|
||
|
||
builder = SDRFactory.get_builder(sdr_device.sdr_type)
|
||
|
||
# Build FM demodulation command
|
||
bias_t = data.get("bias_t", False)
|
||
rtl_cmd = builder.build_fm_demod_command(
|
||
device=sdr_device,
|
||
frequency_mhz=freq,
|
||
sample_rate=22050,
|
||
gain=float(gain) if gain and gain != "0" else None,
|
||
ppm=int(ppm) if ppm and ppm != "0" else None,
|
||
modulation="fm",
|
||
squelch=squelch if squelch and squelch != 0 else None,
|
||
bias_t=bias_t,
|
||
)
|
||
|
||
multimon_path = get_tool_path("multimon-ng")
|
||
if not multimon_path:
|
||
return api_error("multimon-ng not found", 400)
|
||
multimon_cmd = [multimon_path, "-t", "raw"] + decoders + ["-f", "alpha", "-"]
|
||
|
||
full_cmd = " ".join(rtl_cmd) + " | " + " ".join(multimon_cmd)
|
||
logger.info(f"Running: {full_cmd}")
|
||
|
||
try:
|
||
# Create pipe: rtl_fm | multimon-ng
|
||
rtl_process = subprocess.Popen(rtl_cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
|
||
register_process(rtl_process)
|
||
|
||
# Start a thread to monitor rtl_fm stderr for errors
|
||
def monitor_rtl_stderr():
|
||
for line in rtl_process.stderr:
|
||
err_text = line.decode("utf-8", errors="replace").strip()
|
||
if err_text:
|
||
logger.debug(f"[RTL_FM] {err_text}")
|
||
app_module.output_queue.put({"type": "raw", "text": f"[rtl_fm] {err_text}"})
|
||
|
||
rtl_stderr_thread = threading.Thread(target=monitor_rtl_stderr)
|
||
rtl_stderr_thread.daemon = True
|
||
rtl_stderr_thread.start()
|
||
|
||
# Create a pseudo-terminal for multimon-ng output
|
||
master_fd, slave_fd = pty.openpty()
|
||
|
||
multimon_process = subprocess.Popen(
|
||
multimon_cmd, stdin=subprocess.PIPE, stdout=slave_fd, stderr=slave_fd, close_fds=True
|
||
)
|
||
register_process(multimon_process)
|
||
|
||
os.close(slave_fd)
|
||
|
||
# Spawn audio relay thread between rtl_fm and multimon-ng
|
||
stop_relay = threading.Event()
|
||
relay = threading.Thread(
|
||
target=audio_relay_thread,
|
||
args=(rtl_process.stdout, multimon_process.stdin, app_module.output_queue, stop_relay),
|
||
)
|
||
relay.daemon = True
|
||
relay.start()
|
||
|
||
app_module.current_process = multimon_process
|
||
app_module.current_process._rtl_process = rtl_process
|
||
app_module.current_process._master_fd = master_fd
|
||
app_module.current_process._stop_relay = stop_relay
|
||
app_module.current_process._relay_thread = relay
|
||
|
||
# Start output thread with PTY master fd
|
||
thread = threading.Thread(target=stream_decoder, args=(master_fd, multimon_process))
|
||
thread.daemon = True
|
||
thread.start()
|
||
|
||
app_module.output_queue.put({"type": "info", "text": f"Command: {full_cmd}"})
|
||
|
||
return jsonify({"status": "started", "command": full_cmd})
|
||
|
||
except FileNotFoundError as e:
|
||
# Kill orphaned rtl_fm process
|
||
try:
|
||
rtl_process.terminate()
|
||
rtl_process.wait(timeout=2)
|
||
except Exception:
|
||
with contextlib.suppress(Exception):
|
||
rtl_process.kill()
|
||
# Release device on failure
|
||
if pager_active_device is not None:
|
||
app_module.release_sdr_device(pager_active_device, pager_active_sdr_type or "rtlsdr")
|
||
pager_active_device = None
|
||
pager_active_sdr_type = None
|
||
return api_error(f"Tool not found: {e.filename}")
|
||
except Exception as e:
|
||
# Kill orphaned rtl_fm process if it was started
|
||
try:
|
||
rtl_process.terminate()
|
||
rtl_process.wait(timeout=2)
|
||
except Exception:
|
||
with contextlib.suppress(Exception):
|
||
rtl_process.kill()
|
||
# Release device on failure
|
||
if pager_active_device is not None:
|
||
app_module.release_sdr_device(pager_active_device, pager_active_sdr_type or "rtlsdr")
|
||
pager_active_device = None
|
||
pager_active_sdr_type = None
|
||
return api_error(str(e))
|
||
|
||
|
||
@pager_bp.route("/stop", methods=["POST"])
|
||
def stop_decoding() -> Response:
|
||
global pager_active_device, pager_active_sdr_type
|
||
|
||
with app_module.process_lock:
|
||
if app_module.current_process:
|
||
# Signal audio relay thread to stop
|
||
if hasattr(app_module.current_process, "_stop_relay"):
|
||
app_module.current_process._stop_relay.set()
|
||
|
||
# Kill rtl_fm process first
|
||
if hasattr(app_module.current_process, "_rtl_process"):
|
||
try:
|
||
app_module.current_process._rtl_process.terminate()
|
||
app_module.current_process._rtl_process.wait(timeout=2)
|
||
except (subprocess.TimeoutExpired, OSError):
|
||
with contextlib.suppress(OSError):
|
||
app_module.current_process._rtl_process.kill()
|
||
|
||
# Close PTY master fd
|
||
if hasattr(app_module.current_process, "_master_fd"):
|
||
with contextlib.suppress(OSError):
|
||
os.close(app_module.current_process._master_fd)
|
||
|
||
# Kill multimon-ng
|
||
app_module.current_process.terminate()
|
||
try:
|
||
app_module.current_process.wait(timeout=2)
|
||
except subprocess.TimeoutExpired:
|
||
app_module.current_process.kill()
|
||
|
||
app_module.current_process = None
|
||
|
||
# Release device from registry
|
||
if pager_active_device is not None:
|
||
app_module.release_sdr_device(pager_active_device, pager_active_sdr_type or "rtlsdr")
|
||
pager_active_device = None
|
||
pager_active_sdr_type = None
|
||
|
||
return jsonify({"status": "stopped"})
|
||
|
||
return jsonify({"status": "not_running"})
|
||
|
||
|
||
@pager_bp.route("/status")
|
||
def get_status() -> Response:
|
||
"""Check if decoder is currently running."""
|
||
with app_module.process_lock:
|
||
if app_module.current_process and app_module.current_process.poll() is None:
|
||
return jsonify(
|
||
{"running": True, "logging": app_module.logging_enabled, "log_file": app_module.log_file_path}
|
||
)
|
||
return jsonify({"running": False, "logging": app_module.logging_enabled, "log_file": app_module.log_file_path})
|
||
|
||
|
||
@pager_bp.route("/logging", methods=["POST"])
|
||
def toggle_logging() -> Response:
|
||
"""Toggle message logging."""
|
||
data = request.json or {}
|
||
if "enabled" in data:
|
||
app_module.logging_enabled = bool(data["enabled"])
|
||
|
||
if "log_file" in data and data["log_file"]:
|
||
# Validate path to prevent directory traversal
|
||
try:
|
||
requested_path = pathlib.Path(data["log_file"]).resolve()
|
||
# Only allow files in the current directory or logs subdirectory
|
||
cwd = pathlib.Path(".").resolve()
|
||
logs_dir = (cwd / "logs").resolve()
|
||
|
||
# Check if path is within allowed directories
|
||
is_in_cwd = str(requested_path).startswith(str(cwd))
|
||
is_in_logs = str(requested_path).startswith(str(logs_dir))
|
||
|
||
if not (is_in_cwd or is_in_logs):
|
||
return api_error("Invalid log file path", 400)
|
||
|
||
# Ensure it's not a directory
|
||
if requested_path.is_dir():
|
||
return api_error("Log file path must be a file, not a directory", 400)
|
||
|
||
app_module.log_file_path = str(requested_path)
|
||
except (ValueError, OSError) as e:
|
||
logger.warning(f"Invalid log file path: {e}")
|
||
return api_error("Invalid log file path", 400)
|
||
|
||
return jsonify({"logging": app_module.logging_enabled, "log_file": app_module.log_file_path})
|
||
|
||
|
||
@pager_bp.route("/stream")
|
||
def stream() -> Response:
|
||
def _on_msg(msg: dict[str, Any]) -> None:
|
||
process_event("pager", msg, msg.get("type"))
|
||
|
||
response = Response(
|
||
sse_stream_fanout(
|
||
source_queue=app_module.output_queue,
|
||
channel_key="pager",
|
||
timeout=1.0,
|
||
keepalive_interval=30.0,
|
||
on_message=_on_msg,
|
||
),
|
||
mimetype="text/event-stream",
|
||
)
|
||
response.headers["Cache-Control"] = "no-cache"
|
||
response.headers["X-Accel-Buffering"] = "no"
|
||
response.headers["Connection"] = "keep-alive"
|
||
return response
|