Merge branch 'develop' into pycsdr

This commit is contained in:
Jakob Ketterl 2021-01-17 18:16:32 +01:00
commit 297d6b540d
32 changed files with 758 additions and 346 deletions

View File

@ -3,6 +3,7 @@
- Removed `port` configuration option; `rtltcp_compat` takes the port number with the new connectors
- Added support for new WSJT-X modes FST4 and FST4W (only available with WSJT-X 2.3)
- Added support for demodulating M17 digital voice signals using m17-cxx-demod
- New reporting infrastructur, allowing WSPR and FST4W spots to be sent to wsprnet.org
- New devices supported:
- HPSDR devices (Hermes Lite 2)
- BBRF103 / RX666 / RX888 devices supported by libsddc

View File

@ -361,7 +361,7 @@ aprs_symbols_path = "/usr/share/aprs-symbols/png"
# Antenna direction (N, NE, E, SE, S, SW, W, NW). Omnidirectional by default
# aprs_igate_dir = "NE"
# === PSK Reporter setting ===
# === PSK Reporter settings ===
# enable this if you want to upload all ft8, ft4 etc spots to pskreporter.info
# this also uses the receiver_gps setting from above, so make sure it contains a correct locator
pskreporter_enabled = False
@ -369,6 +369,12 @@ pskreporter_callsign = "N0CALL"
# optional antenna information, uncomment to enable
#pskreporter_antenna_information = "Dipole"
# === WSPRNet reporting settings
# enable this if you want to upload WSPR spots to wsprnet.ort
# in addition to these settings also make sure that receiver_gps contains your correct location
wsprnet_enabled = False
wsprnet_callsign = "N0CALL"
# === Web admin settings ===
# this feature is experimental at the moment. it should not be enabled on shared receivers since it allows remote
# changes to the receiver settings. enable for testing in controlled environment only.

View File

@ -556,12 +556,12 @@ class dsp(object):
# wideband fm has a much higher frequency deviation (75kHz).
# we cannot cover this if we immediately decimate to the sample rate the audio will have later on, so we need
# to compensate here.
# the factor of 5 is by experimentation only, with a minimum audio rate of 36kHz (enforced by the client)
# this allows us to cover at least +/- 80kHz of frequency spectrum (may be higher, but that's the worst case).
# the factor of 6 is by experimentation only, with a minimum audio rate of 36kHz (enforced by the client)
# this allows us to cover at least +/- 108kHz of frequency spectrum (may be higher, but that's the worst case).
# the correction factor is automatically compensated for by the secondary decimation stage, which comes
# after the demodulator.
if self.get_demodulator() == "wfm":
correction = 5
correction = 6
while input_rate / (decimation + 1) >= output_rate * correction:
decimation += 1
fraction = float(input_rate / decimation) / output_rate
@ -933,6 +933,3 @@ class dsp(object):
return
self.stop()
self.start()
def __del__(self):
self.stop()

2
debian/changelog vendored
View File

@ -7,6 +7,8 @@ openwebrx (0.21.0) UNRELEASED; urgency=low
WSJT-X 2.3)
* Added support for demodulating M17 digital voice signals using
m17-cxx-demod
* New reporting infrastructur, allowing WSPR and FST4W spots to be sent to
wsprnet.org
* New devices supported:
- HPSDR devices (Hermes Lite 2) (`"type": "hpsdr"`)
- BBRF103 / RX666 / RX888 devices supported by libsddc (`"type": "sddc"`)

2
debian/control vendored
View File

@ -11,6 +11,6 @@ Vcs-Git: https://github.com/jketterl/openwebrx.git
Package: openwebrx
Architecture: all
Depends: adduser, python3 (>= 3.5), python3-pkg-resources, csdr (>= 0.17), netcat, owrx-connector (>= 0.4), soapysdr-tools, python3-js8py (>= 0.1), ${python3:Depends}, ${misc:Depends}
Recommends: digiham (>= 0.3), dsd (>= 1.7), sox, direwolf (>= 1.4), wsjtx, eb200-connector, hpsdrconnector, aprs-symbols
Recommends: digiham (>= 0.3), dsd (>= 1.7), sox, direwolf (>= 1.4), wsjtx, eb200-connector, hpsdrconnector, aprs-symbols, m17-demod
Description: multi-user web sdr
Open source, multi-user SDR receiver with a web interface

View File

