""" PSK Reporter fetcher — pulls recent digital mode signal reports (FT8, WSPR, etc.) from the global PSK Reporter network. No API key required. Docs: https://pskreporter.info/pskdev.html """ import logging import defusedxml.ElementTree as ET import requests from cachetools import TTLCache, cached logger = logging.getLogger(__name__) _cache = TTLCache(maxsize=1, ttl=600) # 10-minute cache _ENDPOINT = "https://retrieve.pskreporter.info/query" def maidenhead_to_latlon(locator: str) -> tuple[float, float] | None: """Convert a 4-or-6 character Maidenhead grid locator to (lat, lon).""" loc = locator.strip().upper() if len(loc) < 4: return None try: lon = (ord(loc[0]) - ord("A")) * 20 - 180 lat = (ord(loc[1]) - ord("A")) * 10 - 90 lon += int(loc[2]) * 2 lat += int(loc[3]) if len(loc) >= 6: lon += (ord(loc[4]) - ord("A")) * (2 / 24) lat += (ord(loc[5]) - ord("A")) * (1 / 24) # center of sub-square lon += 1 / 24 lat += 1 / 48 else: # center of grid square lon += 1 lat += 0.5 if abs(lat) > 90 or abs(lon) > 180: return None return round(lat, 4), round(lon, 4) except (IndexError, ValueError): return None @cached(_cache) def fetch_psk_reporter_spots() -> list[dict]: """Fetch recent FT8 reception reports from PSK Reporter.""" try: resp = requests.get( _ENDPOINT, params={ "mode": "FT8", "flowStartSeconds": -900, # last 15 minutes "rronly": 1, # reception reports only "noactive": 1, # exclude active monitor noise "rptlimit": 5000, # cap payload size }, timeout=30, headers={"Accept": "application/xml"}, ) resp.raise_for_status() root = ET.fromstring(resp.content) # PSK Reporter XML uses namespaces ns = "" if root.tag.startswith("{"): ns = root.tag.split("}")[0] + "}" spots: list[dict] = [] for rec in root.iter(f"{ns}receptionReport"): receiver_loc = rec.get("receiverLocator", "") sender_loc = rec.get("senderLocator", "") # Prefer receiver location (where the signal was heard) loc_str = receiver_loc or sender_loc if not loc_str: continue coords = maidenhead_to_latlon(loc_str) if coords is None: continue lat, lon = coords try: freq = int(rec.get("frequency", "0")) except (ValueError, TypeError): freq = 0 try: snr = int(rec.get("sNR", "0")) except (ValueError, TypeError): snr = 0 spots.append({ "lat": lat, "lon": lon, "sender": (rec.get("senderCallsign") or "")[:20], "receiver": (rec.get("receiverCallsign") or "")[:20], "frequency": freq, "mode": (rec.get("mode") or "FT8")[:10], "snr": snr, "time": rec.get("flowStartSeconds", ""), }) logger.info("PSK Reporter: fetched %d spots", len(spots)) return spots except (requests.RequestException, ET.ParseError, Exception) as e: logger.error("PSK Reporter fetch error: %s", e) return []