diff --git a/csdr.py b/csdr.py
index 43bd0ac..d9452c0 100755
--- a/csdr.py
+++ b/csdr.py
@@ -25,7 +25,7 @@ import os
import signal
import threading
from functools import partial
-from owrx.wsjt import Ft8Chopper
+from owrx.wsjt import Ft8Chopper, WsprChopper
import logging
logger = logging.getLogger(__name__)
@@ -174,7 +174,7 @@ class dsp(object):
"csdr limit_ff"
]
# fixed sample rate necessary for the wsjt-x tools. fix with sox...
- if self.get_secondary_demodulator() == "ft8" and self.get_audio_rate() != self.get_output_rate():
+ if self.isWsjtMode() and self.get_audio_rate() != self.get_output_rate():
chain += [
"sox -t raw -r {audio_rate} -e floating-point -b 32 -c 1 --buffer 32 - -t raw -r {output_rate} -e signed-integer -b 16 -c 1 - "
]
@@ -196,8 +196,8 @@ class dsp(object):
"csdr timing_recovery_cc GARDNER {secondary_samples_per_bits} 0.5 2 --add_q | " + \
"CSDR_FIXED_BUFSIZE=1 csdr dbpsk_decoder_c_u8 | " + \
"CSDR_FIXED_BUFSIZE=1 csdr psk31_varicode_decoder_u8_u8"
- elif which == "ft8":
- chain = secondary_chain_base + "csdr realpart_cf | "
+ elif self.isWsjtMode(which):
+ chain = secondary_chain_base + "csdr realpart_cf | "
if self.last_decimation != 1.0 :
chain += "csdr fractional_decimator_ff {last_decimation} | "
chain += "csdr agc_ff | csdr limit_ff | csdr convert_f_s16"
@@ -271,8 +271,12 @@ class dsp(object):
self.secondary_processes_running = True
self.output.add_output("secondary_fft", partial(self.secondary_process_fft.stdout.read, int(self.get_secondary_fft_bytes_to_read())))
- if self.get_secondary_demodulator() == "ft8":
- chopper = Ft8Chopper(self.secondary_process_demod.stdout)
+ if self.isWsjtMode():
+ smd = self.get_secondary_demodulator()
+ if smd == "ft8":
+ chopper = Ft8Chopper(self.secondary_process_demod.stdout)
+ elif smd == "wspr":
+ chopper = WsprChopper(self.secondary_process_demod.stdout)
chopper.start()
self.output.add_output("wsjt_demod", chopper.read)
else:
@@ -355,7 +359,7 @@ class dsp(object):
def get_audio_rate(self):
if self.isDigitalVoice():
return 48000
- elif self.secondary_demodulator == "ft8":
+ elif self.isWsjtMode():
return 12000
return self.get_output_rate()
@@ -364,6 +368,11 @@ class dsp(object):
demodulator = self.get_demodulator()
return demodulator in ["dmr", "dstar", "nxdn", "ysf"]
+ def isWsjtMode(self, demodulator = None):
+ if demodulator is None:
+ demodulator = self.get_secondary_demodulator()
+ return demodulator in ["ft8", "wspr"]
+
def set_output_rate(self,output_rate):
self.output_rate=output_rate
self.calculate_decimation()
diff --git a/htdocs/css/openwebrx.css b/htdocs/css/openwebrx.css
index b23c50b..87b0608 100644
--- a/htdocs/css/openwebrx.css
+++ b/htdocs/css/openwebrx.css
@@ -835,15 +835,21 @@ img.openwebrx-mirror-img
width: 35px;
}
-#openwebrx-panel-digimodes[data-mode="ft8"] #openwebrx-digimode-content-container {
+#openwebrx-panel-digimodes[data-mode="ft8"] #openwebrx-digimode-content-container,
+#openwebrx-panel-digimodes[data-mode="wspr"] #openwebrx-digimode-content-container
+{
display: none;
}
-#openwebrx-panel-digimodes[data-mode="ft8"] #openwebrx-digimode-canvas-container {
+#openwebrx-panel-digimodes[data-mode="ft8"] #openwebrx-digimode-canvas-container,
+#openwebrx-panel-digimodes[data-mode="wspr"] #openwebrx-digimode-canvas-container
+{
height: 200px;
margin: -10px;
}
-#openwebrx-panel-digimodes[data-mode="ft8"] #openwebrx-digimode-select-channel {
+#openwebrx-panel-digimodes[data-mode="ft8"] #openwebrx-digimode-select-channel,
+#openwebrx-panel-digimodes[data-mode="wspr"] #openwebrx-digimode-select-channel
+{
display: none;
}
\ No newline at end of file
diff --git a/htdocs/index.html b/htdocs/index.html
index ee688c5..45e94e4 100644
--- a/htdocs/index.html
+++ b/htdocs/index.html
@@ -81,6 +81,7 @@
+
diff --git a/htdocs/openwebrx.js b/htdocs/openwebrx.js
index 6917541..c47786e 100644
--- a/htdocs/openwebrx.js
+++ b/htdocs/openwebrx.js
@@ -2693,6 +2693,7 @@ function demodulator_digital_replace(subtype)
case "bpsk31":
case "rtty":
case "ft8":
+ case "wspr":
secondary_demod_start(subtype);
demodulator_analog_replace('usb', true);
demodulator_buttons_update();
@@ -2700,7 +2701,7 @@ function demodulator_digital_replace(subtype)
}
$('#openwebrx-panel-digimodes').attr('data-mode', subtype);
toggle_panel("openwebrx-panel-digimodes", true);
- toggle_panel("openwebrx-panel-wsjt-message", subtype == 'ft8');
+ toggle_panel("openwebrx-panel-wsjt-message", ['ft8', 'wspr'].indexOf(subtype) >= 0);
}
function secondary_demod_create_canvas()
@@ -2862,20 +2863,17 @@ function secondary_demod_waterfall_dequeue()
secondary_demod_listbox_updating = false;
function secondary_demod_listbox_changed()
{
- if(secondary_demod_listbox_updating) return;
- switch ($("#openwebrx-secondary-demod-listbox")[0].value)
- {
+ if (secondary_demod_listbox_updating) return;
+ var sdm = $("#openwebrx-secondary-demod-listbox")[0].value;
+ switch (sdm) {
case "none":
demodulator_analog_replace_last();
break;
case "bpsk31":
- demodulator_digital_replace('bpsk31');
- break;
case "rtty":
- demodulator_digital_replace('rtty');
- break;
case "ft8":
- demodulator_digital_replace('ft8');
+ case "wspr":
+ demodulator_digital_replace(sdm);
break;
}
}
diff --git a/owrx/feature.py b/owrx/feature.py
index 9009e13..cd67f62 100644
--- a/owrx/feature.py
+++ b/owrx/feature.py
@@ -165,13 +165,15 @@ class FeatureDetector(object):
return version >= required_version
except FileNotFoundError:
return False
- return reduce(and_,
- map(
- check_digiham_version,
- ["rrc_filter", "ysf_decoder", "dmr_decoder", "mbe_synthesizer", "gfsk_demodulator",
- "digitalvoice_filter"]
- ),
- True)
+ return reduce(
+ and_,
+ map(
+ check_digiham_version,
+ ["rrc_filter", "ysf_decoder", "dmr_decoder", "mbe_synthesizer", "gfsk_demodulator",
+ "digitalvoice_filter"]
+ ),
+ True
+ )
def has_dsd(self):
"""
@@ -201,4 +203,11 @@ class FeatureDetector(object):
[WSJT-X homepage](https://physics.princeton.edu/pulsar/k1jt/wsjtx.html) for ready-made packages or instructions
on how to build from source.
"""
- return self.command_is_runnable("jt9")
+ return reduce(
+ and_,
+ map(
+ self.command_is_runnable,
+ ["jt9", "wsprd"]
+ ),
+ True
+ )
diff --git a/owrx/wsjt.py b/owrx/wsjt.py
index fc07b10..5d0c447 100644
--- a/owrx/wsjt.py
+++ b/owrx/wsjt.py
@@ -14,7 +14,7 @@ import logging
logger = logging.getLogger(__name__)
-class Ft8Chopper(threading.Thread):
+class WsjtChopper(threading.Thread):
def __init__(self, source):
self.source = source
self.tmp_dir = PropertyManager.getSharedInstance()["temporary_directory"]
@@ -27,7 +27,7 @@ class Ft8Chopper(threading.Thread):
super().__init__()
def getWaveFile(self):
- filename = "{tmp_dir}/openwebrx-ft8chopper-{id}-{timestamp}.wav".format(
+ filename = "{tmp_dir}/openwebrx-wsjtchopper-{id}-{timestamp}.wav".format(
tmp_dir = self.tmp_dir,
id = id(self),
timestamp = datetime.now().strftime("%Y%m%d-%H%M%S")
@@ -40,11 +40,10 @@ class Ft8Chopper(threading.Thread):
def getNextDecodingTime(self):
t = datetime.now()
- seconds = (int(t.second / 15) + 1) * 15
- if seconds >= 60:
- t = t + timedelta(minutes = 1)
- seconds = 0
- t = t.replace(second = seconds, microsecond = 0)
+ 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)
logger.debug("scheduling: {0}".format(t))
return t.timestamp()
@@ -70,10 +69,15 @@ class Ft8Chopper(threading.Thread):
self.fileQueue.append(filename)
self._scheduleNextSwitch()
+ def decoder_commandline(self, file):
+ '''
+ must be overridden in child classes
+ '''
+ return []
+
def decode(self):
def decode_and_unlink(file):
- #TODO expose decoding quality parameters through config
- decoder = subprocess.Popen(["jt9", "--ft8", "-d", "3", file], stdout=subprocess.PIPE, cwd=self.tmp_dir)
+ decoder = subprocess.Popen(self.decoder_commandline(file), stdout=subprocess.PIPE, cwd=self.tmp_dir)
while True:
line = decoder.stdout.readline()
if line is None or (isinstance(line, bytes) and len(line) == 0):
@@ -91,12 +95,12 @@ class Ft8Chopper(threading.Thread):
threading.Thread(target=decode_and_unlink, args=[file]).start()
def run(self) -> None:
- logger.debug("FT8 chopper starting up")
+ logger.debug("WSJT chopper starting up")
self.startScheduler()
while self.doRun:
data = self.source.read(256)
if data is None or (isinstance(data, bytes) and len(data) == 0):
- logger.warning("zero read on ft8 chopper")
+ logger.warning("zero read on WSJT chopper")
self.doRun = False
else:
self.switchingLock.acquire()
@@ -104,7 +108,7 @@ class Ft8Chopper(threading.Thread):
self.switchingLock.release()
self.decode()
- logger.debug("FT8 chopper shutting down")
+ logger.debug("WSJT chopper shutting down")
self.outputReader.close()
self.outputWriter.close()
self.emptyScheduler()
@@ -120,10 +124,34 @@ class Ft8Chopper(threading.Thread):
return None
+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]
+
+
class WsjtParser(object):
+ locator_pattern = re.compile(".*\\s([A-Z0-9]+)\\s([A-R]{2}[0-9]{2})$")
+ jt9_pattern = re.compile("^[0-9]{6} .*")
+ wspr_pattern = re.compile("^[0-9]{4} .*")
+ wspr_splitter_pattern = re.compile("([A-Z0-9]*)\\s([A-R]{2}[0-9]{2})\\s([0-90]+)")
+
def __init__(self, handler):
self.handler = handler
- self.locator_pattern = re.compile(".*\s([A-Z0-9]+)\s([A-R]{2}[0-9]{2})$")
modes = {
"~": "FT8"
@@ -132,8 +160,6 @@ class WsjtParser(object):
def parse(self, data):
try:
msg = data.decode().rstrip()
- # sample
- # '222100 -15 -0.0 508 ~ CQ EA7MJ IM66'
# known debug messages we know to skip
if msg.startswith(""):
return
@@ -141,23 +167,33 @@ class WsjtParser(object):
return
out = {}
- ts = datetime.strptime(msg[0:6], "%H%M%S")
- out["timestamp"] = int(datetime.combine(date.today(), ts.time(), datetime.now().tzinfo).timestamp() * 1000)
- out["db"] = float(msg[7:10])
- out["dt"] = float(msg[11:15])
- out["freq"] = int(msg[16:20])
- modeChar = msg[21:22]
- out["mode"] = mode = WsjtParser.modes[modeChar] if modeChar in WsjtParser.modes else "unknown"
- wsjt_msg = msg[24:60].strip()
- self.parseLocator(wsjt_msg, mode)
- out["msg"] = wsjt_msg
+ if WsjtParser.jt9_pattern.match(msg):
+ out = self.parse_from_jt9(msg)
+ elif WsjtParser.wspr_pattern.match(msg):
+ out = self.parse_from_wsprd(msg)
self.handler.write_wsjt_message(out)
except ValueError:
logger.exception("error while parsing wsjt message")
+ def parse_from_jt9(self, msg):
+ # ft8 sample
+ # '222100 -15 -0.0 508 ~ CQ EA7MJ IM66'
+ out = {}
+ ts = datetime.strptime(msg[0:6], "%H%M%S")
+ out["timestamp"] = int(datetime.combine(date.today(), ts.time(), datetime.now().tzinfo).timestamp() * 1000)
+ out["db"] = float(msg[7:10])
+ out["dt"] = float(msg[11:15])
+ out["freq"] = int(msg[16:20])
+ modeChar = msg[21:22]
+ out["mode"] = mode = WsjtParser.modes[modeChar] if modeChar in WsjtParser.modes else "unknown"
+ wsjt_msg = msg[24:60].strip()
+ self.parseLocator(wsjt_msg, mode)
+ out["msg"] = wsjt_msg
+ return out
+
def parseLocator(self, msg, mode):
- m = self.locator_pattern.match(msg)
+ m = WsjtParser.locator_pattern.match(msg)
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
@@ -165,3 +201,26 @@ class WsjtParser(object):
if m.group(2) == "RR73":
return
Map.getSharedInstance().updateLocation(m.group(1), LocatorLocation(m.group(2)), mode)
+
+ 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])
+ out["mode"] = "wspr"
+ wsjt_msg = msg[29:60].strip()
+ 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
+ Map.getSharedInstance().updateLocation(m.group(1), LocatorLocation(m.group(2)), "WSPR")