Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
331 lines
13 KiB
Python
Executable File
331 lines
13 KiB
Python
Executable File
#!/usr/bin/env python3
|
|
"""Signal-source discovery for the SDR scanner (HackRF, hackrf_sweep).
|
|
|
|
Run by sdr-discover.service (root) every 30 min while sdr-scanner is stopped (~70 s):
|
|
1. hackrf_sweep over voice bands (same gains as the scanner), ~SWEEP_S seconds;
|
|
2. per 5 kHz bin: local noise floor (rolling median over +-250 kHz), excess per sweep;
|
|
3. clusters of bins >= HIT_DB above floor -> sources (peak freq snapped to channel grid,
|
|
bandwidth estimate, activity = fraction of sweeps with the signal, level variance);
|
|
4. merged into /srv/sdr/discovery/sources.json (first/last seen, runs, hits ...).
|
|
Persistent carriers that never change level (HackRF clone spurs, beacons, data links) are
|
|
learned and flagged kind="carrier" so the UI can hide them.
|
|
"""
|
|
import json
|
|
import os
|
|
import subprocess
|
|
import sys
|
|
import time
|
|
|
|
import numpy as np
|
|
|
|
# `python3 -I` implies -P (no script dir on sys.path): put the package root (the parent of discover/) there ourselves.
|
|
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
|
|
|
from radio.config import CHANNELS_FILE, DISC_DIR, SOURCES_FILE # noqa: E402
|
|
from radio.util import read_json # noqa: E402
|
|
|
|
RUNS = os.path.join(DISC_DIR, "runs.jsonl")
|
|
SWEEP_S = int(os.environ.get("SWEEP_S", "60"))
|
|
BIN_HZ = 5000
|
|
HIT_DB = 10.0 # excess (block-averaged) over local floor counted as "signal present"
|
|
EDGE_DB = 6.0 # bandwidth edge
|
|
GAIN = ["-l", "32", "-g", "24", "-a", "0"]
|
|
# hackrf_sweep wants whole MHz; max 10 ranges
|
|
RANGES = [(118, 138), (144, 147), (148, 175), (420, 450)]
|
|
|
|
|
|
MAX_SWEEPS = 400
|
|
|
|
|
|
def sweep():
|
|
"""Run hackrf_sweep for SWEEP_S s; stream-parse into freqs (Hz) and M (n_sweeps x n_bins, dB)."""
|
|
args = ["hackrf_sweep", "-w", str(BIN_HZ)] + GAIN
|
|
for a, b in RANGES:
|
|
args += ["-f", f"{a}:{b}"]
|
|
p = subprocess.Popen(args, stdout=subprocess.PIPE, stderr=subprocess.DEVNULL, text=True)
|
|
t_end = time.time() + SWEEP_S
|
|
sweeps, curr = [], {}
|
|
layout = None # lo -> (bin width, nbins), fixed after the first full sweep
|
|
try:
|
|
for line in p.stdout:
|
|
if time.time() > t_end:
|
|
break
|
|
parts = line.split(",")
|
|
if len(parts) < 8:
|
|
continue
|
|
try:
|
|
lo = int(parts[2])
|
|
bw = float(parts[4])
|
|
vals = np.array(parts[6:], dtype=np.float32)
|
|
except ValueError:
|
|
continue
|
|
if lo in curr: # chunk repeats -> a new sweep begins
|
|
if layout is None:
|
|
layout = {k: (bw, len(v)) for k, (bw, v) in curr.items()}
|
|
sweeps.append({k: v for k, (_, v) in curr.items()})
|
|
curr = {}
|
|
if len(sweeps) >= MAX_SWEEPS:
|
|
break
|
|
curr[lo] = (bw, vals)
|
|
finally:
|
|
p.terminate()
|
|
try:
|
|
p.wait(timeout=10)
|
|
except subprocess.TimeoutExpired:
|
|
p.kill()
|
|
p.wait()
|
|
if layout is None or len(sweeps) < 2:
|
|
return None, None
|
|
los = sorted(layout)
|
|
freqs, offs, o = [], {}, 0
|
|
for lo in los:
|
|
bw, nb = layout[lo]
|
|
offs[lo] = o
|
|
freqs.extend(int(lo + bw * (i + 0.5)) for i in range(nb))
|
|
o += nb
|
|
M = np.full((len(sweeps), o), np.nan, dtype=np.float32)
|
|
for si, sw in enumerate(sweeps):
|
|
for lo, v in sw.items():
|
|
if lo in offs and len(v) == layout[lo][1]:
|
|
M[si, offs[lo]:offs[lo] + len(v)] = v
|
|
order = np.argsort(freqs)
|
|
return np.array(freqs, dtype=np.int64)[order], M[:, order]
|
|
|
|
|
|
def floor_of(freqs, med):
|
|
"""Rolling median of the per-bin median over +-250 kHz (computed per contiguous segment)."""
|
|
fl = np.empty_like(med)
|
|
half = int(250000 / BIN_HZ)
|
|
step = 10
|
|
centers = list(range(0, len(med), step))
|
|
vals = [np.nanpercentile(med[max(0, c - half):min(len(med), c + half + 1)], 40) for c in centers]
|
|
fl[:] = np.interp(np.arange(len(med)), centers, vals)
|
|
return fl
|
|
|
|
|
|
def grid(f):
|
|
"""Snap to the most plausible channel raster for the band."""
|
|
mhz = f / 1e6
|
|
if 118 <= mhz < 137:
|
|
step, off = 25000, 0
|
|
elif 446.0 <= mhz < 446.2:
|
|
step, off = 12500, 6250
|
|
elif 433.05 <= mhz < 434.8:
|
|
step, off = 25000, 0
|
|
else:
|
|
step, off = 12500, 0
|
|
return int(round((f - off) / step) * step + off)
|
|
|
|
|
|
def load_channels():
|
|
try:
|
|
with open(CHANNELS_FILE, encoding="utf-8") as fh:
|
|
return {int(k): v for k, v in json.load(fh).get("channels", {}).items()}
|
|
except (OSError, ValueError):
|
|
return {}
|
|
|
|
|
|
# Community-sourced candidates (2026-10-20 research) that are NOT scanned: discovery labels them
|
|
# when they show up, so the user can promote them with one click.
|
|
CANDIDATES = {
|
|
122.475: ("Левашово ATIS (непрерывно)", "am"),
|
|
127.400: ("Пулково ATIS RU (непрерывно)", "am"),
|
|
127.300: ("Пулково ATIS EN (непрерывно)", "am"),
|
|
128.000: ("кандидат: Пулково резерв 128.0", "am"),
|
|
129.000: ("кандидат: Пулково резерв 129.0", "am"),
|
|
131.800: ("кандидат: Пулково Транзит 131.8", "am"),
|
|
129.800: ("кандидат: Подход (стар.) 129.8", "am"),
|
|
123.850: ("кандидат: воен. канал 3 123.85", "am"),
|
|
135.000: ("кандидат: воен. канал 7 135.0", "am"),
|
|
131.500: ("кандидат: Ржевка верт. 131.5", "am"),
|
|
123.700: ("кандидат: Гостилицы «Бутон» 123.7", "am"),
|
|
125.500: ("кандидат: «Саксофон» 125.5", "am"),
|
|
122.125: ("кандидат: Таллин 122.125", "am"),
|
|
131.725: ("ACARS (данные)", "am"),
|
|
131.825: ("ACARS (данные)", "am"),
|
|
438.750: ("UB1AAM DMR (цифра)", "nfm"),
|
|
438.875: ("C4FM узел (цифра)", "nfm"),
|
|
438.900: ("C4FM узел (цифра)", "nfm"),
|
|
145.7375: ("RD1AS DMR (цифра)", "nfm"),
|
|
144.800: ("APRS 144.800 (данные)", "nfm"),
|
|
}
|
|
|
|
|
|
def guess(f, bw, chans):
|
|
mhz = f / 1e6
|
|
for k, v in chans.items():
|
|
if abs(k - f) <= 3000:
|
|
return v["label"], v.get("mod", "nfm"), True
|
|
for k, (lab, mod) in CANDIDATES.items():
|
|
if abs(k * 1e6 - f) <= 3000:
|
|
return lab, mod, False
|
|
if 118 <= mhz < 137:
|
|
return "Авиа (неизв.)", "am", False
|
|
if 156.0 <= mhz <= 157.45:
|
|
for n in list(range(1, 29)) + list(range(60, 89)):
|
|
ship = 156.000 + 0.05 * n if n <= 28 else 156.025 + 0.05 * (n - 60)
|
|
if abs(ship - mhz) < 0.004:
|
|
return f"Мор. {n}", "nfm", False
|
|
return "Морской диапазон", "nfm", False
|
|
if 160.6 <= mhz <= 162.05:
|
|
for n in list(range(1, 29)) + list(range(60, 89)):
|
|
ship = 156.000 + 0.05 * n if n <= 28 else 156.025 + 0.05 * (n - 60)
|
|
if abs(ship + 4.6 - mhz) < 0.004:
|
|
return f"Мор. {n} (берег)", "nfm", False
|
|
return "Морской (берег)", "nfm", False
|
|
if 144 <= mhz < 146:
|
|
return "Любит. 2 м", "nfm", False
|
|
if 151.7 <= mhz < 152.1:
|
|
return "ЖД (поездная?)", "nfm", False
|
|
if 446.0 <= mhz < 446.2:
|
|
return f"PMR {int(round((mhz - 446.00625) / 0.0125)) + 1}", "nfm", False
|
|
if 433.05 <= mhz < 434.8:
|
|
n = int(round((mhz - 433.075) / 0.025)) + 1
|
|
return (f"LPD {n}" if 1 <= n <= 69 else "ISM 433"), "nfm", False
|
|
if 430 <= mhz < 440:
|
|
return "Любит. 70 см", "nfm", False
|
|
if 146 <= mhz < 175:
|
|
return "Служебная УКВ", "nfm", False
|
|
if 420 <= mhz < 450:
|
|
return "Служебная UHF", "nfm", False
|
|
return "Неизвестно", "nfm", False
|
|
|
|
|
|
BLOCK = 8 # sweeps averaged per block (~0.7 s): per-bin noise std 5.6 dB -> ~1.6 dB
|
|
MIN_BLOCKS = 2 # signal must be present in >= 2 blocks (kills single-sweep noise spikes)
|
|
MERGE_HZ = 15000 # clusters closer than this are one source
|
|
|
|
|
|
def detect(freqs, M):
|
|
inband = np.zeros(len(freqs), bool)
|
|
for a, b in RANGES:
|
|
inband |= (freqs >= a * 1e6) & (freqs < b * 1e6)
|
|
freqs, M = freqs[inband], M[:, inband]
|
|
L = np.power(10.0, M / 10.0)
|
|
nb = L.shape[0] // BLOCK
|
|
T = 10 * np.log10(np.nanmean(L[:nb * BLOCK].reshape(nb, BLOCK, -1), axis=1))
|
|
med = np.nanmedian(T, axis=0)
|
|
fl = floor_of(freqs, med)
|
|
X = T - fl
|
|
hit = X >= HIT_DB
|
|
ok = hit.sum(axis=0) >= MIN_BLOCKS
|
|
mx = np.nanmax(X, axis=0)
|
|
out = []
|
|
idx = np.where(ok)[0]
|
|
groups, g = [], []
|
|
for i in idx:
|
|
if g and freqs[i] - freqs[g[-1]] > MERGE_HZ:
|
|
groups.append(g)
|
|
g = []
|
|
g.append(i)
|
|
if g:
|
|
groups.append(g)
|
|
for g in groups:
|
|
seg = np.array(g)
|
|
pk = int(seg[np.nanargmax(mx[seg])])
|
|
if len(seg) < 2 and mx[pk] < 13:
|
|
continue # lone weak bin: almost always noise
|
|
# bandwidth from the block where the peak occurred (max-over-time is too noisy)
|
|
row = X[int(np.nanargmax(X[:, pk]))]
|
|
edge = max(EDGE_DB, row[pk] - 20)
|
|
a = b = pk
|
|
while a - 1 >= 0 and row[a - 1] >= edge and freqs[pk] - freqs[a - 1] < 200000:
|
|
a -= 1
|
|
while b + 1 < len(freqs) and row[b + 1] >= edge and freqs[b + 1] - freqs[pk] < 200000:
|
|
b += 1
|
|
col = hit[:, seg].any(axis=1)
|
|
lvl = T[:, pk]
|
|
on = lvl[col] if col.any() else lvl
|
|
out.append({
|
|
"f": grid(int(freqs[pk])),
|
|
"peak_hz": int(freqs[pk]),
|
|
"bw_khz": round((b - a + 1) * BIN_HZ / 1000, 1),
|
|
"act": round(float(col.mean()), 3),
|
|
"max_db": round(float(mx[pk]), 1),
|
|
"lvl_dbfs": round(float(np.nanmax(lvl)), 1),
|
|
"std_db": round(float(np.nanstd(on)), 2),
|
|
"hits": int(col.sum()),
|
|
})
|
|
return out, float(np.nanmedian(fl)), nb
|
|
|
|
|
|
def main():
|
|
os.makedirs(DISC_DIR, exist_ok=True)
|
|
t0 = time.time()
|
|
freqs, M = sweep()
|
|
if freqs is None or M.shape[0] < 5:
|
|
print(f"too few sweeps ({0 if M is None else M.shape[0]}), skipping", flush=True)
|
|
return 1
|
|
if os.environ.get("DUMP"):
|
|
np.savez_compressed(os.environ["DUMP"], freqs=freqs, M=M)
|
|
return 0
|
|
found, floor_db, nblocks = detect(freqs, M)
|
|
chans = load_channels()
|
|
db = read_json(SOURCES_FILE, {"sources": {}, "runs": 0})
|
|
db["runs"] = db.get("runs", 0) + 1
|
|
now = int(time.time())
|
|
src = db.setdefault("sources", {})
|
|
seen = set()
|
|
for s in found:
|
|
k = str(s["f"])
|
|
if k in seen: # two clusters snapped to the same channel: keep the stronger
|
|
continue
|
|
seen.add(k)
|
|
e = src.get(k) or {"f": s["f"], "first": now, "runs": 0, "hits": 0, "sweeps": 0,
|
|
"max_db": 0, "act_sum": 0.0, "std_sum": 0.0}
|
|
e["last"] = now
|
|
e["runs"] += 1
|
|
e["hits"] += s["hits"]
|
|
e["sweeps"] += nblocks
|
|
e["max_db"] = max(e["max_db"], s["max_db"])
|
|
e["act_sum"] += s["act"]
|
|
e["std_sum"] += s["std_db"]
|
|
e["bw_khz"] = s["bw_khz"]
|
|
e["last_db"] = s["max_db"]
|
|
e["lvl_dbfs"] = s["lvl_dbfs"]
|
|
lab, mod, known = guess(s["f"], s["bw_khz"], chans)
|
|
e["label"], e["mod"], e["in_scan"] = lab, mod, known
|
|
if s["bw_khz"] > 40:
|
|
e["mod"] = "wide"
|
|
src[k] = e
|
|
# classify: persistent carrier = present in ~every sweep of most runs it was seen in,
|
|
# with a steady level -> spur / beacon / data link, never voice. Intermittent -> voice candidate.
|
|
for k, e in src.items():
|
|
r = max(1, e["runs"])
|
|
act = e["act_sum"] / r
|
|
stdv = e["std_sum"] / r
|
|
e["act"] = round(act, 3)
|
|
e["std"] = round(stdv, 2)
|
|
e["seen_frac"] = round(e["runs"] / db["runs"], 3)
|
|
if act >= 0.9 and stdv < 2.0:
|
|
e["kind"] = "carrier" # steady unmodulated-looking carrier: spur / beacon / data
|
|
elif act >= 0.9:
|
|
e["kind"] = "continuous" # always on but level varies: digital / broadcast / repeater
|
|
elif e.get("mod") == "wide":
|
|
e["kind"] = "wide"
|
|
else:
|
|
e["kind"] = "active" # intermittent: voice candidate
|
|
# forget one-off weak blips older than 7 days
|
|
cutoff = now - 7 * 86400
|
|
for k in [k for k, e in src.items() if e["last"] < cutoff and e["runs"] <= 1]:
|
|
del src[k]
|
|
db["last_run"] = now
|
|
db["last_run_s"] = round(time.time() - t0, 1)
|
|
db["last_sweeps"] = int(M.shape[0])
|
|
db["floor_dbfs"] = round(floor_db, 1)
|
|
tmp = SOURCES_FILE + ".tmp"
|
|
with open(tmp, "w", encoding="utf-8") as fh:
|
|
json.dump(db, fh, ensure_ascii=False, separators=(",", ":"))
|
|
os.chmod(tmp, 0o644)
|
|
os.replace(tmp, SOURCES_FILE)
|
|
with open(RUNS, "a", encoding="utf-8") as fh:
|
|
fh.write(json.dumps({"t": now, "sweeps": int(M.shape[0]), "found": len(found),
|
|
"active": [s for s in found if s["act"] < 0.9]}, ensure_ascii=False) + "\n")
|
|
print(f"sweeps={M.shape[0]} found={len(found)} intermittent={sum(1 for s in found if s['act'] < 0.9)} "
|
|
f"total_known={len(src)}", flush=True)
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
sys.exit(main())
|