feat: add POST /signalid/match route with scoring and caching

This commit is contained in:
James Smith
2026-07-03 08:29:21 +01:00
parent e2d8504beb
commit 29c3f9bdaf
3 changed files with 195 additions and 0 deletions
+4
View File
@@ -517,6 +517,10 @@ ALERT_WEBHOOK_TIMEOUT = _get_env_int("ALERT_WEBHOOK_TIMEOUT", 5)
ADMIN_USERNAME = _get_env("ADMIN_USERNAME", "admin") ADMIN_USERNAME = _get_env("ADMIN_USERNAME", "admin")
ADMIN_PASSWORD = _get_env("ADMIN_PASSWORD", "admin") ADMIN_PASSWORD = _get_env("ADMIN_PASSWORD", "admin")
# Signal identification region (affects match ranking; does not filter results)
# Valid values: GLOBAL, EU, US, UK, AU
REGION = _get_env("REGION", "GLOBAL")
def configure_logging() -> None: def configure_logging() -> None:
"""Configure application logging.""" """Configure application logging."""
+94
View File
@@ -8,10 +8,12 @@ import urllib.parse
import urllib.request import urllib.request
from typing import Any from typing import Any
import config
from flask import Blueprint, Response, jsonify, request from flask import Blueprint, Response, jsonify, request
from utils.logging import get_logger from utils.logging import get_logger
from utils.responses import api_error from utils.responses import api_error
from utils.signal_db import match_signals
logger = get_logger('intercept.signalid') logger = get_logger('intercept.signalid')
@@ -351,3 +353,95 @@ def sigidwiki_lookup() -> Response:
**response_payload, **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', 502)
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}
+97
View File
@@ -0,0 +1,97 @@
"""Integration tests for POST /signalid/match route."""
from __future__ import annotations
import json
import pytest
@pytest.fixture
def client():
"""Minimal Flask test client with only the signalid blueprint registered."""
from flask import Flask
from routes.signalid import signalid_bp
app = Flask(__name__)
app.config["TESTING"] = True
app.register_blueprint(signalid_bp)
with app.test_client() as c:
yield c
class TestSignalidMatchRoute:
def test_missing_frequency_returns_400(self, client):
resp = client.post(
"/signalid/match",
data=json.dumps({}),
content_type="application/json",
)
assert resp.status_code == 400
def test_invalid_frequency_returns_400(self, client):
resp = client.post(
"/signalid/match",
data=json.dumps({"frequency_mhz": -1}),
content_type="application/json",
)
assert resp.status_code == 400
def test_valid_request_returns_200(self, client):
resp = client.post(
"/signalid/match",
data=json.dumps({"frequency_mhz": 98.5}),
content_type="application/json",
)
assert resp.status_code == 200
data = resp.get_json()
assert data["status"] == "ok"
assert isinstance(data["matches"], list)
assert isinstance(data["match_count"], int)
def test_fm_broadcast_in_results_at_98mhz(self, client):
resp = client.post(
"/signalid/match",
data=json.dumps({"frequency_mhz": 98.5, "modulation": "WFM"}),
content_type="application/json",
)
data = resp.get_json()
names = [m["name"] for m in data["matches"]]
assert "FM Broadcast Radio" in names
def test_no_matches_returns_empty_list(self, client):
resp = client.post(
"/signalid/match",
data=json.dumps({"frequency_mhz": 5000.0}),
content_type="application/json",
)
data = resp.get_json()
assert data["status"] == "ok"
assert data["matches"] == []
assert data["match_count"] == 0
def test_limit_param_respected(self, client):
resp = client.post(
"/signalid/match",
data=json.dumps({"frequency_mhz": 98.5, "limit": 2}),
content_type="application/json",
)
data = resp.get_json()
assert len(data["matches"]) <= 2
def test_bandwidth_hz_accepted(self, client):
resp = client.post(
"/signalid/match",
data=json.dumps({"frequency_mhz": 98.5, "bandwidth_hz": 200000}),
content_type="application/json",
)
assert resp.status_code == 200
def test_cached_true_on_second_identical_request(self, client):
import routes.signalid as signalid_module
signalid_module._match_cache.clear()
payload = json.dumps({"frequency_mhz": 98.5})
client.post("/signalid/match", data=payload, content_type="application/json")
resp2 = client.post("/signalid/match", data=payload, content_type="application/json")
data = resp2.get_json()
assert data["cached"] is True