Added CWSkimmer decoder using csdr-cwskimmer tool.

This commit is contained in:
Marat Fayzullin 2024-11-21 19:09:20 -05:00
parent 3964f9a23c
commit c91365a612
12 changed files with 246 additions and 43 deletions

View File

@ -18,6 +18,7 @@ GIT_CSDR_ETI=https://github.com/luarvique/csdr-eti.git
GIT_PYCSDR_ETI=https://github.com/luarvique/pycsdr-eti.git
GIT_JS8PY=https://github.com/jketterl/js8py.git
GIT_REDSEA=https://github.com/luarvique/redsea.git
GIT_CWSKIMMER=https://github.com/luarvique/csdr-cwskimmer.git
GIT_SOAPYSDRPLAY3=https://github.com/luarvique/SoapySDRPlay3.git
GIT_OPENWEBRX=https://github.com/luarvique/openwebrx.git
@ -45,6 +46,8 @@ if [ "${1:-}" == "--ask" ]; then
[[ "$ret" == [Yy]* ]] && BUILD_JS8PY=y || BUILD_JS8PY=n
echo;read -n1 -p "Build Redsea? [yN] " ret
[[ "$ret" == [Yy]* ]] && BUILD_REDSEA=y || BUILD_REDSEA=n
echo;read -n1 -p "Build csdr-cwskimmer? [yN] " ret
[[ "$ret" == [Yy]* ]] && BUILD_CWSKIMMER=y || BUILD_CWSKIMMER=n
echo;read -n1 -p "Build SoapySDRPlay3? [yN] " ret
[[ "$ret" == [Yy]* ]] && BUILD_SOAPYSDRPLAY3=y || BUILD_SOAPYSDRPLAY3=n
echo;read -n1 -p "Build OpenWebRX+? [Yn] " ret
@ -65,6 +68,7 @@ else
BUILD_PYCSDR_ETI=y
BUILD_JS8PY=y
BUILD_REDSEA=y
BUILD_CWSKIMMER=y
CLEAN_OUTPUT=y
fi
@ -100,6 +104,7 @@ echo "csdr-eti: $BUILD_CSDR_ETI"
echo "pycsdr-eti: $BUILD_PYCSDR_ETI"
echo "js8py: $BUILD_JS8PY"
echo "redsea: $BUILD_REDSEA"
echo "csdr-cwskimmer: $BUILD_CWSKIMMER"
echo "SoapySDRPlay3: $BUILD_SOAPYSDRPLAY3"
echo "OpenWebRx: $BUILD_OWRX"
echo "Clean OUTPUT folder: $CLEAN_OUTPUT"
@ -226,6 +231,17 @@ if [ "${BUILD_REDSEA:-}" == "y" ]; then
#sudo dpkg -i *redsea*.deb
fi
if [ "${BUILD_CWSKIMMER:-}" == "y" ]; then
echo "##### Building csdr-cwskimmer... #####"
git clone -b master "$GIT_CWSKIMMER"
pushd csdr-cwskimmer
dpkg-buildpackage -us -uc
popd
# Not installing csdr-cwskimmer here since there are no further
# build steps depending on it
#sudo dpkg -i csdr-cwskimmer*.deb
fi
if [ "${BUILD_SOAPYSDRPLAY3:-}" == "y" ]; then
echo "##### Building SoapySDRPlay3 ... #####"
git clone -b master "$GIT_SOAPYSDRPLAY3"

View File

@ -1,8 +1,8 @@
from csdr.chain.demodulator import ServiceDemodulator, DialFrequencyReceiver
from csdr.module.toolbox import Rtl433Module, MultimonModule, DumpHfdlModule, DumpVdl2Module, Dump1090Module, AcarsDecModule, RedseaModule, SatDumpModule
from csdr.module.toolbox import Rtl433Module, MultimonModule, DumpHfdlModule, DumpVdl2Module, Dump1090Module, AcarsDecModule, RedseaModule, SatDumpModule, CwSkimmerModule
from pycsdr.modules import FmDemod, AudioResampler, Convert, Agc, Squelch
from pycsdr.types import Format
from owrx.toolbox import TextParser, PageParser, SelCallParser, EasParser, IsmParser, RdsParser
from owrx.toolbox import TextParser, PageParser, SelCallParser, EasParser, IsmParser, RdsParser, CwSkimmerParser
from owrx.aircraft import HfdlParser, Vdl2Parser, AdsbParser, AcarsParser
from datetime import datetime
@ -223,6 +223,28 @@ class RdsDemodulator(ServiceDemodulator, DialFrequencyReceiver):
self.parser.setDialFrequency(frequency)
class CwSkimmerDemodulator(ServiceDemodulator, DialFrequencyReceiver):
def __init__(self, sampleRate: int = 48000, charCount: int = 4, service: bool = False):
self.sampleRate = sampleRate
self.parser = CwSkimmerParser(service)
workers = [
Convert(Format.FLOAT, Format.SHORT),
CwSkimmerModule(sampleRate, charCount),
self.parser,
]
# Connect all the workers
super().__init__(workers)
def getFixedAudioRate(self) -> int:
return self.sampleRate
def supportsSquelch(self) -> bool:
return False
def setDialFrequency(self, frequency: int) -> None:
self.parser.setDialFrequency(frequency)
class NoaaAptDemodulator(ServiceDemodulator):
def __init__(self, satellite: int = 19, service: bool = False):
d = datetime.utcnow()

