implement wspr
This commit is contained in:
parent
420b0c60d7
commit
6d5c8491e4
23
csdr.py
23
csdr.py
@ -25,7 +25,7 @@ import os
|
|||||||
import signal
|
import signal
|
||||||
import threading
|
import threading
|
||||||
from functools import partial
|
from functools import partial
|
||||||
from owrx.wsjt import Ft8Chopper
|
from owrx.wsjt import Ft8Chopper, WsprChopper
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
@ -174,7 +174,7 @@ class dsp(object):
|
|||||||
"csdr limit_ff"
|
"csdr limit_ff"
|
||||||
]
|
]
|
||||||
# fixed sample rate necessary for the wsjt-x tools. fix with sox...
|
# 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 += [
|
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 - "
|
"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 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 dbpsk_decoder_c_u8 | " + \
|
||||||
"CSDR_FIXED_BUFSIZE=1 csdr psk31_varicode_decoder_u8_u8"
|
"CSDR_FIXED_BUFSIZE=1 csdr psk31_varicode_decoder_u8_u8"
|
||||||
elif which == "ft8":
|
elif self.isWsjtMode(which):
|
||||||
chain = secondary_chain_base + "csdr realpart_cf | "
|
chain = secondary_chain_base + "csdr realpart_cf | "
|
||||||
if self.last_decimation != 1.0 :
|
if self.last_decimation != 1.0 :
|
||||||
chain += "csdr fractional_decimator_ff {last_decimation} | "
|
chain += "csdr fractional_decimator_ff {last_decimation} | "
|
||||||
chain += "csdr agc_ff | csdr limit_ff | csdr convert_f_s16"
|
chain += "csdr agc_ff | csdr limit_ff | csdr convert_f_s16"
|
||||||
@ -271,8 +271,12 @@ class dsp(object):
|
|||||||
self.secondary_processes_running = True
|
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())))
|
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":
|
if self.isWsjtMode():
|
||||||
chopper = Ft8Chopper(self.secondary_process_demod.stdout)
|
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()
|
chopper.start()
|
||||||
self.output.add_output("wsjt_demod", chopper.read)
|
self.output.add_output("wsjt_demod", chopper.read)
|
||||||
else:
|
else:
|
||||||
@ -355,7 +359,7 @@ class dsp(object):
|
|||||||
def get_audio_rate(self):
|
def get_audio_rate(self):
|
||||||
if self.isDigitalVoice():
|
if self.isDigitalVoice():
|
||||||
return 48000
|
return 48000
|
||||||
elif self.secondary_demodulator == "ft8":
|
elif self.isWsjtMode():
|
||||||
return 12000
|
return 12000
|
||||||
return self.get_output_rate()
|
return self.get_output_rate()
|
||||||
|
|
||||||
@ -364,6 +368,11 @@ class dsp(object):
|
|||||||
demodulator = self.get_demodulator()
|
demodulator = self.get_demodulator()
|
||||||
return demodulator in ["dmr", "dstar", "nxdn", "ysf"]
|
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):
|
def set_output_rate(self,output_rate):
|
||||||
self.output_rate=output_rate
|
self.output_rate=output_rate
|
||||||
self.calculate_decimation()
|
self.calculate_decimation()
|
||||||
|
@ -835,15 +835,21 @@ img.openwebrx-mirror-img
|
|||||||
width: 35px;
|
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;
|
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;
|
height: 200px;
|
||||||
margin: -10px;
|
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;
|
display: none;
|
||||||
}
|
}
|
@ -81,6 +81,7 @@
|
|||||||
<option value="none"></option>
|
<option value="none"></option>
|
||||||
<option value="bpsk31">BPSK31</option>
|
<option value="bpsk31">BPSK31</option>
|
||||||
<option value="ft8">FT8</option>
|
<option value="ft8">FT8</option>
|
||||||
|
<option value="wspr">WSPR</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div class="openwebrx-panel-line">
|
<div class="openwebrx-panel-line">
|
||||||
|
@ -2693,6 +2693,7 @@ function demodulator_digital_replace(subtype)
|
|||||||
case "bpsk31":
|
case "bpsk31":
|
||||||
case "rtty":
|
case "rtty":
|
||||||
case "ft8":
|
case "ft8":
|
||||||
|
case "wspr":
|
||||||
secondary_demod_start(subtype);
|
secondary_demod_start(subtype);
|
||||||
demodulator_analog_replace('usb', true);
|
demodulator_analog_replace('usb', true);
|
||||||
demodulator_buttons_update();
|
demodulator_buttons_update();
|
||||||
@ -2700,7 +2701,7 @@ function demodulator_digital_replace(subtype)
|
|||||||
}
|
}
|
||||||
$('#openwebrx-panel-digimodes').attr('data-mode', subtype);
|
$('#openwebrx-panel-digimodes').attr('data-mode', subtype);
|
||||||
toggle_panel("openwebrx-panel-digimodes", true);
|
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()
|
function secondary_demod_create_canvas()
|
||||||
@ -2862,20 +2863,17 @@ function secondary_demod_waterfall_dequeue()
|
|||||||
secondary_demod_listbox_updating = false;
|
secondary_demod_listbox_updating = false;
|
||||||
function secondary_demod_listbox_changed()
|
function secondary_demod_listbox_changed()
|
||||||
{
|
{
|
||||||
if(secondary_demod_listbox_updating) return;
|
if (secondary_demod_listbox_updating) return;
|
||||||
switch ($("#openwebrx-secondary-demod-listbox")[0].value)
|
var sdm = $("#openwebrx-secondary-demod-listbox")[0].value;
|
||||||
{
|
switch (sdm) {
|
||||||
case "none":
|
case "none":
|
||||||
demodulator_analog_replace_last();
|
demodulator_analog_replace_last();
|
||||||
break;
|
break;
|
||||||
case "bpsk31":
|
case "bpsk31":
|
||||||
demodulator_digital_replace('bpsk31');
|
|
||||||
break;
|
|
||||||
case "rtty":
|
case "rtty":
|
||||||
demodulator_digital_replace('rtty');
|
|
||||||
break;
|
|
||||||
case "ft8":
|
case "ft8":
|
||||||
demodulator_digital_replace('ft8');
|
case "wspr":
|
||||||
|
demodulator_digital_replace(sdm);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -165,13 +165,15 @@ class FeatureDetector(object):
|
|||||||
return version >= required_version
|
return version >= required_version
|
||||||
except FileNotFoundError:
|
except FileNotFoundError:
|
||||||
return False
|
return False
|
||||||
return reduce(and_,
|
return reduce(
|
||||||
map(
|
and_,
|
||||||
check_digiham_version,
|
map(
|
||||||
["rrc_filter", "ysf_decoder", "dmr_decoder", "mbe_synthesizer", "gfsk_demodulator",
|
check_digiham_version,
|
||||||
"digitalvoice_filter"]
|
["rrc_filter", "ysf_decoder", "dmr_decoder", "mbe_synthesizer", "gfsk_demodulator",
|
||||||
),
|
"digitalvoice_filter"]
|
||||||
True)
|
),
|
||||||
|
True
|
||||||
|
)
|
||||||
|
|
||||||
def has_dsd(self):
|
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
|
[WSJT-X homepage](https://physics.princeton.edu/pulsar/k1jt/wsjtx.html) for ready-made packages or instructions
|
||||||
on how to build from source.
|
on how to build from source.
|
||||||
"""
|
"""
|
||||||
return self.command_is_runnable("jt9")
|
return reduce(
|
||||||
|
and_,
|
||||||
|
map(
|
||||||
|
self.command_is_runnable,
|
||||||
|
["jt9", "wsprd"]
|
||||||
|
),
|
||||||
|
True
|
||||||
|
)
|
||||||
|
111
owrx/wsjt.py
111
owrx/wsjt.py
@ -14,7 +14,7 @@ import logging
|
|||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
class Ft8Chopper(threading.Thread):
|
class WsjtChopper(threading.Thread):
|
||||||
def __init__(self, source):
|
def __init__(self, source):
|
||||||
self.source = source
|
self.source = source
|
||||||
self.tmp_dir = PropertyManager.getSharedInstance()["temporary_directory"]
|
self.tmp_dir = PropertyManager.getSharedInstance()["temporary_directory"]
|
||||||
@ -27,7 +27,7 @@ class Ft8Chopper(threading.Thread):
|
|||||||
super().__init__()
|
super().__init__()
|
||||||
|
|
||||||
def getWaveFile(self):
|
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,
|
tmp_dir = self.tmp_dir,
|
||||||
id = id(self),
|
id = id(self),
|
||||||
timestamp = datetime.now().strftime("%Y%m%d-%H%M%S")
|
timestamp = datetime.now().strftime("%Y%m%d-%H%M%S")
|
||||||
@ -40,11 +40,10 @@ class Ft8Chopper(threading.Thread):
|
|||||||
|
|
||||||
def getNextDecodingTime(self):
|
def getNextDecodingTime(self):
|
||||||
t = datetime.now()
|
t = datetime.now()
|
||||||
seconds = (int(t.second / 15) + 1) * 15
|
zeroed = t.replace(minute=0, second=0, microsecond=0)
|
||||||
if seconds >= 60:
|
delta = t - zeroed
|
||||||
t = t + timedelta(minutes = 1)
|
seconds = (int(delta.total_seconds() / self.interval) + 1) * self.interval
|
||||||
seconds = 0
|
t = zeroed + timedelta(seconds = seconds)
|
||||||
t = t.replace(second = seconds, microsecond = 0)
|
|
||||||
logger.debug("scheduling: {0}".format(t))
|
logger.debug("scheduling: {0}".format(t))
|
||||||
return t.timestamp()
|
return t.timestamp()
|
||||||
|
|
||||||
@ -70,10 +69,15 @@ class Ft8Chopper(threading.Thread):
|
|||||||
self.fileQueue.append(filename)
|
self.fileQueue.append(filename)
|
||||||
self._scheduleNextSwitch()
|
self._scheduleNextSwitch()
|
||||||
|
|
||||||
|
def decoder_commandline(self, file):
|
||||||
|
'''
|
||||||
|
must be overridden in child classes
|
||||||
|
'''
|
||||||
|
return []
|
||||||
|
|
||||||
def decode(self):
|
def decode(self):
|
||||||
def decode_and_unlink(file):
|
def decode_and_unlink(file):
|
||||||
#TODO expose decoding quality parameters through config
|
decoder = subprocess.Popen(self.decoder_commandline(file), stdout=subprocess.PIPE, cwd=self.tmp_dir)
|
||||||
decoder = subprocess.Popen(["jt9", "--ft8", "-d", "3", file], stdout=subprocess.PIPE, cwd=self.tmp_dir)
|
|
||||||
while True:
|
while True:
|
||||||
line = decoder.stdout.readline()
|
line = decoder.stdout.readline()
|
||||||
if line is None or (isinstance(line, bytes) and len(line) == 0):
|
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()
|
threading.Thread(target=decode_and_unlink, args=[file]).start()
|
||||||
|
|
||||||
def run(self) -> None:
|
def run(self) -> None:
|
||||||
logger.debug("FT8 chopper starting up")
|
logger.debug("WSJT chopper starting up")
|
||||||
self.startScheduler()
|
self.startScheduler()
|
||||||
while self.doRun:
|
while self.doRun:
|
||||||
data = self.source.read(256)
|
data = self.source.read(256)
|
||||||
if data is None or (isinstance(data, bytes) and len(data) == 0):
|
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
|
self.doRun = False
|
||||||
else:
|
else:
|
||||||
self.switchingLock.acquire()
|
self.switchingLock.acquire()
|
||||||
@ -104,7 +108,7 @@ class Ft8Chopper(threading.Thread):
|
|||||||
self.switchingLock.release()
|
self.switchingLock.release()
|
||||||
|
|
||||||
self.decode()
|
self.decode()
|
||||||
logger.debug("FT8 chopper shutting down")
|
logger.debug("WSJT chopper shutting down")
|
||||||
self.outputReader.close()
|
self.outputReader.close()
|
||||||
self.outputWriter.close()
|
self.outputWriter.close()
|
||||||
self.emptyScheduler()
|
self.emptyScheduler()
|
||||||
@ -120,10 +124,34 @@ class Ft8Chopper(threading.Thread):
|
|||||||
return None
|
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):
|
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):
|
def __init__(self, handler):
|
||||||
self.handler = handler
|
self.handler = handler
|
||||||
self.locator_pattern = re.compile(".*\s([A-Z0-9]+)\s([A-R]{2}[0-9]{2})$")
|
|
||||||
|
|
||||||
modes = {
|
modes = {
|
||||||
"~": "FT8"
|
"~": "FT8"
|
||||||
@ -132,8 +160,6 @@ class WsjtParser(object):
|
|||||||
def parse(self, data):
|
def parse(self, data):
|
||||||
try:
|
try:
|
||||||
msg = data.decode().rstrip()
|
msg = data.decode().rstrip()
|
||||||
# sample
|
|
||||||
# '222100 -15 -0.0 508 ~ CQ EA7MJ IM66'
|
|
||||||
# known debug messages we know to skip
|
# known debug messages we know to skip
|
||||||
if msg.startswith("<DecodeFinished>"):
|
if msg.startswith("<DecodeFinished>"):
|
||||||
return
|
return
|
||||||
@ -141,23 +167,33 @@ class WsjtParser(object):
|
|||||||
return
|
return
|
||||||
|
|
||||||
out = {}
|
out = {}
|
||||||
ts = datetime.strptime(msg[0:6], "%H%M%S")
|
if WsjtParser.jt9_pattern.match(msg):
|
||||||
out["timestamp"] = int(datetime.combine(date.today(), ts.time(), datetime.now().tzinfo).timestamp() * 1000)
|
out = self.parse_from_jt9(msg)
|
||||||
out["db"] = float(msg[7:10])
|
elif WsjtParser.wspr_pattern.match(msg):
|
||||||
out["dt"] = float(msg[11:15])
|
out = self.parse_from_wsprd(msg)
|
||||||
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
|
|
||||||
|
|
||||||
self.handler.write_wsjt_message(out)
|
self.handler.write_wsjt_message(out)
|
||||||
except ValueError:
|
except ValueError:
|
||||||
logger.exception("error while parsing wsjt message")
|
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):
|
def parseLocator(self, msg, mode):
|
||||||
m = self.locator_pattern.match(msg)
|
m = WsjtParser.locator_pattern.match(msg)
|
||||||
if m is None:
|
if m is None:
|
||||||
return
|
return
|
||||||
# this is a valid locator in theory, but it's somewhere in the arctic ocean, near the north pole, so it's very
|
# 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":
|
if m.group(2) == "RR73":
|
||||||
return
|
return
|
||||||
Map.getSharedInstance().updateLocation(m.group(1), LocatorLocation(m.group(2)), mode)
|
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")
|
||||||
|
Loading…
Reference in New Issue
Block a user