mirror of
https://github.com/smittix/intercept.git
synced 2026-07-19 14:58:10 -07:00
feat: expand signal database to 594 signals from Artemis-DB
Imports 574 signals from AresValley/Artemis-DB (v73) into data/signals.json, growing the bundled offline database from 20 to 594 signals. Adds bin/import_artemis.py to refresh the database when Artemis-DB releases updates. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Executable
+418
@@ -0,0 +1,418 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Import signals from the Artemis signal database into data/signals.json.
|
||||||
|
|
||||||
|
The Artemis signal database (https://github.com/AresValley/Artemis-DB) is
|
||||||
|
distributed as a tar archive and can be downloaded without installing Artemis.
|
||||||
|
This script handles the full workflow: download → extract → import → validate.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
# Download the latest database and import automatically (easiest):
|
||||||
|
python3 bin/import_artemis.py --download
|
||||||
|
|
||||||
|
# Or point at an already-extracted data.sqlite:
|
||||||
|
python3 bin/import_artemis.py /path/to/data.sqlite
|
||||||
|
|
||||||
|
Options:
|
||||||
|
--download Fetch the latest Artemis-DB tar, extract, and import
|
||||||
|
--dry-run Show what would be imported without writing anything
|
||||||
|
--no-merge Replace data/signals.json entirely instead of merging
|
||||||
|
--limit N Only process the first N signals (for testing)
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import shutil
|
||||||
|
import sqlite3
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import tarfile
|
||||||
|
import tempfile
|
||||||
|
import urllib.request
|
||||||
|
from collections import defaultdict
|
||||||
|
from contextlib import closing
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
REPO_ROOT = Path(__file__).resolve().parent.parent
|
||||||
|
SIGNALS_JSON = REPO_ROOT / "data" / "signals.json"
|
||||||
|
|
||||||
|
RELEASE_INFO_URL = (
|
||||||
|
"https://raw.githubusercontent.com/AresValley/Artemis/master/config/release-info.json"
|
||||||
|
)
|
||||||
|
|
||||||
|
_LOCATION_MAP: dict[str, str] = {
|
||||||
|
"worldwide": "GLOBAL",
|
||||||
|
"global": "GLOBAL",
|
||||||
|
"international": "GLOBAL",
|
||||||
|
"europe": "EU",
|
||||||
|
"european union": "EU",
|
||||||
|
"eu": "EU",
|
||||||
|
"north america": "US",
|
||||||
|
"united states": "US",
|
||||||
|
"usa": "US",
|
||||||
|
"us": "US",
|
||||||
|
"united kingdom": "UK",
|
||||||
|
"uk": "UK",
|
||||||
|
"great britain": "UK",
|
||||||
|
"australia": "AU",
|
||||||
|
"au": "AU",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Download helpers
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def _fetch_json(url: str) -> dict:
|
||||||
|
with urllib.request.urlopen(url, timeout=15) as r:
|
||||||
|
return json.loads(r.read())
|
||||||
|
|
||||||
|
|
||||||
|
def _download_db(dest_dir: Path) -> Path:
|
||||||
|
"""Download the latest Artemis-DB tar and extract it. Returns the data.sqlite path."""
|
||||||
|
print("Fetching release info…")
|
||||||
|
info = _fetch_json(RELEASE_INFO_URL)
|
||||||
|
db_info = info["sigID_DB"]
|
||||||
|
version = db_info["version"]
|
||||||
|
url = db_info["url"]
|
||||||
|
total = db_info.get("total_bytes", 0)
|
||||||
|
|
||||||
|
print(f"Artemis-DB version {version} ({total // 1_000_000} MB)")
|
||||||
|
print(f"Downloading: {url}")
|
||||||
|
|
||||||
|
tar_path = dest_dir / url.split("/")[-1]
|
||||||
|
|
||||||
|
# curl gives real-time progress and is significantly faster than urllib
|
||||||
|
result = subprocess.run(
|
||||||
|
["curl", "-L", "--progress-bar", "-o", str(tar_path), url],
|
||||||
|
check=False,
|
||||||
|
)
|
||||||
|
if result.returncode != 0:
|
||||||
|
raise RuntimeError(f"curl failed with exit code {result.returncode}")
|
||||||
|
|
||||||
|
print("Extracting…")
|
||||||
|
extract_dir = dest_dir / "extracted"
|
||||||
|
extract_dir.mkdir()
|
||||||
|
with tarfile.open(tar_path) as tf:
|
||||||
|
# Safety: strip any absolute paths or '..' traversal
|
||||||
|
members = [
|
||||||
|
m for m in tf.getmembers()
|
||||||
|
if not os.path.isabs(m.name) and ".." not in m.name
|
||||||
|
]
|
||||||
|
tf.extractall(extract_dir, members=members)
|
||||||
|
|
||||||
|
# Find data.sqlite anywhere in the extracted tree
|
||||||
|
matches = list(extract_dir.rglob("data.sqlite"))
|
||||||
|
if not matches:
|
||||||
|
raise FileNotFoundError(
|
||||||
|
f"No data.sqlite found after extracting {tar_path}. "
|
||||||
|
"The Artemis-DB tar structure may have changed."
|
||||||
|
)
|
||||||
|
|
||||||
|
return matches[0]
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Discovery (for users who have Artemis installed)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def _find_installed_db() -> Path | None:
|
||||||
|
home = Path.home()
|
||||||
|
candidates = [
|
||||||
|
home / "Library" / "Application Support" / "AresValley" / "Artemis" / "data",
|
||||||
|
home / ".local" / "share" / "AresValley" / "Artemis" / "data",
|
||||||
|
home / "AppData" / "Local" / "AresValley" / "Artemis" / "data",
|
||||||
|
]
|
||||||
|
xdg = os.environ.get("XDG_DATA_HOME")
|
||||||
|
if xdg:
|
||||||
|
candidates.append(Path(xdg) / "AresValley" / "Artemis" / "data")
|
||||||
|
|
||||||
|
for base in candidates:
|
||||||
|
if base.is_dir():
|
||||||
|
for candidate in sorted(base.glob("*/data.sqlite")):
|
||||||
|
return candidate
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Conversion helpers
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def _slugify(name: str) -> str:
|
||||||
|
slug = re.sub(r"[^a-z0-9]+", "-", name.lower()).strip("-")
|
||||||
|
return slug[:60]
|
||||||
|
|
||||||
|
|
||||||
|
def _unique_slug(base: str, used: set[str]) -> str:
|
||||||
|
if base not in used:
|
||||||
|
return base
|
||||||
|
n = 2
|
||||||
|
while f"{base}-{n}" in used:
|
||||||
|
n += 1
|
||||||
|
return f"{base}-{n}"
|
||||||
|
|
||||||
|
|
||||||
|
def _map_location(value: str) -> str:
|
||||||
|
return _LOCATION_MAP.get(value.lower().strip(), "GLOBAL")
|
||||||
|
|
||||||
|
|
||||||
|
def _group_frequencies(freq_hz_list: list[int]) -> list[dict[str, int]]:
|
||||||
|
"""Convert point frequencies (Hz) into min/max range dicts.
|
||||||
|
|
||||||
|
Consecutive frequencies within a 1.5× ratio are merged into one range
|
||||||
|
(e.g. 87.5 MHz + 108 MHz → one FM-band range). Isolated points get
|
||||||
|
a ±0.5% window, minimum 1 kHz each side.
|
||||||
|
"""
|
||||||
|
freqs = sorted(f for f in freq_hz_list if f > 0)
|
||||||
|
if not freqs:
|
||||||
|
return []
|
||||||
|
|
||||||
|
groups: list[list[int]] = []
|
||||||
|
current = [freqs[0]]
|
||||||
|
for f in freqs[1:]:
|
||||||
|
if f / current[-1] < 1.5:
|
||||||
|
current.append(f)
|
||||||
|
else:
|
||||||
|
groups.append(current)
|
||||||
|
current = [f]
|
||||||
|
groups.append(current)
|
||||||
|
|
||||||
|
ranges = []
|
||||||
|
for g in groups:
|
||||||
|
lo, hi = min(g), max(g)
|
||||||
|
if lo == hi:
|
||||||
|
margin = max(int(lo * 0.005), 1000)
|
||||||
|
lo = max(1, lo - margin)
|
||||||
|
hi = hi + margin
|
||||||
|
ranges.append({"min_hz": lo, "max_hz": hi})
|
||||||
|
return ranges
|
||||||
|
|
||||||
|
|
||||||
|
def _bandwidth_range(bw_values: list[int]) -> dict[str, int] | None:
|
||||||
|
values = [v for v in bw_values if v > 0]
|
||||||
|
if not values:
|
||||||
|
return None
|
||||||
|
lo, hi = min(values), max(values)
|
||||||
|
if lo == hi:
|
||||||
|
margin = max(int(lo * 0.10), 1)
|
||||||
|
lo = max(1, lo - margin)
|
||||||
|
hi = hi + margin
|
||||||
|
return {"min_hz": lo, "max_hz": hi}
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Database loading
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def load_artemis(db_path: Path, limit: int = 0, existing_ids: set[str] | None = None) -> list[dict]:
|
||||||
|
"""Read signals from an Artemis data.sqlite and return them in Intercept's schema."""
|
||||||
|
with closing(sqlite3.connect(str(db_path))) as conn:
|
||||||
|
cur = conn.cursor()
|
||||||
|
cur.execute("SELECT SIG_ID, NAME, DESCRIPTION, URL FROM signals ORDER BY NAME")
|
||||||
|
all_signals = cur.fetchall()
|
||||||
|
|
||||||
|
cur.execute("SELECT SIG_ID, VALUE FROM frequency")
|
||||||
|
freq_rows = cur.fetchall()
|
||||||
|
cur.execute("SELECT SIG_ID, VALUE FROM bandwidth")
|
||||||
|
bw_rows = cur.fetchall()
|
||||||
|
cur.execute("SELECT SIG_ID, VALUE FROM modulation")
|
||||||
|
mod_rows = cur.fetchall()
|
||||||
|
cur.execute("SELECT SIG_ID, VALUE FROM location")
|
||||||
|
loc_rows = cur.fetchall()
|
||||||
|
cur.execute("""
|
||||||
|
SELECT category.SIG_ID, category_label.VALUE
|
||||||
|
FROM category
|
||||||
|
JOIN category_label ON category.CLB_ID = category_label.CLB_ID
|
||||||
|
""")
|
||||||
|
cat_rows = cur.fetchall()
|
||||||
|
|
||||||
|
freqs: dict = defaultdict(list)
|
||||||
|
for sid, v in freq_rows:
|
||||||
|
if v:
|
||||||
|
freqs[sid].append(int(v))
|
||||||
|
|
||||||
|
bws: dict = defaultdict(list)
|
||||||
|
for sid, v in bw_rows:
|
||||||
|
if v:
|
||||||
|
bws[sid].append(int(v))
|
||||||
|
|
||||||
|
mods: dict = defaultdict(list)
|
||||||
|
for sid, v in mod_rows:
|
||||||
|
if v:
|
||||||
|
token = v.strip().upper()
|
||||||
|
if token not in mods[sid]:
|
||||||
|
mods[sid].append(token)
|
||||||
|
|
||||||
|
locs: dict = defaultdict(list)
|
||||||
|
for sid, v in loc_rows:
|
||||||
|
if v:
|
||||||
|
r = _map_location(v)
|
||||||
|
if r not in locs[sid]:
|
||||||
|
locs[sid].append(r)
|
||||||
|
|
||||||
|
cats: dict = defaultdict(list)
|
||||||
|
for sid, v in cat_rows:
|
||||||
|
if v:
|
||||||
|
label = v.strip().lower()
|
||||||
|
if label not in cats[sid]:
|
||||||
|
cats[sid].append(label)
|
||||||
|
|
||||||
|
if limit:
|
||||||
|
all_signals = all_signals[:limit]
|
||||||
|
|
||||||
|
used_ids: set[str] = set(existing_ids or [])
|
||||||
|
results = []
|
||||||
|
skipped_no_freq = 0
|
||||||
|
|
||||||
|
for sig_id, name, description, url in all_signals:
|
||||||
|
if not name:
|
||||||
|
continue
|
||||||
|
freq_ranges = _group_frequencies(freqs[sig_id])
|
||||||
|
if not freq_ranges:
|
||||||
|
skipped_no_freq += 1
|
||||||
|
continue
|
||||||
|
|
||||||
|
sigidwiki_url: str | None = None
|
||||||
|
if url and "sigidwiki" in url.lower():
|
||||||
|
sigidwiki_url = url.strip()
|
||||||
|
|
||||||
|
slug = _unique_slug(_slugify(name.strip()), used_ids)
|
||||||
|
used_ids.add(slug)
|
||||||
|
|
||||||
|
results.append({
|
||||||
|
"id": slug,
|
||||||
|
"name": name.strip(),
|
||||||
|
"description": (description or "").strip(),
|
||||||
|
"categories": cats[sig_id],
|
||||||
|
"frequency_ranges": freq_ranges,
|
||||||
|
"bandwidth_range": _bandwidth_range(bws[sig_id]),
|
||||||
|
"modulations": mods[sig_id],
|
||||||
|
"regions": locs[sig_id] or ["GLOBAL"],
|
||||||
|
"sigidwiki_url": sigidwiki_url,
|
||||||
|
})
|
||||||
|
|
||||||
|
if skipped_no_freq:
|
||||||
|
print(f" Skipped {skipped_no_freq} signals with no frequency data")
|
||||||
|
return results
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Merge
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def merge(existing: list[dict], imported: list[dict]) -> tuple[list[dict], int, int]:
|
||||||
|
existing_names = {s["name"].lower() for s in existing}
|
||||||
|
merged = list(existing)
|
||||||
|
added = skipped = 0
|
||||||
|
for sig in imported:
|
||||||
|
if sig["name"].lower() in existing_names:
|
||||||
|
skipped += 1
|
||||||
|
else:
|
||||||
|
merged.append(sig)
|
||||||
|
existing_names.add(sig["name"].lower())
|
||||||
|
added += 1
|
||||||
|
return merged, added, skipped
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Main
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
parser = argparse.ArgumentParser(
|
||||||
|
description="Import Artemis signal database into data/signals.json",
|
||||||
|
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||||||
|
epilog=__doc__,
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"db_path", nargs="?",
|
||||||
|
help="Path to data.sqlite (omit to auto-detect installed Artemis DB)",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--download", action="store_true",
|
||||||
|
help="Download the latest Artemis-DB tar (~290 MB) and import automatically",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--dry-run", action="store_true",
|
||||||
|
help="Show import stats without writing data/signals.json",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--no-merge", action="store_true",
|
||||||
|
help="Replace data/signals.json entirely instead of merging",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--limit", type=int, default=0, metavar="N",
|
||||||
|
help="Only process the first N signals (for testing)",
|
||||||
|
)
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
tmp_dir: tempfile.TemporaryDirectory | None = None
|
||||||
|
db_path: Path | None = None
|
||||||
|
|
||||||
|
try:
|
||||||
|
if args.download:
|
||||||
|
tmp_dir = tempfile.TemporaryDirectory(prefix="artemis-db-")
|
||||||
|
db_path = _download_db(Path(tmp_dir.name))
|
||||||
|
|
||||||
|
elif args.db_path:
|
||||||
|
db_path = Path(args.db_path)
|
||||||
|
if not db_path.exists():
|
||||||
|
print(f"Error: {db_path} does not exist", file=sys.stderr)
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
else:
|
||||||
|
db_path = _find_installed_db()
|
||||||
|
if db_path is None:
|
||||||
|
print(
|
||||||
|
"No Artemis database found automatically.\n\n"
|
||||||
|
"Options:\n"
|
||||||
|
" 1. Download automatically (~290 MB):\n"
|
||||||
|
" python3 bin/import_artemis.py --download\n\n"
|
||||||
|
" 2. Provide the path manually:\n"
|
||||||
|
" python3 bin/import_artemis.py /path/to/data.sqlite",
|
||||||
|
file=sys.stderr,
|
||||||
|
)
|
||||||
|
sys.exit(1)
|
||||||
|
print(f"Found installed Artemis database: {db_path}")
|
||||||
|
|
||||||
|
existing = json.loads(SIGNALS_JSON.read_text()) if SIGNALS_JSON.exists() else []
|
||||||
|
existing_ids = {s["id"] for s in existing}
|
||||||
|
|
||||||
|
print(f"Loading: {db_path}")
|
||||||
|
imported = load_artemis(db_path, limit=args.limit, existing_ids=existing_ids)
|
||||||
|
print(f" {len(imported)} signals with frequency data")
|
||||||
|
|
||||||
|
if args.no_merge:
|
||||||
|
final, added, skipped = imported, len(imported), 0
|
||||||
|
else:
|
||||||
|
print(f" Existing signals.json: {len(existing)} signals")
|
||||||
|
final, added, skipped = merge(existing, imported)
|
||||||
|
if skipped:
|
||||||
|
print(f" Skipped {skipped} duplicates (name already in signals.json)")
|
||||||
|
|
||||||
|
print(f" Adding {added} signals → total {len(final)}")
|
||||||
|
|
||||||
|
if args.dry_run:
|
||||||
|
print("\nDry run — nothing written.")
|
||||||
|
if imported:
|
||||||
|
print("\nSample (first 2 imported signals):")
|
||||||
|
for s in imported[:2]:
|
||||||
|
print(json.dumps(s, indent=2))
|
||||||
|
return
|
||||||
|
|
||||||
|
SIGNALS_JSON.write_text(json.dumps(final, indent=2) + "\n")
|
||||||
|
print(f"\nWritten: {SIGNALS_JSON}")
|
||||||
|
print("\nValidate:")
|
||||||
|
print(" python3 -m pytest tests/test_signals_json.py --noconftest -v")
|
||||||
|
|
||||||
|
finally:
|
||||||
|
if tmp_dir is not None:
|
||||||
|
print("Cleaning up temporary files…")
|
||||||
|
tmp_dir.cleanup()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
+16869
-111
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user