mirror of
https://github.com/smittix/intercept.git
synced 2026-07-07 09:08: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>
459 lines
14 KiB
Python
459 lines
14 KiB
Python
"""Signal identification enrichment routes (SigID Wiki proxy lookup)."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import time
|
|
import urllib.parse
|
|
import urllib.request
|
|
from typing import Any
|
|
|
|
from flask import Blueprint, Response, jsonify, request
|
|
|
|
import config
|
|
from utils.logging import get_logger
|
|
from utils.responses import api_error
|
|
from utils.signal_db import match_signals
|
|
|
|
logger = get_logger("intercept.signalid")
|
|
|
|
signalid_bp = Blueprint("signalid", __name__, url_prefix="/signalid")
|
|
|
|
SIGID_API_URL = "https://www.sigidwiki.com/api.php"
|
|
SIGID_USER_AGENT = "INTERCEPT-SignalID/1.0"
|
|
SIGID_TIMEOUT_SECONDS = 12
|
|
SIGID_CACHE_TTL_SECONDS = 600
|
|
|
|
_cache: dict[str, dict[str, Any]] = {}
|
|
|
|
|
|
def _cache_get(key: str) -> Any | None:
|
|
entry = _cache.get(key)
|
|
if not entry:
|
|
return None
|
|
if time.time() >= entry["expires"]:
|
|
_cache.pop(key, None)
|
|
return None
|
|
return entry["data"]
|
|
|
|
|
|
def _cache_set(key: str, data: Any, ttl_seconds: int = SIGID_CACHE_TTL_SECONDS) -> None:
|
|
_cache[key] = {
|
|
"data": data,
|
|
"expires": time.time() + ttl_seconds,
|
|
}
|
|
|
|
|
|
def _fetch_api_json(params: dict[str, str]) -> dict[str, Any] | None:
|
|
query = urllib.parse.urlencode(params, doseq=True)
|
|
url = f"{SIGID_API_URL}?{query}"
|
|
req = urllib.request.Request(url, headers={"User-Agent": SIGID_USER_AGENT})
|
|
try:
|
|
with urllib.request.urlopen(req, timeout=SIGID_TIMEOUT_SECONDS) as resp:
|
|
payload = resp.read().decode("utf-8", errors="replace")
|
|
data = json.loads(payload)
|
|
except Exception as exc:
|
|
logger.warning("SigID API request failed: %s", exc)
|
|
return None
|
|
if isinstance(data, dict) and data.get("error"):
|
|
logger.warning("SigID API returned error: %s", data.get("error"))
|
|
return None
|
|
return data if isinstance(data, dict) else None
|
|
|
|
|
|
def _ask_query(query: str) -> dict[str, Any] | None:
|
|
return _fetch_api_json(
|
|
{
|
|
"action": "ask",
|
|
"query": query,
|
|
"format": "json",
|
|
}
|
|
)
|
|
|
|
|
|
def _search_query(search_text: str, limit: int) -> dict[str, Any] | None:
|
|
return _fetch_api_json(
|
|
{
|
|
"action": "query",
|
|
"list": "search",
|
|
"srsearch": search_text,
|
|
"srlimit": str(limit),
|
|
"format": "json",
|
|
}
|
|
)
|
|
|
|
|
|
def _to_float_list(values: Any) -> list[float]:
|
|
if not isinstance(values, list):
|
|
return []
|
|
out: list[float] = []
|
|
for value in values:
|
|
try:
|
|
out.append(float(value))
|
|
except (TypeError, ValueError):
|
|
continue
|
|
return out
|
|
|
|
|
|
def _to_text_list(values: Any) -> list[str]:
|
|
if not isinstance(values, list):
|
|
return []
|
|
out: list[str] = []
|
|
for value in values:
|
|
text = str(value or "").strip()
|
|
if text:
|
|
out.append(text)
|
|
return out
|
|
|
|
|
|
def _normalize_modes(values: list[str]) -> list[str]:
|
|
out: list[str] = []
|
|
for value in values:
|
|
for token in str(value).replace("/", ",").split(","):
|
|
mode = token.strip().upper()
|
|
if mode and mode not in out:
|
|
out.append(mode)
|
|
return out
|
|
|
|
|
|
def _extract_matches_from_ask(data: dict[str, Any]) -> list[dict[str, Any]]:
|
|
results = data.get("query", {}).get("results", {})
|
|
if not isinstance(results, dict):
|
|
return []
|
|
|
|
matches: list[dict[str, Any]] = []
|
|
for title, entry in results.items():
|
|
if not isinstance(entry, dict):
|
|
continue
|
|
|
|
printouts = entry.get("printouts", {})
|
|
if not isinstance(printouts, dict):
|
|
printouts = {}
|
|
|
|
frequencies_hz = _to_float_list(printouts.get("Frequencies"))
|
|
frequencies_mhz = [round(v / 1e6, 6) for v in frequencies_hz if v > 0]
|
|
|
|
modes = _normalize_modes(_to_text_list(printouts.get("Mode")))
|
|
modulations = _normalize_modes(_to_text_list(printouts.get("Modulation")))
|
|
|
|
match = {
|
|
"title": str(entry.get("fulltext") or title),
|
|
"url": str(entry.get("fullurl") or ""),
|
|
"frequencies_mhz": frequencies_mhz,
|
|
"modes": modes,
|
|
"modulations": modulations,
|
|
"source": "SigID Wiki",
|
|
}
|
|
matches.append(match)
|
|
|
|
return matches
|
|
|
|
|
|
def _dedupe_matches(matches: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
|
deduped: dict[str, dict[str, Any]] = {}
|
|
for match in matches:
|
|
key = f"{match.get('title', '')}|{match.get('url', '')}"
|
|
if key not in deduped:
|
|
deduped[key] = match
|
|
continue
|
|
|
|
# Merge frequencies/modes/modulations from duplicates.
|
|
existing = deduped[key]
|
|
for field in ("frequencies_mhz", "modes", "modulations"):
|
|
base = existing.get(field, [])
|
|
extra = match.get(field, [])
|
|
if not isinstance(base, list):
|
|
base = []
|
|
if not isinstance(extra, list):
|
|
extra = []
|
|
merged = list(base)
|
|
for item in extra:
|
|
if item not in merged:
|
|
merged.append(item)
|
|
existing[field] = merged
|
|
return list(deduped.values())
|
|
|
|
|
|
def _rank_matches(
|
|
matches: list[dict[str, Any]],
|
|
*,
|
|
frequency_mhz: float,
|
|
modulation: str,
|
|
) -> list[dict[str, Any]]:
|
|
target_hz = frequency_mhz * 1e6
|
|
wanted_mod = str(modulation or "").strip().upper()
|
|
|
|
def score(match: dict[str, Any]) -> tuple[int, float, str]:
|
|
score_value = 0
|
|
freqs_mhz = match.get("frequencies_mhz") or []
|
|
distances_hz: list[float] = []
|
|
for f_mhz in freqs_mhz:
|
|
try:
|
|
distances_hz.append(abs((float(f_mhz) * 1e6) - target_hz))
|
|
except (TypeError, ValueError):
|
|
continue
|
|
min_distance_hz = min(distances_hz) if distances_hz else 1e12
|
|
|
|
if min_distance_hz <= 100:
|
|
score_value += 120
|
|
elif min_distance_hz <= 1_000:
|
|
score_value += 90
|
|
elif min_distance_hz <= 10_000:
|
|
score_value += 70
|
|
elif min_distance_hz <= 100_000:
|
|
score_value += 40
|
|
|
|
if wanted_mod:
|
|
modes = [str(v).upper() for v in (match.get("modes") or [])]
|
|
modulations = [str(v).upper() for v in (match.get("modulations") or [])]
|
|
if wanted_mod in modes:
|
|
score_value += 25
|
|
if wanted_mod in modulations:
|
|
score_value += 25
|
|
|
|
title = str(match.get("title") or "")
|
|
title_lower = title.lower()
|
|
if "unidentified" in title_lower or "unknown" in title_lower:
|
|
score_value -= 10
|
|
|
|
return (score_value, min_distance_hz, title.lower())
|
|
|
|
ranked = sorted(matches, key=score, reverse=True)
|
|
for match in ranked:
|
|
try:
|
|
nearest = min(abs((float(f) * 1e6) - target_hz) for f in (match.get("frequencies_mhz") or []))
|
|
match["distance_hz"] = int(round(nearest))
|
|
except Exception:
|
|
match["distance_hz"] = None
|
|
return ranked
|
|
|
|
|
|
def _format_freq_variants_mhz(freq_mhz: float) -> list[str]:
|
|
variants = [
|
|
f"{freq_mhz:.6f}".rstrip("0").rstrip("."),
|
|
f"{freq_mhz:.4f}".rstrip("0").rstrip("."),
|
|
f"{freq_mhz:.3f}".rstrip("0").rstrip("."),
|
|
]
|
|
out: list[str] = []
|
|
for value in variants:
|
|
if value and value not in out:
|
|
out.append(value)
|
|
return out
|
|
|
|
|
|
def _lookup_sigidwiki_matches(frequency_mhz: float, modulation: str, limit: int) -> dict[str, Any]:
|
|
all_matches: list[dict[str, Any]] = []
|
|
exact_queries: list[str] = []
|
|
|
|
for freq_token in _format_freq_variants_mhz(frequency_mhz):
|
|
query = (
|
|
f"[[Category:Signal]][[Frequencies::{freq_token} MHz]]"
|
|
f"|?Frequencies|?Mode|?Modulation|limit={max(10, limit * 2)}"
|
|
)
|
|
exact_queries.append(query)
|
|
data = _ask_query(query)
|
|
if data:
|
|
all_matches.extend(_extract_matches_from_ask(data))
|
|
if all_matches:
|
|
break
|
|
|
|
search_used = False
|
|
if not all_matches:
|
|
search_used = True
|
|
search_terms = [f"{frequency_mhz:.4f} MHz"]
|
|
if modulation:
|
|
search_terms.insert(0, f"{frequency_mhz:.4f} MHz {modulation.upper()}")
|
|
|
|
seen_titles: set[str] = set()
|
|
for term in search_terms:
|
|
search_data = _search_query(term, max(5, min(limit * 2, 10)))
|
|
search_results = search_data.get("query", {}).get("search", []) if isinstance(search_data, dict) else []
|
|
if not isinstance(search_results, list) or not search_results:
|
|
continue
|
|
|
|
for item in search_results:
|
|
title = str(item.get("title") or "").strip()
|
|
if not title or title in seen_titles:
|
|
continue
|
|
seen_titles.add(title)
|
|
page_query = f"[[{title}]]|?Frequencies|?Mode|?Modulation|limit=1"
|
|
page_data = _ask_query(page_query)
|
|
if page_data:
|
|
all_matches.extend(_extract_matches_from_ask(page_data))
|
|
if len(all_matches) >= max(limit * 3, 12):
|
|
break
|
|
if all_matches:
|
|
break
|
|
|
|
deduped = _dedupe_matches(all_matches)
|
|
ranked = _rank_matches(deduped, frequency_mhz=frequency_mhz, modulation=modulation)
|
|
return {
|
|
"matches": ranked[:limit],
|
|
"search_used": search_used,
|
|
"exact_queries": exact_queries,
|
|
}
|
|
|
|
|
|
@signalid_bp.route("/sigidwiki", methods=["POST"])
|
|
def sigidwiki_lookup() -> Response:
|
|
"""Lookup likely signal types from SigID Wiki by tuned frequency."""
|
|
payload = request.get_json(silent=True) or {}
|
|
|
|
freq_raw = payload.get("frequency_mhz")
|
|
if freq_raw is None:
|
|
return api_error("frequency_mhz is required", 400)
|
|
|
|
try:
|
|
frequency_mhz = float(freq_raw)
|
|
except (TypeError, ValueError):
|
|
return api_error("Invalid frequency_mhz", 400)
|
|
|
|
if frequency_mhz <= 0:
|
|
return api_error("frequency_mhz must be positive", 400)
|
|
|
|
modulation = str(payload.get("modulation") or "").strip().upper()
|
|
if modulation and len(modulation) > 16:
|
|
modulation = modulation[:16]
|
|
|
|
limit_raw = payload.get("limit", 8)
|
|
try:
|
|
limit = int(limit_raw)
|
|
except (TypeError, ValueError):
|
|
limit = 8
|
|
limit = max(1, min(limit, 20))
|
|
|
|
cache_key = f"{round(frequency_mhz, 6)}|{modulation}|{limit}"
|
|
cached = _cache_get(cache_key)
|
|
if cached is not None:
|
|
return jsonify(
|
|
{
|
|
"status": "ok",
|
|
"source": "sigidwiki",
|
|
"frequency_mhz": round(frequency_mhz, 6),
|
|
"modulation": modulation or None,
|
|
"cached": True,
|
|
**cached,
|
|
}
|
|
)
|
|
|
|
try:
|
|
lookup = _lookup_sigidwiki_matches(frequency_mhz, modulation, limit)
|
|
except Exception as exc:
|
|
logger.error("SigID lookup failed: %s", exc)
|
|
return api_error("SigID lookup failed", 502)
|
|
|
|
response_payload = {
|
|
"matches": lookup.get("matches", []),
|
|
"match_count": len(lookup.get("matches", [])),
|
|
"search_used": bool(lookup.get("search_used")),
|
|
"exact_queries": lookup.get("exact_queries", []),
|
|
}
|
|
_cache_set(cache_key, response_payload)
|
|
|
|
return jsonify(
|
|
{
|
|
"status": "ok",
|
|
"source": "sigidwiki",
|
|
"frequency_mhz": round(frequency_mhz, 6),
|
|
"modulation": modulation or None,
|
|
"cached": False,
|
|
**response_payload,
|
|
}
|
|
)
|
|
|
|
|
|
_match_cache: dict[str, dict[str, Any]] = {}
|
|
|
|
|
|
@signalid_bp.route("/match", methods=["POST"])
|
|
def signalid_match() -> Response:
|
|
"""Match a signal by frequency, bandwidth, and modulation against the local database."""
|
|
payload = request.get_json(silent=True) or {}
|
|
|
|
freq_raw = payload.get("frequency_mhz")
|
|
if freq_raw is None:
|
|
return api_error("frequency_mhz is required", 400)
|
|
try:
|
|
frequency_mhz = float(freq_raw)
|
|
except (TypeError, ValueError):
|
|
return api_error("Invalid frequency_mhz", 400)
|
|
if frequency_mhz <= 0:
|
|
return api_error("frequency_mhz must be positive", 400)
|
|
|
|
bw_raw = payload.get("bandwidth_hz")
|
|
bandwidth_hz: int | None = None
|
|
if bw_raw is not None:
|
|
try:
|
|
bandwidth_hz = int(float(bw_raw))
|
|
except (TypeError, ValueError):
|
|
return api_error("Invalid bandwidth_hz", 400)
|
|
if bandwidth_hz <= 0:
|
|
return api_error("bandwidth_hz must be positive", 400)
|
|
|
|
modulation = str(payload.get("modulation") or "").strip().upper()[:16] or None
|
|
|
|
limit_raw = payload.get("limit", 8)
|
|
try:
|
|
limit = max(1, min(int(limit_raw), 20))
|
|
except (TypeError, ValueError):
|
|
limit = 8
|
|
|
|
region = getattr(config, "REGION", "GLOBAL")
|
|
|
|
cache_key = f"{round(frequency_mhz, 6)}|{bandwidth_hz}|{modulation}|{limit}|{region}"
|
|
cached = _cache_get_match(cache_key)
|
|
if cached is not None:
|
|
return jsonify(
|
|
{
|
|
"status": "ok",
|
|
"frequency_mhz": round(frequency_mhz, 6),
|
|
"bandwidth_hz": bandwidth_hz,
|
|
"modulation": modulation,
|
|
"cached": True,
|
|
**cached,
|
|
}
|
|
)
|
|
|
|
try:
|
|
matches = match_signals(
|
|
frequency_mhz=frequency_mhz,
|
|
bandwidth_hz=bandwidth_hz,
|
|
modulation=modulation,
|
|
region=region,
|
|
limit=limit,
|
|
)
|
|
except Exception as exc:
|
|
logger.error("Signal match failed: %s", exc)
|
|
return api_error("Signal match failed", 503)
|
|
|
|
response_data = {
|
|
"matches": matches,
|
|
"match_count": len(matches),
|
|
}
|
|
_cache_set_match(cache_key, response_data)
|
|
|
|
return jsonify(
|
|
{
|
|
"status": "ok",
|
|
"frequency_mhz": round(frequency_mhz, 6),
|
|
"bandwidth_hz": bandwidth_hz,
|
|
"modulation": modulation,
|
|
"cached": False,
|
|
**response_data,
|
|
}
|
|
)
|
|
|
|
|
|
def _cache_get_match(key: str) -> Any | None:
|
|
entry = _match_cache.get(key)
|
|
if not entry:
|
|
return None
|
|
if time.time() >= entry["expires"]:
|
|
_match_cache.pop(key, None)
|
|
return None
|
|
return entry["data"]
|
|
|
|
|
|
def _cache_set_match(key: str, data: Any, ttl: int = 60) -> None:
|
|
_match_cache[key] = {"data": data, "expires": time.time() + ttl}
|