2019-05-30 15:19:46 +00:00
|
|
|
import json
|
|
|
|
import logging
|
2019-05-30 16:32:08 +00:00
|
|
|
import threading
|
2021-09-09 13:11:33 +00:00
|
|
|
import pickle
|
2021-09-17 16:24:33 +00:00
|
|
|
import re
|
|
|
|
from abc import ABC, ABCMeta, abstractmethod
|
2021-09-09 13:11:33 +00:00
|
|
|
from datetime import datetime, timedelta
|
|
|
|
from urllib import request
|
2021-09-12 21:31:33 +00:00
|
|
|
from urllib.error import HTTPError
|
2021-09-09 13:11:33 +00:00
|
|
|
|
2021-09-09 20:25:45 +00:00
|
|
|
from csdr.module import PickleModule
|
2021-09-09 13:11:33 +00:00
|
|
|
from owrx.aprs import AprsParser, AprsLocation
|
|
|
|
from owrx.config import Config
|
|
|
|
from owrx.map import Map, LatLngLocation
|
|
|
|
from owrx.bands import Bandplan
|
2019-05-30 15:19:46 +00:00
|
|
|
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
2019-07-21 17:40:28 +00:00
|
|
|
|
2021-06-08 16:38:53 +00:00
|
|
|
class Enricher(ABC):
|
|
|
|
def __init__(self, parser):
|
|
|
|
self.parser = parser
|
|
|
|
|
|
|
|
@abstractmethod
|
2021-08-06 19:23:44 +00:00
|
|
|
def enrich(self, meta, callback):
|
2021-06-08 16:38:53 +00:00
|
|
|
pass
|
|
|
|
|
|
|
|
|
2021-06-15 20:50:30 +00:00
|
|
|
class RadioIDCache(object):
|
2019-05-30 16:54:45 +00:00
|
|
|
sharedInstance = None
|
2019-07-21 17:40:28 +00:00
|
|
|
|
2019-05-30 16:54:45 +00:00
|
|
|
@staticmethod
|
|
|
|
def getSharedInstance():
|
2021-06-15 20:50:30 +00:00
|
|
|
if RadioIDCache.sharedInstance is None:
|
|
|
|
RadioIDCache.sharedInstance = RadioIDCache()
|
|
|
|
return RadioIDCache.sharedInstance
|
2019-07-01 19:20:53 +00:00
|
|
|
|
2019-05-30 15:19:46 +00:00
|
|
|
def __init__(self):
|
|
|
|
self.cache = {}
|
2019-07-21 17:40:28 +00:00
|
|
|
self.cacheTimeout = timedelta(seconds=86400)
|
2019-07-01 19:20:53 +00:00
|
|
|
|
2021-06-15 20:50:30 +00:00
|
|
|
def isValid(self, mode, radio_id):
|
|
|
|
key = self.__key(mode, radio_id)
|
2021-01-17 18:21:13 +00:00
|
|
|
if key not in self.cache:
|
2019-07-21 17:40:28 +00:00
|
|
|
return False
|
2019-05-30 16:54:45 +00:00
|
|
|
entry = self.cache[key]
|
2019-05-30 15:19:46 +00:00
|
|
|
return entry["timestamp"] + self.cacheTimeout > datetime.now()
|
2019-07-01 19:20:53 +00:00
|
|
|
|
2021-06-15 20:50:30 +00:00
|
|
|
def __key(self, mode, radio_id):
|
|
|
|
return "{}-{}".format(mode, radio_id)
|
2019-07-01 19:20:53 +00:00
|
|
|
|
2021-06-15 20:50:30 +00:00
|
|
|
def put(self, mode, radio_id, value):
|
|
|
|
self.cache[self.__key(mode, radio_id)] = {"timestamp": datetime.now(), "data": value}
|
|
|
|
|
|
|
|
def get(self, mode, radio_id):
|
|
|
|
if not self.isValid(mode, radio_id):
|
2019-07-21 17:40:28 +00:00
|
|
|
return None
|
2021-06-15 20:50:30 +00:00
|
|
|
return self.cache[self.__key(mode, radio_id)]["data"]
|
2019-05-30 16:54:45 +00:00
|
|
|
|
|
|
|
|
2021-06-15 20:50:30 +00:00
|
|
|
class RadioIDEnricher(Enricher):
|
|
|
|
def __init__(self, mode, parser):
|
2021-06-08 16:38:53 +00:00
|
|
|
super().__init__(parser)
|
2021-06-15 20:50:30 +00:00
|
|
|
self.mode = mode
|
2019-05-30 16:54:45 +00:00
|
|
|
self.threads = {}
|
2021-08-06 19:23:44 +00:00
|
|
|
self.callbacks = {}
|
2019-07-01 19:20:53 +00:00
|
|
|
|
2021-06-28 11:04:47 +00:00
|
|
|
def _fillCache(self, id):
|
2021-08-06 19:23:44 +00:00
|
|
|
data = self._downloadRadioIdData(id)
|
|
|
|
RadioIDCache.getSharedInstance().put(self.mode, id, data)
|
|
|
|
if id in self.callbacks:
|
|
|
|
while self.callbacks[id]:
|
|
|
|
self.callbacks[id].pop()(data)
|
|
|
|
del self.callbacks[id]
|
2021-06-28 11:04:47 +00:00
|
|
|
del self.threads[id]
|
|
|
|
|
|
|
|
def _downloadRadioIdData(self, id):
|
2019-05-30 16:32:08 +00:00
|
|
|
try:
|
2021-06-15 20:50:30 +00:00
|
|
|
logger.debug("requesting radioid metadata for mode=%s and id=%s", self.mode, id)
|
2021-06-28 11:04:47 +00:00
|
|
|
res = request.urlopen("https://www.radioid.net/api/{0}/user/?id={1}".format(self.mode, id), timeout=30)
|
|
|
|
if res.status != 200:
|
|
|
|
logger.warning("radioid API returned error %i for mode=%s and id=%s", res.status, self.mode, id)
|
|
|
|
return None
|
|
|
|
data = json.loads(res.read().decode("utf-8"))
|
|
|
|
if "count" in data and data["count"] > 0 and "results" in data:
|
|
|
|
for item in data["results"]:
|
|
|
|
if "id" in item and item["id"] == id:
|
|
|
|
return item
|
2019-05-30 16:32:08 +00:00
|
|
|
except json.JSONDecodeError:
|
2021-06-28 11:04:47 +00:00
|
|
|
logger.warning("unable to parse radioid response JSON")
|
2021-09-12 21:31:33 +00:00
|
|
|
except HTTPError as e:
|
|
|
|
logger.warning("radioid responded with error: %s", str(e))
|
2021-06-28 11:04:47 +00:00
|
|
|
|
|
|
|
return None
|
2019-07-01 19:20:53 +00:00
|
|
|
|
2021-08-06 19:23:44 +00:00
|
|
|
def enrich(self, meta, callback):
|
2021-06-15 20:50:30 +00:00
|
|
|
config_key = "digital_voice_{}_id_lookup".format(self.mode)
|
|
|
|
if not Config.get()[config_key]:
|
2021-01-17 18:21:13 +00:00
|
|
|
return meta
|
|
|
|
if "source" not in meta:
|
|
|
|
return meta
|
2021-06-28 11:04:47 +00:00
|
|
|
id = int(meta["source"])
|
2021-06-15 20:50:30 +00:00
|
|
|
cache = RadioIDCache.getSharedInstance()
|
|
|
|
if not cache.isValid(self.mode, id):
|
2021-01-17 18:21:13 +00:00
|
|
|
if id not in self.threads:
|
2021-06-28 11:04:47 +00:00
|
|
|
self.threads[id] = threading.Thread(target=self._fillCache, args=[id], daemon=True)
|
2019-05-30 16:32:08 +00:00
|
|
|
self.threads[id].start()
|
2021-08-06 19:23:44 +00:00
|
|
|
if id not in self.callbacks:
|
|
|
|
self.callbacks[id] = []
|
|
|
|
|
|
|
|
def onFinish(data):
|
|
|
|
if data is not None:
|
|
|
|
meta["additional"] = data
|
|
|
|
callback(meta)
|
|
|
|
|
|
|
|
self.callbacks[id].append(onFinish)
|
2021-01-17 18:21:13 +00:00
|
|
|
return meta
|
2021-06-15 20:50:30 +00:00
|
|
|
data = cache.get(self.mode, id)
|
2021-06-28 11:04:47 +00:00
|
|
|
if data is not None:
|
|
|
|
meta["additional"] = data
|
2021-01-17 18:21:13 +00:00
|
|
|
return meta
|
2019-05-30 15:19:46 +00:00
|
|
|
|
|
|
|
|
2021-09-17 16:24:33 +00:00
|
|
|
class DigihamEnricher(Enricher, metaclass=ABCMeta):
|
|
|
|
def parseCoordinate(self, meta, mode):
|
2021-01-17 18:21:13 +00:00
|
|
|
for key in ["lat", "lon"]:
|
|
|
|
if key in meta:
|
|
|
|
meta[key] = float(meta[key])
|
2021-09-17 16:24:33 +00:00
|
|
|
callsign = self.getCallsign(meta)
|
|
|
|
if callsign is not None and "lat" in meta and "lon" in meta:
|
2021-01-17 18:21:13 +00:00
|
|
|
loc = LatLngLocation(meta["lat"], meta["lon"])
|
2022-11-30 00:07:16 +00:00
|
|
|
Map.getSharedInstance().updateLocation({"callsign": callsign}, loc, mode, self.parser.getBand())
|
2021-01-17 18:21:13 +00:00
|
|
|
return meta
|
2019-07-01 19:20:53 +00:00
|
|
|
|
2021-09-17 16:24:33 +00:00
|
|
|
@abstractmethod
|
|
|
|
def getCallsign(self, meta):
|
|
|
|
pass
|
|
|
|
|
|
|
|
|
|
|
|
class DmrEnricher(DigihamEnricher, RadioIDEnricher):
|
|
|
|
# callsign must be uppercase alphanumeric and at the beginning
|
|
|
|
# if there's anything after the callsign, it must be separated by a whitespace
|
|
|
|
talkerAliasRegex = re.compile("^([A-Z0-9]+)(\\s.*)?$")
|
|
|
|
|
|
|
|
def __init__(self, parser):
|
|
|
|
super().__init__("dmr", parser)
|
|
|
|
|
|
|
|
def getCallsign(self, meta):
|
|
|
|
# there's no explicit callsign data in dmr, so we can only rely on one of the following:
|
|
|
|
# a) a callsign provided by a radioid lookup
|
|
|
|
if "additional" in meta and "callsign" in meta["additional"]:
|
|
|
|
return meta["additional"]["callsign"]
|
|
|
|
# b) a callsign in the talker alias
|
|
|
|
if "talkeralias" in meta:
|
|
|
|
matches = DmrEnricher.talkerAliasRegex.match(meta["talkeralias"])
|
|
|
|
if matches:
|
|
|
|
return matches.group(1)
|
2019-07-01 19:20:53 +00:00
|
|
|
|
2021-08-06 19:23:44 +00:00
|
|
|
def enrich(self, meta, callback):
|
2021-09-17 16:24:33 +00:00
|
|
|
def asyncParse(meta):
|
|
|
|
self.parseCoordinate(meta, "DMR")
|
|
|
|
callback(meta)
|
|
|
|
meta = super().enrich(meta, asyncParse)
|
|
|
|
meta = self.parseCoordinate(meta, "DMR")
|
|
|
|
return meta
|
|
|
|
|
|
|
|
|
|
|
|
class YsfMetaEnricher(DigihamEnricher):
|
|
|
|
def getCallsign(self, meta):
|
|
|
|
if "source" in meta:
|
|
|
|
return meta["source"]
|
|
|
|
|
|
|
|
def enrich(self, meta, callback):
|
|
|
|
meta = self.parseCoordinate(meta, "YSF")
|
|
|
|
return meta
|
|
|
|
|
|
|
|
|
|
|
|
class DStarEnricher(DigihamEnricher):
|
|
|
|
def getCallsign(self, meta):
|
|
|
|
if "ourcall" in meta:
|
|
|
|
return meta["ourcall"]
|
|
|
|
|
|
|
|
def enrich(self, meta, callback):
|
|
|
|
meta = self.parseCoordinate(meta, "D-Star")
|
|
|
|
meta = self.parseDprs(meta)
|
|
|
|
return meta
|
|
|
|
|
|
|
|
def parseDprs(self, meta):
|
2021-06-11 12:36:11 +00:00
|
|
|
if "dprs" in meta:
|
2021-07-09 11:52:33 +00:00
|
|
|
try:
|
|
|
|
# we can send the DPRS stuff through our APRS parser to extract the information
|
|
|
|
# TODO: only third-party parsing accepts this format right now
|
2021-09-06 13:05:33 +00:00
|
|
|
parser = AprsParser()
|
2021-07-09 11:52:33 +00:00
|
|
|
dprsData = parser.parseThirdpartyAprsData(meta["dprs"])
|
|
|
|
if "data" in dprsData:
|
|
|
|
data = dprsData["data"]
|
|
|
|
if "lat" in data and "lon" in data:
|
|
|
|
# TODO: we could actually get the symbols from the parsed APRS data and show that on the meta panel
|
|
|
|
meta["lat"] = data["lat"]
|
|
|
|
meta["lon"] = data["lon"]
|
|
|
|
|
|
|
|
if "ourcall" in meta:
|
|
|
|
# send location info to map as well (it will show up with the correct symbol there!)
|
|
|
|
loc = AprsLocation(data)
|
2022-11-30 00:07:16 +00:00
|
|
|
Map.getSharedInstance().updateLocation({"callsign": meta["ourcall"]}, loc, "DPRS", self.parser.getBand())
|
2021-07-09 11:52:33 +00:00
|
|
|
except Exception:
|
|
|
|
logger.exception("Error while parsing DPRS data")
|
2021-06-08 16:38:53 +00:00
|
|
|
|
|
|
|
return meta
|
|
|
|
|
|
|
|
|
2021-09-09 20:25:45 +00:00
|
|
|
class MetaParser(PickleModule):
|
2021-09-09 13:11:33 +00:00
|
|
|
def __init__(self):
|
2021-06-15 20:50:30 +00:00
|
|
|
self.enrichers = {
|
2021-09-17 16:24:33 +00:00
|
|
|
"DMR": DmrEnricher(self),
|
2021-06-15 20:50:30 +00:00
|
|
|
"YSF": YsfMetaEnricher(self),
|
|
|
|
"DSTAR": DStarEnricher(self),
|
|
|
|
"NXDN": RadioIDEnricher("nxdn", self),
|
|
|
|
}
|
2021-08-06 19:23:44 +00:00
|
|
|
self.currentMetaData = None
|
2021-09-09 13:11:33 +00:00
|
|
|
self.band = None
|
|
|
|
super().__init__()
|
|
|
|
|
2021-09-09 20:25:45 +00:00
|
|
|
def process(self, meta):
|
|
|
|
self.currentMetaData = None
|
|
|
|
if "protocol" in meta:
|
|
|
|
protocol = meta["protocol"]
|
|
|
|
if protocol in self.enrichers:
|
|
|
|
self.currentMetaData = meta = self.enrichers[protocol].enrich(meta, self.receive)
|
|
|
|
return meta
|
2021-08-06 19:23:44 +00:00
|
|
|
|
|
|
|
def receive(self, meta):
|
|
|
|
# we may have moved on in the meantime
|
|
|
|
if meta is not self.currentMetaData:
|
|
|
|
return
|
2021-09-09 13:11:33 +00:00
|
|
|
self.writer.write(pickle.dumps(meta))
|
|
|
|
|
|
|
|
def setDialFrequency(self, freq):
|
|
|
|
self.band = Bandplan.getSharedInstance().findBand(freq)
|
|
|
|
|
|
|
|
def getBand(self):
|
|
|
|
return self.band
|