-
+
diff --git a/htdocs/lib/AudioEngine.js b/htdocs/lib/AudioEngine.js
index 9434c83..fb08bfc 100644
--- a/htdocs/lib/AudioEngine.js
+++ b/htdocs/lib/AudioEngine.js
@@ -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;
diff --git a/htdocs/lib/Demodulator.js b/htdocs/lib/Demodulator.js
index de6f3c5..bd0e579 100644
--- a/htdocs/lib/Demodulator.js
+++ b/htdocs/lib/Demodulator.js
@@ -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;
diff --git a/htdocs/lib/DemodulatorPanel.js b/htdocs/lib/DemodulatorPanel.js
index 2cdca68..aed1b17 100644
--- a/htdocs/lib/DemodulatorPanel.js
+++ b/htdocs/lib/DemodulatorPanel.js
@@ -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;
diff --git a/htdocs/lib/Header.js b/htdocs/lib/Header.js
index cd3f4bf..746c282 100644
--- a/htdocs/lib/Header.js
+++ b/htdocs/lib/Header.js
@@ -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();
}
diff --git a/htdocs/lib/MessagePanel.js b/htdocs/lib/MessagePanel.js
index a4c8b40..597475b 100644
--- a/htdocs/lib/MessagePanel.js
+++ b/htdocs/lib/MessagePanel.js
@@ -78,14 +78,14 @@ WsjtMessagePanel.prototype.pushMessage = function(msg) {
return $('
').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]) + '
' + matches[2] + '';
} 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]) + '
' + matches[2] + '' + html_escape(matches[3]);
diff --git a/htdocs/lib/MetaPanel.js b/htdocs/lib/MetaPanel.js
new file mode 100644
index 0000000..849cb21
--- /dev/null
+++ b/htdocs/lib/MetaPanel.js
@@ -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 = '
';
+ }
+ 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');
+ });
+};
\ No newline at end of file
diff --git a/htdocs/map.html b/htdocs/map.html
index 08e40b4..f93d2e2 100644
--- a/htdocs/map.html
+++ b/htdocs/map.html
@@ -2,6 +2,9 @@
OpenWebRX Map
+
+
+
diff --git a/htdocs/openwebrx.js b/htdocs/openwebrx.js
index 05bce97..e7d5303 100644
--- a/htdocs/openwebrx.js
+++ b/htdocs/openwebrx.js
@@ -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 '
";
- }).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 = "
" + 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 = "
" + what + "";
toggle_panel("openwebrx-panel-log", true); //show panel if any error is present
}
- e("openwebrx-debugdiv").innerHTML += what + "
";
+ $('#openwebrx-debugdiv')[0].innerHTML += what + "
";
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)';
diff --git a/owrx/__main__.py b/owrx/__main__.py
index a0e83dc..908767f 100644
--- a/owrx/__main__.py
+++ b/owrx/__main__.py
@@ -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()
diff --git a/owrx/audio.py b/owrx/audio.py
index d6ba3f5..9a5b07f 100644
--- a/owrx/audio.py
+++ b/owrx/audio.py
@@ -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
diff --git a/owrx/connection.py b/owrx/connection.py
index 73e1f6d..62718a1 100644
--- a/owrx/connection.py
+++ b/owrx/connection.py
@@ -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",
diff --git a/owrx/controllers/assets.py b/owrx/controllers/assets.py
index bac09e6..7563325 100644
--- a/owrx/controllers/assets.py
+++ b/owrx/controllers/assets.py
@@ -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",
diff --git a/owrx/cpu.py b/owrx/cpu.py
index 6b2b82c..cad912f 100644
--- a/owrx/cpu.py
+++ b/owrx/cpu.py
@@ -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()
diff --git a/owrx/feature.py b/owrx/feature.py
index fbdb425..1a37494 100644
--- a/owrx/feature.py
+++ b/owrx/feature.py
@@ -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/)
diff --git a/owrx/js8.py b/owrx/js8.py
index 18a6cef..79e1850 100644
--- a/owrx/js8.py
+++ b/owrx/js8.py
@@ -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,
diff --git a/owrx/modes.py b/owrx/modes.py
index 8b642e1..0fcf366 100644
--- a/owrx/modes.py
+++ b/owrx/modes.py
@@ -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",
diff --git a/owrx/pskreporter.py b/owrx/pskreporter.py
index 662b2a7..533a251 100644
--- a/owrx/pskreporter.py
+++ b/owrx/pskreporter.py
@@ -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
diff --git a/owrx/reporting.py b/owrx/reporting.py
new file mode 100644
index 0000000..faa427c
--- /dev/null
+++ b/owrx/reporting.py
@@ -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)
diff --git a/owrx/source/__init__.py b/owrx/source/__init__.py
index a4c8150..2c3d074 100644
--- a/owrx/source/__init__.py
+++ b/owrx/source/__init__.py
@@ -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):
diff --git a/owrx/source/resampler.py b/owrx/source/resampler.py
index a367894..03c2097 100644
--- a/owrx/source/resampler.py
+++ b/owrx/source/resampler.py
@@ -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
diff --git a/owrx/wsjt.py b/owrx/wsjt.py
index 90c4d9c..c432831 100644
--- a/owrx/wsjt.py
+++ b/owrx/wsjt.py
@@ -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)}
diff --git a/owrx/wsprnet.py b/owrx/wsprnet.py
new file mode 100644
index 0000000..35bbe44
--- /dev/null
+++ b/owrx/wsprnet.py
@@ -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"]