View File

@ -119,6 +119,12 @@ class AcarsDecModule(WavFileModule):
return Format.CHAR
class CwSkimmerModule(ExecModule):
def __init__(self, sampleRate: int = 48000, charCount: int = 4):
cmd = ["csdr-cwskimmer", "-i", "-r", str(sampleRate), "-n", str(charCount)]
super().__init__(Format.SHORT, Format.CHAR, cmd)
class RedseaModule(ExecModule):
def __init__(self, sampleRate: int = 171000, rbds: bool = False):
cmd = [ "redsea", "--input", "mpx", "--samplerate", str(sampleRate) ]

View File

@ -1376,6 +1376,62 @@ img.openwebrx-mirror-img
text-align: right;
}
#openwebrx-panel-cwskimmer-message {
height: 310px;
}
#openwebrx-panel-cwskimmer-message tbody {
height: 280px;
}
#openwebrx-panel-cwskimmer-message .freq {
width: 70px;
}
#openwebrx-panel-cwskimmer-message td.freq {
text-align: right;
}
#openwebrx-panel-cwskimmer-message .text {
width: 500px;
}
#openwebrx-panel-cwskimmer-message td.text {
font-family: monospace;
white-space: nowrap;
}
#openwebrx-panel-ism-message {
height: 310px;
}
#openwebrx-panel-ism-message tbody {
height: 280px;
}
#openwebrx-panel-ism-message .address {
width: 120px;
text-align: center;
}
#openwebrx-panel-ism-message .device {
width: 220px;
text-align: center;
}
#openwebrx-panel-ism-message .timestamp {
width: 246px;
max-width: 486px;
white-space: pre;
text-align: center;
}
#openwebrx-panel-ism-message .attr {
width: 283px;
padding-left: 10px;
padding-right: 10px;
}
#openwebrx-panel-dsc-message {
height: 310px;
}
@ -1415,37 +1471,6 @@ img.openwebrx-mirror-img
word-wrap: break-word;
}
#openwebrx-panel-ism-message {
height: 310px;
}
#openwebrx-panel-ism-message tbody {
height: 280px;
}
#openwebrx-panel-ism-message .address {
width: 120px;
text-align: center;
}
#openwebrx-panel-ism-message .device {
width: 220px;
text-align: center;
}
#openwebrx-panel-ism-message .timestamp {
width: 246px;
max-width: 486px;
white-space: pre;
text-align: center;
}
#openwebrx-panel-ism-message .attr {
width: 283px;
padding-left: 10px;
padding-right: 10px;
}
#openwebrx-panel-sstv-message {
height: 310px;
width: 365px;
@ -1588,6 +1613,7 @@ img.openwebrx-mirror-img
#openwebrx-panel-digimodes[data-mode="fax"] #openwebrx-digimode-content-container,
#openwebrx-panel-digimodes[data-mode="ism"] #openwebrx-digimode-content-container,
#openwebrx-panel-digimodes[data-mode="dsc"] #openwebrx-digimode-content-container,
#openwebrx-panel-digimodes[data-mode="cwskimmer"] #openwebrx-digimode-content-container,
#openwebrx-panel-digimodes[data-mode="ft8"] #openwebrx-digimode-select-channel,
#openwebrx-panel-digimodes[data-mode="wspr"] #openwebrx-digimode-select-channel,
#openwebrx-panel-digimodes[data-mode="jt65"] #openwebrx-digimode-select-channel,
@ -1611,7 +1637,8 @@ img.openwebrx-mirror-img
#openwebrx-panel-digimodes[data-mode="ism"] #openwebrx-digimode-select-channel,
#openwebrx-panel-digimodes[data-mode="selcall"] #openwebrx-digimode-select-channel,
#openwebrx-panel-digimodes[data-mode="zvei"] #openwebrx-digimode-select-channel,
#openwebrx-panel-digimodes[data-mode="eas"] #openwebrx-digimode-select-channel
#openwebrx-panel-digimodes[data-mode="eas"] #openwebrx-digimode-select-channel,
#openwebrx-panel-digimodes[data-mode="cwskimmer"] #openwebrx-digimode-select-channel
{
display: none;
}
@ -1637,7 +1664,8 @@ img.openwebrx-mirror-img
#openwebrx-panel-digimodes[data-mode="sstv"] #openwebrx-digimode-canvas-container,
#openwebrx-panel-digimodes[data-mode="fax"] #openwebrx-digimode-canvas-container,
#openwebrx-panel-digimodes[data-mode="ism"] #openwebrx-digimode-canvas-container,
#openwebrx-panel-digimodes[data-mode="dsc"] #openwebrx-digimode-canvas-container
#openwebrx-panel-digimodes[data-mode="dsc"] #openwebrx-digimode-canvas-container,
#openwebrx-panel-digimodes[data-mode="cwskimmer"] #openwebrx-digimode-canvas-container
{
height: 200px;
margin: -10px;

View File

@ -98,6 +98,7 @@
<div class="openwebrx-panel openwebrx-message-panel" id="openwebrx-panel-adsb-message" style="display: none; width: 619px;" data-panel-name="adsb-message"></div>
<div class="openwebrx-panel openwebrx-message-panel" id="openwebrx-panel-ism-message" style="display: none; width: 619px;" data-panel-name="ism-message"></div>
<div class="openwebrx-panel openwebrx-message-panel" id="openwebrx-panel-dsc-message" style="display: none; width: 619px;" data-panel-name="dsc-message"></div>
<div class="openwebrx-panel openwebrx-message-panel" id="openwebrx-panel-cwskimmer-message" style="display: none; width: 619px;" data-panel-name="cwskimmer-message"></div>
<div class="openwebrx-panel openwebrx-meta-panel" id="openwebrx-panel-metadata-m17" style="display: none;" data-panel-name="metadata-m17">
<div class="openwebrx-meta-slot">
<div class="openwebrx-meta-user-image">

View File

@ -173,12 +173,10 @@ DemodulatorPanel.prototype.updatePanels = function() {
toggle_panel("openwebrx-panel-wsjt-message", ['ft8', 'wspr', 'jt65', 'jt9', 'ft4', 'fst4', 'fst4w', "q65", "msk144"].indexOf(modulation) >= 0);
// Aeronautic modes share the same panel
toggle_panel("openwebrx-panel-hfdl-message", ['hfdl', 'vdl2', 'acars'].indexOf(modulation) >= 0);
// But ADSB has its own panel
toggle_panel("openwebrx-panel-adsb-message", modulation === 'adsb');
// Packet modes share the same panel
toggle_panel("openwebrx-panel-packet-message", ['packet', 'ais'].indexOf(modulation) >= 0);
// these modes come with their own
['js8', 'page', 'pocsag', 'sstv', 'fax', 'ism', 'dsc'].forEach(function(m) {
// These modes come with their own panels
['js8', 'page', 'pocsag', 'sstv', 'fax', 'ism', 'dsc', 'adsb', 'cwskimmer'].forEach(function(m) {
toggle_panel('openwebrx-panel-' + m + '-message', modulation === m);
});

View File

@ -877,3 +877,78 @@ $.fn.faxMessagePanel = function() {
}
return this.data('panel');
};
CwSkimmerMessagePanel = function(el) {
MessagePanel.call(this, el);
this.initClearTimer();
this.texts = [];
}
CwSkimmerMessagePanel.prototype = Object.create(MessagePanel.prototype);
CwSkimmerMessagePanel.prototype.supportsMessage = function(message) {
return message['mode'] === 'CW';
};
CwSkimmerMessagePanel.prototype.render = function() {
$(this.el).append($(
'<table width="100%">' +
'<thead><tr>' +
'<th class="freq">Freq</th>' +
'<th class="text">Text</th>' +
'</tr></thead>' +
'<tbody></tbody>' +
'</table>'
));
};
CwSkimmerMessagePanel.prototype.pushMessage = function(msg) {
// Must have some text
if (!msg.text) return;
// Clear cache if requested
if (msg.changed) this.texts = [];
// Current time
var now = Date.now();
// Modify or add a new entry
var j = this.texts.findIndex(function(x) { return x.freq >= msg.freq });
if (j < 0) {
// Append a new entry
this.texts.push({ freq: msg.freq, text: msg.text, ts: now });
} else if (this.texts[j].freq == msg.freq) {
// Update existing entry
this.texts[j].text = (this.texts[j].text + msg.text).slice(-64);
this.texts[j].ts = now;
} else {
// Insert a new entry
this.texts.splice(j, 0, { freq: msg.freq, text: msg.text, ts: now });
}
// Generate table body
var body = '';
for (var j = 0 ; j < this.texts.length ; j++) {
// Limit the lifetime of entries depending on their length
var cutoff = 5000 * this.texts[j].text.length;
if (now - this.texts[j].ts >= cutoff) {
this.texts.splice(j--, 1);
} else {
var f = Math.floor(this.texts[j].freq / 100.0) / 10.0;
body +=
'<tr style="color:black;background-color:' + (j&1? '#E0FFE0':'#FFFFFF') +
';"><td class="freq">' + f.toFixed(1) +
'</td><td class="text">' + this.texts[j].text + '</td></tr>\n';
}
}
// Assign new table body
$(this.el).find('tbody').html(body);
};
$.fn.cwskimmerMessagePanel = function() {
if (!this.data('panel')) {
this.data('panel', new CwSkimmerMessagePanel(this));
}
return this.data('panel');
};

View File

@ -1056,7 +1056,7 @@ function on_ws_recv(evt) {
break;
case 'secondary_demod':
var value = json['value'];
var panels = ['wsjt', 'packet', 'pocsag', 'page', 'sstv', 'fax', 'ism', 'hfdl', 'adsb', 'dsc'].map(function(id) {
var panels = ['wsjt', 'packet', 'pocsag', 'page', 'sstv', 'fax', 'ism', 'hfdl', 'adsb', 'dsc', 'cwskimmer'].map(function(id) {
return $('#openwebrx-panel-' + id + '-message')[id + 'MessagePanel']();
});
panels.push($('#openwebrx-panel-js8-message').js8());

View File

@ -720,6 +720,9 @@ class DspManager(SdrSourceEventClient, ClientDemodulatorSecondaryDspEventClient)
elif mod == "cwdecoder":
from csdr.chain.digimodes import CwDemodulator
return CwDemodulator(75.0)
elif mod == "cwskimmer":
from csdr.chain.toolbox import CwSkimmerDemodulator
return CwSkimmerDemodulator()
elif mod == "mfrtty170":
from csdr.chain.digimodes import MFRttyDemodulator
return MFRttyDemodulator(170.0, 45.45, reverse = False)

View File

@ -101,6 +101,7 @@ class FeatureDetector(object):
"mqtt": ["paho_mqtt"],
"hdradio": ["nrsc5"],
"rigcontrol": ["hamlib"],
"cwskimmer": ["csdr_cwskimmer"],
}
def feature_availability(self):
@ -775,3 +776,11 @@ class FeatureDetector(object):
The `hamlib` package is available in most Linux distributions.
"""
return self.command_is_runnable("rigctl -V")
def has_csdr_cwskimmer(self):
"""
OpenWebRX uses the [CSDR CWSkimmer](https://github.com/luarvique/csdr-cwskimmer)
to decode multiple CW signals at once. You can install the
`csdr-cwskimmer` package from the OpenWebRX+ repositories.
"""
return self.command_is_runnable("csdr-cwskimmer -h")

View File

@ -174,6 +174,9 @@ class Modes(object):
service=True,
squelch=False,
),
# Replaced by Jakob's RTTY decoder
# DigitalMode("mfrtty170", "RTTY-170", underlying=["usb"]),
# DigitalMode("mfrtty450", "RTTY-450", underlying=["usb"]),
# Replaced by the general paging decoder (both POCSAG and FLEX)
# DigitalMode(
# "pocsag",
@ -194,9 +197,14 @@ class Modes(object):
squelch=False,
),
DigitalMode("cwdecoder", "CW Decoder", underlying=["usb", "lsb"]),
# Replaced by Jakob's RTTY decoder
# DigitalMode("mfrtty170", "RTTY-170", underlying=["usb"]),
# DigitalMode("mfrtty450", "RTTY-450", underlying=["usb"]),
DigitalMode(
"cwskimmer",
"CW Skimmer",
underlying=["usbd"],
requirements=["cwskimmer"],
service=False,
squelch=False,
),
DigitalMode(
"sstv",
"SSTV",

View File

@ -414,3 +414,40 @@ class EasParser(TextParser):
# Return received message as text
return "\n".join(out)
class CwSkimmerParser(TextParser):
def __init__(self, service: bool = False):
self.reLine = re.compile("^(\d+):(.+)$")
self.freqChanged = False
# Construct parent object
super().__init__(filePrefix="CW", service=service)
def parse(self, msg: bytes):
# Do not parse in service mode
if self.service:
return None
# Parse CW messages by frequency
msg = msg.decode("utf-8", "replace")
r = self.reLine.match(msg)
if r is not None:
freq = int(r.group(1))
text = r.group(2)
if len(text) > 0:
# Compose output
out = { "mode": "CW", "text": text }
# Add frequency, if known
if self.frequency:
out["freq"] = self.frequency + freq
# Report frequency changes
if self.freqChanged:
self.freqChanged = False
out["changed"] = True
# Done
return out
# No result
return None
def setDialFrequency(self, frequency: int) -> None:
self.freqChanged = frequency != self.frequency
super().setDialFrequency(frequency)