2019-07-06 16:21:43 +00:00
|
|
|
import threading
|
|
|
|
import wave
|
2019-07-06 18:03:17 +00:00
|
|
|
from datetime import datetime, timedelta, date
|
2019-07-06 16:21:43 +00:00
|
|
|
import time
|
|
|
|
import sched
|
|
|
|
import subprocess
|
2019-07-06 18:03:17 +00:00
|
|
|
import os
|
|
|
|
from multiprocessing.connection import Pipe
|
2019-07-06 20:21:47 +00:00
|
|
|
from owrx.map import Map, LocatorLocation
|
|
|
|
import re
|
2019-07-13 15:16:38 +00:00
|
|
|
from owrx.config import PropertyManager
|
2019-07-14 17:32:48 +00:00
|
|
|
from owrx.bands import Bandplan
|
2019-07-06 16:21:43 +00:00
|
|
|
|
|
|
|
import logging
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
|
|
|
2019-07-13 21:16:25 +00:00
|
|
|
class WsjtChopper(threading.Thread):
|
2019-07-06 16:21:43 +00:00
|
|
|
def __init__(self, source):
|
|
|
|
self.source = source
|
2019-07-13 15:16:38 +00:00
|
|
|
self.tmp_dir = PropertyManager.getSharedInstance()["temporary_directory"]
|
2019-07-06 16:21:43 +00:00
|
|
|
(self.wavefilename, self.wavefile) = self.getWaveFile()
|
2019-07-10 20:09:31 +00:00
|
|
|
self.switchingLock = threading.Lock()
|
2019-07-06 16:21:43 +00:00
|
|
|
self.scheduler = sched.scheduler(time.time, time.sleep)
|
2019-07-06 18:03:17 +00:00
|
|
|
self.fileQueue = []
|
|
|
|
(self.outputReader, self.outputWriter) = Pipe()
|
2019-07-06 16:21:43 +00:00
|
|
|
self.doRun = True
|
|
|
|
super().__init__()
|
|
|
|
|
|
|
|
def getWaveFile(self):
|
2019-07-13 21:16:25 +00:00
|
|
|
filename = "{tmp_dir}/openwebrx-wsjtchopper-{id}-{timestamp}.wav".format(
|
2019-07-13 15:16:38 +00:00
|
|
|
tmp_dir = self.tmp_dir,
|
2019-07-11 18:48:02 +00:00
|
|
|
id = id(self),
|
|
|
|
timestamp = datetime.now().strftime("%Y%m%d-%H%M%S")
|
|
|
|
)
|
2019-07-06 16:21:43 +00:00
|
|
|
wavefile = wave.open(filename, "wb")
|
|
|
|
wavefile.setnchannels(1)
|
|
|
|
wavefile.setsampwidth(2)
|
|
|
|
wavefile.setframerate(12000)
|
|
|
|
return (filename, wavefile)
|
|
|
|
|
|
|
|
def getNextDecodingTime(self):
|
|
|
|
t = datetime.now()
|
2019-07-13 21:16:25 +00:00
|
|
|
zeroed = t.replace(minute=0, second=0, microsecond=0)
|
|
|
|
delta = t - zeroed
|
|
|
|
seconds = (int(delta.total_seconds() / self.interval) + 1) * self.interval
|
|
|
|
t = zeroed + timedelta(seconds = seconds)
|
2019-07-06 16:21:43 +00:00
|
|
|
logger.debug("scheduling: {0}".format(t))
|
|
|
|
return t.timestamp()
|
|
|
|
|
|
|
|
def startScheduler(self):
|
|
|
|
self._scheduleNextSwitch()
|
|
|
|
threading.Thread(target = self.scheduler.run).start()
|
|
|
|
|
|
|
|
def emptyScheduler(self):
|
|
|
|
for event in self.scheduler.queue:
|
|
|
|
self.scheduler.cancel(event)
|
|
|
|
|
|
|
|
def _scheduleNextSwitch(self):
|
|
|
|
self.scheduler.enterabs(self.getNextDecodingTime(), 1, self.switchFiles)
|
|
|
|
|
|
|
|
def switchFiles(self):
|
2019-07-10 20:09:31 +00:00
|
|
|
self.switchingLock.acquire()
|
2019-07-06 16:21:43 +00:00
|
|
|
file = self.wavefile
|
|
|
|
filename = self.wavefilename
|
|
|
|
(self.wavefilename, self.wavefile) = self.getWaveFile()
|
2019-07-10 20:09:31 +00:00
|
|
|
self.switchingLock.release()
|
2019-07-06 16:21:43 +00:00
|
|
|
|
|
|
|
file.close()
|
2019-07-06 18:03:17 +00:00
|
|
|
self.fileQueue.append(filename)
|
2019-07-06 16:21:43 +00:00
|
|
|
self._scheduleNextSwitch()
|
|
|
|
|
2019-07-13 21:16:25 +00:00
|
|
|
def decoder_commandline(self, file):
|
|
|
|
'''
|
|
|
|
must be overridden in child classes
|
|
|
|
'''
|
|
|
|
return []
|
|
|
|
|
2019-07-06 16:21:43 +00:00
|
|
|
def decode(self):
|
2019-07-06 18:03:17 +00:00
|
|
|
def decode_and_unlink(file):
|
2019-07-13 21:16:25 +00:00
|
|
|
decoder = subprocess.Popen(self.decoder_commandline(file), stdout=subprocess.PIPE, cwd=self.tmp_dir)
|
2019-07-06 18:03:17 +00:00
|
|
|
while True:
|
|
|
|
line = decoder.stdout.readline()
|
|
|
|
if line is None or (isinstance(line, bytes) and len(line) == 0):
|
|
|
|
break
|
|
|
|
self.outputWriter.send(line)
|
|
|
|
rc = decoder.wait()
|
|
|
|
logger.debug("decoder return code: %i", rc)
|
|
|
|
os.unlink(file)
|
|
|
|
|
|
|
|
self.decoder = decoder
|
|
|
|
|
|
|
|
if self.fileQueue:
|
|
|
|
file = self.fileQueue.pop()
|
|
|
|
logger.debug("processing file {0}".format(file))
|
|
|
|
threading.Thread(target=decode_and_unlink, args=[file]).start()
|
2019-07-06 16:21:43 +00:00
|
|
|
|
|
|
|
def run(self) -> None:
|
2019-07-13 21:16:25 +00:00
|
|
|
logger.debug("WSJT chopper starting up")
|
2019-07-06 16:21:43 +00:00
|
|
|
self.startScheduler()
|
|
|
|
while self.doRun:
|
|
|
|
data = self.source.read(256)
|
|
|
|
if data is None or (isinstance(data, bytes) and len(data) == 0):
|
2019-07-13 21:16:25 +00:00
|
|
|
logger.warning("zero read on WSJT chopper")
|
2019-07-06 16:21:43 +00:00
|
|
|
self.doRun = False
|
|
|
|
else:
|
2019-07-10 20:09:31 +00:00
|
|
|
self.switchingLock.acquire()
|
2019-07-06 16:21:43 +00:00
|
|
|
self.wavefile.writeframes(data)
|
2019-07-10 20:09:31 +00:00
|
|
|
self.switchingLock.release()
|
2019-07-06 16:21:43 +00:00
|
|
|
|
|
|
|
self.decode()
|
2019-07-13 21:16:25 +00:00
|
|
|
logger.debug("WSJT chopper shutting down")
|
2019-07-06 18:03:17 +00:00
|
|
|
self.outputReader.close()
|
|
|
|
self.outputWriter.close()
|
2019-07-06 16:21:43 +00:00
|
|
|
self.emptyScheduler()
|
2019-07-12 17:34:04 +00:00
|
|
|
try:
|
|
|
|
os.unlink(self.wavefilename)
|
|
|
|
except Exception:
|
|
|
|
logger.exception("error removing undecoded file")
|
2019-07-06 18:03:17 +00:00
|
|
|
|
|
|
|
def read(self):
|
|
|
|
try:
|
|
|
|
return self.outputReader.recv()
|
|
|
|
except EOFError:
|
|
|
|
return None
|
|
|
|
|
|
|
|
|
2019-07-13 21:16:25 +00:00
|
|
|
class Ft8Chopper(WsjtChopper):
|
|
|
|
def __init__(self, source):
|
|
|
|
self.interval = 15
|
|
|
|
super().__init__(source)
|
|
|
|
|
|
|
|
def decoder_commandline(self, file):
|
|
|
|
#TODO expose decoding quality parameters through config
|
|
|
|
return ["jt9", "--ft8", "-d", "3", file]
|
|
|
|
|
|
|
|
|
|
|
|
class WsprChopper(WsjtChopper):
|
|
|
|
def __init__(self, source):
|
|
|
|
self.interval = 120
|
|
|
|
super().__init__(source)
|
|
|
|
|
|
|
|
def decoder_commandline(self, file):
|
|
|
|
#TODO expose decoding quality parameters through config
|
|
|
|
return ["wsprd", "-d", file]
|
|
|
|
|
|
|
|
|
2019-07-14 15:09:34 +00:00
|
|
|
class Jt65Chopper(WsjtChopper):
|
|
|
|
def __init__(self, source):
|
|
|
|
self.interval = 60
|
|
|
|
super().__init__(source)
|
|
|
|
|
|
|
|
def decoder_commandline(self, file):
|
|
|
|
#TODO expose decoding quality parameters through config
|
|
|
|
return ["jt9", "--jt65", "-d", "3", file]
|
|
|
|
|
|
|
|
|
|
|
|
class Jt9Chopper(WsjtChopper):
|
|
|
|
def __init__(self, source):
|
|
|
|
self.interval = 60
|
|
|
|
super().__init__(source)
|
|
|
|
|
|
|
|
def decoder_commandline(self, file):
|
|
|
|
#TODO expose decoding quality parameters through config
|
|
|
|
return ["jt9", "--jt9", "-d", "3", file]
|
|
|
|
|
|
|
|
|
2019-07-06 18:03:17 +00:00
|
|
|
class WsjtParser(object):
|
2019-07-13 21:16:25 +00:00
|
|
|
locator_pattern = re.compile(".*\\s([A-Z0-9]+)\\s([A-R]{2}[0-9]{2})$")
|
2019-07-14 15:09:34 +00:00
|
|
|
jt9_pattern = re.compile("^([0-9]{6}|\\*{4}) .*")
|
2019-07-13 21:16:25 +00:00
|
|
|
wspr_pattern = re.compile("^[0-9]{4} .*")
|
2019-07-14 12:33:30 +00:00
|
|
|
wspr_splitter_pattern = re.compile("([A-Z0-9]*)\\s([A-R]{2}[0-9]{2})\\s([0-9]+)")
|
2019-07-13 21:16:25 +00:00
|
|
|
|
2019-07-06 18:03:17 +00:00
|
|
|
def __init__(self, handler):
|
|
|
|
self.handler = handler
|
2019-07-14 17:32:48 +00:00
|
|
|
self.dial_freq = None
|
|
|
|
self.band = None
|
2019-07-06 18:03:17 +00:00
|
|
|
|
2019-07-11 21:40:09 +00:00
|
|
|
modes = {
|
2019-07-14 15:09:34 +00:00
|
|
|
"~": "FT8",
|
|
|
|
"#": "JT65",
|
|
|
|
"@": "JT9"
|
2019-07-11 21:40:09 +00:00
|
|
|
}
|
|
|
|
|
2019-07-06 18:03:17 +00:00
|
|
|
def parse(self, data):
|
|
|
|
try:
|
|
|
|
msg = data.decode().rstrip()
|
|
|
|
# known debug messages we know to skip
|
|
|
|
if msg.startswith("<DecodeFinished>"):
|
|
|
|
return
|
|
|
|
if msg.startswith(" EOF on input file"):
|
|
|
|
return
|
|
|
|
|
|
|
|
out = {}
|
2019-07-13 21:16:25 +00:00
|
|
|
if WsjtParser.jt9_pattern.match(msg):
|
|
|
|
out = self.parse_from_jt9(msg)
|
|
|
|
elif WsjtParser.wspr_pattern.match(msg):
|
|
|
|
out = self.parse_from_wsprd(msg)
|
2019-07-06 18:03:17 +00:00
|
|
|
|
|
|
|
self.handler.write_wsjt_message(out)
|
|
|
|
except ValueError:
|
|
|
|
logger.exception("error while parsing wsjt message")
|
2019-07-06 20:21:47 +00:00
|
|
|
|
2019-07-13 21:16:25 +00:00
|
|
|
def parse_from_jt9(self, msg):
|
|
|
|
# ft8 sample
|
|
|
|
# '222100 -15 -0.0 508 ~ CQ EA7MJ IM66'
|
2019-07-14 15:09:34 +00:00
|
|
|
# jt65 sample
|
|
|
|
# '**** -10 0.4 1556 # CQ RN6AM KN95'
|
2019-07-13 21:16:25 +00:00
|
|
|
out = {}
|
2019-07-14 15:09:34 +00:00
|
|
|
if msg.startswith("****"):
|
|
|
|
out["timestamp"] = int(datetime.now().timestamp() * 1000)
|
|
|
|
msg = msg[5:]
|
|
|
|
else:
|
|
|
|
ts = datetime.strptime(msg[0:6], "%H%M%S")
|
|
|
|
out["timestamp"] = int(datetime.combine(date.today(), ts.time(), datetime.now().tzinfo).timestamp() * 1000)
|
|
|
|
msg = msg[7:]
|
|
|
|
out["db"] = float(msg[0:3])
|
|
|
|
out["dt"] = float(msg[4:8])
|
|
|
|
out["freq"] = int(msg[9:13])
|
|
|
|
modeChar = msg[14:15]
|
2019-07-13 21:16:25 +00:00
|
|
|
out["mode"] = mode = WsjtParser.modes[modeChar] if modeChar in WsjtParser.modes else "unknown"
|
2019-07-14 15:09:34 +00:00
|
|
|
wsjt_msg = msg[17:53].strip()
|
2019-07-13 21:16:25 +00:00
|
|
|
self.parseLocator(wsjt_msg, mode)
|
|
|
|
out["msg"] = wsjt_msg
|
|
|
|
return out
|
|
|
|
|
2019-07-11 21:40:09 +00:00
|
|
|
def parseLocator(self, msg, mode):
|
2019-07-13 21:16:25 +00:00
|
|
|
m = WsjtParser.locator_pattern.match(msg)
|
2019-07-06 20:21:47 +00:00
|
|
|
if m is None:
|
|
|
|
return
|
|
|
|
# this is a valid locator in theory, but it's somewhere in the arctic ocean, near the north pole, so it's very
|
|
|
|
# likely this just means roger roger goodbye.
|
|
|
|
if m.group(2) == "RR73":
|
|
|
|
return
|
2019-07-14 17:32:48 +00:00
|
|
|
Map.getSharedInstance().updateLocation(m.group(1), LocatorLocation(m.group(2)), mode, self.band)
|
2019-07-13 21:16:25 +00:00
|
|
|
|
|
|
|
def parse_from_wsprd(self, msg):
|
|
|
|
# wspr sample
|
|
|
|
# '2600 -24 0.4 0.001492 -1 G8AXA JO01 33'
|
|
|
|
out = {}
|
|
|
|
now = datetime.now()
|
|
|
|
ts = datetime.strptime(msg[0:4], "%M%S").replace(hour=now.hour)
|
|
|
|
out["timestamp"] = int(datetime.combine(date.today(), ts.time(), now.tzinfo).timestamp() * 1000)
|
|
|
|
out["db"] = float(msg[5:8])
|
|
|
|
out["dt"] = float(msg[9:13])
|
|
|
|
out["freq"] = float(msg[14:24])
|
|
|
|
out["drift"] = int(msg[25:28])
|
2019-07-14 12:33:30 +00:00
|
|
|
out["mode"] = "WSPR"
|
|
|
|
wsjt_msg = msg[29:].strip()
|
2019-07-13 21:16:25 +00:00
|
|
|
out["msg"] = wsjt_msg
|
|
|
|
self.parseWsprMessage(wsjt_msg)
|
|
|
|
return out
|
|
|
|
|
|
|
|
def parseWsprMessage(self, msg):
|
|
|
|
m = WsjtParser.wspr_splitter_pattern.match(msg)
|
|
|
|
if m is None:
|
|
|
|
return
|
2019-07-14 17:32:48 +00:00
|
|
|
Map.getSharedInstance().updateLocation(m.group(1), LocatorLocation(m.group(2)), "WSPR", self.band)
|
|
|
|
|
|
|
|
def setDialFrequency(self, freq):
|
|
|
|
self.dial_freq = freq
|
|
|
|
self.band = Bandplan.getSharedInstance().findBand(freq)
|