@ -25,12 +25,19 @@ ul {
padding-inline-start: 25px;
}
/* don't show the filter in it's initial position */
.openwebrx-map-legend {
display: none;
background-color: #fff;
padding: 10px;
margin: 10px;
}
/* show it as soon as google maps has moved it to its container */
.openwebrx-map .openwebrx-map-legend {
display: block;
}
.openwebrx-map-legend ul {
list-style-type: none;
padding: 0;

View File

@ -1,32 +1,36 @@
#webrx-top-container
{
#webrx-top-container {
position: relative;
z-index:1000;
background-color: #575757;
}
#webrx-top-photo
{
width: 100%;
display: block;
}
background-image: url(../gfx/openwebrx-top-photo.jpg);
background-position-x: center;
background-position-y: top;
background-repeat: no-repeat;
background-size: cover;
#webrx-top-photo-clip
{
min-height: 67px;
max-height: 67px;
height: 350px;
overflow: hidden;
position: relative;
}
.webrx-top-bar-parts
{
#openwebrx-description-container {
transition-property: height, opacity;
transition-duration: 1s;
transition-timing-function: ease-out;
opacity: 0;
height: 0;
/* originally, top-bar + description was 350px */
max-height: 283px;
overflow: hidden;
}
#openwebrx-description-container.expanded {
opacity: 1;
height: 283px;
}
.webrx-top-bar-parts {
height:67px;
}
#webrx-top-bar
{
background: rgba(128, 128, 128, 0.15);
margin:0;
padding:0;
@ -37,31 +41,28 @@
-moz-user-select: none;
-ms-user-select: none;
overflow: hidden;
position: absolute;
left: 0;
top: 0;
right: 0;
display: flex;
flex-direction: row;
}
#webrx-tob-container, #webrx-top-container * {
.webrx-top-bar-parts > * {
flex: 0;
}
#webrx-top-container, #webrx-top-container * {
line-height: initial;
box-sizing: initial;
}
#webrx-top-container img {
vertical-align: initial;
}
#webrx-top-logo
{
#webrx-top-logo {
padding: 12px;
float: left;
/* overwritten by media queries */
display: none;
}
#webrx-rx-avatar
{
#webrx-rx-avatar {
background-color: rgba(154, 154, 154, .5);
float: left;
margin: 7px;
cursor:pointer;
@ -73,49 +74,45 @@
}
#webrx-rx-texts {
float: left;
padding: 10px;
/* minimum layout width */
width: 0;
/* will be getting wider with flex */
flex: 1;
overflow: hidden;
}
#webrx-rx-texts div {
margin: 0 10px;
padding: 3px;
}
#webrx-rx-title
{
white-space:nowrap;
overflow: hidden;
cursor:pointer;
font-family: "DejaVu Sans", Verdana, Geneva, sans-serif;
color: #909090;
}
#webrx-rx-texts div:first-child {
margin-top: 10px;
}
#webrx-rx-title {
font-family: "DejaVu Sans", Verdana, Geneva, sans-serif;
font-size: 11pt;
font-weight: bold;
}
#webrx-rx-desc
{
white-space:nowrap;
overflow: hidden;
cursor:pointer;
#webrx-rx-desc {
font-size: 10pt;
color: #909090;
}
#webrx-rx-desc a
{
color: #909090;
}
#openwebrx-rx-details-arrow
{
#openwebrx-rx-details-arrow {
cursor:pointer;
position: absolute;
left: 470px;
top: 55px;
bottom: 0;
left: 50%;
transform: translate(-50%, 0);
}
#openwebrx-rx-details-arrow a
{
#openwebrx-rx-details-arrow a {
margin: 0;
padding: 0;
line-height: 0;
@ -128,6 +125,11 @@
cursor:pointer;
}
#openwebrx-main-buttons .button[data-toggle-panel] {
/* will be enabled by javascript if the panel is present in the DOM */
display: none;
}
#openwebrx-main-buttons .button img {
height: 38px;
}
@ -137,23 +139,19 @@
text-decoration: inherit;
}
#openwebrx-main-buttons .button:hover
{
#openwebrx-main-buttons .button:hover {
background-color: rgba(255, 255, 255, 0.3);
}
#openwebrx-main-buttons .button:active
{
#openwebrx-main-buttons .button:active {
background-color: rgba(255, 255, 255, 0.55);
}
#openwebrx-main-buttons
{
#openwebrx-main-buttons {
padding: 5px 15px;
display: flex;
list-style: none;
float: right;
margin:0;
color: white;
text-shadow: 0px 0px 4px #000000;
@ -162,23 +160,17 @@
font-weight: bold;
}
#webrx-rx-photo-title
{
position: absolute;
left: 15px;
top: 78px;
color: White;
#webrx-rx-photo-title {
margin: 10px 15px;
color: white;
font-size: 16pt;
text-shadow: 1px 1px 4px #444;
opacity: 1;
}
#webrx-rx-photo-desc
{
position: absolute;
left: 15px;
top: 109px;
color: White;
#webrx-rx-photo-desc {
margin: 10px 15px;
color: white;
font-size: 10pt;
font-weight: bold;
text-shadow: 0px 0px 6px #444;
@ -186,12 +178,37 @@
line-height: 1.5em;
}
#webrx-rx-photo-desc a
{
#webrx-rx-photo-desc a {
color: #5ca8ff;
text-shadow: none;
}
/*
* Responsive stuff
*/
@media (min-width: 576px) {
#webrx-rx-texts {
display: initial;
}
}
@media (min-width: 768px) {
}
@media (min-width: 992px) {
#webrx-top-logo {
display: initial;
}
}
@media (min-width: 1200px) {
}
/*
* Sprites (images)
*/
.sprite-panel-status {
background-position: 0 0;
width: 44px;

View File

@ -573,9 +573,10 @@ img.openwebrx-mirror-img
.openwebrx-progressbar-text
{
position: absolute;
left:0px;
top:4px;
width: inherit;
left:50;
top:50%;
transform: translate(-50%, -50%);
white-space: nowrap;
z-index: 1;
}
@ -907,18 +908,30 @@ img.openwebrx-mirror-img
border-color: Red;
}
.openwebrx-meta-panel {
display: flex;
flex-direction: row;
gap: 10px;
}
.openwebrx-meta-slot {
flex: 1;
width: 145px;
height: 196px;
float: left;
margin-right: 10px;
background-color: #676767;
padding: 2px 0;
color: #333;
text-align: center;
position: relative;
display: flex;
flex-direction: column;
}
.openwebrx-meta-slot > * {
flex: 0;
flex-basis: 1.125em;
}
.openwebrx-meta-slot, .openwebrx-meta-slot.muted:before {
@ -960,13 +973,8 @@ img.openwebrx-mirror-img
box-shadow: rgba(0, 0, 0, 0.2) 0 -1px 7px 1px, inset #304701 0 -1px 9px, #89FF00 0 2px 12px;
}
.openwebrx-meta-slot:last-child {
margin-right: 0;
}
.openwebrx-meta-slot .openwebrx-meta-user-image {
width:100%;
height:133px;
flex: 1;
background-position: center;
background-repeat: no-repeat;
}
@ -1233,12 +1241,12 @@ img.openwebrx-mirror-img
height: 15px;
}
#openwebrx-mute-on .sprite-speaker {
background-position: -117px -38px;
.openwebrx-mute-button .sprite-speaker {
background-position: -103px -38px;
}
#openwebrx-mute-off .sprite-speaker {
background-position: -103px -38px;
.openwebrx-mute-button.muted .sprite-speaker {
background-position: -117px -38px;
}
.sprite-squelch {

View File

@ -1,26 +1,25 @@
<div id="webrx-top-container">
<div id="webrx-top-photo-clip">
<img src="static/gfx/openwebrx-top-photo.jpg" id="webrx-top-photo" alt="Receiver panorama"/>
<div id="webrx-top-bar" class="webrx-top-bar-parts">
<a href="https://www.openwebrx.de/" target="_blank"><img src="static/gfx/openwebrx-top-logo.png" id="webrx-top-logo" alt="OpenWebRX Logo"/></a>
<img id="webrx-rx-avatar" class="openwebrx-photo-trigger" src="static/gfx/openwebrx-avatar.png" alt="Receiver avatar"/>
<div id="webrx-rx-texts">
<div id="webrx-rx-title" class="openwebrx-photo-trigger"></div>
<div id="webrx-rx-desc" class="openwebrx-photo-trigger"></div>
</div>
<div id="openwebrx-rx-details-arrow">
<a id="openwebrx-rx-details-arrow-up" class="openwebrx-photo-trigger" style="display: none;"><span class="sprite sprite-rx-details-arrow-up"></span></a>
<a id="openwebrx-rx-details-arrow-down" class="openwebrx-photo-trigger"><span class="sprite sprite-rx-details-arrow-down"></span></a>
</div>
<section id="openwebrx-main-buttons">
<div class="button" data-toggle-panel="openwebrx-panel-status"><span class="sprite sprite-panel-status"></span><br/>Status</div>
<div class="button" data-toggle-panel="openwebrx-panel-log"><span class="sprite sprite-panel-log"></span><br/>Log</div>
<div class="button" data-toggle-panel="openwebrx-panel-receiver"><span class="sprite sprite-panel-receiver"></span><br/>Receiver</div>
<a class="button" href="map" target="openwebrx-map"><span class="sprite sprite-panel-map"></span><br/>Map</a>
${settingslink}
</section>
<div id="webrx-top-bar" class="webrx-top-bar-parts">
<a href="https://www.openwebrx.de/" target="_blank"><img src="static/gfx/openwebrx-top-logo.png" id="webrx-top-logo" alt="OpenWebRX Logo"/></a>
<img id="webrx-rx-avatar" class="openwebrx-photo-trigger" src="static/gfx/openwebrx-avatar.png" alt="Receiver avatar"/>
<div id="webrx-rx-texts">
<div id="webrx-rx-title" class="openwebrx-photo-trigger"></div>
<div id="webrx-rx-desc" class="openwebrx-photo-trigger"></div>
</div>
<section id="openwebrx-main-buttons">
<div class="button" data-toggle-panel="openwebrx-panel-status"><span class="sprite sprite-panel-status"></span><br/>Status</div>
<div class="button" data-toggle-panel="openwebrx-panel-log"><span class="sprite sprite-panel-log"></span><br/>Log</div>
<div class="button" data-toggle-panel="openwebrx-panel-receiver"><span class="sprite sprite-panel-receiver"></span><br/>Receiver</div>
<a class="button" href="map" target="openwebrx-map"><span class="sprite sprite-panel-map"></span><br/>Map</a>
${settingslink}
</section>
</div>
<div id="openwebrx-description-container">
<div id="webrx-rx-photo-title"></div>
<div id="webrx-rx-photo-desc"></div>
</div>
<div id="openwebrx-rx-details-arrow">
<a id="openwebrx-rx-details-arrow-up" class="openwebrx-photo-trigger" style="display: none;"><span class="sprite sprite-rx-details-arrow-up"></span></a>
<a id="openwebrx-rx-details-arrow-down" class="openwebrx-photo-trigger"><span class="sprite sprite-rx-details-arrow-down"></span></a>
</div>
</div>

View File

@ -65,32 +65,28 @@
<div class="openwebrx-panel openwebrx-message-panel" id="openwebrx-panel-packet-message" style="display: none; width: 619px;" data-panel-name="aprs-message"></div>
<div class="openwebrx-panel openwebrx-message-panel" id="openwebrx-panel-pocsag-message" style="display: none; width: 619px;" data-panel-name="pocsag-message"></div>
<div class="openwebrx-panel openwebrx-meta-panel" id="openwebrx-panel-metadata-ysf" style="display: none;" data-panel-name="metadata-ysf">
<div class="openwebrx-meta-frame">
<div class="openwebrx-meta-slot">
<div class="openwebrx-ysf-mode openwebrx-meta-autoclear"></div>
<div class="openwebrx-meta-user-image"></div>
<div class="openwebrx-ysf-source openwebrx-meta-autoclear"></div>
<div class="openwebrx-ysf-up openwebrx-meta-autoclear"></div>
<div class="openwebrx-ysf-down openwebrx-meta-autoclear"></div>
</div>
<div class="openwebrx-meta-slot">
<div class="openwebrx-ysf-mode"></div>
<div class="openwebrx-meta-user-image"></div>
<div class="openwebrx-ysf-source"><span class="location"></span><span class="callsign"></span></div>
<div class="openwebrx-ysf-up"></div>
<div class="openwebrx-ysf-down"></div>
</div>
</div>
<div class="openwebrx-panel openwebrx-meta-panel" id="openwebrx-panel-metadata-dmr" style="display: none;" data-panel-name="metadata-dmr">
<div class="openwebrx-meta-frame">
<div class="openwebrx-meta-slot openwebrx-dmr-timeslot-panel">
<div class="openwebrx-dmr-slot">Timeslot 1</div>
<div class="openwebrx-meta-user-image"></div>
<div class="openwebrx-dmr-id openwebrx-meta-autoclear"></div>
<div class="openwebrx-dmr-name openwebrx-meta-autoclear"></div>
<div class="openwebrx-dmr-target openwebrx-meta-autoclear"></div>
</div>
<div class="openwebrx-meta-slot openwebrx-dmr-timeslot-panel">
<div class="openwebrx-dmr-slot">Timeslot 2</div>
<div class="openwebrx-meta-user-image"></div>
<div class="openwebrx-dmr-id openwebrx-meta-autoclear"></div>
<div class="openwebrx-dmr-name openwebrx-meta-autoclear"></div>
<div class="openwebrx-dmr-target openwebrx-meta-autoclear"></div>
</div>
<div class="openwebrx-meta-slot openwebrx-dmr-timeslot-panel">
<div class="openwebrx-dmr-slot">Timeslot 1</div>
<div class="openwebrx-meta-user-image"></div>
<div class="openwebrx-dmr-id"></div>
<div class="openwebrx-dmr-name"></div>
<div class="openwebrx-dmr-target"></div>
</div>
<div class="openwebrx-meta-slot openwebrx-dmr-timeslot-panel">
<div class="openwebrx-dmr-slot">Timeslot 2</div>
<div class="openwebrx-meta-user-image"></div>
<div class="openwebrx-dmr-id"></div>
<div class="openwebrx-dmr-name"></div>
<div class="openwebrx-dmr-target"></div>
</div>
</div>
<div class="openwebrx-panel" id="openwebrx-panel-log" data-panel-name="debug" style="width: 619px;">
@ -132,7 +128,7 @@
</div>
<div class="openwebrx-modes openwebrx-panel-line"></div>
<div class="openwebrx-panel-line">
<div title="Mute on/off" id="openwebrx-mute-off" class="openwebrx-button" onclick="toggleMute();"><span class="sprite sprite-speaker openwebrx-sliderbtn-img"></span></div>
<div title="Mute on/off" class="openwebrx-button openwebrx-mute-button" onclick="toggleMute();"><span class="sprite sprite-speaker openwebrx-sliderbtn-img"></span></div>
<input title="Volume" id="openwebrx-panel-volume" class="openwebrx-panel-slider" type="range" min="0" max="150" value="50" step="1" onchange="updateVolume()" oninput="updateVolume()">
<div title="Auto-adjust waterfall colors (right-click for continuous)" id="openwebrx-waterfall-colors-auto" class="openwebrx-button"><span class="sprite sprite-waterfall-auto openwebrx-sliderbtn-img"></span></div>
<input title="Waterfall minimum level" id="openwebrx-waterfall-color-min" class="openwebrx-panel-slider" type="range" min="-200" max="100" value="50" step="1" onchange="updateWaterfallColors(0);" oninput="updateVolume()">

View File

@ -14,7 +14,19 @@ function AudioEngine(maxBufferLength, audioReporter) {
this.onStartCallbacks = [];
this.started = false;
this.audioContext = new ctx();
// try common working sample rates
if (![48000, 44100].some(function(sr) {
try {
this.audioContext = new ctx({sampleRate: sr});
return true;
} catch (e) {
return false;
}
}, this)) {
// fallback: let the browser decide
// this may cause playback problems down the line
this.audioContext = new ctx();
}
var me = this;
this.audioContext.onstatechange = function() {
if (me.audioContext.state !== 'running') return;

View File

@ -8,7 +8,7 @@ Filter.prototype.getLimits = function() {
if (this.demodulator.get_secondary_demod() === 'pocsag') {
max_bw = 12500;
} else if (this.demodulator.get_modulation() === 'wfm') {
max_bw = 80000;
max_bw = 100000;
} else if (this.demodulator.get_modulation() === 'drm') {
max_bw = 100000;
} else if (this.demodulator.get_secondary_demod() === 'packet') {
@ -236,7 +236,7 @@ Demodulator.prototype.emit = function(event, params) {
};
Demodulator.prototype.set_offset_frequency = function(to_what) {
if (to_what > bandwidth / 2 || to_what < -bandwidth / 2) return;
if (typeof(to_what) == 'undefined' || to_what > bandwidth / 2 || to_what < -bandwidth / 2) return;
to_what = Math.round(to_what);
if (this.offset_frequency === to_what) {
return;

View File

@ -165,10 +165,13 @@ DemodulatorPanel.prototype.updatePanels = function() {
modulation = this.getDemodulator().get_modulation();
var showing = 'openwebrx-panel-metadata-' + modulation;
$(".openwebrx-meta-panel").each(function (_, p) {
var metaPanels = $(".openwebrx-meta-panel");
metaPanels.each(function (_, p) {
toggle_panel(p.id, p.id === showing);
});
clear_metadata();
metaPanels.metaPanel().each(function() {
this.clear();
});
};
DemodulatorPanel.prototype.getDemodulator = function() {
@ -181,7 +184,7 @@ DemodulatorPanel.prototype.collectParams = function() {
squelch_level: -150,
mod: 'nfm'
}
return $.extend(new Object(), defaults, this.initialParams, this.transformHashParams(this.parseHash()));
return $.extend(new Object(), defaults, this.validateInitialParams(this.initialParams), this.transformHashParams(this.parseHash()));
};
DemodulatorPanel.prototype.startDemodulator = function() {
@ -287,7 +290,7 @@ DemodulatorPanel.prototype.validateHash = function(params) {
var self = this;
params = Object.keys(params).filter(function(key) {
if (key == 'freq' || key == 'mod' || key == 'secondary_mod' || key == 'sql') {
return params.freq && Math.abs(params.freq - self.center_freq) < bandwidth / 2;
return params.freq && Math.abs(params.freq - self.center_freq) <= bandwidth / 2;
}
return true;
}).reduce(function(p, key) {
@ -303,6 +306,17 @@ DemodulatorPanel.prototype.validateHash = function(params) {
return params;
};
DemodulatorPanel.prototype.validateInitialParams = function(params) {
return Object.fromEntries(
Object.entries(params).filter(function(a) {
if (a[0] == "offset_frequency") {
return Math.abs(a[1]) <= bandwidth / 2;
}
return true;
})
);
};
DemodulatorPanel.prototype.updateHash = function() {
var demod = this.getDemodulator();
if (!demod) return;

View File

@ -1,7 +1,12 @@
function Header(el) {
this.el = el;
this.el.find('#openwebrx-main-buttons').find('[data-toggle-panel]').click(function () {
var $buttons = this.el.find('#openwebrx-main-buttons').find('[data-toggle-panel]').filter(function(){
// ignore buttons when the corresponding panel is not in the DOM
return $('#' + $(this).data('toggle-panel'))[0];
});
$buttons.css({display: 'block'}).click(function () {
toggle_panel($(this).data('toggle-panel'));
});
@ -30,18 +35,14 @@ Header.prototype.init_rx_photo = function() {
Header.prototype.close_rx_photo = function() {
this.rx_photo_state = 0;
this.el.find("#webrx-rx-photo-desc").animate({opacity: 0});
this.el.find("#webrx-rx-photo-title").animate({opacity: 0});
this.el.find('#webrx-top-photo-clip').animate({maxHeight: 67}, {duration: 1000, easing: 'easeOutCubic'});
this.el.find('#openwebrx-description-container').removeClass('expanded');
this.el.find("#openwebrx-rx-details-arrow-down").show();
this.el.find("#openwebrx-rx-details-arrow-up").hide();
}
Header.prototype.open_rx_photo = function() {
this.rx_photo_state = 1;
this.el.find("#webrx-rx-photo-desc").animate({opacity: 1});
this.el.find("#webrx-rx-photo-title").animate({opacity: 1});
this.el.find('#webrx-top-photo-clip').animate({maxHeight: 350}, {duration: 1000, easing: 'easeOutCubic'});
this.el.find('#openwebrx-description-container').addClass('expanded');
this.el.find("#openwebrx-rx-details-arrow-down").hide();
this.el.find("#openwebrx-rx-details-arrow-up").show();
}

View File

@ -78,14 +78,14 @@ WsjtMessagePanel.prototype.pushMessage = function(msg) {
return $('<div/>').text(input).html()
};
if (['FT8', 'JT65', 'JT9', 'FT4', 'FST4', 'FST4W'].indexOf(msg['mode']) >= 0) {
if (['FT8', 'JT65', 'JT9', 'FT4', 'FST4'].indexOf(msg['mode']) >= 0) {
matches = linkedmsg.match(/(.*\s[A-Z0-9]+\s)([A-R]{2}[0-9]{2})$/);
if (matches && matches[2] !== 'RR73') {
linkedmsg = html_escape(matches[1]) + '<a href="map?locator=' + matches[2] + '" target="openwebrx-map">' + matches[2] + '</a>';
} else {
linkedmsg = html_escape(linkedmsg);
}
} else if (msg['mode'] === 'WSPR') {
} else if (['WSPR', 'FST4W'].indexOf(msg['mode']) >= 0) {
matches = linkedmsg.match(/([A-Z0-9]*\s)([A-R]{2}[0-9]{2})(\s[0-9]+)/);
if (matches) {
linkedmsg = html_escape(matches[1]) + '<a href="map?locator=' + matches[2] + '" target="openwebrx-map">' + matches[2] + '</a>' + html_escape(matches[3]);

205
htdocs/lib/MetaPanel.js Normal file
View File

@ -0,0 +1,205 @@
function MetaPanel(el) {
this.el = el;
this.modes = [];
}
MetaPanel.prototype.update = function(data) {
};
MetaPanel.prototype.isSupported = function(data) {
return this.modes.includes(data.protocol);
};
MetaPanel.prototype.clear = function() {
this.el.find(".openwebrx-meta-slot").removeClass("active").removeClass("sync");
};
function DmrMetaSlot(el) {
this.el = $(el);
this.clear();
}
DmrMetaSlot.prototype.update = function(data) {
this.el[data['sync'] ? "addClass" : "removeClass"]("sync");
if (data['sync'] && data['sync'] === "voice") {
this.setId(data['additional'] && data['additional']['callsign'] || data['source']);
this.setName(data['additional'] && data['additional']['fname']);
if (data['type'] === "group") {
this.setTalkgroup(data['target']);
}
if (data['type'] === "direct") {
this.setDirect(data['target']);
}
this.el.addClass("active");
} else {
this.clear();
}
};
DmrMetaSlot.prototype.setId = function(id) {
if (this.id === id) return;
this.id = id;
this.el.find('.openwebrx-dmr-id').text(id || '');
}
DmrMetaSlot.prototype.setName = function(name) {
if (this.name === name) return;
this.name = name;
this.el.find('.openwebrx-dmr-name').text(name || '');
};
DmrMetaSlot.prototype.setTalkgroup = function(talkgroup) {
if (this.talkgroup === talkgroup && this.targetMode === 'talkgroup') return;
this.talkgroup = talkgroup;
this.targetMode = 'talkgroup';
var text = '';
if (talkgroup && talkgroup != '') {
text = 'Talkgroup: ' + talkgroup;
}
this.el.find('.openwebrx-dmr-target').text(text);
this.el.find(".openwebrx-meta-user-image").addClass("group");
};
DmrMetaSlot.prototype.setDirect = function(call) {
if (this.call === call && this.targetMode === 'direct') return;
this.call = call;
this.targetMode = 'direct';
var text = '';
if (call && call != '') {
text = 'Direct: ' + call;
}
this.el.find('.openwebrx-dmr-target').text(text);
this.el.find(".openwebrx-meta-user-image").removeClass("group");
};
DmrMetaSlot.prototype.clear = function() {
this.setId();
this.setName();
this.setTalkgroup();
this.setDirect();
this.el.removeClass("active");
};
function DmrMetaPanel(el) {
MetaPanel.call(this, el);
this.modes = ['DMR'];
this.slots = this.el.find('.openwebrx-meta-slot').toArray().map(function(el){
return new DmrMetaSlot(el);
});
}
DmrMetaPanel.prototype = new MetaPanel();
DmrMetaPanel.prototype.update = function(data) {
if (!this.isSupported(data)) return;
if (data['slot']) {
var slot = this.slots[data['slot']];
slot.update(data);
} else {
this.clear();
}
}
DmrMetaPanel.prototype.clear = function() {
MetaPanel.prototype.clear.call(this);
this.el.find(".openwebrx-dmr-timeslot-panel").removeClass("muted");
this.slots.forEach(function(slot) {
slot.clear();
});
};
function YsfMetaPanel(el) {
MetaPanel.call(this, el);
this.modes = ['YSF'];
this.clear();
}
YsfMetaPanel.prototype = new MetaPanel();
YsfMetaPanel.prototype.update = function(data) {
if (!this.isSupported(data)) return;
this.setMode(data['mode']);
if (data['mode'] && data['mode'] !== "") {
this.setSource(data['source']);
this.setLocation(data['lat'], data['lon'], data['source']);
this.setUp(data['up']);
this.setDown(data['down']);
this.el.find(".openwebrx-meta-slot").addClass("active");
} else {
this.clear();
}
};
YsfMetaPanel.prototype.clear = function() {
MetaPanel.prototype.clear.call(this);
this.setMode();
this.setSource();
this.setLocation();
this.setUp();
this.setDown();
};
YsfMetaPanel.prototype.setMode = function(mode) {
if (this.mode === mode) return;
this.mode = mode;
var text = '';
if (mode && mode != '') {
text = 'Mode: ' + mode;
}
this.el.find('.openwebrx-ysf-mode').text(text);
};
YsfMetaPanel.prototype.setSource = function(source) {
if (this.source === source) return;
this.source = source;
this.el.find('.openwebrx-ysf-source .callsign').text(source || '');
};
YsfMetaPanel.prototype.setLocation = function(lat, lon, callsign) {
var hasLocation = lat && lon && callsign && callsign != '';
if (hasLocation === this.hasLocation && this.callsign === callsign) return;
this.hasLocation = hasLocation; this.callsign = callsign;
var html = '';
if (hasLocation) {
html = '<a class="openwebrx-maps-pin" href="map?callsign=' + callsign + '" target="_blank"></a>';
}
this.el.find('.openwebrx-ysf-source .location').html(html);
};
YsfMetaPanel.prototype.setUp = function(up) {
if (this.up === up) return;
this.up = up;
var text = '';
if (up && up != '') {
text = 'Up: ' + up;
}
this.el.find('.openwebrx-ysf-up').text(text);
};
YsfMetaPanel.prototype.setDown = function(down) {
if (this.down === down) return;
this.down = down;
var text = '';
if (down && down != '') {
text = 'Down: ' + down;
}
this.el.find('.openwebrx-ysf-down').text(text);
}
MetaPanel.types = {
dmr: DmrMetaPanel,
ysf: YsfMetaPanel
};
$.fn.metaPanel = function() {
return this.map(function() {
var $self = $(this);
if (!$self.data('metapanel')) {
var matches = /^openwebrx-panel-metadata-([a-z]+)$/.exec($self.prop('id'));
var constructor = matches && MetaPanel.types[matches[1]] || MetaPanel;
$self.data('metapanel', new constructor($self));
}
return $self.data('metapanel');
});
};

View File

@ -2,6 +2,9 @@
<html>
<head>
<title>OpenWebRX Map</title>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no" />
<meta name="theme-color" content="#222" />
<link rel="shortcut icon" type="image/x-icon" href="static/favicon.ico" />
<script src="compiled/map.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/moment.js/2.24.0/moment.min.js"></script>

View File

@ -32,26 +32,20 @@ var fft_codec;
var waterfall_setup_done = 0;
var secondary_fft_size;
function e(what) {
return document.getElementById(what);
}
function updateVolume() {
audioEngine.setVolume(parseFloat(e("openwebrx-panel-volume").value) / 100);
audioEngine.setVolume(parseFloat($("#openwebrx-panel-volume").val()) / 100);
}
function toggleMute() {
if (mute) {
mute = false;
e("openwebrx-mute-on").id = "openwebrx-mute-off";
e("openwebrx-panel-volume").disabled = false;
e("openwebrx-panel-volume").value = volumeBeforeMute;
var $muteButton = $('.openwebrx-mute-button');
var $volumePanel = $('#openwebrx-panel-volume');
if ($muteButton.hasClass('muted')) {
$muteButton.removeClass('muted');
$volumePanel.prop('disabled', false).val(volumeBeforeMute);
} else {
mute = true;
e("openwebrx-mute-off").id = "openwebrx-mute-on";
e("openwebrx-panel-volume").disabled = true;
volumeBeforeMute = e("openwebrx-panel-volume").value;
e("openwebrx-panel-volume").value = 0;
$muteButton.addClass('muted');
volumeBeforeMute = $volumePanel.val();
$volumePanel.prop('disabled', true).val(0);
}
updateVolume();
@ -191,7 +185,7 @@ function setSmeterAbsoluteValue(value) //the value that comes from `csdr squelch
var highLevel = waterfall_max_level + 20;
var percent = (logValue - lowLevel) / (highLevel - lowLevel);
setSmeterRelativeValue(percent);
e("openwebrx-smeter-db").innerHTML = logValue.toFixed(1) + " dB";
$("#openwebrx-smeter-db").html(logValue.toFixed(1) + " dB");
}
function typeInAnimation(element, timeout, what, onFinish) {
@ -244,14 +238,14 @@ var scale_ctx;
var scale_canvas;
function scale_setup() {
scale_canvas = e("openwebrx-scale-canvas");
scale_canvas = $("#openwebrx-scale-canvas")[0];
scale_ctx = scale_canvas.getContext("2d");
scale_canvas.addEventListener("mousedown", scale_canvas_mousedown, false);
scale_canvas.addEventListener("mousemove", scale_canvas_mousemove, false);
scale_canvas.addEventListener("mouseup", scale_canvas_mouseup, false);
resize_scale();
var frequency_container = e("openwebrx-frequency-container");
frequency_container.addEventListener("mousemove", frequency_container_mousemove, false);
var frequency_container = $("#openwebrx-frequency-container");
frequency_container.on("mousemove", frequency_container_mousemove, false);
}
var scale_canvas_drag_params = {
@ -784,10 +778,10 @@ function on_ws_recv(evt) {
$('#openwebrx-bar-clients').progressbar().setClients(json['value']);
break;
case "profiles":
var listbox = e("openwebrx-sdr-profiles-listbox");
listbox.innerHTML = json['value'].map(function (profile) {
var listbox = $("#openwebrx-sdr-profiles-listbox");
listbox.html(json['value'].map(function (profile) {
return '<option value="' + profile['id'] + '">' + profile['name'] + "</option>";
}).join("");
}).join(""));
if (currentprofile) {
$('#openwebrx-sdr-profiles-listbox').val(currentprofile);
}
@ -796,7 +790,9 @@ function on_ws_recv(evt) {
Modes.setFeatures(json['value']);
break;
case "metadata":
update_metadata(json['value']);
$('.openwebrx-meta-panel').metaPanel().each(function(){
this.update(json['value']);
});
break;
case "js8_message":
$("#openwebrx-panel-js8-message").js8().pushMessage(json['value']);
@ -906,75 +902,6 @@ function on_ws_recv(evt) {
}
}
function update_metadata(meta) {
var el;
if (meta['protocol']) switch (meta['protocol']) {
case 'DMR':
if (meta['slot']) {
el = $("#openwebrx-panel-metadata-dmr").find(".openwebrx-dmr-timeslot-panel").get(meta['slot']);
var id = "";
var name = "";
var target = "";
var group = false;
$(el)[meta['sync'] ? "addClass" : "removeClass"]("sync");
if (meta['sync'] && meta['sync'] === "voice") {
id = (meta['additional'] && meta['additional']['callsign']) || meta['source'] || "";
name = (meta['additional'] && meta['additional']['fname']) || "";
if (meta['type'] === "group") {
target = "Talkgroup: ";
group = true;
}
if (meta['type'] === "direct") target = "Direct: ";
target += meta['target'] || "";
$(el).addClass("active");
} else {
$(el).removeClass("active");
}
$(el).find(".openwebrx-dmr-id").text(id);
$(el).find(".openwebrx-dmr-name").text(name);
$(el).find(".openwebrx-dmr-target").text(target);
$(el).find(".openwebrx-meta-user-image")[group ? "addClass" : "removeClass"]("group");
} else {
clear_metadata();
}
break;
case 'YSF':
el = $("#openwebrx-panel-metadata-ysf");
var mode = " ";
var source = "";
var up = "";
var down = "";
if (meta['mode'] && meta['mode'] !== "") {
mode = "Mode: " + meta['mode'];
source = meta['source'] || "";
if (meta['lat'] && meta['lon'] && meta['source']) {
source = "<a class=\"openwebrx-maps-pin\" href=\"map?callsign=" + meta['source'] + "\" target=\"_blank\"></a>" + source;
}
up = meta['up'] ? "Up: " + meta['up'] : "";
down = meta['down'] ? "Down: " + meta['down'] : "";
$(el).find(".openwebrx-meta-slot").addClass("active");
} else {
$(el).find(".openwebrx-meta-slot").removeClass("active");
}
$(el).find(".openwebrx-ysf-mode").text(mode);
$(el).find(".openwebrx-ysf-source").html(source);
$(el).find(".openwebrx-ysf-up").text(up);
$(el).find(".openwebrx-ysf-down").text(down);
break;
} else {
clear_metadata();
}
}
function clear_metadata() {
$(".openwebrx-meta-panel .openwebrx-meta-autoclear").text("");
$(".openwebrx-meta-slot").removeClass("active").removeClass("sync");
$(".openwebrx-dmr-timeslot-panel").removeClass("muted");
}
var waterfall_measure_minmax_now = false;
var waterfall_measure_minmax_continuous = false;
@ -1019,7 +946,7 @@ function divlog(what, is_error) {
what = "<span class=\"webrx-error\">" + what + "</span>";
toggle_panel("openwebrx-panel-log", true); //show panel if any error is present
}
e("openwebrx-debugdiv").innerHTML += what + "<br />";
$('#openwebrx-debugdiv')[0].innerHTML += what + "<br />";
var nano = $('.nano');
nano.nanoScroller();
nano.nanoScroller({scroll: 'bottom'});
@ -1145,14 +1072,14 @@ function add_canvas() {
function init_canvas_container() {
canvas_container = e("webrx-canvas-container");
canvas_container = $("#webrx-canvas-container")[0];
canvas_container.addEventListener("mouseleave", canvas_container_mouseleave, false);
canvas_container.addEventListener("mousemove", canvas_mousemove, false);
canvas_container.addEventListener("mouseup", canvas_mouseup, false);
canvas_container.addEventListener("mousedown", canvas_mousedown, false);
canvas_container.addEventListener("wheel", canvas_mousewheel, false);
var frequency_container = e("openwebrx-frequency-container");
frequency_container.addEventListener("wheel", canvas_mousewheel, false);
var frequency_container = $("#openwebrx-frequency-container");
frequency_container.on("wheel", canvas_mousewheel, false);
add_canvas();
}
@ -1307,6 +1234,8 @@ function digimodes_init() {
$(e.currentTarget).toggleClass("muted");
update_dmr_timeslot_filtering();
});
$('.openwebrx-meta-panel').metaPanel();
}
function update_dmr_timeslot_filtering() {
@ -1354,7 +1283,7 @@ function toggle_panel(what, on) {
item.style.transitionProperty = 'transform';
} else {
item.movement = 'expand';
item.style.display = 'block';
item.style.display = null;
setTimeout(function(){
item.style.transitionProperty = 'transform';
item.style.transform = 'perspective(600px) rotateX(0deg)';

View File

@ -11,8 +11,9 @@ from owrx.sdr import SdrService
from socketserver import ThreadingMixIn
from owrx.service import Services
from owrx.websocket import WebSocketConnection
from owrx.pskreporter import PskReporter
from owrx.reporting import ReportingEngine
from owrx.version import openwebrx_version
from owrx.audio import DecoderQueue
class ThreadedHttpServer(ThreadingMixIn, HTTPServer):
@ -67,4 +68,5 @@ Support and info: https://groups.io/g/openwebrx
except KeyboardInterrupt:
WebSocketConnection.closeAll()
Services.stop()
PskReporter.stop()
ReportingEngine.stopAll()
DecoderQueue.stopAll()

View File

@ -7,7 +7,7 @@ import subprocess
import os
from multiprocessing.connection import Pipe, wait
from datetime import datetime, timedelta
from queue import Queue, Full
from queue import Queue, Full, Empty
import logging
@ -32,22 +32,30 @@ class QueueJob(object):
pass
PoisonPill = object()
class QueueWorker(threading.Thread):
def __init__(self, queue):
self.queue = queue
self.doRun = True
super().__init__(daemon=True)
super().__init__()
def run(self) -> None:
while self.doRun:
job = self.queue.get()
try:
job.run()
except Exception:
logger.exception("failed to decode job")
self.queue.onError()
finally:
job.unlink()
if job is PoisonPill:
self.doRun = False
# put the poison pill back on the queue for the next worker
self.queue.put(PoisonPill)
else:
try:
job.run()
except Exception:
logger.exception("failed to decode job")
self.queue.onError()
finally:
job.unlink()
self.queue.task_done()
@ -64,6 +72,13 @@ class DecoderQueue(Queue):
DecoderQueue.sharedInstance = DecoderQueue(maxsize=pm["decoding_queue_length"], workers=pm["decoding_queue_workers"])
return DecoderQueue.sharedInstance
@staticmethod
def stopAll():
with DecoderQueue.creationLock:
if DecoderQueue.sharedInstance is not None:
DecoderQueue.sharedInstance.stop()
DecoderQueue.sharedInstance = None
def __init__(self, maxsize, workers):
super().__init__(maxsize)
metrics = Metrics.getSharedInstance()
@ -78,6 +93,18 @@ class DecoderQueue(Queue):
metrics.addMetric("decoding.queue.error", self.errorCounter)
self.workers = [self.newWorker() for _ in range(0, workers)]
def stop(self):
logger.debug("shutting down the queue")
try:
# purge all remaining jobs
while not self.empty():
job = self.get()
job.unlink()
except Empty:
pass
# put() PoisonPill to tell workers to shut down
self.put(PoisonPill)
def put(self, item, **kwars):
self.inCounter.inc()
try:
@ -161,11 +188,10 @@ class AudioWriter(object):
self.timer.start()
def switchFiles(self):
self.switchingLock.acquire()
file = self.wavefile
filename = self.wavefilename
(self.wavefilename, self.wavefile) = self.getWaveFile()
self.switchingLock.release()
with self.switchingLock:
file = self.wavefile
filename = self.wavefilename
(self.wavefilename, self.wavefile) = self.getWaveFile()
file.close()
job = QueueJob(self, filename, self.dsp.get_operating_freq())
@ -205,9 +231,8 @@ class AudioWriter(object):
self._scheduleNextSwitch()
def write(self, data):
self.switchingLock.acquire()
self.wavefile.writeframes(data)
self.switchingLock.release()
with self.switchingLock:
self.wavefile.writeframes(data)
def stop(self):
self.outputWriter.close()
@ -229,7 +254,8 @@ class AudioWriter(object):
except Exception:
logger.exception("error closing wave file")
try:
os.unlink(self.wavefilename)
with self.switchingLock:
os.unlink(self.wavefilename)
except Exception:
logger.exception("error removing undecoded file")
self.wavefile = None

View File

@ -1,4 +1,3 @@
from owrx.config import Config
from owrx.details import ReceiverDetails
from owrx.dsp import DspManager
from owrx.cpu import CpuUsageThread
@ -110,7 +109,6 @@ class OpenWebRxClient(Client, metaclass=ABCMeta):
class OpenWebRxReceiverClient(OpenWebRxClient, SdrSourceEventClient):
sdr_config_keys = [
"waterfall_min_level",
"waterfall_min_level",
"waterfall_max_level",
"samp_rate",

View File

@ -129,6 +129,7 @@ class CompiledAssetsController(GzipMixin, ModificationAwareController):
"lib/MessagePanel.js",
"lib/Js8Threads.js",
"lib/Modes.js",
"lib/MetaPanel.js",
],
"map.js": [
"lib/jquery-3.2.1.min.js",

View File

@ -71,6 +71,7 @@ class CpuUsageThread(threading.Thread):
self.shutdown()
def shutdown(self):
CpuUsageThread.sharedInstance = None
with CpuUsageThread.creationLock:
CpuUsageThread.sharedInstance = None
self.doRun = False
self.endEvent.set()

View File

@ -77,6 +77,7 @@ class FeatureDetector(object):
"digital_voice_freedv": ["freedv_rx", "sox"],
"digital_voice_m17": ["m17_demod", "sox"],
"wsjt-x": ["wsjtx", "sox"],
"wsjt-x-2-3": ["wsjtx_2_3", "sox"],
"packet": ["direwolf", "sox"],
"pocsag": ["digiham", "sox"],
"js8call": ["js8", "sox"],
@ -459,6 +460,26 @@ class FeatureDetector(object):
"""
return reduce(and_, map(self.command_is_runnable, ["jt9", "wsprd"]), True)
def _has_wsjtx_version(self, required_version):
wsjt_version_regex = re.compile("^WSJT-X (.*)$")
try:
process = subprocess.Popen(["wsjtx_app_version", "--version"], stdout=subprocess.PIPE)
matches = wsjt_version_regex.match(process.stdout.readline().decode())
if matches is None:
return False
version = LooseVersion(matches.group(1))
process.wait(1)
return version >= required_version
except FileNotFoundError:
return False
def has_wsjtx_2_3(self):
"""
Newer digital modes (e.g. FST4, FST4) require WSJT-X in at least version 2.3.
"""
return self.has_wsjtx() and self._has_wsjtx_version(LooseVersion("2.3"))
def has_js8(self):
"""
To decode JS8, you will need to install [JS8Call](http://js8call.com/)

View File

@ -4,10 +4,10 @@ import re
from js8py import Js8
from js8py.frames import Js8FrameHeartbeat, Js8FrameCompound
from .map import Map, LocatorLocation
from .pskreporter import PskReporter
from .metrics import Metrics, CounterMetric
from .config import Config
from abc import ABCMeta, abstractmethod
from owrx.reporting import ReportingEngine
import logging
@ -102,7 +102,7 @@ class Js8Parser(Parser):
Map.getSharedInstance().updateLocation(
frame.callsign, LocatorLocation(frame.grid), "JS8", self.band
)
PskReporter.getSharedInstance().spot({
ReportingEngine.getSharedInstance().spot({
"callsign": frame.callsign,
"mode": "JS8",
"locator": frame.grid,

View File

@ -54,7 +54,7 @@ class DigitalMode(Mode):
class Modes(object):
mappings = [
AnalogMode("nfm", "FM", bandpass=Bandpass(-4000, 4000)),
AnalogMode("wfm", "WFM", bandpass=Bandpass(-50000, 50000)),
AnalogMode("wfm", "WFM", bandpass=Bandpass(-75000, 75000)),
AnalogMode("am", "AM", bandpass=Bandpass(-4000, 4000)),
AnalogMode("lsb", "LSB", bandpass=Bandpass(-3000, -300)),
AnalogMode("usb", "USB", bandpass=Bandpass(300, 3000)),
@ -75,8 +75,8 @@ class Modes(object):
DigitalMode(
"wspr", "WSPR", underlying=["usb"], bandpass=Bandpass(1350, 1650), requirements=["wsjt-x"], service=True
),
DigitalMode("fst4", "FST4", underlying=["usb"], bandpass=Bandpass(0, 3000), requirements=["wsjt-x"], service=True),
DigitalMode("fst4w", "FST4W", underlying=["usb"], bandpass=Bandpass(1350, 1650), requirements=["wsjt-x"], service=True),
DigitalMode("fst4", "FST4", underlying=["usb"], bandpass=Bandpass(0, 3000), requirements=["wsjt-x-2-3"], service=True),
DigitalMode("fst4w", "FST4W", underlying=["usb"], bandpass=Bandpass(1350, 1650), requirements=["wsjt-x-2-3"], service=True),
DigitalMode("js8", "JS8Call", underlying=["usb"], bandpass=Bandpass(0, 3000), requirements=["js8call"], service=True),
DigitalMode(
"packet",

View File

@ -9,43 +9,19 @@ from owrx.config import Config
from owrx.version import openwebrx_version
from owrx.locator import Locator
from owrx.metrics import Metrics, CounterMetric
from owrx.reporting import Reporter
logger = logging.getLogger(__name__)
class PskReporterDummy(object):
"""
used in place of the PskReporter when reporting is disabled.
does nothing.
"""
def spot(self, spot):
pass
def cancelTimer(self):
pass
class PskReporter(object):
sharedInstance = None
creationLock = threading.Lock()
class PskReporter(Reporter):
interval = 300
supportedModes = ["FT8", "FT4", "JT9", "JT65", "FST4", "FST4W", "JS8"]
@staticmethod
def getSharedInstance():
with PskReporter.creationLock:
if PskReporter.sharedInstance is None:
if Config.get()["pskreporter_enabled"]:
PskReporter.sharedInstance = PskReporter()
else:
PskReporter.sharedInstance = PskReporterDummy()
return PskReporter.sharedInstance
def getSupportedModes(self):
return ["FT8", "FT4", "JT9", "JT65", "FST4", "JS8"]
@staticmethod
def stop():
if PskReporter.sharedInstance:
PskReporter.sharedInstance.cancelTimer()
def stop(self):
self.cancelTimer()
def __init__(self):
self.spots = []
@ -72,8 +48,6 @@ class PskReporter(object):
return reduce(and_, map(lambda key: s1[key] == s2[key], keys))
def spot(self, spot):
if not spot["mode"] in PskReporter.supportedModes:
return
with self.spotLock:
if any(x for x in self.spots if self.spotEquals(spot, x)):
# dupe

56
owrx/reporting.py Normal file
View File

@ -0,0 +1,56 @@
import threading
from abc import ABC, abstractmethod
from owrx.config import Config
class Reporter(ABC):
@abstractmethod
def stop(self):
pass
@abstractmethod
def spot(self, spot):
pass
@abstractmethod
def getSupportedModes(self):
return []
class ReportingEngine(object):
creationLock = threading.Lock()
sharedInstance = None
@staticmethod
def getSharedInstance():
with ReportingEngine.creationLock:
if ReportingEngine.sharedInstance is None:
ReportingEngine.sharedInstance = ReportingEngine()
return ReportingEngine.sharedInstance
@staticmethod
def stopAll():
with ReportingEngine.creationLock:
if ReportingEngine.sharedInstance is not None:
ReportingEngine.sharedInstance.stop()
def __init__(self):
self.reporters = []
config = Config.get()
if "pskreporter_enabled" in config and config["pskreporter_enabled"]:
# inline import due to circular dependencies
from owrx.pskreporter import PskReporter
self.reporters += [PskReporter()]
if "wsprnet_enabled" in config and config["wsprnet_enabled"]:
# inline import due to circular dependencies
from owrx.wsprnet import WsprnetReporter
self.reporters += [WsprnetReporter()]
def stop(self):
for r in self.reporters:
r.stop()
def spot(self, spot):
for r in self.reporters:
if spot["mode"] in r.getSupportedModes():
r.spot(spot)

View File

@ -75,9 +75,38 @@ class SdrSource(ABC):
self.state = SdrSource.STATE_STOPPED
self.busyState = SdrSource.BUSYSTATE_IDLE
self.validateProfiles()
if self.isAlwaysOn():
self.start()
def validateProfiles(self):
props = PropertyStack()
props.addLayer(1, self.props)
for id, p in self.props["profiles"].items():
props.replaceLayer(0, self._getProfilePropertyLayer(p))
if "center_freq" not in props:
logger.warning("Profile \"%s\" does not specify a center_freq", id)
continue
if "samp_rate" not in props:
logger.warning("Profile \"%s\" does not specify a samp_rate", id)
continue
if "start_freq" in props:
start_freq = props["start_freq"]
srh = props["samp_rate"] / 2
center_freq = props["center_freq"]
if start_freq < center_freq - srh or start_freq > center_freq + srh:
logger.warning("start_freq for profile \"%s\" is out of range", id)
def _getProfilePropertyLayer(self, profile):
layer = PropertyLayer()
for (key, value) in profile.items():
# skip the name, that would overwrite the source name.
if key == "name":
continue
layer[key] = value
return layer
def isAlwaysOn(self):
return "always-on" in self.props and self.props["always-on"]
@ -119,12 +148,7 @@ class SdrSource(ABC):
profile = profiles[profile_id]
self.profile_id = profile_id
layer = PropertyLayer()
for (key, value) in profile.items():
# skip the name, that would overwrite the source name.
if key == "name":
continue
layer[key] = value
layer = self._getProfilePropertyLayer(profile)
self.props.replaceLayer(0, layer)
def getId(self):

View File

@ -32,3 +32,7 @@ class Resampler(DirectSource):
def activateProfile(self, profile_id=None):
logger.warning("Resampler does not support setting profiles")
pass
def validateProfiles(self):
# resampler does not support profiles
pass

View File

@ -2,7 +2,7 @@ from datetime import datetime, timezone
from owrx.map import Map, LocatorLocation
import re
from owrx.metrics import Metrics, CounterMetric
from owrx.pskreporter import PskReporter
from owrx.reporting import ReportingEngine
from owrx.parser import Parser
from owrx.audio import AudioChopperProfile
from abc import ABC, ABCMeta, abstractmethod
@ -156,19 +156,24 @@ class WsjtParser(Parser):
return
mode = profile.getMode()
if mode == "WSPR":
decoder = WsprDecoder(profile)
if mode in ["WSPR", "FST4W"]:
messageParser = BeaconMessageParser()
else:
decoder = Jt9Decoder(profile)
messageParser = QsoMessageParser()
if mode == "WSPR":
decoder = WsprDecoder(profile, messageParser)
else:
decoder = Jt9Decoder(profile, messageParser)
out = decoder.parse(msg, freq)
out["mode"] = mode
out["interval"] = profile.getInterval()
self.pushDecode(mode)
if "callsign" in out and "locator" in out:
Map.getSharedInstance().updateLocation(
out["callsign"], LocatorLocation(out["locator"]), mode, self.band
)
PskReporter.getSharedInstance().spot(out)
ReportingEngine.getSharedInstance().spot(out)
self.handler.write_wsjt_message(out)
except (ValueError, IndexError):
@ -195,10 +200,9 @@ class WsjtParser(Parser):
class Decoder(ABC):
locator_pattern = re.compile(".*\\s([A-Z0-9/]{2,})(\\sR)?\\s([A-R]{2}[0-9]{2})$")
def __init__(self, profile):
def __init__(self, profile, messageParser):
self.profile = profile
self.messageParser = messageParser
def parse_timestamp(self, instring):
dateformat = self.profile.getTimestampFormat()
@ -215,8 +219,19 @@ class Decoder(ABC):
def parse(self, msg, dial_freq):
pass
def parseMessage(self, msg):
m = Decoder.locator_pattern.match(msg)
class MessageParser(ABC):
@abstractmethod
def parse(self, msg):
pass
# Used in QSO-style modes (FT8, FT4, FST4)
class QsoMessageParser(MessageParser):
locator_pattern = re.compile(".*\\s([A-Z0-9/]{2,})(\\sR)?\\s([A-R]{2}[0-9]{2})$")
def parse(self, msg):
m = QsoMessageParser.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
@ -226,6 +241,17 @@ class Decoder(ABC):
return {"callsign": m.group(1), "locator": m.group(3)}
# Used in propagation reporting / beacon modes (WSPR / FST4W)
class BeaconMessageParser(MessageParser):
wspr_splitter_pattern = re.compile("([A-Z0-9/]*)\\s([A-R]{2}[0-9]{2})\\s([0-9]+)")
def parse(self, msg):
m = BeaconMessageParser.wspr_splitter_pattern.match(msg)
if m is None:
return {}
return {"callsign": m.group(1), "locator": m.group(2), "dbm": m.group(3)}
class Jt9Decoder(Decoder):
def parse(self, msg, dial_freq):
# ft8 sample
@ -245,13 +271,11 @@ class Jt9Decoder(Decoder):
"freq": dial_freq + int(msg[9:13]),
"msg": wsjt_msg,
}
result.update(self.parseMessage(wsjt_msg))
result.update(self.messageParser.parse(wsjt_msg))
return result
class WsprDecoder(Decoder):
wspr_splitter_pattern = re.compile("([A-Z0-9/]*)\\s([A-R]{2}[0-9]{2})\\s([0-9]+)")
def parse(self, msg, dial_freq):
# wspr sample
# '2600 -24 0.4 0.001492 -1 G8AXA JO01 33'
@ -266,11 +290,5 @@ class WsprDecoder(Decoder):
"drift": int(msg[20:23]),
"msg": wsjt_msg,
}
result.update(self.parseMessage(wsjt_msg))
result.update(self.messageParser.parse(wsjt_msg))
return result
def parseMessage(self, msg):
m = WsprDecoder.wspr_splitter_pattern.match(msg)
if m is None:
return {}
return {"callsign": m.group(1), "locator": m.group(2)}

90
owrx/wsprnet.py Normal file
View File

@ -0,0 +1,90 @@
from owrx.reporting import Reporter
from owrx.version import openwebrx_version
from owrx.config import Config
from owrx.locator import Locator
from owrx.metrics import Metrics, CounterMetric
from queue import Queue, Full
from urllib import request, parse
import threading
import logging
from datetime import datetime, timezone
logger = logging.getLogger(__name__)
class Worker(threading.Thread):
def __init__(self, queue: Queue):
self.queue = queue
self.doRun = True
# some constants that we don't expect to change
config = Config.get()
self.callsign = config["wsprnet_callsign"]
self.locator = Locator.fromCoordinates(config["receiver_gps"])
super().__init__(daemon=True)
def run(self):
while self.doRun:
try:
spot = self.queue.get()
self.uploadSpot(spot)
self.queue.task_done()
except Exception:
logger.exception("Exception while uploading WSPRNet spot")
def _getMode(self, spot):
interval = round(spot["interval"] / 60)
# FST4W modes are mapped not to conflict with WSPR modes 2 and 15:
if spot["mode"] != "WSPR" and interval in [2, 15]:
return interval + 1
return interval
def uploadSpot(self, spot):
# function=wspr&date=210114&time=1732&sig=-15&dt=0.5&drift=0&tqrg=7.040019&tcall=DF2UU&tgrid=JN48&dbm=37&version=2.3.0-rc3&rcall=DD5JFK&rgrid=JN58SC&rqrg=7.040047&mode=2
# {'timestamp': 1610655960000, 'db': -23.0, 'dt': 0.3, 'freq': 7040048, 'drift': -1, 'msg': 'LA3JJ JO59 37', 'callsign': 'LA3JJ', 'locator': 'JO59', 'mode': 'WSPR'}
date = datetime.fromtimestamp(spot["timestamp"] / 1000, tz=timezone.utc)
data = parse.urlencode({
"function": "wspr",
"date": date.strftime("%y%m%d"),
"time": date.strftime("%H%M"),
"sig": spot["db"],
"dt": spot["dt"],
# FST4W does not have drift
"drift": spot["drift"] if "drift" in spot else 0,
"tqrg": spot["freq"] / 1E6,
"tcall": spot["callsign"],
"tgrid": spot["locator"],
"dbm": spot["dbm"],
"version": openwebrx_version,
"rcall": self.callsign,
"rgrid": self.locator,
# mode 2 = WSPR 2 minutes
"mode": self._getMode(spot)
}).encode()
request.urlopen("http://wsprnet.org/post/", data)
class WsprnetReporter(Reporter):
def __init__(self):
# max 100 entries
self.queue = Queue(100)
# single worker
Worker(self.queue).start()
# metrics
metrics = Metrics.getSharedInstance()
self.spotCounter = CounterMetric()
metrics.addMetric("wsprnet.spots", self.spotCounter)
def stop(self):
pass
def spot(self, spot):
try:
self.queue.put(spot, block=False)
self.spotCounter.inc()
except Full:
logger.warning("WSPRNet Queue overflow, one spot lost")
def getSupportedModes(self):
return ["WSPR", "FST4W"]