');
+ this.digitContainer = $('
');
+ this.displayContainer.html([this.digitContainer, $(' MHz')]);
+ this.element.html(this.displayContainer);
+};
+
FrequencyDisplay.prototype.setFrequency = function(freq) {
this.frequency = freq;
var formatted = (freq / 1e6).toLocaleString(undefined, {maximumFractionDigits: 4, minimumFractionDigits: 4});
@@ -36,9 +41,17 @@ function TuneableFrequencyDisplay(element) {
TuneableFrequencyDisplay.prototype = new FrequencyDisplay();
+TuneableFrequencyDisplay.prototype.setupElements = function() {
+ FrequencyDisplay.prototype.setupElements.call(this);
+ this.input = $('');
+ this.input.hide();
+ this.element.append(this.input);
+};
+
TuneableFrequencyDisplay.prototype.setupEvents = function() {
var me = this;
- this.element.on('wheel', function(e){
+
+ me.element.on('wheel', function(e){
e.preventDefault();
e.stopPropagation();
@@ -49,13 +62,45 @@ TuneableFrequencyDisplay.prototype.setupEvents = function() {
if (e.originalEvent.deltaY > 0) delta *= -1;
var newFrequency = me.frequency + delta;
- me.listeners.forEach(function(l) {
- l(newFrequency);
- });
+ me.element.trigger('frequencychange', newFrequency);
+ });
+
+ var submit = function(){
+ var freq = parseInt(me.input.val());
+ if (!isNaN(freq)) {
+ me.element.trigger('frequencychange', freq);
+ }
+ me.input.hide();
+ me.displayContainer.show();
+ };
+ me.input.on('blur', submit).on('keyup', function(e){
+ if (e.keyCode == 13) return submit();
+ if (e.keyCode == 27) {
+ me.input.hide();
+ me.displayContainer.show();
+ }
+ });
+ me.input.on('click', function(e){
+ e.stopPropagation();
+ });
+ me.element.on('click', function(){
+ me.input.val(me.frequency);
+ me.input.show();
+ me.displayContainer.hide();
+ me.input.focus();
});
- this.listeners = [];
};
-TuneableFrequencyDisplay.prototype.onFrequencyChange = function(listener){
- this.listeners.push(listener);
-};
\ No newline at end of file
+$.fn.frequencyDisplay = function() {
+ if (!this.data('frequencyDisplay')) {
+ this.data('frequencyDisplay', new FrequencyDisplay(this));
+ }
+ return this.data('frequencyDisplay');
+}
+
+$.fn.tuneableFrequencyDisplay = function() {
+ if (!this.data('frequencyDisplay')) {
+ this.data('frequencyDisplay', new TuneableFrequencyDisplay(this));
+ }
+ return this.data('frequencyDisplay');
+}
diff --git a/htdocs/lib/Header.js b/htdocs/lib/Header.js
new file mode 100644
index 0000000..cced6b6
--- /dev/null
+++ b/htdocs/lib/Header.js
@@ -0,0 +1,77 @@
+function Header(el) {
+ this.el = el;
+
+ this.el.find('#openwebrx-main-buttons').find('[data-toggle-panel]').click(function () {
+ toggle_panel($(this).data('toggle-panel'));
+ });
+
+ this.init_rx_photo();
+ this.download_details();
+};
+
+Header.prototype.setDetails = function(details) {
+ this.el.find('#webrx-rx-title').html(details['receiver_name']);
+ var query = encodeURIComponent(details['receiver_gps']['lat'] + ',' + details['receiver_gps']['lon']);
+ this.el.find('#webrx-rx-desc').html(details['receiver_location'] + ' | Loc: ' + details['locator'] + ', ASL: ' + details['receiver_asl'] + ' m, [maps]');
+ this.el.find('#webrx-rx-photo-title').html(details['photo_title']);
+ this.el.find('#webrx-rx-photo-desc').html(details['photo_desc']);
+};
+
+Header.prototype.init_rx_photo = function() {
+ this.rx_photo_state = 0;
+
+ $.extend($.easing, {
+ easeOutCubic:function(x) {
+ return 1 - Math.pow( 1 - x, 3 );
+ }
+ });
+
+ $('#webrx-top-container').find('.openwebrx-photo-trigger').click(this.toggle_rx_photo.bind(this));
+};
+
+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-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-rx-details-arrow-down").hide();
+ this.el.find("#openwebrx-rx-details-arrow-up").show();
+}
+
+Header.prototype.toggle_rx_photo = function(ev) {
+ if (ev && ev.target && ev.target.tagName == 'A') {
+ return;
+ }
+ if (this.rx_photo_state) {
+ this.close_rx_photo();
+ } else {
+ this.open_rx_photo();
+ }
+};
+
+Header.prototype.download_details = function() {
+ var self = this;
+ $.ajax('api/receiverdetails').done(function(data){
+ self.setDetails(data);
+ });
+};
+
+$.fn.header = function() {
+ if (!this.data('header')) {
+ this.data('header', new Header(this));
+ }
+ return this.data('header');
+};
+
+$(function(){
+ $('#webrx-top-container').header();
+});
diff --git a/htdocs/lib/Js8Threads.js b/htdocs/lib/Js8Threads.js
new file mode 100644
index 0000000..4cecf46
--- /dev/null
+++ b/htdocs/lib/Js8Threads.js
@@ -0,0 +1,150 @@
+Js8Thread = function(el){
+ this.messages = [];
+ this.el = el;
+};
+
+Js8Thread.prototype.getAverageFrequency = function(){
+ var total = this.messages.map(function(message){
+ return message.freq;
+ }).reduce(function(t, f){
+ return t + f;
+ }, 0);
+ return total / this.messages.length;
+};
+
+Js8Thread.prototype.pushMessage = function(message) {
+ this.messages.push(message);
+ this.render();
+};
+
+Js8Thread.prototype.render = function() {
+ this.el.html(
+ '' + this.renderTimestamp(this.getLatestTimestamp()) + ' | ' +
+ '
' + Math.round(this.getAverageFrequency()) + ' | ' +
+ '
' + this.renderMessages() + ' | '
+ );
+};
+
+Js8Thread.prototype.getLatestTimestamp = function() {
+ return this.messages[0].timestamp;
+};
+
+Js8Thread.prototype.isOpen = function() {
+ if (!this.messages.length) return true;
+ var last_message = this.messages[this.messages.length - 1];
+ return (last_message.thread_type & 2) === 0;
+};
+
+Js8Thread.prototype.renderMessages = function() {
+ var res = [];
+ for (var i = 0; i < this.messages.length; i++) {
+ var msg = this.messages[i];
+ if (msg.thread_type & 1) {
+ res.push('[ ');
+ } else if (i === 0 || msg.timestamp - this.messages[i - 1].timestamp > this.getMessageDuration()) {
+ res.push(' ... ');
+ }
+ res.push(msg.msg);
+ if (msg.thread_type & 2) {
+ res.push(' ]');
+ } else if (i === this.messages.length -1) {
+ res.push(' ... ');
+ }
+ }
+ return res.join('');
+};
+
+Js8Thread.prototype.getMessageDuration = function() {
+ switch (this.getMode()) {
+ case 'A':
+ return 15000;
+ case 'E':
+ return 30000;
+ case 'B':
+ return 10000;
+ case 'C':
+ return 6000;
+ }
+};
+
+Js8Thread.prototype.getMode = function() {
+ // we filter messages by mode, so the first one is as good as any
+ if (!this.messages.length) return;
+ return this.messages[0].mode;
+};
+
+Js8Thread.prototype.acceptsMode = function(mode) {
+ var currentMode = this.getMode();
+ return typeof(currentMode) === 'undefined' || currentMode === mode;
+};
+
+Js8Thread.prototype.renderTimestamp = function(timestamp) {
+ var t = new Date(timestamp);
+ var pad = function (i) {
+ return ('' + i).padStart(2, "0");
+ };
+ return pad(t.getUTCHours()) + pad(t.getUTCMinutes()) + pad(t.getUTCSeconds());
+};
+
+Js8Thread.prototype.purgeOldMessages = function() {
+ var now = new Date().getTime();
+ this.messages = this.messages.filter(function(m) {
+ // keep messages around for 20 minutes
+ return now - m.timestamp < 20 * 60 * 1000;
+ });
+ if (!this.messages.length) {
+ this.el.remove();
+ } else {
+ this.render();
+ }
+ return this.messages.length;
+};
+
+Js8Threader = function(el){
+ this.threads = [];
+ this.tbody = $(el).find('tbody');
+ var me = this;
+ this.interval = setInterval(function(){
+ me.purgeOldMessages();
+ }, 15000);
+};
+
+Js8Threader.prototype.purgeOldMessages = function() {
+ this.threads = this.threads.filter(function(t) {
+ return t.purgeOldMessages();
+ });
+};
+
+Js8Threader.prototype.findThread = function(freq, mode) {
+ var matching = this.threads.filter(function(thread) {
+ // max frequency deviation: 5 Hz. this may be a little tight.
+ return thread.isOpen() && thread.acceptsMode(mode) && Math.abs(thread.getAverageFrequency() - freq) <= 5;
+ });
+ matching.sort(function(a, b){
+ return b.getLatestTimestamp() - a.getLatestTimestamp();
+ });
+ return matching[0] || false;
+};
+
+Js8Threader.prototype.pushMessage = function(message) {
+ var thread;
+ // only look for exising threads if the message is not a starting message
+ if ((message.thread_type & 1) === 0) {
+ thread = this.findThread(message.freq, message.mode);
+ }
+ if (!thread) {
+ var line = $("
|
");
+ this.tbody.append(line);
+ thread = new Js8Thread(line);
+ this.threads.push(thread);
+ }
+ thread.pushMessage(message);
+ this.tbody.scrollTop(this.tbody[0].scrollHeight);
+};
+
+$.fn.js8 = function() {
+ if (!this.data('threader')) {
+ this.data('threader', new Js8Threader(this));
+ }
+ return this.data('threader');
+};
\ No newline at end of file
diff --git a/htdocs/lib/Measurement.js b/htdocs/lib/Measurement.js
index 202070e..e07b196 100644
--- a/htdocs/lib/Measurement.js
+++ b/htdocs/lib/Measurement.js
@@ -1,4 +1,5 @@
function Measurement() {
+ this.reporters = [];
this.reset();
}
@@ -21,10 +22,13 @@ Measurement.prototype.getRate = function() {
Measurement.prototype.reset = function() {
this.value = 0;
this.start = new Date();
+ this.reporters.forEach(function(r){ r.reset(); });
};
Measurement.prototype.report = function(range, interval, callback) {
- return new Reporter(this, range, interval, callback);
+ var reporter = new Reporter(this, range, interval, callback);
+ this.reporters.push(reporter);
+ return reporter;
};
function Reporter(measurement, range, interval, callback) {
@@ -59,4 +63,8 @@ Reporter.prototype.report = function(){
var accumulated = newest.value - oldest.value;
// we want rate per second, but our time is in milliseconds... compensate by 1000
this.callback(accumulated * 1000 / elapsed);
+};
+
+Reporter.prototype.reset = function(){
+ this.samples = [];
};
\ No newline at end of file
diff --git a/htdocs/lib/Modes.js b/htdocs/lib/Modes.js
new file mode 100644
index 0000000..c68466a
--- /dev/null
+++ b/htdocs/lib/Modes.js
@@ -0,0 +1,55 @@
+var Modes = {
+ modes: [],
+ features: {},
+ panels: [],
+ setModes:function(json){
+ this.modes = json.map(function(m){ return new Mode(m); });
+ this.updatePanels();
+ $('#openwebrx-dialog-bookmark').bookmarkDialog().setModes(this.modes);
+ },
+ getModes:function(){
+ return this.modes;
+ },
+ setFeatures:function(features){
+ this.features = features;
+ this.updatePanels();
+ },
+ findByModulation:function(modulation){
+ matches = this.modes.filter(function(m) { return m.modulation === modulation; });
+ if (matches.length) return matches[0]
+ },
+ registerModePanel: function(el) {
+ this.panels.push(el);
+ },
+ initComplete: function() {
+ return this.modes.length && Object.keys(this.features).length;
+ },
+ updatePanels: function() {
+ this.panels.forEach(function(p) {
+ p.render();
+ p.startDemodulator();
+ });
+ }
+};
+
+var Mode = function(json){
+ this.modulation = json.modulation;
+ this.name = json.name;
+ this.type = json.type;
+ this.requirements = json.requirements;
+ this.squelch = json.squelch;
+ if (json.bandpass) {
+ this.bandpass = json.bandpass;
+ }
+ if (this.type === 'digimode') {
+ this.underlying = json.underlying;
+ }
+};
+
+Mode.prototype.isAvailable = function(){
+ return this.requirements.map(function(r){
+ return Modes.features[r];
+ }).reduce(function(a, b){
+ return a && b;
+ }, true);
+};
diff --git a/htdocs/lib/ProgressBar.js b/htdocs/lib/ProgressBar.js
index 9d0736d..691d2ff 100644
--- a/htdocs/lib/ProgressBar.js
+++ b/htdocs/lib/ProgressBar.js
@@ -1,10 +1,15 @@
ProgressBar = function(el) {
this.$el = $(el);
- this.$innerText = this.$el.find('.openwebrx-progressbar-text');
- this.$innerBar = this.$el.find('.openwebrx-progressbar-bar');
+ this.$innerText = $('
' + this.getDefaultText() + '');
+ this.$innerBar = $('
');
+ this.$el.empty().append(this.$innerText, this.$innerBar);
this.$innerBar.css('width', '0%');
};
+ProgressBar.prototype.getDefaultText = function() {
+ return '';
+}
+
ProgressBar.prototype.set = function(val, text, over) {
this.setValue(val);
this.setText(text);
@@ -25,13 +30,20 @@ ProgressBar.prototype.setOver = function(over) {
this.$innerBar.css('backgroundColor', (over) ? "#ff6262" : "#00aba6");
};
-AudioBufferProgressBar = function(el, sampleRate) {
+AudioBufferProgressBar = function(el) {
ProgressBar.call(this, el);
- this.sampleRate = sampleRate;
};
AudioBufferProgressBar.prototype = new ProgressBar();
+AudioBufferProgressBar.prototype.getDefaultText = function() {
+ return 'Audio buffer [0 ms]';
+};
+
+AudioBufferProgressBar.prototype.setSampleRate = function(sampleRate) {
+ this.sampleRate = sampleRate;
+};
+
AudioBufferProgressBar.prototype.setBuffersize = function(buffersize) {
var audio_buffer_value = buffersize / this.sampleRate;
var overrun = audio_buffer_value > audio_buffer_maximal_length_sec;
@@ -53,6 +65,10 @@ NetworkSpeedProgressBar = function(el) {
NetworkSpeedProgressBar.prototype = new ProgressBar();
+NetworkSpeedProgressBar.prototype.getDefaultText = function() {
+ return 'Network usage [0 kbps]';
+};
+
NetworkSpeedProgressBar.prototype.setSpeed = function(speed) {
var speedInKilobits = speed * 8 / 1000;
this.set(speedInKilobits / 2000, "Network usage [" + speedInKilobits.toFixed(1) + " kbps]", false);
@@ -64,18 +80,29 @@ AudioSpeedProgressBar = function(el) {
AudioSpeedProgressBar.prototype = new ProgressBar();
+AudioSpeedProgressBar.prototype.getDefaultText = function() {
+ return 'Audio stream [0 kbps]';
+};
+
AudioSpeedProgressBar.prototype.setSpeed = function(speed) {
- this.set(speed / 500000, "Audio stream [" + (speed / 1000).toFixed(0) + " kbps]", false);
+ this.set(speed / 1000000, "Audio stream [" + (speed / 1000).toFixed(0) + " kbps]", false);
};
AudioOutputProgressBar = function(el, sampleRate) {
ProgressBar.call(this, el);
- this.maxRate = sampleRate * 1.25;
- this.minRate = sampleRate * .25;
};
AudioOutputProgressBar.prototype = new ProgressBar();
+AudioOutputProgressBar.prototype.getDefaultText = function() {
+ return 'Audio output [0 sps]';
+};
+
+AudioOutputProgressBar.prototype.setSampleRate = function(sampleRate) {
+ this.maxRate = sampleRate * 1.25;
+ this.minRate = sampleRate * .25;
+};
+
AudioOutputProgressBar.prototype.setAudioRate = function(audioRate) {
this.set(audioRate / this.maxRate, "Audio output [" + (audioRate / 1000).toFixed(1) + " ksps]", audioRate > this.maxRate || audioRate < this.minRate);
};
@@ -88,6 +115,10 @@ ClientsProgressBar = function(el) {
ClientsProgressBar.prototype = new ProgressBar();
+ClientsProgressBar.prototype.getDefaultText = function() {
+ return 'Clients [1]';
+};
+
ClientsProgressBar.prototype.setClients = function(clients) {
this.clients = clients;
this.render();
@@ -108,6 +139,27 @@ CpuProgressBar = function(el) {
CpuProgressBar.prototype = new ProgressBar();
+CpuProgressBar.prototype.getDefaultText = function() {
+ return 'Server CPU [0%]';
+};
+
CpuProgressBar.prototype.setUsage = function(usage) {
this.set(usage, "Server CPU [" + Math.round(usage * 100) + "%]", usage > .85);
};
+
+ProgressBar.types = {
+ cpu: CpuProgressBar,
+ audiobuffer: AudioBufferProgressBar,
+ audiospeed: AudioSpeedProgressBar,
+ audiooutput: AudioOutputProgressBar,
+ clients: ClientsProgressBar,
+ networkspeed: NetworkSpeedProgressBar
+}
+
+$.fn.progressbar = function() {
+ if (!this.data('progressbar')) {
+ var constructor = ProgressBar.types[this.data('type')] || ProgressBar;
+ this.data('progressbar', new constructor(this));
+ }
+ return this.data('progressbar');
+};
diff --git a/htdocs/lib/settings/Input.js b/htdocs/lib/settings/Input.js
new file mode 100644
index 0000000..f638257
--- /dev/null
+++ b/htdocs/lib/settings/Input.js
@@ -0,0 +1,138 @@
+function Input(name, value, options) {
+ this.name = name;
+ this.value = value;
+ this.options = options;
+ this.label = options && options.label || name;
+};
+
+Input.prototype.getClasses = function() {
+ return ['form-control', 'form-control-sm'];
+}
+
+Input.prototype.bootstrapify = function(input) {
+ this.getClasses().forEach(input.addClass.bind(input));
+ return [
+ '
'
+ ].join('');
+};
+
+function TextInput() {
+ Input.apply(this, arguments);
+};
+
+TextInput.prototype = new Input();
+
+TextInput.prototype.render = function() {
+ return this.bootstrapify($('
'));
+}
+
+function NumberInput() {
+ Input.apply(this, arguments);
+};
+
+NumberInput.prototype = new Input();
+
+NumberInput.prototype.render = function() {
+ return this.bootstrapify($('
'));
+};
+
+function SoapyGainInput() {
+ Input.apply(this, arguments);
+}
+
+SoapyGainInput.prototype = new Input();
+
+SoapyGainInput.prototype.getClasses = function() {
+ return [];
+};
+
+SoapyGainInput.prototype.render = function(){
+ var markup = $(
+ '
' +
+ '
' +
+ this.options.gains.map(function(g){
+ return '
';
+ }).join('')
+ );
+ var el = $(this.bootstrapify(markup))
+ var setMode = function(mode){
+ el.find('select').val(mode);
+ el.find('.option').hide();
+ el.find('.gain-mode-' + mode).show();
+ };
+ el.on('change', 'select', function(){
+ var mode = $(this).val();
+ setMode(mode);
+ });
+ if (typeof(this.value) === 'number') {
+ setMode('single');
+ el.find('.gain-mode-single input').val(this.value);
+ } else if (typeof(this.value) === 'string') {
+ if (this.value === 'auto') {
+ setMode('auto');
+ } else {
+ setMode('separate');
+ values = $.extend.apply($, this.value.split(',').map(function(seg){
+ var split = seg.split('=');
+ if (split.length < 2) return;
+ var res = {};
+ res[split[0]] = parseInt(split[1]);
+ return res;
+ }));
+ el.find('.gain-mode-separate input').each(function(){
+ var $input = $(this);
+ var g = $input.data('gain');
+ $input.val(g in values ? values[g] : 0);
+ });
+ }
+ } else {
+ setMode('auto');
+ }
+ return el;
+};
+
+function ProfileInput() {
+ Input.apply(this, arguments);
+};
+
+ProfileInput.prototype = new Input();
+
+ProfileInput.prototype.render = function() {
+ return $('
Profiles
');
+};
+
+function SchedulerInput() {
+ Input.apply(this, arguments);
+};
+
+SchedulerInput.prototype = new Input();
+
+SchedulerInput.prototype.render = function() {
+ return $('
Scheduler
');
+};
diff --git a/htdocs/lib/settings/SdrDevice.js b/htdocs/lib/settings/SdrDevice.js
new file mode 100644
index 0000000..25f85c9
--- /dev/null
+++ b/htdocs/lib/settings/SdrDevice.js
@@ -0,0 +1,252 @@
+function SdrDevice(el, data) {
+ this.el = el;
+ this.data = data;
+ this.inputs = {};
+ this.render();
+
+ var self = this;
+ el.on('click', '.fieldselector .btn', function() {
+ var key = el.find('.fieldselector select').val();
+ self.data[key] = self.getInitialValue(key);
+ self.render();
+ });
+};
+
+SdrDevice.create = function(el) {
+ var data = JSON.parse(decodeURIComponent(el.data('config')));
+ var type = data.type;
+ var constructor = SdrDevice.types[type] || SdrDevice;
+ return new constructor(el, data);
+};
+
+SdrDevice.prototype.getData = function() {
+ return $.extend(new Object(), this.getDefaults(), this.data);
+};
+
+SdrDevice.prototype.getDefaults = function() {
+ var defaults = {}
+ $.each(this.getMappings(), function(k, v) {
+ if (!v.includeInDefault) return;
+ defaults[k] = 'initialValue' in v ? v['initialValue'] : false;
+ });
+ return defaults;
+};
+
+SdrDevice.prototype.getMappings = function() {
+ return {
+ "name": {
+ constructor: TextInput,
+ inputOptions: {
+ label: "Name"
+ },
+ initialValue: "",
+ includeInDefault: true
+ },
+ "type": {
+ constructor: TextInput,
+ inputOptions: {
+ label: "Type"
+ },
+ initialValue: '',
+ includeInDefault: true
+ },
+ "ppm": {
+ constructor: NumberInput,
+ inputOptions: {
+ label: "PPM"
+ },
+ initialValue: 0
+ },
+ "profiles": {
+ constructor: ProfileInput,
+ inputOptions: {
+ label: "Profiles"
+ },
+ initialValue: [],
+ includeInDefault: true,
+ position: 100
+ },
+ "scheduler": {
+ constructor: SchedulerInput,
+ inputOptions: {
+ label: "Scheduler",
+ },
+ initialValue: {},
+ position: 101
+ },
+ "rf_gain": {
+ constructor: TextInput,
+ inputOptions: {
+ label: "Gain",
+ },
+ initialValue: 0
+ }
+ };
+};
+
+SdrDevice.prototype.getMapping = function(key) {
+ var mappings = this.getMappings();
+ return mappings[key];
+};
+
+SdrDevice.prototype.getInputClass = function(key) {
+ var mapping = this.getMapping(key);
+ return mapping && mapping.constructor || TextInput;
+};
+
+SdrDevice.prototype.getInitialValue = function(key) {
+ var mapping = this.getMapping(key);
+ return mapping && ('initialValue' in mapping) ? mapping['initialValue'] : false;
+};
+
+SdrDevice.prototype.getPosition = function(key) {
+ var mapping = this.getMapping(key);
+ return mapping && mapping.position || 10;
+};
+
+SdrDevice.prototype.getInputOptions = function(key) {
+ var mapping = this.getMapping(key);
+ return mapping && mapping.inputOptions || {};
+};
+
+SdrDevice.prototype.getLabel = function(key) {
+ var options = this.getInputOptions(key);
+ return options && options.label || key;
+};
+
+SdrDevice.prototype.render = function() {
+ var self = this;
+ self.el.empty();
+ var data = this.getData();
+ Object.keys(data).sort(function(a, b){
+ return self.getPosition(a) - self.getPosition(b);
+ }).forEach(function(key){
+ var value = data[key];
+ var inputClass = self.getInputClass(key);
+ var input = new inputClass(key, value, self.getInputOptions(key));
+ self.inputs[key] = input;
+ self.el.append(input.render());
+ });
+ self.el.append(this.renderFieldSelector());
+};
+
+SdrDevice.prototype.renderFieldSelector = function() {
+ var self = this;
+ return '
' +
+ '
Add new configuration options' +
+ '' +
+ '
';
+};
+
+RtlSdrDevice = function() {
+ SdrDevice.apply(this, arguments);
+};
+
+RtlSdrDevice.prototype = Object.create(SdrDevice.prototype);
+RtlSdrDevice.prototype.constructor = RtlSdrDevice;
+
+RtlSdrDevice.prototype.getMappings = function() {
+ var mappings = SdrDevice.prototype.getMappings.apply(this, arguments);
+ return $.extend(new Object(), mappings, {
+ "device": {
+ constructor: TextInput,
+ inputOptions:{
+ label: "Serial number"
+ },
+ initialValue: ""
+ }
+ });
+};
+
+SoapySdrDevice = function() {
+ SdrDevice.apply(this, arguments);
+};
+
+SoapySdrDevice.prototype = Object.create(SdrDevice.prototype);
+SoapySdrDevice.prototype.constructor = SoapySdrDevice;
+
+SoapySdrDevice.prototype.getMappings = function() {
+ var mappings = SdrDevice.prototype.getMappings.apply(this, arguments);
+ return $.extend(new Object(), mappings, {
+ "device": {
+ constructor: TextInput,
+ inputOptions:{
+ label: "Soapy device selector"
+ },
+ initialValue: ""
+ },
+ "rf_gain": {
+ constructor: SoapyGainInput,
+ initialValue: 0,
+ inputOptions: {
+ label: "Gain",
+ gains: this.getGains()
+ }
+ }
+ });
+};
+
+SoapySdrDevice.prototype.getGains = function() {
+ return [];
+};
+
+SdrplaySdrDevice = function() {
+ SoapySdrDevice.apply(this, arguments);
+};
+
+SdrplaySdrDevice.prototype = Object.create(SoapySdrDevice.prototype);
+SdrplaySdrDevice.prototype.constructor = SdrplaySdrDevice;
+
+SdrplaySdrDevice.prototype.getGains = function() {
+ return ['RFGR', 'IFGR'];
+};
+
+AirspyHfSdrDevice = function() {
+ SoapySdrDevice.apply(this, arguments);
+};
+
+AirspyHfSdrDevice.prototype = Object.create(SoapySdrDevice.prototype);
+AirspyHfSdrDevice.prototype.constructor = AirspyHfSdrDevice;
+
+AirspyHfSdrDevice.prototype.getGains = function() {
+ return ['RF', 'VGA'];
+};
+
+HackRfSdrDevice = function() {
+ SoapySdrDevice.apply(this, arguments);
+};
+
+HackRfSdrDevice.prototype = Object.create(SoapySdrDevice.prototype);
+HackRfSdrDevice.prototype.constructor = HackRfSdrDevice;
+
+HackRfSdrDevice.prototype.getGains = function() {
+ return ['LNA', 'VGA', 'AMP'];
+};
+
+SdrDevice.types = {
+ 'rtl_sdr': RtlSdrDevice,
+ 'sdrplay': SdrplaySdrDevice,
+ 'airspyhf': AirspyHfSdrDevice,
+ 'hackrf': HackRfSdrDevice
+};
+
+$.fn.sdrdevice = function() {
+ return this.map(function(){
+ var el = $(this);
+ if (!el.data('sdrdevice')) {
+ el.data('sdrdevice', SdrDevice.create(el));
+ }
+ return el.data('sdrdevice');
+ });
+};
diff --git a/htdocs/login.html b/htdocs/login.html
new file mode 100644
index 0000000..4f4c554
--- /dev/null
+++ b/htdocs/login.html
@@ -0,0 +1,27 @@
+
+
+
+
OpenWebRX Login
+
+
+
+
+
+
+
+
+ ${header}
+
+
\ No newline at end of file
diff --git a/htdocs/map.html b/htdocs/map.html
index 23aabeb..08e40b4 100644
--- a/htdocs/map.html
+++ b/htdocs/map.html
@@ -3,9 +3,7 @@
OpenWebRX Map
-
-
-
+
diff --git a/htdocs/map.js b/htdocs/map.js
index 95cfa97..0d447e2 100644
--- a/htdocs/map.js
+++ b/htdocs/map.js
@@ -135,7 +135,11 @@
if (expectedCallsign && expectedCallsign == update.callsign.trim()) {
map.panTo(pos);
showMarkerInfoWindow(update.callsign, pos);
- delete(expectedCallsign);
+ expectedCallsign = false;
+ }
+
+ if (infowindow && infowindow.callsign && infowindow.callsign == update.callsign.trim()) {
+ showMarkerInfoWindow(infowindow.callsign, pos);
}
break;
case 'locator':
@@ -176,7 +180,11 @@
if (expectedLocator && expectedLocator == update.location.locator) {
map.panTo(center);
showLocatorInfoWindow(expectedLocator, center);
- delete(expectedLocator);
+ expectedLocator = false;
+ }
+
+ if (infowindow && infowindow.locator && infowindow.locator == update.location.locator) {
+ showLocatorInfoWindow(infowindow.locator, center);
}
break;
}
@@ -215,13 +223,26 @@
case "config":
var config = json.value;
if (!map) $.getScript("https://maps.googleapis.com/maps/api/js?key=" + config.google_maps_api_key).done(function(){
+ var mapTypeId = config.google_maps_api_key ? 'roadmap' : 'OSM';
+
map = new google.maps.Map($('.openwebrx-map')[0], {
center: {
- lat: config.receiver_gps[0],
- lng: config.receiver_gps[1]
+ lat: config.receiver_gps.lat,
+ lng: config.receiver_gps.lon
},
- zoom: 5
+ zoom: 5,
+ mapTypeId: mapTypeId
});
+
+ map.mapTypes.set("OSM", new google.maps.ImageMapType({
+ getTileUrl: function(coord, zoom) {
+ return "https://maps.wikimedia.org/osm-intl/" + zoom + "/" + coord.x + "/" + coord.y + ".png";
+ },
+ tileSize: new google.maps.Size(256, 256),
+ name: "OpenStreetMap",
+ maxZoom: 18
+ }));
+
$.getScript("static/lib/nite-overlay.js").done(function(){
nite.init(map);
setInterval(function() { nite.refresh() }, 10000); // every 10s
@@ -237,6 +258,11 @@
case "update":
processUpdates(json.value);
break;
+ case 'receiver_details':
+ $('#webrx-top-container').header().setDetails(json['value']);
+ break;
+ default:
+ console.warn('received message of unknown type: ' + json['type']);
}
} catch (e) {
// don't lose exception
@@ -269,9 +295,21 @@
connect();
+ var getInfoWindow = function() {
+ if (!infowindow) {
+ infowindow = new google.maps.InfoWindow();
+ google.maps.event.addListener(infowindow, 'closeclick', function() {
+ delete infowindow.locator;
+ delete infowindow.callsign;
+ });
+ }
+ return infowindow;
+ }
+
var infowindow;
var showLocatorInfoWindow = function(locator, pos) {
- if (!infowindow) infowindow = new google.maps.InfoWindow();
+ var infowindow = getInfoWindow();
+ infowindow.locator = locator;
var inLocator = $.map(rectangles, function(r, callsign) {
return {callsign: callsign, locator: r.locator, lastseen: r.lastseen, mode: r.mode, band: r.band}
}).filter(function(d) {
@@ -297,7 +335,8 @@
};
var showMarkerInfoWindow = function(callsign, pos) {
- if (!infowindow) infowindow = new google.maps.InfoWindow();
+ var infowindow = getInfoWindow();
+ infowindow.callsign = callsign;
var marker = markers[callsign];
var timestring = moment(marker.lastseen).fromNow();
var commentString = "";
diff --git a/htdocs/openwebrx.js b/htdocs/openwebrx.js
index 50cfdec..36ab5ea 100644
--- a/htdocs/openwebrx.js
+++ b/htdocs/openwebrx.js
@@ -27,73 +27,15 @@ is_firefox = navigator.userAgent.indexOf("Firefox") >= 0;
var bandwidth;
var center_freq;
var fft_size;
-var fft_fps;
var fft_compression = "none";
var fft_codec;
var waterfall_setup_done = 0;
var secondary_fft_size;
-var rx_photo_state = 1;
function e(what) {
return document.getElementById(what);
}
-var rx_photo_height;
-
-function init_rx_photo() {
- var clip = e("webrx-top-photo-clip");
- rx_photo_height = clip.clientHeight;
- clip.style.maxHeight = rx_photo_height + "px";
-
- $.extend($.easing, {
- easeOutCubic:function(x) {
- return 1 - Math.pow( 1 - x, 3 );
- }
- });
-
- window.setTimeout(function () {
- $('#webrx-rx-photo-title').animate({opacity: 0}, 500);
- }, 1000);
- window.setTimeout(function () {
- $('#webrx-rx-photo-desc').animate({opacity: 0}, 500);
- }, 1500);
- window.setTimeout(function () {
- close_rx_photo()
- }, 2500);
- $('#webrx-top-container').find('.openwebrx-photo-trigger').click(toggle_rx_photo);
-}
-
-var dont_toggle_rx_photo_flag = 0;
-
-function dont_toggle_rx_photo() {
- dont_toggle_rx_photo_flag = 1;
-}
-
-function toggle_rx_photo() {
- if (dont_toggle_rx_photo_flag) {
- dont_toggle_rx_photo_flag = 0;
- return;
- }
- if (rx_photo_state) close_rx_photo();
- else open_rx_photo()
-}
-
-function close_rx_photo() {
- rx_photo_state = 0;
- $('#webrx-top-photo-clip').animate({maxHeight: 67}, {duration: 1000, easing: 'easeOutCubic'});
- e("openwebrx-rx-details-arrow-down").style.display = "block";
- e("openwebrx-rx-details-arrow-up").style.display = "none";
-}
-
-function open_rx_photo() {
- rx_photo_state = 1;
- e("webrx-rx-photo-desc").style.opacity = 1;
- e("webrx-rx-photo-title").style.opacity = 1;
- $('#webrx-top-photo-clip').animate({maxHeight: rx_photo_height}, {duration: 1000, easing: 'easeOutCubic'});
- e("openwebrx-rx-details-arrow-down").style.display = "none";
- e("openwebrx-rx-details-arrow-up").style.display = "block";
-}
-
function updateVolume() {
audioEngine.setVolume(parseFloat(e("openwebrx-panel-volume").value) / 100);
}
@@ -104,14 +46,12 @@ function toggleMute() {
e("openwebrx-mute-on").id = "openwebrx-mute-off";
e("openwebrx-mute-img").src = "static/gfx/openwebrx-speaker.png";
e("openwebrx-panel-volume").disabled = false;
- e("openwebrx-panel-volume").style.opacity = 1.0;
e("openwebrx-panel-volume").value = volumeBeforeMute;
} else {
mute = true;
e("openwebrx-mute-off").id = "openwebrx-mute-on";
e("openwebrx-mute-img").src = "static/gfx/openwebrx-speaker-muted.png";
e("openwebrx-panel-volume").disabled = true;
- e("openwebrx-panel-volume").style.opacity = 0.5;
volumeBeforeMute = e("openwebrx-panel-volume").value;
e("openwebrx-panel-volume").value = 0;
}
@@ -135,16 +75,6 @@ function zoomOutTotal() {
zoom_set(0);
}
-function setSquelchToAuto() {
- e("openwebrx-panel-squelch").value = (getLogSmeterValue(smeter_level) + 10).toString();
- updateSquelch();
-}
-
-function updateSquelch() {
- var sliderValue = parseInt($("#openwebrx-panel-squelch").val());
- ws.send(JSON.stringify({"type": "dspcontrol", "params": {"squelch_level": sliderValue}}));
-}
-
var waterfall_min_level;
var waterfall_max_level;
var waterfall_min_level_default;
@@ -171,8 +101,8 @@ function waterfallColorsDefault() {
}
function waterfallColorsAuto() {
- e("openwebrx-waterfall-color-min").value = (waterfall_measure_minmax_min - waterfall_auto_level_margin[0]).toString();
- e("openwebrx-waterfall-color-max").value = (waterfall_measure_minmax_max + waterfall_auto_level_margin[1]).toString();
+ e("openwebrx-waterfall-color-min").value = (waterfall_measure_minmax_min - waterfall_auto_level_margin.min).toString();
+ e("openwebrx-waterfall-color-max").value = (waterfall_measure_minmax_max + waterfall_auto_level_margin.max).toString();
updateWaterfallColors(0);
}
@@ -189,7 +119,7 @@ function setSmeterRelativeValue(value) {
}
function setSquelchSliderBackground(val) {
- var $slider = $('#openwebrx-panel-squelch');
+ var $slider = $('#openwebrx-panel-receiver .openwebrx-squelch-slider');
var min = Number($slider.attr('min'));
var max = Number($slider.attr('max'));
var sliderPosition = $slider.val();
@@ -236,336 +166,25 @@ function typeInAnimation(element, timeout, what, onFinish) {
// ================ DEMODULATOR ROUTINES ================
// ========================================================
-demodulators = [];
-
-var demodulator_color_index = 0;
-var demodulator_colors = ["#ffff00", "#00ff00", "#00ffff", "#058cff", "#ff9600", "#a1ff39", "#ff4e39", "#ff5dbd"];
-
-function demodulators_get_next_color() {
- if (demodulator_color_index >= demodulator_colors.length) demodulator_color_index = 0;
- return (demodulator_colors[demodulator_color_index++]);
-}
-
-function demod_envelope_draw(range, from, to, color, line) { // ____
- // Draws a standard filter envelope like this: _/ \_
- // Parameters are given in offset frequency (Hz).
- // Envelope is drawn on the scale canvas.
- // A "drag range" object is returned, containing information about the draggable areas of the envelope
- // (beginning, ending and the line showing the offset frequency).
- if (typeof color === "undefined") color = "#ffff00"; //yellow
- var env_bounding_line_w = 5; //
- var env_att_w = 5; // _______ ___env_h2 in px ___|_____
- var env_h1 = 17; // _/| \_ ___env_h1 in px _/ |_ \_
- var env_h2 = 5; // |||env_att_line_w |_env_lineplus
- var env_lineplus = 1; // ||env_bounding_line_w
- var env_line_click_area = 6;
- //range=get_visible_freq_range();
- var from_px = scale_px_from_freq(from, range);
- var to_px = scale_px_from_freq(to, range);
- if (to_px < from_px) /* swap'em */ {
- var temp_px = to_px;
- to_px = from_px;
- from_px = temp_px;
- }
-
- /*from_px-=env_bounding_line_w/2;
- to_px+=env_bounding_line_w/2;*/
- from_px -= (env_att_w + env_bounding_line_w);
- to_px += (env_att_w + env_bounding_line_w);
- // do drawing:
- scale_ctx.lineWidth = 3;
- scale_ctx.strokeStyle = color;
- scale_ctx.fillStyle = color;
- var drag_ranges = {envelope_on_screen: false, line_on_screen: false};
- if (!(to_px < 0 || from_px > window.innerWidth)) // out of screen?
- {
- drag_ranges.beginning = {x1: from_px, x2: from_px + env_bounding_line_w + env_att_w};
- drag_ranges.ending = {x1: to_px - env_bounding_line_w - env_att_w, x2: to_px};
- drag_ranges.whole_envelope = {x1: from_px, x2: to_px};
- drag_ranges.envelope_on_screen = true;
- scale_ctx.beginPath();
- scale_ctx.moveTo(from_px, env_h1);
- scale_ctx.lineTo(from_px + env_bounding_line_w, env_h1);
- scale_ctx.lineTo(from_px + env_bounding_line_w + env_att_w, env_h2);
- scale_ctx.lineTo(to_px - env_bounding_line_w - env_att_w, env_h2);
- scale_ctx.lineTo(to_px - env_bounding_line_w, env_h1);
- scale_ctx.lineTo(to_px, env_h1);
- scale_ctx.globalAlpha = 0.3;
- scale_ctx.fill();
- scale_ctx.globalAlpha = 1;
- scale_ctx.stroke();
- }
- if (typeof line !== "undefined") // out of screen?
- {
- var line_px = scale_px_from_freq(line, range);
- if (!(line_px < 0 || line_px > window.innerWidth)) {
- drag_ranges.line = {x1: line_px - env_line_click_area / 2, x2: line_px + env_line_click_area / 2};
- drag_ranges.line_on_screen = true;
- scale_ctx.moveTo(line_px, env_h1 + env_lineplus);
- scale_ctx.lineTo(line_px, env_h2 - env_lineplus);
- scale_ctx.stroke();
- }
- }
- return drag_ranges;
-}
-
-function demod_envelope_where_clicked(x, drag_ranges, key_modifiers) { // Check exactly what the user has clicked based on ranges returned by demod_envelope_draw().
- var in_range = function (x, range) {
- return range.x1 <= x && range.x2 >= x;
- };
- var dr = Demodulator.draggable_ranges;
-
- if (key_modifiers.shiftKey) {
- //Check first: shift + center drag emulates BFO knob
- if (drag_ranges.line_on_screen && in_range(x, drag_ranges.line)) return dr.bfo;
- //Check second: shift + envelope drag emulates PBF knob
- if (drag_ranges.envelope_on_screen && in_range(x, drag_ranges.whole_envelope)) return dr.pbs;
- }
- if (drag_ranges.envelope_on_screen) {
- // For low and high cut:
- if (in_range(x, drag_ranges.beginning)) return dr.beginning;
- if (in_range(x, drag_ranges.ending)) return dr.ending;
- // Last priority: having clicked anything else on the envelope, without holding the shift key
- if (in_range(x, drag_ranges.whole_envelope)) return dr.anything_else;
- }
- return dr.none; //User doesn't drag the envelope for this demodulator
-}
-
-//******* class Demodulator *******
-// this can be used as a base class for ANY demodulator
-Demodulator = function (offset_frequency) {
- this.offset_frequency = offset_frequency;
- this.envelope = {};
- this.color = demodulators_get_next_color();
- this.stop = function () {
- };
+function getDemodulators() {
+ return [
+ $('#openwebrx-panel-receiver').demodulatorPanel().getDemodulator()
+ ].filter(function(d) {
+ return !!d;
+ });
};
-//ranges on filter envelope that can be dragged:
-Demodulator.draggable_ranges = {
- none: 0,
- beginning: 1 /*from*/,
- ending: 2 /*to*/,
- anything_else: 3,
- bfo: 4 /*line (while holding shift)*/,
- pbs: 5
-}; //to which parameter these correspond in demod_envelope_draw()
-
-//******* class Demodulator_default_analog *******
-// This can be used as a base for basic audio demodulators.
-// It already supports most basic modulations used for ham radio and commercial services: AM/FM/LSB/USB
-
-demodulator_response_time = 50;
-
-function Demodulator_default_analog(offset_frequency, subtype) {
- Demodulator.call(this, offset_frequency);
- this.subtype = subtype;
- this.filter = {
- min_passband: 100,
- getLimits: function() {
- var max_bw;
- if (secondary_demod === 'pocsag') {
- max_bw = 12500;
- } else {
- max_bw = (audioEngine.getOutputRate() / 2) - 1;
- }
- return {
- high: max_bw,
- low: -max_bw
- };
- }
- };
- //Subtypes only define some filter parameters and the mod string sent to server,
- //so you may set these parameters in your custom child class.
- //Why? As of demodulation is done on the server, difference is mainly on the server side.
- this.server_mod = subtype;
- if (subtype === "lsb") {
- this.low_cut = -3000;
- this.high_cut = -300;
- this.server_mod = "ssb";
- }
- else if (subtype === "usb") {
- this.low_cut = 300;
- this.high_cut = 3000;
- this.server_mod = "ssb";
- }
- else if (subtype === "cw") {
- this.low_cut = 700;
- this.high_cut = 900;
- this.server_mod = "ssb";
- }
- else if (subtype === "nfm") {
- this.low_cut = -4000;
- this.high_cut = 4000;
- }
- else if (subtype === "dmr" || subtype === "ysf") {
- this.low_cut = -4000;
- this.high_cut = 4000;
- }
- else if (subtype === "dstar" || subtype === "nxdn") {
- this.low_cut = -3250;
- this.high_cut = 3250;
- }
- else if (subtype === "am") {
- this.low_cut = -4000;
- this.high_cut = 4000;
- }
-
- this.wait_for_timer = false;
- this.set_after = false;
- this.set = function () { //set() is a wrapper to call doset(), but it ensures that doset won't execute more frequently than demodulator_response_time.
- if (!this.wait_for_timer) {
- this.doset(false);
- this.set_after = false;
- this.wait_for_timer = true;
- var timeout_this = this; //http://stackoverflow.com/a/2130411
- window.setTimeout(function () {
- timeout_this.wait_for_timer = false;
- if (timeout_this.set_after) timeout_this.set();
- }, demodulator_response_time);
- } else {
- this.set_after = true;
- }
- };
-
- this.doset = function (first_time) { //this function sends demodulator parameters to the server
- var params = {
- "low_cut": this.low_cut,
- "high_cut": this.high_cut,
- "offset_freq": this.offset_frequency
- };
- if (first_time) params.mod = this.server_mod;
- ws.send(JSON.stringify({"type": "dspcontrol", "params": params}));
- mkenvelopes(get_visible_freq_range());
- };
- this.doset(true); //we set parameters on object creation
-
- //******* envelope object *******
- // for drawing the filter envelope above scale
- this.envelope.parent = this;
-
- this.envelope.draw = function (visible_range) {
- this.visible_range = visible_range;
- this.drag_ranges = demod_envelope_draw(range,
- center_freq + this.parent.offset_frequency + this.parent.low_cut,
- center_freq + this.parent.offset_frequency + this.parent.high_cut,
- this.color, center_freq + this.parent.offset_frequency);
- };
-
- this.envelope.dragged_range = Demodulator.draggable_ranges.none;
-
- // event handlers
- this.envelope.drag_start = function (x, key_modifiers) {
- this.key_modifiers = key_modifiers;
- this.dragged_range = demod_envelope_where_clicked(x, this.drag_ranges, key_modifiers);
- this.drag_origin = {
- x: x,
- low_cut: this.parent.low_cut,
- high_cut: this.parent.high_cut,
- offset_frequency: this.parent.offset_frequency
- };
- return this.dragged_range !== Demodulator.draggable_ranges.none;
- };
-
- this.envelope.drag_move = function (x) {
- var dr = Demodulator.draggable_ranges;
- var new_value;
- if (this.dragged_range === dr.none) return false; // we return if user is not dragging (us) at all
- var freq_change = Math.round(this.visible_range.hps * (x - this.drag_origin.x));
-
- //dragging the line in the middle of the filter envelope while holding Shift does emulate
- //the BFO knob on radio equipment: moving offset frequency, while passband remains unchanged
- //Filter passband moves in the opposite direction than dragged, hence the minus below.
- var minus = (this.dragged_range === dr.bfo) ? -1 : 1;
- //dragging any other parts of the filter envelope while holding Shift does emulate the PBS knob
- //(PassBand Shift) on radio equipment: PBS does move the whole passband without moving the offset
- //frequency.
- if (this.dragged_range === dr.beginning || this.dragged_range === dr.bfo || this.dragged_range === dr.pbs) {
- //we don't let low_cut go beyond its limits
- if ((new_value = this.drag_origin.low_cut + minus * freq_change) < this.parent.filter.getLimits().low) return true;
- //nor the filter passband be too small
- if (this.parent.high_cut - new_value < this.parent.filter.min_passband) return true;
- //sanity check to prevent GNU Radio "firdes check failed: fa <= fb"
- if (new_value >= this.parent.high_cut) return true;
- this.parent.low_cut = new_value;
- }
- if (this.dragged_range === dr.ending || this.dragged_range === dr.bfo || this.dragged_range === dr.pbs) {
- //we don't let high_cut go beyond its limits
- if ((new_value = this.drag_origin.high_cut + minus * freq_change) > this.parent.filter.getLimits().high) return true;
- //nor the filter passband be too small
- if (new_value - this.parent.low_cut < this.parent.filter.min_passband) return true;
- //sanity check to prevent GNU Radio "firdes check failed: fa <= fb"
- if (new_value <= this.parent.low_cut) return true;
- this.parent.high_cut = new_value;
- }
- if (this.dragged_range === dr.anything_else || this.dragged_range === dr.bfo) {
- //when any other part of the envelope is dragged, the offset frequency is changed (whole passband also moves with it)
- new_value = this.drag_origin.offset_frequency + freq_change;
- if (new_value > bandwidth / 2 || new_value < -bandwidth / 2) return true; //we don't allow tuning above Nyquist frequency :-)
- this.parent.offset_frequency = new_value;
- }
- //now do the actual modifications:
- mkenvelopes(this.visible_range);
- this.parent.set();
- //will have to change this when changing to multi-demodulator mode:
- tunedFrequencyDisplay.setFrequency(center_freq + this.parent.offset_frequency);
- return true;
- };
-
- this.envelope.drag_end = function () { //in this demodulator we've already changed values in the drag_move() function so we shouldn't do too much here.
- demodulator_buttons_update();
- var to_return = this.dragged_range !== Demodulator.draggable_ranges.none; //this part is required for cliking anywhere on the scale to set offset
- this.dragged_range = Demodulator.draggable_ranges.none;
- return to_return;
- };
-
-}
-
-Demodulator_default_analog.prototype = new Demodulator();
function mkenvelopes(visible_range) //called from mkscale
{
+ var demodulators = getDemodulators();
scale_ctx.clearRect(0, 0, scale_ctx.canvas.width, 22); //clear the upper part of the canvas (where filter envelopes reside)
for (var i = 0; i < demodulators.length; i++) {
demodulators[i].envelope.draw(visible_range);
}
- if (demodulators.length) secondary_demod_waterfall_set_zoom(demodulators[0].low_cut, demodulators[0].high_cut);
-}
-
-function demodulator_remove(which) {
- demodulators[which].stop();
- demodulators.splice(which, 1);
-}
-
-function demodulator_add(what) {
- demodulators.push(what);
- mkenvelopes(get_visible_freq_range());
-}
-
-var last_analog_demodulator_subtype = 'nfm';
-var last_digital_demodulator_subtype = 'bpsk31';
-
-function demodulator_analog_replace(subtype, for_digital) { //this function should only exist until the multi-demodulator capability is added
- if (!(typeof for_digital !== "undefined" && for_digital && secondary_demod)) {
- secondary_demod_close_window();
- secondary_demod_listbox_update();
- }
- last_analog_demodulator_subtype = subtype;
- var temp_offset = 0;
if (demodulators.length) {
- temp_offset = demodulators[0].offset_frequency;
- demodulator_remove(0);
+ var bandpass = demodulators[0].getBandpass()
+ secondary_demod_waterfall_set_zoom(bandpass.low_cut, bandpass.high_cut);
}
- demodulator_add(new Demodulator_default_analog(temp_offset, subtype));
- demodulator_buttons_update();
- update_digitalvoice_panels("openwebrx-panel-metadata-" + subtype);
-}
-
-Demodulator.prototype.set_offset_frequency = function(to_what) {
- if (to_what > bandwidth / 2 || to_what < -bandwidth / 2) return;
- this.offset_frequency = Math.round(to_what);
- this.set();
- mkenvelopes(get_visible_freq_range());
- tunedFrequencyDisplay.setFrequency(center_freq + to_what);
}
function waterfallWidth() {
@@ -579,11 +198,8 @@ function waterfallWidth() {
var scale_ctx;
var scale_canvas;
-var tunedFrequencyDisplay;
-var mouseFrequencyDisplay;
function scale_setup() {
- tunedFrequencyDisplay.setFrequency(canvas_get_frequency(window.innerWidth / 2));
scale_canvas = e("openwebrx-scale-canvas");
scale_ctx = scale_canvas.getContext("2d");
scale_canvas.addEventListener("mousedown", scale_canvas_mousedown, false);
@@ -619,6 +235,7 @@ function scale_offset_freq_from_px(x, visible_range) {
function scale_canvas_mousemove(evt) {
var event_handled = false;
var i;
+ var demodulators = getDemodulators();
if (scale_canvas_drag_params.mouse_down && !scale_canvas_drag_params.drag && Math.abs(evt.pageX - scale_canvas_drag_params.start_x) > canvas_drag_min_delta)
//we can use the main drag_min_delta thing of the main canvas
{
@@ -637,7 +254,7 @@ function scale_canvas_mousemove(evt) {
function frequency_container_mousemove(evt) {
var frequency = center_freq + scale_offset_freq_from_px(evt.pageX);
- mouseFrequencyDisplay.setFrequency(frequency);
+ $('.webrx-mouse-freq').frequencyDisplay().setFrequency(frequency);
}
function scale_canvas_end_drag(x) {
@@ -645,6 +262,7 @@ function scale_canvas_end_drag(x) {
scale_canvas_drag_params.drag = false;
scale_canvas_drag_params.mouse_down = false;
var event_handled = false;
+ var demodulators = getDemodulators();
for (var i = 0; i < demodulators.length; i++) event_handled |= demodulators[i].envelope.drag_end();
if (!event_handled) demodulators[0].set_offset_frequency(scale_offset_freq_from_px(x));
}
@@ -914,7 +532,7 @@ function canvas_mousemove(evt) {
bookmarks.position();
}
} else {
- mouseFrequencyDisplay.setFrequency(canvas_get_frequency(relativeX));
+ $('.webrx-mouse-freq').frequencyDisplay().setFrequency(canvas_get_frequency(relativeX));
}
}
@@ -927,7 +545,7 @@ function canvas_mouseup(evt) {
var relativeX = get_relative_x(evt);
if (!canvas_drag) {
- demodulators[0].set_offset_frequency(canvas_get_freq_offset(relativeX));
+ $('#openwebrx-panel-receiver').demodulatorPanel().getDemodulator().set_offset_frequency(canvas_get_freq_offset(relativeX));
}
else {
canvas_end_drag();
@@ -1010,7 +628,7 @@ function zoom_set(level) {
level = parseInt(level);
zoom_level = level;
//zoom_center_rel=canvas_get_freq_offset(-canvases[0].offsetLeft+waterfallWidth()/2); //zoom to screen center instead of demod envelope
- zoom_center_rel = demodulators[0].offset_frequency;
+ zoom_center_rel = $('#openwebrx-panel-receiver').demodulatorPanel().getDemodulator().get_offset_frequency();
zoom_center_where = 0.5 + (zoom_center_rel / bandwidth); //this is a kind of hack
resize_canvases(true);
mkscale();
@@ -1050,24 +668,26 @@ function on_ws_recv(evt) {
waterfall_auto_level_margin = config['waterfall_auto_level_margin'];
waterfallColorsDefault();
- starting_mod = config['start_mod'];
- starting_offset_frequency = config['start_offset_freq'];
+ var initial_demodulator_params = {
+ mod: config['start_mod'],
+ offset_frequency: config['start_offset_freq'],
+ squelch_level: Number.isInteger(config['initial_squelch_level']) ? config['initial_squelch_level'] : -150
+ };
+
bandwidth = config['samp_rate'];
center_freq = config['center_freq'];
fft_size = config['fft_size'];
- fft_fps = config['fft_fps'];
var audio_compression = config['audio_compression'];
audioEngine.setCompression(audio_compression);
divlog("Audio stream is " + ((audio_compression === "adpcm") ? "compressed" : "uncompressed") + ".");
fft_compression = config['fft_compression'];
divlog("FFT stream is " + ((fft_compression === "adpcm") ? "compressed" : "uncompressed") + ".");
- clientProgressBar.setMaxClients(config['max_clients']);
- var sql = Number.isInteger(config['initial_squelch_level']) ? config['initial_squelch_level'] : -150;
- $("#openwebrx-panel-squelch").val(sql);
- updateSquelch();
+ $('#openwebrx-bar-clients').progressbar().setMaxClients(config['max_clients']);
waterfall_init();
- initialize_demodulator();
+ var demodulatorPanel = $('#openwebrx-panel-receiver').demodulatorPanel();
+ demodulatorPanel.setInitialParams(initial_demodulator_params);
+ demodulatorPanel.setCenterFrequency(center_freq);
bookmarks.loadLocalBookmarks();
waterfall_clear();
@@ -1084,21 +704,17 @@ function on_ws_recv(evt) {
secondary_demod_init_canvases();
break;
case "receiver_details":
- var r = json['value'];
- e('webrx-rx-title').innerHTML = r['receiver_name'];
- e('webrx-rx-desc').innerHTML = r['receiver_location'] + ' | Loc: ' + r['locator'] + ', ASL: ' + r['receiver_asl'] + ' m,
[maps]';
- e('webrx-rx-photo-title').innerHTML = r['photo_title'];
- e('webrx-rx-photo-desc').innerHTML = r['photo_desc'];
+ $('#webrx-top-container').header().setDetails(json['value']);
break;
case "smeter":
smeter_level = json['value'];
setSmeterAbsoluteValue(smeter_level);
break;
case "cpuusage":
- cpuProgressBar.setUsage(json['value']);
+ $('#openwebrx-bar-server-cpu').progressbar().setUsage(json['value']);
break;
case "clients":
- clientProgressBar.setClients(json['value']);
+ $('#openwebrx-bar-clients').progressbar().setClients(json['value']);
break;
case "profiles":
var listbox = e("openwebrx-sdr-profiles-listbox");
@@ -1110,16 +726,14 @@ function on_ws_recv(evt) {
}
break;
case "features":
- var features = json['value'];
- for (var feature in features) {
- if (features.hasOwnProperty(feature)) {
- $('[data-feature="' + feature + '"]')[features[feature] ? "show" : "hide"]();
- }
- }
+ Modes.setFeatures(json['value']);
break;
case "metadata":
update_metadata(json['value']);
break;
+ case "js8_message":
+ $("#openwebrx-panel-js8-message").js8().pushMessage(json['value']);
+ break;
case "wsjt_message":
update_wsjt_panel(json['value']);
break;
@@ -1127,7 +741,7 @@ function on_ws_recv(evt) {
var as_bookmarks = json['value'].map(function (d) {
return {
name: d['mode'].toUpperCase(),
- digital_modulation: d['mode'],
+ modulation: d['mode'],
frequency: d['frequency']
};
});
@@ -1162,6 +776,9 @@ function on_ws_recv(evt) {
// set a higher reconnection timeout right away to avoid additional load
reconnect_timeout = 16000;
break;
+ case 'modes':
+ Modes.setModes(json['value']);
+ break;
default:
console.warn('received message of unknown type: ' + json['type']);
}
@@ -1212,6 +829,10 @@ function on_ws_recv(evt) {
secondary_demod_waterfall_add(waterfall_f32);
}
break;
+ case 4:
+ // hd audio data
+ audioEngine.pushHdAudio(data);
+ break;
default:
console.warn('unknown type of binary message: ' + type)
}
@@ -1296,14 +917,14 @@ function update_wsjt_panel(msg) {
if (['FT8', 'JT65', 'JT9', 'FT4'].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] + '';
+ linkedmsg = html_escape(matches[1]) + '
' + matches[2] + '';
} else {
linkedmsg = html_escape(linkedmsg);
}
} else if (msg['mode'] === 'WSPR') {
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]);
+ linkedmsg = html_escape(matches[1]) + '
' + matches[2] + '' + html_escape(matches[3]);
} else {
linkedmsg = html_escape(linkedmsg);
}
@@ -1389,7 +1010,7 @@ function update_packet_panel(msg) {
'style="' + stylesToString(styles) + '"'
].join(' ');
if (msg.lat && msg.lon) {
- link = '
' + overlay + '';
+ link = '
' + overlay + '';
} else {
link = '
' + overlay + '
'
}
@@ -1416,13 +1037,6 @@ function update_pocsag_panel(msg) {
$b.scrollTop($b[0].scrollHeight);
}
-function update_digitalvoice_panels(showing) {
- $(".openwebrx-meta-panel").each(function (_, p) {
- toggle_panel(p.id, p.id === showing);
- });
- clear_metadata();
-}
-
function clear_metadata() {
$(".openwebrx-meta-panel .openwebrx-meta-autoclear").text("");
$(".openwebrx-meta-slot").removeClass("active").removeClass("sync");
@@ -1435,8 +1049,11 @@ var waterfall_measure_minmax_min = 1e100;
var waterfall_measure_minmax_max = -1e100;
function waterfall_measure_minmax_do(what) {
- waterfall_measure_minmax_min = Math.min(waterfall_measure_minmax_min, Math.min.apply(Math, what));
- waterfall_measure_minmax_max = Math.max(waterfall_measure_minmax_max, Math.max.apply(Math, what));
+ // this is based on an oversampling factor of about 1,25
+ var ignored = .1 * what.length;
+ var data = what.slice(ignored, -ignored);
+ waterfall_measure_minmax_min = Math.min(waterfall_measure_minmax_min, Math.min.apply(Math, data));
+ waterfall_measure_minmax_max = Math.max(waterfall_measure_minmax_max, Math.max.apply(Math, data));
}
function on_ws_opened() {
@@ -1446,7 +1063,7 @@ function on_ws_opened() {
if (!networkSpeedMeasurement) {
networkSpeedMeasurement = new Measurement();
networkSpeedMeasurement.report(60000, 1000, function(rate){
- networkSpeedProgressBar.setSpeed(rate);
+ $('#openwebrx-bar-network-speed').progressbar().setSpeed(rate);
});
} else {
networkSpeedMeasurement.reset();
@@ -1454,11 +1071,10 @@ function on_ws_opened() {
reconnect_timeout = false;
ws.send(JSON.stringify({
"type": "connectionproperties",
- "params": {"output_rate": audioEngine.getOutputRate()}
- }));
- ws.send(JSON.stringify({
- "type": "dspcontrol",
- "action": "start"
+ "params": {
+ "output_rate": audioEngine.getOutputRate(),
+ "hd_output_rate": audioEngine.getHdOutputRate()
+ }
}));
}
@@ -1483,41 +1099,13 @@ var mute = false;
// Optimalise these if audio lags or is choppy:
var audio_buffer_maximal_length_sec = 1; //actual number of samples are calculated from sample rate
-function webrx_set_param(what, value) {
- var params = {};
- params[what] = value;
- ws.send(JSON.stringify({"type": "dspcontrol", "params": params}));
-}
-
-var starting_offset_frequency;
-var starting_mod;
-
-function parseHash() {
- var h;
- if (h = window.location.hash) {
- h.substring(1).split(",").forEach(function (x) {
- var harr = x.split("=");
- if (harr[0] === "mute") toggleMute();
- else if (harr[0] === "mod") starting_mod = harr[1];
- else if (harr[0] === "sql") {
- e("openwebrx-panel-squelch").value = harr[1];
- updateSquelch();
- }
- else if (harr[0] === "freq") {
- console.log(parseInt(harr[1]));
- console.log(center_freq);
- starting_offset_frequency = parseInt(harr[1]) - center_freq;
- }
- });
-
- }
-}
-
-function onAudioStart(success, apiType){
+function onAudioStart(apiType){
divlog('Web Audio API succesfully initialized, using ' + apiType + ' API, sample rate: ' + audioEngine.getSampleRate() + " Hz");
+ hideOverlay();
+
// canvas_container is set after waterfall_init() has been called. we cannot initialize before.
- if (canvas_container) initialize_demodulator();
+ //if (canvas_container) synchronize_demodulator_init();
//hide log panel in a second (if user has not hidden it yet)
window.setTimeout(function () {
@@ -1528,19 +1116,10 @@ function onAudioStart(success, apiType){
updateVolume();
}
-function initialize_demodulator() {
- demodulator_analog_replace(starting_mod);
- if (starting_offset_frequency) {
- demodulators[0].offset_frequency = starting_offset_frequency;
- tunedFrequencyDisplay.setFrequency(center_freq + starting_offset_frequency);
- demodulators[0].set();
- mkscale();
- }
-}
-
var reconnect_timeout = false;
function on_ws_closed() {
+ $("#openwebrx-panel-receiver").demodulatorPanel().stopDemodulator();
if (reconnect_timeout) {
// max value: roundabout 8 and a half minutes
reconnect_timeout = Math.min(reconnect_timeout * 2, 512000);
@@ -1700,21 +1279,6 @@ function waterfall_add(data) {
if (canvas_actual_line < 0) add_canvas();
}
-function check_top_bar_congestion() {
- var rmf = function (x) {
- return x.offsetLeft + x.offsetWidth;
- };
- var wet = e("webrx-rx-title");
- var wed = e("webrx-rx-desc");
- var tl = e("openwebrx-main-buttons");
-
- [wet, wed].map(function (what) {
- if (rmf(what) > tl.offsetLeft - 20) what.style.opacity = what.style.opacity = "0";
- else wet.style.opacity = wed.style.opacity = "1";
- });
-
-}
-
function waterfall_clear() {
while (canvases.length) //delete all canvases
{
@@ -1727,42 +1291,28 @@ function waterfall_clear() {
function openwebrx_resize() {
resize_canvases();
resize_scale();
- check_top_bar_congestion();
}
-function init_header() {
- $('#openwebrx-main-buttons').find('li[data-toggle-panel]').click(function () {
- toggle_panel($(this).data('toggle-panel'));
- });
-}
-
-var audioBufferProgressBar;
-var networkSpeedProgressBar;
-var audioSpeedProgressBar;
-var audioOutputProgressBar;
-var clientProgressBar;
-var cpuProgressBar;
-
function initProgressBars() {
- audioBufferProgressBar = new AudioBufferProgressBar($('#openwebrx-bar-audio-buffer'), audioEngine.getSampleRate());
- networkSpeedProgressBar = new NetworkSpeedProgressBar($('#openwebrx-bar-network-speed'));
- audioSpeedProgressBar = new AudioSpeedProgressBar($('#openwebrx-bar-audio-speed'));
- audioOutputProgressBar = new AudioOutputProgressBar($('#openwebrx-bar-audio-output'), audioEngine.getSampleRate());
- clientProgressBar = new ClientsProgressBar($('#openwebrx-bar-clients'));
- cpuProgressBar = new CpuProgressBar($('#openwebrx-bar-server-cpu'));
+ $(".openwebrx-progressbar").each(function(){
+ var bar = $(this).progressbar();
+ if ('setSampleRate' in bar) {
+ bar.setSampleRate(audioEngine.getSampleRate());
+ }
+ })
}
function audioReporter(stats) {
if (typeof(stats.buffersize) !== 'undefined') {
- audioBufferProgressBar.setBuffersize(stats.buffersize);
+ $('#openwebrx-bar-audio-buffer').progressbar().setBuffersize(stats.buffersize);
}
if (typeof(stats.audioByteRate) !== 'undefined') {
- audioSpeedProgressBar.setSpeed(stats.audioByteRate * 8);
+ $('#openwebrx-bar-audio-speed').progressbar().setSpeed(stats.audioByteRate * 8);
}
if (typeof(stats.audioRate) !== 'undefined') {
- audioOutputProgressBar.setAudioRate(stats.audioRate);
+ $('#openwebrx-bar-audio-output').progressbar().setAudioRate(stats.audioRate);
}
}
@@ -1772,29 +1322,23 @@ var audioEngine;
function openwebrx_init() {
audioEngine = new AudioEngine(audio_buffer_maximal_length_sec, audioReporter);
$overlay = $('#openwebrx-autoplay-overlay');
- $overlay.on('click', playButtonClick);
+ $overlay.on('click', function(){
+ audioEngine.resume();
+ });
+ audioEngine.onStart(onAudioStart);
if (!audioEngine.isAllowed()) {
$overlay.show();
- } else {
- audioEngine.start(onAudioStart);
}
fft_codec = new ImaAdpcmCodec();
initProgressBars();
- init_rx_photo();
open_websocket();
secondary_demod_init();
digimodes_init();
initPanels();
- tunedFrequencyDisplay = new TuneableFrequencyDisplay($('#webrx-actual-freq'));
- tunedFrequencyDisplay.onFrequencyChange(function(f) {
- demodulators[0].set_offset_frequency(f - center_freq);
- });
- mouseFrequencyDisplay = new FrequencyDisplay($('#webrx-mouse-freq'));
+ $('.webrx-mouse-freq').frequencyDisplay();
+ $('#openwebrx-panel-receiver').demodulatorPanel();
window.addEventListener("resize", openwebrx_resize);
- check_top_bar_congestion();
- init_header();
bookmarks = new BookmarkBar();
- parseHash();
initSliders();
}
@@ -1826,12 +1370,10 @@ function update_dmr_timeslot_filtering() {
}).toArray().reduce(function (acc, v) {
return acc | v;
}, 0);
- webrx_set_param("dmr_filter", filter);
+ $('#openwebrx-panel-receiver').demodulatorPanel().getDemodulator().setDmrFilter(filter);
}
-function playButtonClick() {
- //On iOS, we can only start audio from a click or touch event.
- audioEngine.start(onAudioStart);
+function hideOverlay() {
var $overlay = $('#openwebrx-autoplay-overlay');
$overlay.css('opacity', 0);
$overlay.on('transitionend', function() {
@@ -1918,36 +1460,6 @@ function initPanels() {
});
}
-function demodulator_buttons_update() {
- $(".openwebrx-demodulator-button").removeClass("highlighted");
- if (secondary_demod) {
- $("#openwebrx-button-dig").addClass("highlighted");
- $('#openwebrx-secondary-demod-listbox').val(secondary_demod);
- } else switch (demodulators[0].subtype) {
- case "lsb":
- case "usb":
- case "cw":
- if (demodulators[0].high_cut - demodulators[0].low_cut < 300)
- $("#openwebrx-button-cw").addClass("highlighted");
- else {
- if (demodulators[0].high_cut < 0)
- $("#openwebrx-button-lsb").addClass("highlighted");
- else if (demodulators[0].low_cut > 0)
- $("#openwebrx-button-usb").addClass("highlighted");
- else $("#openwebrx-button-lsb, #openwebrx-button-usb").addClass("highlighted");
- }
- break;
- default:
- var mod = demodulators[0].subtype;
- $("#openwebrx-button-" + mod).addClass("highlighted");
- break;
- }
-}
-
-function demodulator_analog_replace_last() {
- demodulator_analog_replace(last_analog_demodulator_subtype);
-}
-
/*
_____ _ _ _
| __ \(_) (_) | |
@@ -1959,7 +1471,6 @@ function demodulator_analog_replace_last() {
|___/
*/
-var secondary_demod = false;
var secondary_demod_fft_offset_db = 30; //need to calculate that later
var secondary_demod_canvases_initialized = false;
var secondary_demod_listbox_updating = false;
@@ -1976,51 +1487,6 @@ var secondary_demod_current_canvas_context;
var secondary_demod_current_canvas_index;
var secondary_demod_canvases;
-function demodulator_digital_replace_last() {
- demodulator_digital_replace(last_digital_demodulator_subtype);
- secondary_demod_listbox_update();
-}
-
-function demodulator_digital_replace(subtype) {
- switch (subtype) {
- case "bpsk31":
- case "bpsk63":
- case "rtty":
- case "ft8":
- case "jt65":
- case "jt9":
- case "ft4":
- secondary_demod_start(subtype);
- demodulator_analog_replace('usb', true);
- break;
- case "wspr":
- secondary_demod_start(subtype);
- demodulator_analog_replace('usb', true);
- // WSPR only samples between 1400 and 1600 Hz
- demodulators[0].low_cut = 1350;
- demodulators[0].high_cut = 1650;
- demodulators[0].set();
- break;
- case "packet":
- secondary_demod_start(subtype);
- demodulator_analog_replace('nfm', true);
- break;
- case "pocsag":
- secondary_demod_start(subtype);
- demodulator_analog_replace('nfm', true);
- demodulators[0].low_cut = -6000;
- demodulators[0].high_cut = 6000;
- demodulators[0].set();
- break;
- }
- demodulator_buttons_update();
- $('#openwebrx-panel-digimodes').attr('data-mode', subtype);
- toggle_panel("openwebrx-panel-digimodes", true);
- toggle_panel("openwebrx-panel-wsjt-message", ['ft8', 'wspr', 'jt65', 'jt9', 'ft4'].indexOf(subtype) >= 0);
- toggle_panel("openwebrx-panel-packet-message", subtype === "packet");
- toggle_panel("openwebrx-panel-pocsag-message", subtype === "pocsag");
-}
-
function secondary_demod_create_canvas() {
var new_canvas = document.createElement("canvas");
new_canvas.width = secondary_fft_size;
@@ -2073,17 +1539,6 @@ function secondary_demod_init() {
init_digital_removal_timer();
}
-function secondary_demod_start(subtype) {
- secondary_demod_canvases_initialized = false;
- ws.send(JSON.stringify({"type": "dspcontrol", "params": {"secondary_mod": subtype}}));
- secondary_demod = subtype;
-}
-
-function secondary_demod_stop() {
- ws.send(JSON.stringify({"type": "dspcontrol", "params": {"secondary_mod": false}}));
- secondary_demod = false;
-}
-
function secondary_demod_push_data(x) {
x = Array.from(x).filter(function (y) {
var c = y.charCodeAt(0);
@@ -2103,16 +1558,7 @@ function secondary_demod_push_data(x) {
$("#openwebrx-cursor-blink").before(x);
}
-function secondary_demod_close_window() {
- secondary_demod_stop();
- toggle_panel("openwebrx-panel-digimodes", false);
- toggle_panel("openwebrx-panel-wsjt-message", false);
- toggle_panel("openwebrx-panel-packet-message", false);
- toggle_panel("openwebrx-panel-pocsag-message", false);
-}
-
function secondary_demod_waterfall_add(data) {
- if (!secondary_demod) return;
var w = secondary_fft_size;
//Add line to waterfall image
@@ -2132,22 +1578,6 @@ function secondary_demod_waterfall_add(data) {
if (secondary_demod_current_canvas_actual_line < 0) secondary_demod_swap_canvases();
}
-function secondary_demod_listbox_changed() {
- if (secondary_demod_listbox_updating) return;
- var sdm = $("#openwebrx-secondary-demod-listbox")[0].value;
- if (sdm === "none") {
- demodulator_analog_replace_last();
- } else {
- demodulator_digital_replace(sdm);
- }
-}
-
-function secondary_demod_listbox_update() {
- secondary_demod_listbox_updating = true;
- $("#openwebrx-secondary-demod-listbox").val((secondary_demod) ? secondary_demod : "none");
- secondary_demod_listbox_updating = false;
-}
-
function secondary_demod_update_marker() {
var width = Math.max((secondary_bw / (if_samp_rate / 2)) * secondary_demod_canvas_width, 5);
var center_at = (secondary_demod_channel_freq / (if_samp_rate / 2)) * secondary_demod_canvas_width + secondary_demod_canvas_left;
@@ -2164,10 +1594,7 @@ function secondary_demod_update_channel_freq_from_event(evt) {
if (!secondary_demod_waiting_for_set) {
secondary_demod_waiting_for_set = true;
window.setTimeout(function () {
- ws.send(JSON.stringify({
- "type": "dspcontrol",
- "params": {"secondary_offset_freq": Math.floor(secondary_demod_channel_freq)}
- }));
+ $('#openwebrx-panel-receiver').demodulatorPanel().getDemodulator().set_secondary_offset_freq(Math.floor(secondary_demod_channel_freq));
secondary_demod_waiting_for_set = false;
},
50
@@ -2200,7 +1627,7 @@ function secondary_demod_canvas_container_mouseup(evt) {
function secondary_demod_waterfall_set_zoom(low_cut, high_cut) {
- if (!secondary_demod || !secondary_demod_canvases_initialized) return;
+ if (!secondary_demod_canvases_initialized) return;
if (low_cut < 0 && high_cut < 0) {
var hctmp = high_cut;
var lctmp = low_cut;
diff --git a/htdocs/sdrsettings.html b/htdocs/sdrsettings.html
new file mode 100644
index 0000000..74aa8b7
--- /dev/null
+++ b/htdocs/sdrsettings.html
@@ -0,0 +1,21 @@
+
+
+
+
OpenWebRX Settings
+
+
+
+
+
+
+
+${header}
+
+
+
SDR device settings
+
+
+ ${devices}
+
+
+
\ No newline at end of file
diff --git a/htdocs/settings.html b/htdocs/settings.html
new file mode 100644
index 0000000..80dce91
--- /dev/null
+++ b/htdocs/settings.html
@@ -0,0 +1,27 @@
+
+
+
+
OpenWebRX Settings
+
+
+
+
+
+
+
+${header}
+
+
\ No newline at end of file
diff --git a/htdocs/settings.js b/htdocs/settings.js
new file mode 100644
index 0000000..e95e2fe
--- /dev/null
+++ b/htdocs/settings.js
@@ -0,0 +1,25 @@
+$(function(){
+ $(".map-input").each(function(el) {
+ var $el = $(this);
+ var field_id = $el.attr("for");
+ var $lat = $('#' + field_id + '-lat');
+ var $lon = $('#' + field_id + '-lon');
+ $.getScript("https://maps.googleapis.com/maps/api/js?key=" + $el.data("key")).done(function(){
+ $el.css("height", "200px");
+ var lp = new locationPicker($el.get(0), {
+ lat: parseFloat($lat.val()),
+ lng: parseFloat($lon.val())
+ }, {
+ zoom: 7
+ });
+
+ google.maps.event.addListener(lp.map, 'idle', function(event){
+ var pos = lp.getMarkerPosition();
+ $lat.val(pos.lat);
+ $lon.val(pos.lng);
+ });
+ });
+ });
+
+ $(".sdrdevice").sdrdevice();
+});
\ No newline at end of file
diff --git a/manifest.sh b/manifest.sh
new file mode 100755
index 0000000..5d5160b
--- /dev/null
+++ b/manifest.sh
@@ -0,0 +1,15 @@
+#!/usr/bin/env bash
+set -euxo pipefail
+. docker/env
+
+for image in ${IMAGES}; do
+ # there's no docker manifest rm command, and the create --amend does not work, so we have to clean up manually
+ rm -rf "${HOME}/.docker/manifests/docker.io_jketterl_${image}-${TAG}"
+ IMAGE_LIST=""
+ for a in $ALL_ARCHS; do
+ IMAGE_LIST="$IMAGE_LIST jketterl/$image:$TAG-$a"
+ done
+ docker manifest create jketterl/$image:$TAG $IMAGE_LIST
+ docker manifest push --purge jketterl/$image:$TAG
+ docker pull jketterl/$image:$TAG
+done
diff --git a/owrx/__main__.py b/owrx/__main__.py
index d452e4a..2bf3ec9 100644
--- a/owrx/__main__.py
+++ b/owrx/__main__.py
@@ -1,17 +1,18 @@
-from http.server import HTTPServer
-from owrx.http import RequestHandler
-from owrx.config import PropertyManager
-from owrx.feature import FeatureDetector
-from owrx.sdr import SdrService
-from socketserver import ThreadingMixIn
-from owrx.sdrhu import SdrHuUpdater
-from owrx.service import Services
-from owrx.websocket import WebSocketConnection
-from owrx.pskreporter import PskReporter
-
import logging
logging.basicConfig(level=logging.DEBUG, format="%(asctime)s - %(name)s - %(levelname)s - %(message)s")
+logger = logging.getLogger(__name__)
+
+from http.server import HTTPServer
+from owrx.http import RequestHandler
+from owrx.config import Config
+from owrx.feature import FeatureDetector
+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.version import openwebrx_version
class ThreadedHttpServer(ThreadingMixIn, HTTPServer):
@@ -26,32 +27,41 @@ OpenWebRX - Open Source SDR Web App for Everyone! | for license see LICENSE fil
_________________________________________________________________________________________________
Author contact info: Jakob Ketterl, DD5JFK
+Documentation: https://github.com/jketterl/openwebrx/wiki
+Support and info: https://groups.io/g/openwebrx
"""
)
- pm = PropertyManager.getSharedInstance().loadConfig()
+ logger.info("OpenWebRX version {0} starting up...".format(openwebrx_version))
+
+ pm = Config.get()
+
+ configErrors = Config.validateConfig()
+ if configErrors:
+ logger.error(
+ "your configuration contains errors. please address the following errors:"
+ )
+ for e in configErrors:
+ logger.error(e)
+ return
featureDetector = FeatureDetector()
if not featureDetector.is_available("core"):
- print(
+ logger.error(
"you are missing required dependencies to run openwebrx. "
"please check that the following core requirements are installed:"
)
- print(", ".join(featureDetector.get_requirements("core")))
+ logger.error(", ".join(featureDetector.get_requirements("core")))
return
# Get error messages about unknown / unavailable features as soon as possible
SdrService.loadProps()
- if "sdrhu_key" in pm and pm["sdrhu_public_listing"]:
- updater = SdrHuUpdater()
- updater.start()
-
Services.start()
try:
- server = ThreadedHttpServer(("0.0.0.0", pm.getPropertyValue("web_port")), RequestHandler)
+ server = ThreadedHttpServer(("0.0.0.0", pm["web_port"]), RequestHandler)
server.serve_forever()
except KeyboardInterrupt:
WebSocketConnection.closeAll()
diff --git a/owrx/audio.py b/owrx/audio.py
new file mode 100644
index 0000000..330b88b
--- /dev/null
+++ b/owrx/audio.py
@@ -0,0 +1,270 @@
+from abc import ABC, ABCMeta, abstractmethod
+from owrx.config import Config
+from owrx.metrics import Metrics, CounterMetric, DirectMetric
+import threading
+import wave
+import subprocess
+import os
+from multiprocessing.connection import Pipe, wait
+from datetime import datetime, timedelta
+from queue import Queue, Full
+
+
+import logging
+
+logger = logging.getLogger(__name__)
+logger.setLevel(logging.INFO)
+
+
+class QueueJob(object):
+ def __init__(self, decoder, file, freq):
+ self.decoder = decoder
+ self.file = file
+ self.freq = freq
+
+ def run(self):
+ self.decoder.decode(self)
+
+ def unlink(self):
+ try:
+ os.unlink(self.file)
+ except FileNotFoundError:
+ pass
+
+
+class QueueWorker(threading.Thread):
+ def __init__(self, queue):
+ self.queue = queue
+ self.doRun = True
+ super().__init__(daemon=True)
+
+ 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()
+
+ self.queue.task_done()
+
+
+class DecoderQueue(Queue):
+ sharedInstance = None
+ creationLock = threading.Lock()
+
+ @staticmethod
+ def getSharedInstance():
+ with DecoderQueue.creationLock:
+ if DecoderQueue.sharedInstance is None:
+ pm = Config.get()
+ DecoderQueue.sharedInstance = DecoderQueue(maxsize=pm["decoding_queue_length"], workers=pm["decoding_queue_workers"])
+ return DecoderQueue.sharedInstance
+
+ def __init__(self, maxsize, workers):
+ super().__init__(maxsize)
+ metrics = Metrics.getSharedInstance()
+ metrics.addMetric("decoding.queue.length", DirectMetric(self.qsize))
+ self.inCounter = CounterMetric()
+ metrics.addMetric("decoding.queue.in", self.inCounter)
+ self.outCounter = CounterMetric()
+ metrics.addMetric("decoding.queue.out", self.outCounter)
+ self.overflowCounter = CounterMetric()
+ metrics.addMetric("decoding.queue.overflow", self.overflowCounter)
+ self.errorCounter = CounterMetric()
+ metrics.addMetric("decoding.queue.error", self.errorCounter)
+ self.workers = [self.newWorker() for _ in range(0, workers)]
+
+ def put(self, item, **kwars):
+ self.inCounter.inc()
+ try:
+ super(DecoderQueue, self).put(item, block=False)
+ except Full:
+ self.overflowCounter.inc()
+ raise
+
+ def get(self, **kwargs):
+ # super.get() is blocking, so it would mess up the stats to inc() first
+ out = super(DecoderQueue, self).get(**kwargs)
+ self.outCounter.inc()
+ return out
+
+ def newWorker(self):
+ worker = QueueWorker(self)
+ worker.start()
+ return worker
+
+ def onError(self):
+ self.errorCounter.inc()
+
+
+class AudioChopperProfile(ABC):
+ @abstractmethod
+ def getInterval(self):
+ pass
+
+ @abstractmethod
+ def getFileTimestampFormat(self):
+ pass
+
+ @abstractmethod
+ def decoder_commandline(self, file):
+ pass
+
+
+class AudioWriter(object):
+ def __init__(self, dsp, source, profile: AudioChopperProfile):
+ self.dsp = dsp
+ self.source = source
+ self.profile = profile
+ self.tmp_dir = Config.get()["temporary_directory"]
+ self.wavefile = None
+ self.wavefilename = None
+ self.switchingLock = threading.Lock()
+ self.timer = None
+ (self.outputReader, self.outputWriter) = Pipe()
+
+ def getWaveFile(self):
+ filename = "{tmp_dir}/openwebrx-audiochopper-{id}-{timestamp}.wav".format(
+ tmp_dir=self.tmp_dir,
+ id=id(self),
+ timestamp=datetime.utcnow().strftime(self.profile.getFileTimestampFormat()),
+ )
+ wavefile = wave.open(filename, "wb")
+ wavefile.setnchannels(1)
+ wavefile.setsampwidth(2)
+ wavefile.setframerate(12000)
+ return filename, wavefile
+
+ def getNextDecodingTime(self):
+ t = datetime.utcnow()
+ zeroed = t.replace(minute=0, second=0, microsecond=0)
+ delta = t - zeroed
+ interval = self.profile.getInterval()
+ seconds = (int(delta.total_seconds() / interval) + 1) * interval
+ t = zeroed + timedelta(seconds=seconds)
+ logger.debug("scheduling: {0}".format(t))
+ return t
+
+ def cancelTimer(self):
+ if self.timer:
+ self.timer.cancel()
+ self.timer = None
+
+ def _scheduleNextSwitch(self):
+ self.cancelTimer()
+ delta = self.getNextDecodingTime() - datetime.utcnow()
+ self.timer = threading.Timer(delta.total_seconds(), self.switchFiles)
+ self.timer.start()
+
+ def switchFiles(self):
+ self.switchingLock.acquire()
+ file = self.wavefile
+ filename = self.wavefilename
+ (self.wavefilename, self.wavefile) = self.getWaveFile()
+ self.switchingLock.release()
+
+ file.close()
+ job = QueueJob(self, filename, self.dsp.get_operating_freq())
+ try:
+ DecoderQueue.getSharedInstance().put(job)
+ except Full:
+ logger.warning("decoding queue overflow; dropping one file")
+ job.unlink()
+ self._scheduleNextSwitch()
+
+ def decode(self, job: QueueJob):
+ logger.debug("processing file %s", job.file)
+ decoder = subprocess.Popen(
+ ["nice", "-n", "10"] + self.profile.decoder_commandline(job.file),
+ stdout=subprocess.PIPE,
+ cwd=self.tmp_dir,
+ close_fds=True,
+ )
+ try:
+ for line in decoder.stdout:
+ self.outputWriter.send((job.freq, line))
+ except OSError:
+ decoder.stdout.flush()
+ # TODO uncouple parsing from the output so that decodes can still go to the map and the spotters
+ logger.debug("output has gone away while decoding job.")
+ try:
+ rc = decoder.wait(timeout=10)
+ if rc != 0:
+ logger.warning("decoder return code: %i", rc)
+ except subprocess.TimeoutExpired:
+ logger.warning("subprocess (pid=%i}) did not terminate correctly; sending kill signal.", decoder.pid)
+ decoder.kill()
+
+ def start(self):
+ (self.wavefilename, self.wavefile) = self.getWaveFile()
+ self._scheduleNextSwitch()
+
+ def write(self, data):
+ self.switchingLock.acquire()
+ self.wavefile.writeframes(data)
+ self.switchingLock.release()
+
+ def stop(self):
+ self.outputWriter.close()
+ self.outputWriter = None
+
+ # drain messages left in the queue so that the queue can be successfully closed
+ # this is necessary since python keeps the file descriptors open otherwise
+ try:
+ while True:
+ self.outputReader.recv()
+ except EOFError:
+ pass
+ self.outputReader.close()
+ self.outputReader = None
+
+ self.cancelTimer()
+ try:
+ self.wavefile.close()
+ except Exception:
+ logger.exception("error closing wave file")
+ try:
+ os.unlink(self.wavefilename)
+ except Exception:
+ logger.exception("error removing undecoded file")
+ self.wavefile = None
+ self.wavefilename = None
+
+
+class AudioChopper(threading.Thread, metaclass=ABCMeta):
+ def __init__(self, dsp, source, *profiles: AudioChopperProfile):
+ self.source = source
+ self.writers = [AudioWriter(dsp, source, p) for p in profiles]
+ self.doRun = True
+ super().__init__()
+
+ def run(self) -> None:
+ logger.debug("Audio chopper starting up")
+ for w in self.writers:
+ w.start()
+ while self.doRun:
+ data = None
+ try:
+ data = self.source.read(256)
+ except ValueError:
+ pass
+ if data is None or (isinstance(data, bytes) and len(data) == 0):
+ self.doRun = False
+ else:
+ for w in self.writers:
+ w.write(data)
+
+ logger.debug("Audio chopper shutting down")
+ for w in self.writers:
+ w.stop()
+
+ def read(self):
+ try:
+ readers = wait([w.outputReader for w in self.writers])
+ return [r.recv() for r in readers]
+ except (EOFError, OSError):
+ return None
diff --git a/owrx/bands.py b/owrx/bands.py
index cf2bd7c..fbb7ae6 100644
--- a/owrx/bands.py
+++ b/owrx/bands.py
@@ -58,7 +58,10 @@ class Bandplan(object):
except FileNotFoundError:
pass
except json.JSONDecodeError:
- logger.exception("error while parsing bandplan from %s", file)
+ logger.exception("error while parsing bandplan file %s", file)
+ return []
+ except Exception:
+ logger.exception("error while processing bandplan from %s", file)
return []
return []
diff --git a/owrx/bookmarks.py b/owrx/bookmarks.py
index 1456c8f..d5a38d8 100644
--- a/owrx/bookmarks.py
+++ b/owrx/bookmarks.py
@@ -50,7 +50,10 @@ class Bookmarks(object):
except FileNotFoundError:
pass
except json.JSONDecodeError:
- logger.exception("error while parsing bookmarks from %s", file)
+ logger.exception("error while parsing bookmarks file %s", file)
+ return []
+ except Exception:
+ logger.exception("error while processing bookmarks from %s", file)
return []
return []
diff --git a/owrx/client.py b/owrx/client.py
index 4fda3d4..f992e45 100644
--- a/owrx/client.py
+++ b/owrx/client.py
@@ -1,5 +1,4 @@
-from owrx.config import PropertyManager
-from owrx.metrics import Metrics, DirectMetric
+from owrx.config import Config
import threading
import logging
@@ -24,7 +23,6 @@ class ClientRegistry(object):
def __init__(self):
self.clients = []
- Metrics.getSharedInstance().addMetric("openwebrx.users", DirectMetric(self.clientCount))
super().__init__()
def broadcast(self):
@@ -33,7 +31,7 @@ class ClientRegistry(object):
c.write_clients(n)
def addClient(self, client):
- pm = PropertyManager.getSharedInstance()
+ pm = Config.get()
if len(self.clients) >= pm["max_clients"]:
raise TooManyClientsException()
self.clients.append(client)
diff --git a/owrx/command.py b/owrx/command.py
index 9cf0327..87ae711 100644
--- a/owrx/command.py
+++ b/owrx/command.py
@@ -33,6 +33,9 @@ class CommandMapper(object):
self.static = static
return self
+ def keys(self):
+ return self.mappings.keys()
+
class CommandMapping(ABC):
@abstractmethod
@@ -69,3 +72,8 @@ class Option(CommandMapping):
def setSpacer(self, spacer):
self.spacer = spacer
return self
+
+
+class Argument(CommandMapping):
+ def map(self, value):
+ return value
diff --git a/owrx/config.py b/owrx/config.py
index 820fee8..bdecd04 100644
--- a/owrx/config.py
+++ b/owrx/config.py
@@ -1,149 +1,134 @@
+from owrx.property import PropertyManager, PropertyLayer
import importlib.util
+import os
import logging
+import json
+from abc import ABC, abstractmethod
logger = logging.getLogger(__name__)
-class Subscription(object):
- def __init__(self, subscriptee, subscriber):
- self.subscriptee = subscriptee
- self.subscriber = subscriber
-
- def call(self, *args, **kwargs):
- self.subscriber(*args, **kwargs)
-
- def cancel(self):
- self.subscriptee.unwire(self)
-
-
-class Property(object):
- def __init__(self, value=None):
- self.value = value
- self.subscribers = []
-
- def getValue(self):
- return self.value
-
- def setValue(self, value):
- if self.value == value:
- return self
- self.value = value
- for c in self.subscribers:
- try:
- c.call(self.value)
- except Exception as e:
- logger.exception(e)
- return self
-
- def wire(self, callback):
- sub = Subscription(self, callback)
- self.subscribers.append(sub)
- if not self.value is None:
- sub.call(self.value)
- return sub
-
- def unwire(self, sub):
- try:
- self.subscribers.remove(sub)
- except ValueError:
- # happens when already removed before
- pass
- return self
-
-
class ConfigNotFoundException(Exception):
pass
-class PropertyManager(object):
- sharedInstance = None
+class ConfigError(object):
+ def __init__(self, key, message):
+ self.key = key
+ self.message = message
+
+ def __str__(self):
+ return "Configuration Error (key: {0}): {1}".format(self.key, self.message)
+
+
+class ConfigMigrator(ABC):
+ @abstractmethod
+ def migrate(self, config):
+ pass
+
+ def renameKey(self, config, old, new):
+ if old in config and not new in config:
+ config[new] = config[old]
+ del config[old]
+
+
+class ConfigMigratorVersion1(ConfigMigrator):
+ def migrate(self, config):
+ if "receiver_gps" in config:
+ gps = config["receiver_gps"]
+ config["receiver_gps"] = {"lat": gps[0], "lon": gps[1]}
+
+ if "waterfall_auto_level_margin" in config:
+ levels = config["waterfall_auto_level_margin"]
+ config["waterfall_auto_level_margin"] = {"min": levels[0], "max": levels[1]}
+
+ self.renameKey(config, "wsjt_queue_workers", "decoding_queue_workers")
+ self.renameKey(config, "wsjt_queue_length", "decoding_queue_length")
+
+ config["version"] = 2
+ return config
+
+
+class Config:
+ sharedConfig = None
+ currentVersion = 2
+ migrators = {
+ 1: ConfigMigratorVersion1()
+ }
@staticmethod
- def getSharedInstance():
- if PropertyManager.sharedInstance is None:
- PropertyManager.sharedInstance = PropertyManager()
- return PropertyManager.sharedInstance
+ def _loadPythonFile(file):
+ spec = importlib.util.spec_from_file_location("config_webrx", file)
+ cfg = importlib.util.module_from_spec(spec)
+ spec.loader.exec_module(cfg)
+ pm = PropertyLayer()
+ for name, value in cfg.__dict__.items():
+ if name.startswith("__"):
+ continue
+ pm[name] = value
+ return pm
- def collect(self, *props):
- return PropertyManager(
- {name: self.getProperty(name) if self.hasProperty(name) else Property() for name in props}
- )
+ @staticmethod
+ def _loadJsonFile(file):
+ with open(file, "r") as f:
+ pm = PropertyLayer()
+ for k, v in json.load(f).items():
+ pm[k] = v
+ return pm
- def __init__(self, properties=None):
- self.properties = {}
- self.subscribers = []
- if properties is not None:
- for (name, prop) in properties.items():
- self.add(name, prop)
-
- def add(self, name, prop):
- self.properties[name] = prop
-
- def fireCallbacks(value):
- for c in self.subscribers:
- try:
- c.call(name, value)
- except Exception as e:
- logger.exception(e)
-
- prop.wire(fireCallbacks)
- return self
-
- def __contains__(self, name):
- return self.hasProperty(name)
-
- def __getitem__(self, name):
- return self.getPropertyValue(name)
-
- def __setitem__(self, name, value):
- if not self.hasProperty(name):
- self.add(name, Property())
- self.getProperty(name).setValue(value)
-
- def __dict__(self):
- return {k: v.getValue() for k, v in self.properties.items()}
-
- def hasProperty(self, name):
- return name in self.properties
-
- def getProperty(self, name):
- if not self.hasProperty(name):
- self.add(name, Property())
- return self.properties[name]
-
- def getPropertyValue(self, name):
- return self.getProperty(name).getValue()
-
- def wire(self, callback):
- sub = Subscription(self, callback)
- self.subscribers.append(sub)
- return sub
-
- def unwire(self, sub):
- try:
- self.subscribers.remove(sub)
- except ValueError:
- # happens when already removed before
- pass
- return self
-
- def defaults(self, other_pm):
- for (key, p) in self.properties.items():
- if p.getValue() is None:
- p.setValue(other_pm[key])
- return self
-
- def loadConfig(self):
- for file in ["/etc/openwebrx/config_webrx.py", "./config_webrx.py"]:
+ @staticmethod
+ def _loadConfig():
+ for file in ["./settings.json", "/etc/openwebrx/config_webrx.py", "./config_webrx.py"]:
try:
- spec = importlib.util.spec_from_file_location("config_webrx", file)
- cfg = importlib.util.module_from_spec(spec)
- spec.loader.exec_module(cfg)
- for name, value in cfg.__dict__.items():
- if name.startswith("__"):
- continue
- self[name] = value
- return self
+ if file.endswith(".py"):
+ return Config._loadPythonFile(file)
+ elif file.endswith(".json"):
+ return Config._loadJsonFile(file)
+ else:
+ logger.warning("unsupported file type: %s", file)
except FileNotFoundError:
- logger.debug("not found: %s", file)
+ pass
raise ConfigNotFoundException("no usable config found! please make sure you have a valid configuration file!")
+
+ @staticmethod
+ def get():
+ if Config.sharedConfig is None:
+ Config.sharedConfig = Config._migrate(Config._loadConfig())
+ return Config.sharedConfig
+
+ @staticmethod
+ def store():
+ with open("settings.json", "w") as file:
+ json.dump(Config.get().__dict__(), file, indent=4)
+
+ @staticmethod
+ def validateConfig():
+ pm = Config.get()
+ errors = [
+ Config.checkTempDirectory(pm)
+ ]
+
+ return [e for e in errors if e is not None]
+
+ @staticmethod
+ def checkTempDirectory(pm: PropertyManager):
+ key = "temporary_directory"
+ if key not in pm or pm[key] is None:
+ return ConfigError(key, "temporary directory is not set")
+ if not os.path.exists(pm[key]):
+ return ConfigError(key, "temporary directory doesn't exist")
+ if not os.path.isdir(pm[key]):
+ return ConfigError(key, "temporary directory path is not a directory")
+ if not os.access(pm[key], os.W_OK):
+ return ConfigError(key, "temporary directory is not writable")
+ return None
+
+ @staticmethod
+ def _migrate(config):
+ version = config["version"] if "version" in config else 1
+ if version == Config.currentVersion:
+ return config
+
+ logger.debug("migrating config from version %i", version)
+ migrator = Config.migrators[version]
+ return migrator.migrate(config)
diff --git a/owrx/connection.py b/owrx/connection.py
index 7aa6fc6..7bfd805 100644
--- a/owrx/connection.py
+++ b/owrx/connection.py
@@ -1,4 +1,5 @@
-from owrx.config import PropertyManager
+from owrx.config import Config
+from owrx.details import ReceiverDetails
from owrx.dsp import DspManager
from owrx.cpu import CpuUsageThread
from owrx.sdr import SdrService
@@ -9,9 +10,11 @@ from owrx.version import openwebrx_version
from owrx.bands import Bandplan
from owrx.bookmarks import Bookmarks
from owrx.map import Map
-from owrx.locator import Locator
-from multiprocessing import Queue
-from queue import Full
+from owrx.property import PropertyStack
+from owrx.modes import Modes, DigitalMode
+from queue import Queue, Full
+from js8py import Js8Frame
+from abc import ABC, ABCMeta, abstractmethod
import json
import threading
@@ -19,36 +22,54 @@ import logging
logger = logging.getLogger(__name__)
+PoisonPill = object()
-class Client(object):
+
+class Client(ABC):
def __init__(self, conn):
self.conn = conn
- self.multiprocessingPipe = Queue(100)
+ self.multithreadingQueue = Queue(100)
def mp_passthru():
run = True
while run:
try:
- data = self.multiprocessingPipe.get()
- self.send(data)
- except (EOFError, OSError):
+ data = self.multithreadingQueue.get()
+ if data is PoisonPill:
+ run = False
+ else:
+ self.send(data)
+ self.multithreadingQueue.task_done()
+ except (EOFError, OSError, ValueError):
run = False
+ except Exception:
+ logger.exception("Exception on client multithreading queue")
- threading.Thread(target=mp_passthru).start()
+ # unset the queue object to free shared memory file descriptors
+ self.multithreadingQueue = None
+
+ threading.Thread(target=mp_passthru, name="connection_mp_passthru").start()
def send(self, data):
- self.conn.send(data)
+ try:
+ self.conn.send(data)
+ except IOError:
+ self.close()
def close(self):
+ if self.multithreadingQueue is not None:
+ self.multithreadingQueue.put(PoisonPill)
self.conn.close()
- self.multiprocessingPipe.close()
def mp_send(self, data):
+ if self.multithreadingQueue is None:
+ return
try:
- self.multiprocessingPipe.put(data, block=False)
+ self.multithreadingQueue.put(data, block=False)
except Full:
self.close()
+ @abstractmethod
def handleTextMessage(self, conn, message):
pass
@@ -59,7 +80,25 @@ class Client(object):
self.close()
-class OpenWebRxReceiverClient(Client):
+class OpenWebRxClient(Client, metaclass=ABCMeta):
+ def __init__(self, conn):
+ super().__init__(conn)
+
+ receiver_details = ReceiverDetails()
+
+ def send_receiver_info(*args):
+ receiver_info = receiver_details.__dict__()
+ self.write_receiver_details(receiver_info)
+
+ # TODO unsubscribe
+ receiver_details.wire(send_receiver_info)
+ send_receiver_info()
+
+ def write_receiver_details(self, details):
+ self.send({"type": "receiver_details", "value": details})
+
+
+class OpenWebRxReceiverClient(OpenWebRxClient):
config_keys = [
"waterfall_colors",
"waterfall_min_level",
@@ -67,7 +106,6 @@ class OpenWebRxReceiverClient(Client):
"waterfall_auto_level_margin",
"samp_rate",
"fft_size",
- "fft_fps",
"audio_compression",
"fft_compression",
"max_clients",
@@ -93,28 +131,16 @@ class OpenWebRxReceiverClient(Client):
self.close()
raise
- pm = PropertyManager.getSharedInstance()
-
self.setSdr()
- # send receiver info
- receiver_keys = [
- "receiver_name",
- "receiver_location",
- "receiver_asl",
- "receiver_gps",
- "photo_title",
- "photo_desc",
- ]
- receiver_details = dict((key, pm.getPropertyValue(key)) for key in receiver_keys)
- receiver_details["locator"] = Locator.fromCoordinates(receiver_details["receiver_gps"])
- self.write_receiver_details(receiver_details)
-
- self.__sendProfiles()
-
features = FeatureDetector().feature_availability()
self.write_features(features)
+ modes = Modes.getModes()
+ self.write_modes(modes)
+
+ self.__sendProfiles()
+
CpuUsageThread.getSharedInstance().add_client(self)
def __sendProfiles(self):
@@ -134,8 +160,12 @@ class OpenWebRxReceiverClient(Client):
self.startDsp()
if "params" in message:
- params = message["params"]
- self.setDspProperties(params)
+ dsp = self.getDsp()
+ if dsp is None:
+ logger.warning("DSP not available; discarding client data")
+ else:
+ params = message["params"]
+ dsp.setProperties(params)
elif message["type"] == "config":
if "params" in message:
@@ -152,7 +182,7 @@ class OpenWebRxReceiverClient(Client):
if "params" in message:
self.connectionProperties = message["params"]
if self.dsp:
- self.setDspProperties(self.connectionProperties)
+ self.getDsp().setProperties(self.connectionProperties)
else:
logger.warning("received message without type: {0}".format(message))
@@ -169,6 +199,7 @@ class OpenWebRxReceiverClient(Client):
next = SdrService.getFirstSource()
if next is None:
# exit condition: no sdrs available
+ logger.warning("no more SDR devices available")
self.handleNoSdrsAvailable()
return
@@ -184,25 +215,25 @@ class OpenWebRxReceiverClient(Client):
self.sdr = next
- self.startDsp()
+ self.getDsp()
- # keep trying until we find a suitable SDR
- if self.sdr.getState() == SdrSource.STATE_FAILED:
- self.write_log_message('SDR device "{0}" has failed, selecting new device'.format(self.sdr.getName()))
- else:
+ # found a working sdr, exit the loop
+ if self.sdr.getState() != SdrSource.STATE_FAILED:
break
- # send initial config
- self.setDspProperties(self.connectionProperties)
+ logger.warning('SDR device "%s" has failed, selecing new device', self.sdr.getName())
+ self.write_log_message('SDR device "{0}" has failed, selecting new device'.format(self.sdr.getName()))
- configProps = (
- self.sdr.getProps()
- .collect(*OpenWebRxReceiverClient.config_keys)
- .defaults(PropertyManager.getSharedInstance())
- )
+ # send initial config
+ self.getDsp().setProperties(self.connectionProperties)
+
+ stack = PropertyStack()
+ stack.addLayer(0, self.sdr.getProps())
+ stack.addLayer(1, Config.get())
+ configProps = stack.filter(*OpenWebRxReceiverClient.config_keys)
def sendConfig(key, value):
- config = dict((key, configProps[key]) for key in OpenWebRxReceiverClient.config_keys)
+ config = configProps.__dict__()
# TODO mathematical properties? hmmmm
config["start_offset_freq"] = configProps["start_freq"] - configProps["center_freq"]
# TODO this is a hack to support multiple sdrs
@@ -226,9 +257,7 @@ class OpenWebRxReceiverClient(Client):
self.write_sdr_error("No SDR Devices available")
def startDsp(self):
- if self.dsp is None and self.sdr is not None:
- self.dsp = DspManager(self, self.sdr)
- self.dsp.start()
+ self.getDsp().start()
def close(self):
self.stopDsp()
@@ -247,18 +276,28 @@ class OpenWebRxReceiverClient(Client):
self.sdr.removeSpectrumClient(self)
def setParams(self, params):
+ config = Config.get()
+ # allow direct configuration only if enabled in the config
+ if "configurable_keys" not in config:
+ return
+ keys = config["configurable_keys"]
+ if not keys:
+ return
# only the keys in the protected property manager can be overridden from the web
- protected = (
- self.sdr.getProps()
- .collect("samp_rate", "center_freq", "rf_gain", "type")
- .defaults(PropertyManager.getSharedInstance())
- )
+ stack = PropertyStack()
+ stack.addLayer(0, self.sdr.getProps())
+ stack.addLayer(1, config)
+ protected = stack.filter(*keys)
for key, value in params.items():
- protected[key] = value
+ try:
+ protected[key] = value
+ except KeyError:
+ pass
- def setDspProperties(self, params):
- for key, value in params.items():
- self.dsp.setProperty(key, value)
+ def getDsp(self):
+ if self.dsp is None and self.sdr is not None:
+ self.dsp = DspManager(self, self.sdr)
+ return self.dsp
def write_spectrum_data(self, data):
self.mp_send(bytes([0x01]) + data)
@@ -266,6 +305,9 @@ class OpenWebRxReceiverClient(Client):
def write_dsp_data(self, data):
self.send(bytes([0x02]) + data)
+ def write_hd_audio(self, data):
+ self.send(bytes([0x04]) + data)
+
def write_s_meter_level(self, level):
self.send({"type": "smeter", "value": level})
@@ -288,9 +330,6 @@ class OpenWebRxReceiverClient(Client):
def write_config(self, cfg):
self.send({"type": "config", "value": cfg})
- def write_receiver_details(self, details):
- self.send({"type": "receiver_details", "value": details})
-
def write_profiles(self, profiles):
self.send({"type": "profiles", "value": profiles})
@@ -324,13 +363,44 @@ class OpenWebRxReceiverClient(Client):
def write_backoff_message(self, reason):
self.send({"type": "backoff", "reason": reason})
+ def write_js8_message(self, frame: Js8Frame, freq: int):
+ self.send({"type": "js8_message", "value": {
+ "msg": str(frame),
+ "timestamp": frame.timestamp,
+ "db": frame.db,
+ "dt": frame.dt,
+ "freq": freq + frame.freq,
+ "thread_type": frame.thread_type,
+ "mode": frame.mode
+ }})
-class MapConnection(Client):
+ def write_modes(self, modes):
+ def to_json(m):
+ res = {
+ "modulation": m.modulation,
+ "name": m.name,
+ "type": "digimode" if isinstance(m, DigitalMode) else "analog",
+ "requirements": m.requirements,
+ "squelch": m.squelch,
+ }
+ if m.bandpass is not None:
+ res["bandpass"] = {
+ "low_cut": m.bandpass.low_cut,
+ "high_cut": m.bandpass.high_cut
+ }
+ if isinstance(m, DigitalMode):
+ res["underlying"] = m.underlying
+ return res
+
+ self.send({"type": "modes", "value": [to_json(m) for m in modes]})
+
+
+class MapConnection(OpenWebRxClient):
def __init__(self, conn):
super().__init__(conn)
- pm = PropertyManager.getSharedInstance()
- self.write_config(pm.collect("google_maps_api_key", "receiver_gps", "map_position_retention_time").__dict__())
+ pm = Config.get()
+ self.write_config(pm.filter("google_maps_api_key", "receiver_gps", "map_position_retention_time").__dict__())
Map.getSharedInstance().addClient(self)
diff --git a/owrx/controllers.py b/owrx/controllers.py
deleted file mode 100644
index a26ebc3..0000000
--- a/owrx/controllers.py
+++ /dev/null
@@ -1,168 +0,0 @@
-import os
-import mimetypes
-import json
-import pkg_resources
-from datetime import datetime
-from string import Template
-from owrx.websocket import WebSocketConnection
-from owrx.config import PropertyManager
-from owrx.client import ClientRegistry
-from owrx.connection import WebSocketMessageHandler
-from owrx.version import openwebrx_version
-from owrx.feature import FeatureDetector
-from owrx.metrics import Metrics
-
-import logging
-
-logger = logging.getLogger(__name__)
-
-
-class Controller(object):
- def __init__(self, handler, request):
- self.handler = handler
- self.request = request
-
- def send_response(self, content, code=200, content_type="text/html", last_modified: datetime = None, max_age=None):
- self.handler.send_response(code)
- if content_type is not None:
- self.handler.send_header("Content-Type", content_type)
- if last_modified is not None:
- self.handler.send_header("Last-Modified", last_modified.strftime("%a, %d %b %Y %H:%M:%S GMT"))
- if max_age is not None:
- self.handler.send_header("Cache-Control", "max-age: {0}".format(max_age))
- self.handler.end_headers()
- if type(content) == str:
- content = content.encode()
- self.handler.wfile.write(content)
-
-
-class StatusController(Controller):
- def handle_request(self):
- pm = PropertyManager.getSharedInstance()
- # TODO keys that have been left out since they are no longer simple strings: sdr_hw, bands, antenna
- vars = {
- "status": "active",
- "name": pm["receiver_name"],
- "op_email": pm["receiver_admin"],
- "users": ClientRegistry.getSharedInstance().clientCount(),
- "users_max": pm["max_clients"],
- "gps": pm["receiver_gps"],
- "asl": pm["receiver_asl"],
- "loc": pm["receiver_location"],
- "sw_version": openwebrx_version,
- "avatar_ctime": os.path.getctime("htdocs/gfx/openwebrx-avatar.png"),
- }
- self.send_response("\n".join(["{key}={value}".format(key=key, value=value) for key, value in vars.items()]))
-
-
-class AssetsController(Controller):
- def getModified(self, file):
- return None
-
- def openFile(self, file):
- pass
-
- def serve_file(self, file, content_type=None):
- try:
- modified = self.getModified(file)
-
- if modified is not None and "If-Modified-Since" in self.handler.headers:
- client_modified = datetime.strptime(
- self.handler.headers["If-Modified-Since"], "%a, %d %b %Y %H:%M:%S %Z"
- )
- if modified <= client_modified:
- self.send_response("", code=304)
- return
-
- f = self.openFile(file)
- data = f.read()
- f.close()
-
- if content_type is None:
- (content_type, encoding) = mimetypes.MimeTypes().guess_type(file)
- self.send_response(data, content_type=content_type, last_modified=modified, max_age=3600)
- except FileNotFoundError:
- self.send_response("file not found", code=404)
-
- def handle_request(self):
- filename = self.request.matches.group(1)
- self.serve_file(filename)
-
-
-class OwrxAssetsController(AssetsController):
- def openFile(self, file):
- return pkg_resources.resource_stream("htdocs", file)
-
-
-class AprsSymbolsController(AssetsController):
- def __init__(self, handler, request):
- pm = PropertyManager.getSharedInstance()
- path = pm["aprs_symbols_path"]
- if not path.endswith("/"):
- path += "/"
- self.path = path
- super().__init__(handler, request)
-
- def getFilePath(self, file):
- return self.path + file
-
- def getModified(self, file):
- return datetime.fromtimestamp(os.path.getmtime(self.getFilePath(file)))
-
- def openFile(self, file):
- return open(self.getFilePath(file), "rb")
-
-
-class TemplateController(Controller):
- def render_template(self, file, **vars):
- file_content = pkg_resources.resource_string("htdocs", file).decode("utf-8")
- template = Template(file_content)
-
- return template.safe_substitute(**vars)
-
- def serve_template(self, file, **vars):
- self.send_response(self.render_template(file, **vars), content_type="text/html")
-
- def default_variables(self):
- return {}
-
-
-class WebpageController(TemplateController):
- def template_variables(self):
- header = self.render_template("include/header.include.html")
- return {"header": header}
-
-
-class IndexController(WebpageController):
- def handle_request(self):
- self.serve_template("index.html", **self.template_variables())
-
-
-class MapController(WebpageController):
- def handle_request(self):
- # TODO check if we have a google maps api key first?
- self.serve_template("map.html", **self.template_variables())
-
-
-class FeatureController(WebpageController):
- def handle_request(self):
- self.serve_template("features.html", **self.template_variables())
-
-
-class ApiController(Controller):
- def handle_request(self):
- data = json.dumps(FeatureDetector().feature_report())
- self.send_response(data, content_type="application/json")
-
-
-class MetricsController(Controller):
- def handle_request(self):
- data = json.dumps(Metrics.getSharedInstance().getMetrics())
- self.send_response(data, content_type="application/json")
-
-
-class WebSocketController(Controller):
- def handle_request(self):
- conn = WebSocketConnection(self.handler, WebSocketMessageHandler())
- # enter read loop
- conn.handle()
diff --git a/owrx/controllers/__init__.py b/owrx/controllers/__init__.py
new file mode 100644
index 0000000..c00eebd
--- /dev/null
+++ b/owrx/controllers/__init__.py
@@ -0,0 +1,44 @@
+from datetime import datetime, timezone
+
+
+class Controller(object):
+ def __init__(self, handler, request, options):
+ self.handler = handler
+ self.request = request
+ self.options = options
+
+ def send_response(self, content, code=200, content_type="text/html", last_modified: datetime = None, max_age=None, headers=None):
+ self.handler.send_response(code)
+ if headers is None:
+ headers = {}
+ if content_type is not None:
+ headers["Content-Type"] = content_type
+ if last_modified is not None:
+ headers["Last-Modified"] = last_modified.astimezone(tz=timezone.utc).strftime("%a, %d %b %Y %H:%M:%S GMT")
+ if max_age is not None:
+ headers["Cache-Control"] = "max-age: {0}".format(max_age)
+ for key, value in headers.items():
+ self.handler.send_header(key, value)
+ self.handler.end_headers()
+ if type(content) == str:
+ content = content.encode()
+ self.handler.wfile.write(content)
+
+ def send_redirect(self, location, code=303, cookies=None):
+ self.handler.send_response(code)
+ if cookies is not None:
+ self.handler.send_header("Set-Cookie", cookies.output(header=''))
+ self.handler.send_header("Location", location)
+ self.handler.end_headers()
+
+ def get_body(self):
+ if "Content-Length" not in self.handler.headers:
+ return None
+ length = int(self.handler.headers["Content-Length"])
+ return self.handler.rfile.read(length)
+
+ def handle_request(self):
+ action = "indexAction"
+ if "action" in self.options:
+ action = self.options["action"]
+ getattr(self, action)()
diff --git a/owrx/controllers/admin.py b/owrx/controllers/admin.py
new file mode 100644
index 0000000..3879141
--- /dev/null
+++ b/owrx/controllers/admin.py
@@ -0,0 +1,33 @@
+from .template import WebpageController
+from .session import SessionStorage
+from owrx.config import Config
+from urllib import parse
+
+import logging
+
+logger = logging.getLogger(__name__)
+
+
+class Authentication(object):
+ def isAuthenticated(self, request):
+ if "owrx-session" in request.cookies:
+ session = SessionStorage.getSharedInstance().getSession(request.cookies["owrx-session"].value)
+ return session is not None
+ return False
+
+
+class AdminController(WebpageController):
+ def __init__(self, handler, request, options):
+ self.authentication = Authentication()
+ super().__init__(handler, request, options)
+
+ def handle_request(self):
+ config = Config.get()
+ if "webadmin_enabled" not in config or not config["webadmin_enabled"]:
+ self.send_response("Web Admin is disabled", code=403)
+ return
+ if self.authentication.isAuthenticated(self.request):
+ super().handle_request()
+ else:
+ target = "/login?{0}".format(parse.urlencode({"ref": self.request.path}))
+ self.send_redirect(target)
diff --git a/owrx/controllers/api.py b/owrx/controllers/api.py
new file mode 100644
index 0000000..4dcde14
--- /dev/null
+++ b/owrx/controllers/api.py
@@ -0,0 +1,15 @@
+from . import Controller
+from owrx.feature import FeatureDetector
+from owrx.details import ReceiverDetails
+import json
+
+
+class ApiController(Controller):
+ def indexAction(self):
+ data = json.dumps(FeatureDetector().feature_report())
+ self.send_response(data, content_type="application/json")
+
+ def receiverDetails(self):
+ receiver_details = ReceiverDetails()
+ data = json.dumps(receiver_details.__dict__())
+ self.send_response(data, content_type="application/json")
diff --git a/owrx/controllers/assets.py b/owrx/controllers/assets.py
new file mode 100644
index 0000000..b15ff64
--- /dev/null
+++ b/owrx/controllers/assets.py
@@ -0,0 +1,142 @@
+from . import Controller
+from owrx.config import Config
+from datetime import datetime, timezone
+import mimetypes
+import os
+import pkg_resources
+from abc import ABCMeta, abstractmethod
+
+
+class ModificationAwaraController(Controller, metaclass=ABCMeta):
+ @abstractmethod
+ def getModified(self, file):
+ pass
+
+ def wasModified(self, file):
+ try:
+ modified = self.getModified(file).replace(microsecond=0)
+
+ if modified is not None and "If-Modified-Since" in self.handler.headers:
+ client_modified = datetime.strptime(
+ self.handler.headers["If-Modified-Since"], "%a, %d %b %Y %H:%M:%S %Z"
+ ).replace(tzinfo=timezone.utc)
+ if modified <= client_modified:
+ return False
+ except FileNotFoundError:
+ pass
+
+ return True
+
+
+class AssetsController(ModificationAwaraController, metaclass=ABCMeta):
+ def getModified(self, file):
+ return datetime.fromtimestamp(os.path.getmtime(self.getFilePath(file)), timezone.utc)
+
+ def openFile(self, file):
+ return open(self.getFilePath(file), "rb")
+
+ @abstractmethod
+ def getFilePath(self, file):
+ pass
+
+ def serve_file(self, file, content_type=None):
+ try:
+ modified = self.getModified(file)
+
+ if not self.wasModified(file):
+ self.send_response("", code=304)
+ return
+
+ f = self.openFile(file)
+ data = f.read()
+ f.close()
+
+ if content_type is None:
+ (content_type, encoding) = mimetypes.MimeTypes().guess_type(file)
+ self.send_response(data, content_type=content_type, last_modified=modified, max_age=3600)
+ except FileNotFoundError:
+ self.send_response("file not found", code=404)
+
+ def indexAction(self):
+ filename = self.request.matches.group(1)
+ self.serve_file(filename)
+
+
+class OwrxAssetsController(AssetsController):
+ def getFilePath(self, file):
+ return pkg_resources.resource_filename("htdocs", file)
+
+
+class AprsSymbolsController(AssetsController):
+ def __init__(self, handler, request, options):
+ pm = Config.get()
+ path = pm["aprs_symbols_path"]
+ if not path.endswith("/"):
+ path += "/"
+ self.path = path
+ super().__init__(handler, request, options)
+
+ def getFilePath(self, file):
+ return self.path + file
+
+
+class CompiledAssetsController(ModificationAwaraController):
+ profiles = {
+ "receiver.js": [
+ "openwebrx.js",
+ "lib/jquery-3.2.1.min.js",
+ "lib/jquery.nanoscroller.js",
+ "lib/Header.js",
+ "lib/Demodulator.js",
+ "lib/DemodulatorPanel.js",
+ "lib/BookmarkBar.js",
+ "lib/BookmarkDialog.js",
+ "lib/AudioEngine.js",
+ "lib/ProgressBar.js",
+ "lib/Measurement.js",
+ "lib/FrequencyDisplay.js",
+ "lib/Js8Threads.js",
+ "lib/Modes.js",
+ ],
+ "map.js": [
+ "lib/jquery-3.2.1.min.js",
+ "lib/chroma.min.js",
+ "lib/Header.js",
+ "map.js",
+ ],
+ "settings.js": [
+ "lib/jquery-3.2.1.min.js",
+ "lib/Header.js",
+ "lib/settings/Input.js",
+ "lib/settings/SdrDevice.js",
+ "settings.js",
+ ]
+ }
+
+ def indexAction(self):
+ profileName = self.request.matches.group(1)
+ if profileName not in CompiledAssetsController.profiles:
+ self.send_response("profile not found", code=404)
+ return
+
+ files = CompiledAssetsController.profiles[profileName]
+ files = [pkg_resources.resource_filename("htdocs", f) for f in files]
+
+ modified = self.getModified(files)
+
+ if not self.wasModified(files):
+ self.send_response("", code=304)
+ return
+
+ contents = [self.getContents(f) for f in files]
+
+ (content_type, encoding) = mimetypes.MimeTypes().guess_type(profileName)
+ self.send_response("\n".join(contents), content_type=content_type, last_modified=modified, max_age=3600)
+
+ def getContents(self, file):
+ with open(file) as f:
+ return f.read()
+
+ def getModified(self, files):
+ modified = [os.path.getmtime(f) for f in files]
+ return datetime.fromtimestamp(max(*modified), timezone.utc)
diff --git a/owrx/controllers/metrics.py b/owrx/controllers/metrics.py
new file mode 100644
index 0000000..e817e9b
--- /dev/null
+++ b/owrx/controllers/metrics.py
@@ -0,0 +1,9 @@
+from . import Controller
+from owrx.metrics import Metrics
+import json
+
+
+class MetricsController(Controller):
+ def indexAction(self):
+ data = json.dumps(Metrics.getSharedInstance().getMetrics())
+ self.send_response(data, content_type="application/json")
diff --git a/owrx/controllers/receiverid.py b/owrx/controllers/receiverid.py
new file mode 100644
index 0000000..667c6be
--- /dev/null
+++ b/owrx/controllers/receiverid.py
@@ -0,0 +1,22 @@
+from owrx.controllers import Controller
+from owrx.receiverid import ReceiverId
+from datetime import datetime
+
+
+class ReceiverIdController(Controller):
+ def __init__(self, handler, request, options):
+ super().__init__(handler, request, options)
+ self.authHeader = None
+
+ def send_response(self, content, code=200, content_type="text/html", last_modified: datetime = None, max_age=None, headers=None):
+ if self.authHeader is not None:
+ if headers is None:
+ headers = {}
+ headers['Authorization'] = self.authHeader
+ super().send_response(content, code=code, content_type=content_type, last_modified=last_modified, max_age=max_age, headers=headers)
+ pass
+
+ def handle_request(self):
+ if "Authorization" in self.request.headers:
+ self.authHeader = ReceiverId.getResponseHeader(self.request.headers['Authorization'])
+ super().handle_request()
diff --git a/owrx/controllers/session.py b/owrx/controllers/session.py
new file mode 100644
index 0000000..ac38a43
--- /dev/null
+++ b/owrx/controllers/session.py
@@ -0,0 +1,59 @@
+from .template import WebpageController
+from urllib.parse import parse_qs
+from uuid import uuid4
+from http.cookies import SimpleCookie
+from owrx.users import UserList
+
+
+class SessionStorage(object):
+ sharedInstance = None
+
+ @staticmethod
+ def getSharedInstance():
+ if SessionStorage.sharedInstance is None:
+ SessionStorage.sharedInstance = SessionStorage()
+ return SessionStorage.sharedInstance
+
+ def __init__(self):
+ self.sessions = {}
+
+ def generateKey(self):
+ return str(uuid4())
+
+ def startSession(self, data):
+ key = self.generateKey()
+ self.updateSession(key, data)
+ return key
+
+ def getSession(self, key):
+ if key not in self.sessions:
+ return None
+ return self.sessions[key]
+
+ def updateSession(self, key, data):
+ self.sessions[key] = data
+
+
+class SessionController(WebpageController):
+ def loginAction(self):
+ self.serve_template("login.html", **self.template_variables())
+
+ def processLoginAction(self):
+ data = parse_qs(self.get_body().decode("utf-8"))
+ data = {k: v[0] for k, v in data.items()}
+ userlist = UserList.getSharedInstance()
+ if "user" in data and "password" in data:
+ if data["user"] in userlist:
+ user = userlist[data["user"]]
+ if user.password.is_valid(data["password"]):
+ # TODO evaluate password force_change and redirect to password change
+ key = SessionStorage.getSharedInstance().startSession({"user": user.name})
+ cookie = SimpleCookie()
+ cookie["owrx-session"] = key
+ target = self.request.query["ref"][0] if "ref" in self.request.query else "/settings"
+ self.send_redirect(target, cookies=cookie)
+ return
+ self.send_redirect("/login")
+
+ def logoutAction(self):
+ self.send_redirect("logout happening here")
diff --git a/owrx/controllers/settings.py b/owrx/controllers/settings.py
new file mode 100644
index 0000000..368a167
--- /dev/null
+++ b/owrx/controllers/settings.py
@@ -0,0 +1,279 @@
+from .admin import AdminController
+from owrx.config import Config
+from urllib.parse import parse_qs
+from owrx.form import (
+ TextInput,
+ NumberInput,
+ FloatInput,
+ LocationInput,
+ TextAreaInput,
+ CheckboxInput,
+ DropdownInput,
+ Option,
+ ServicesCheckboxInput,
+ Js8ProfileCheckboxInput,
+)
+from urllib.parse import quote
+import json
+import logging
+
+logger = logging.getLogger(__name__)
+
+
+class Section(object):
+ def __init__(self, title, *inputs):
+ self.title = title
+ self.inputs = inputs
+
+ def render_inputs(self):
+ config = Config.get()
+ return "".join([i.render(config) for i in self.inputs])
+
+ def render(self):
+ return """
+
+
+ {inputs}
+
+ """.format(
+ title=self.title, inputs=self.render_inputs()
+ )
+
+ def parse(self, data):
+ return {k: v for i in self.inputs for k, v in i.parse(data).items()}
+
+
+class SettingsController(AdminController):
+ def indexAction(self):
+ self.serve_template("settings.html", **self.template_variables())
+
+
+class SdrSettingsController(AdminController):
+ def template_variables(self):
+ variables = super().template_variables()
+ variables["devices"] = self.render_devices()
+ return variables
+
+ def render_devices(self):
+ return "".join(self.render_device(key, value) for key, value in Config.get()["sdrs"].items())
+
+ def render_device(self, device_id, config):
+ return """
+
+ """.format(device_name=config["name"], form=self.render_form(device_id, config))
+
+ def render_form(self, device_id, config):
+ return """
+
+ """.format(device_id=device_id, formdata=quote(json.dumps(config)))
+
+ def indexAction(self):
+ self.serve_template("sdrsettings.html", **self.template_variables())
+
+
+class GeneralSettingsController(AdminController):
+ sections = [
+ Section(
+ "General settings",
+ TextInput("receiver_name", "Receiver name"),
+ TextInput("receiver_location", "Receiver location"),
+ NumberInput(
+ "receiver_asl",
+ "Receiver elevation",
+ infotext="Elevation in meters above mean see level",
+ ),
+ TextInput("receiver_admin", "Receiver admin"),
+ LocationInput("receiver_gps", "Receiver coordinates"),
+ TextInput("photo_title", "Photo title"),
+ TextAreaInput("photo_desc", "Photo description"),
+ ),
+ Section(
+ "Waterfall settings",
+ NumberInput(
+ "fft_fps",
+ "FFT frames per second",
+ infotext="This setting specifies how many lines are being added to the waterfall per second. "
+ + "Higher values will give you a faster waterfall, but will also use more CPU.",
+ ),
+ NumberInput("fft_size", "FFT size"),
+ FloatInput(
+ "fft_voverlap_factor",
+ "FFT vertical overlap factor",
+ infotext="If fft_voverlap_factor is above 0, multiple FFTs will be used for creating a line on the "
+ + "diagram.",
+ ),
+ NumberInput("waterfall_min_level", "Lowest waterfall level"),
+ NumberInput("waterfall_max_level", "Highest waterfall level"),
+ ),
+ Section(
+ "Compression",
+ DropdownInput(
+ "audio_compression",
+ "Audio compression",
+ options=[Option("adpcm", "ADPCM"), Option("none", "None"),],
+ ),
+ DropdownInput(
+ "fft_compression",
+ "Waterfall compression",
+ options=[Option("adpcm", "ADPCM"), Option("none", "None"),],
+ ),
+ ),
+ Section(
+ "Digimodes",
+ CheckboxInput("digimodes_enable", "", checkboxText="Enable Digimodes"),
+ NumberInput("digimodes_fft_size", "Digimodes FFT size"),
+ ),
+ Section(
+ "Digital voice",
+ NumberInput(
+ "digital_voice_unvoiced_quality",
+ "Quality of unvoiced sounds in synthesized voice",
+ infotext="Determines the quality, and thus the cpu usage, for the ambe codec used by digital voice"
+ + "modes.
If you're running on a Raspi (up to 3B+) you should leave this set at 1",
+ ),
+ CheckboxInput(
+ "digital_voice_dmr_id_lookup",
+ "DMR id lookup",
+ checkboxText="Enable lookup of DMR ids in the radioid database to show callsigns and names",
+ ),
+ ),
+ Section(
+ "Experimental pipe settings",
+ CheckboxInput(
+ "csdr_dynamic_bufsize",
+ "",
+ checkboxText="Enable dynamic buffer sizes",
+ infotext="This allows you to change the buffering mode of csdr.",
+ ),
+ CheckboxInput(
+ "csdr_print_bufsizes",
+ "",
+ checkboxText="Print buffer sizez",
+ infotext="This prints the buffer sizes used for csdr processes.",
+ ),
+ CheckboxInput(
+ "csdr_through",
+ "",
+ checkboxText="Print throughput",
+ infotext="Enabling this will print out how much data is going into the DSP chains.",
+ ),
+ ),
+ Section(
+ "Map settings",
+ TextInput(
+ "google_maps_api_key",
+ "Google Maps API key",
+ infotext="Google Maps requires an API key, check out "
+ + ''
+ + "their documentation on how to obtain one.",
+ ),
+ NumberInput(
+ "map_position_retention_time",
+ "Map retention time",
+ infotext="Unit is seconds
Specifies how log markers / grids will remain visible on the map",
+ ),
+ ),
+ Section(
+ "Decoding settings",
+ NumberInput("decoding_queue_workers", "Number of decoding workers"),
+ NumberInput("decoding_queue_length", "Maximum length of decoding job queue"),
+ NumberInput(
+ "wsjt_decoding_depth",
+ "Default WSJT decoding depth",
+ infotext="A higher decoding depth will allow more results, but will also consume more cpu",
+ ),
+ NumberInput(
+ "js8_decoding_depth",
+ "Js8Call decoding depth",
+ infotext="A higher decoding depth will allow more results, but will also consume more cpu",
+ ),
+ Js8ProfileCheckboxInput(
+ "js8_enabled_profiles",
+ "Js8Call enabled modes"
+ ),
+ ),
+ Section(
+ "Background decoding",
+ CheckboxInput(
+ "services_enabled",
+ "Service",
+ checkboxText="Enable background decoding services",
+ ),
+ ServicesCheckboxInput("services_decoders", "Enabled services"),
+ ),
+ Section(
+ "APRS settings",
+ TextInput(
+ "aprs_callsign",
+ "APRS callsign",
+ infotext="This callsign will be used to send data to the APRS-IS network",
+ ),
+ CheckboxInput(
+ "aprs_igate_enabled",
+ "APRS I-Gate",
+ checkboxText="Enable APRS receive-only I-Gate",
+ ),
+ TextInput("aprs_igate_server", "APRS-IS server"),
+ TextInput("aprs_igate_password", "APRS-IS network password"),
+ CheckboxInput(
+ "aprs_igate_beacon",
+ "APRS beacon",
+ checkboxText="Send the receiver position to the APRS-IS network",
+ infotext="Please check that your receiver location is setup correctly",
+ ),
+ ),
+ Section(
+ "pskreporter settings",
+ CheckboxInput(
+ "pskreporter_enabled",
+ "Reporting",
+ checkboxText="Enable sending spots to pskreporter.info",
+ ),
+ TextInput(
+ "pskreporter_callsign",
+ "pskreporter callsign",
+ infotext="This callsign will be used to send spots to pskreporter.info",
+ ),
+ ),
+ ]
+
+ def render_sections(self):
+ sections = "".join(section.render() for section in GeneralSettingsController.sections)
+ return """
+
+ """.format(
+ sections=sections
+ )
+
+ def indexAction(self):
+ self.serve_template("generalsettings.html", **self.template_variables())
+
+ def template_variables(self):
+ variables = super().template_variables()
+ variables["sections"] = self.render_sections()
+ return variables
+
+ def processFormData(self):
+ data = parse_qs(self.get_body().decode("utf-8"))
+ data = {
+ k: v for i in GeneralSettingsController.sections for k, v in i.parse(data).items()
+ }
+ config = Config.get()
+ for k, v in data.items():
+ config[k] = v
+ Config.store()
+ self.send_redirect("/admin")
diff --git a/owrx/controllers/status.py b/owrx/controllers/status.py
new file mode 100644
index 0000000..9e6a820
--- /dev/null
+++ b/owrx/controllers/status.py
@@ -0,0 +1,43 @@
+from .receiverid import ReceiverIdController
+from owrx.version import openwebrx_version
+from owrx.sdr import SdrService
+from owrx.config import Config
+import json
+
+import logging
+
+logger = logging.getLogger(__name__)
+
+
+class StatusController(ReceiverIdController):
+ def getProfileStats(self, profile):
+ return {
+ "name": profile["name"],
+ "center_freq": profile["center_freq"],
+ "sample_rate": profile["samp_rate"],
+ }
+
+ def getReceiverStats(self, receiver):
+ stats = {
+ "name": receiver.getName(),
+ # TODO would be better to have types from the config here
+ "type": type(receiver).__name__,
+ "profiles": [self.getProfileStats(p) for p in receiver.getProfiles().values()]
+ }
+ return stats
+
+ def indexAction(self):
+ pm = Config.get()
+ status = {
+ "receiver": {
+ "name": pm["receiver_name"],
+ "admin": pm["receiver_admin"],
+ "gps": pm["receiver_gps"],
+ "asl": pm["receiver_asl"],
+ "location": pm["receiver_location"],
+ },
+ "max_clients": pm["max_clients"],
+ "version": openwebrx_version,
+ "sdrs": [self.getReceiverStats(r) for r in SdrService.getSources().values()]
+ }
+ self.send_response(json.dumps(status), content_type="application/json")
diff --git a/owrx/controllers/template.py b/owrx/controllers/template.py
new file mode 100644
index 0000000..3d57861
--- /dev/null
+++ b/owrx/controllers/template.py
@@ -0,0 +1,44 @@
+from . import Controller
+import pkg_resources
+from string import Template
+from owrx.config import Config
+
+
+class TemplateController(Controller):
+ def render_template(self, file, **vars):
+ file_content = pkg_resources.resource_string("htdocs", file).decode("utf-8")
+ template = Template(file_content)
+
+ return template.safe_substitute(**vars)
+
+ def serve_template(self, file, **vars):
+ self.send_response(self.render_template(file, **vars), content_type="text/html")
+
+ def default_variables(self):
+ return {}
+
+
+class WebpageController(TemplateController):
+ def template_variables(self):
+ settingslink = ""
+ pm = Config.get()
+ if "webadmin_enabled" in pm and pm["webadmin_enabled"]:
+ settingslink = """
Settings"""
+ header = self.render_template("include/header.include.html", settingslink=settingslink)
+ return {"header": header}
+
+
+class IndexController(WebpageController):
+ def indexAction(self):
+ self.serve_template("index.html", **self.template_variables())
+
+
+class MapController(WebpageController):
+ def indexAction(self):
+ # TODO check if we have a google maps api key first?
+ self.serve_template("map.html", **self.template_variables())
+
+
+class FeatureController(WebpageController):
+ def indexAction(self):
+ self.serve_template("features.html", **self.template_variables())
diff --git a/owrx/controllers/websocket.py b/owrx/controllers/websocket.py
new file mode 100644
index 0000000..f242f2c
--- /dev/null
+++ b/owrx/controllers/websocket.py
@@ -0,0 +1,10 @@
+from . import Controller
+from owrx.websocket import WebSocketConnection
+from owrx.connection import WebSocketMessageHandler
+
+
+class WebSocketController(Controller):
+ def indexAction(self):
+ conn = WebSocketConnection(self.handler, WebSocketMessageHandler())
+ # enter read loop
+ conn.handle()
diff --git a/owrx/details.py b/owrx/details.py
new file mode 100644
index 0000000..5bc7253
--- /dev/null
+++ b/owrx/details.py
@@ -0,0 +1,21 @@
+from owrx.config import Config
+from owrx.locator import Locator
+from owrx.property import PropertyFilter
+
+
+class ReceiverDetails(PropertyFilter):
+ def __init__(self):
+ super().__init__(
+ Config.get(),
+ "receiver_name",
+ "receiver_location",
+ "receiver_asl",
+ "receiver_gps",
+ "photo_title",
+ "photo_desc",
+ )
+
+ def __dict__(self):
+ receiver_info = super().__dict__()
+ receiver_info["locator"] = Locator.fromCoordinates(receiver_info["receiver_gps"])
+ return receiver_info
diff --git a/owrx/dsp.py b/owrx/dsp.py
index f12d171..eabe244 100644
--- a/owrx/dsp.py
+++ b/owrx/dsp.py
@@ -1,9 +1,11 @@
-from owrx.config import PropertyManager
from owrx.meta import MetaParser
from owrx.wsjt import WsjtParser
+from owrx.js8 import Js8Parser
from owrx.aprs import AprsParser
from owrx.pocsag import PocsagParser
from owrx.source import SdrSource
+from owrx.property import PropertyStack, PropertyLayer
+from owrx.modes import Modes
from csdr import csdr
import threading
@@ -21,26 +23,39 @@ class DspManager(csdr.output):
"wsjt_demod": WsjtParser(self.handler),
"packet_demod": AprsParser(self.handler),
"pocsag_demod": PocsagParser(self.handler),
+ "js8_demod": Js8Parser(self.handler),
}
- self.localProps = (
- self.sdrSource.getProps()
- .collect(
- "audio_compression",
- "fft_compression",
- "digimodes_fft_size",
- "csdr_dynamic_bufsize",
- "csdr_print_bufsizes",
- "csdr_through",
- "digimodes_enable",
- "samp_rate",
- "digital_voice_unvoiced_quality",
- "dmr_filter",
- "temporary_directory",
- "center_freq",
- )
- .defaults(PropertyManager.getSharedInstance())
- )
+ self.props = PropertyStack()
+ # local demodulator properties not forwarded to the sdr
+ self.props.addLayer(0, PropertyLayer().filter(
+ "output_rate",
+ "hd_output_rate",
+ "squelch_level",
+ "secondary_mod",
+ "low_cut",
+ "high_cut",
+ "offset_freq",
+ "mod",
+ "secondary_offset_freq",
+ "dmr_filter",
+ ))
+ # properties that we inherit from the sdr
+ self.props.addLayer(1, self.sdrSource.getProps().filter(
+ "audio_compression",
+ "fft_compression",
+ "digimodes_fft_size",
+ "csdr_dynamic_bufsize",
+ "csdr_print_bufsizes",
+ "csdr_through",
+ "digimodes_enable",
+ "samp_rate",
+ "digital_voice_unvoiced_quality",
+ "temporary_directory",
+ "center_freq",
+ "start_mod",
+ "start_freq",
+ ))
self.dsp = csdr.dsp(self)
self.dsp.nc_port = self.sdrSource.getPort()
@@ -56,34 +71,48 @@ class DspManager(csdr.output):
self.dsp.set_bpf(*bpf)
def set_dial_freq(key, value):
- freq = self.localProps["center_freq"] + self.localProps["offset_freq"]
+ freq = self.props["center_freq"] + self.props["offset_freq"]
for parser in self.parsers.values():
parser.setDialFrequency(freq)
+ if "start_mod" in self.props:
+ self.dsp.set_demodulator(self.props["start_mod"])
+ mode = Modes.findByModulation(self.props["start_mod"])
+
+ if mode and mode.bandpass:
+ self.dsp.set_bpf(mode.bandpass.low_cut, mode.bandpass.high_cut)
+ else:
+ self.dsp.set_bpf(-4000, 4000)
+
+ if "start_freq" in self.props and "center_freq" in self.props:
+ self.dsp.set_offset_freq(self.props["start_freq"] - self.props["center_freq"])
+ else:
+ self.dsp.set_offset_freq(0)
+
self.subscriptions = [
- self.localProps.getProperty("audio_compression").wire(self.dsp.set_audio_compression),
- self.localProps.getProperty("fft_compression").wire(self.dsp.set_fft_compression),
- self.localProps.getProperty("digimodes_fft_size").wire(self.dsp.set_secondary_fft_size),
- self.localProps.getProperty("samp_rate").wire(self.dsp.set_samp_rate),
- self.localProps.getProperty("output_rate").wire(self.dsp.set_output_rate),
- self.localProps.getProperty("offset_freq").wire(self.dsp.set_offset_freq),
- self.localProps.getProperty("squelch_level").wire(self.dsp.set_squelch_level),
- self.localProps.getProperty("low_cut").wire(set_low_cut),
- self.localProps.getProperty("high_cut").wire(set_high_cut),
- self.localProps.getProperty("mod").wire(self.dsp.set_demodulator),
- self.localProps.getProperty("digital_voice_unvoiced_quality").wire(self.dsp.set_unvoiced_quality),
- self.localProps.getProperty("dmr_filter").wire(self.dsp.set_dmr_filter),
- self.localProps.getProperty("temporary_directory").wire(self.dsp.set_temporary_directory),
- self.localProps.collect("center_freq", "offset_freq").wire(set_dial_freq),
+ self.props.wireProperty("audio_compression", self.dsp.set_audio_compression),
+ self.props.wireProperty("fft_compression", self.dsp.set_fft_compression),
+ self.props.wireProperty("digimodes_fft_size", self.dsp.set_secondary_fft_size),
+ self.props.wireProperty("samp_rate", self.dsp.set_samp_rate),
+ self.props.wireProperty("output_rate", self.dsp.set_output_rate),
+ self.props.wireProperty("hd_output_rate", self.dsp.set_hd_output_rate),
+ self.props.wireProperty("offset_freq", self.dsp.set_offset_freq),
+ self.props.wireProperty("center_freq", self.dsp.set_center_freq),
+ self.props.wireProperty("squelch_level", self.dsp.set_squelch_level),
+ self.props.wireProperty("low_cut", set_low_cut),
+ self.props.wireProperty("high_cut", set_high_cut),
+ self.props.wireProperty("mod", self.dsp.set_demodulator),
+ self.props.wireProperty("digital_voice_unvoiced_quality", self.dsp.set_unvoiced_quality),
+ self.props.wireProperty("dmr_filter", self.dsp.set_dmr_filter),
+ self.props.wireProperty("temporary_directory", self.dsp.set_temporary_directory),
+ self.props.filter("center_freq", "offset_freq").wire(set_dial_freq),
]
- self.dsp.set_offset_freq(0)
- self.dsp.set_bpf(-4000, 4000)
- self.dsp.csdr_dynamic_bufsize = self.localProps["csdr_dynamic_bufsize"]
- self.dsp.csdr_print_bufsizes = self.localProps["csdr_print_bufsizes"]
- self.dsp.csdr_through = self.localProps["csdr_through"]
+ self.dsp.csdr_dynamic_bufsize = self.props["csdr_dynamic_bufsize"]
+ self.dsp.csdr_print_bufsizes = self.props["csdr_print_bufsizes"]
+ self.dsp.csdr_through = self.props["csdr_through"]
- if self.localProps["digimodes_enable"]:
+ if self.props["digimodes_enable"]:
def set_secondary_mod(mod):
if mod == False:
@@ -92,17 +121,19 @@ class DspManager(csdr.output):
if mod is not None:
self.handler.write_secondary_dsp_config(
{
- "secondary_fft_size": self.localProps["digimodes_fft_size"],
+ "secondary_fft_size": self.props["digimodes_fft_size"],
"if_samp_rate": self.dsp.if_samp_rate(),
"secondary_bw": self.dsp.secondary_bw(),
}
)
self.subscriptions += [
- self.localProps.getProperty("secondary_mod").wire(set_secondary_mod),
- self.localProps.getProperty("secondary_offset_freq").wire(self.dsp.set_secondary_offset_freq),
+ self.props.wireProperty("secondary_mod", set_secondary_mod),
+ self.props.wireProperty("secondary_offset_freq", self.dsp.set_secondary_offset_freq),
]
+ self.startOnAvailable = False
+
self.sdrSource.addClient(self)
super().__init__()
@@ -110,11 +141,14 @@ class DspManager(csdr.output):
def start(self):
if self.sdrSource.isAvailable():
self.dsp.start()
+ else:
+ self.startOnAvailable = True
def receive_output(self, t, read_fn):
logger.debug("adding new output of type %s", t)
writers = {
"audio": self.handler.write_dsp_data,
+ "hd_audio": self.handler.write_hd_audio,
"smeter": self.handler.write_s_meter_level,
"secondary_fft": self.handler.write_secondary_fft,
"secondary_demod": self.handler.write_secondary_demod,
@@ -124,17 +158,22 @@ class DspManager(csdr.output):
write = writers[t]
- threading.Thread(target=self.pump(read_fn, write)).start()
+ threading.Thread(target=self.pump(read_fn, write), name="dsp_pump_{}".format(t)).start()
def stop(self):
self.dsp.stop()
+ self.startOnAvailable = False
self.sdrSource.removeClient(self)
for sub in self.subscriptions:
sub.cancel()
self.subscriptions = []
+ def setProperties(self, props):
+ for k, v in props.items():
+ self.setProperty(k, v)
+
def setProperty(self, prop, value):
- self.localProps.getProperty(prop).setValue(value)
+ self.props[prop] = value
def getClientClass(self):
return SdrSource.CLIENT_USER
@@ -142,7 +181,9 @@ class DspManager(csdr.output):
def onStateChange(self, state):
if state == SdrSource.STATE_RUNNING:
logger.debug("received STATE_RUNNING, attempting DspSource restart")
- self.dsp.start()
+ if self.startOnAvailable:
+ self.dsp.start()
+ self.startOnAvailable = False
elif state == SdrSource.STATE_STOPPING:
logger.debug("received STATE_STOPPING, shutting down DspSource")
self.dsp.stop()
diff --git a/owrx/feature.py b/owrx/feature.py
index 2798a87..73f05a6 100644
--- a/owrx/feature.py
+++ b/owrx/feature.py
@@ -4,7 +4,7 @@ from operator import and_, or_
import re
from distutils.version import LooseVersion
import inspect
-from owrx.config import PropertyManager
+from owrx.config import Config
import shlex
import logging
@@ -23,19 +23,28 @@ class FeatureDetector(object):
# different types of sdrs and their requirements
"rtl_sdr": ["rtl_connector"],
"rtl_sdr_soapy": ["soapy_connector", "soapy_rtl_sdr"],
+ "rtl_tcp": ["rtl_tcp_connector"],
"sdrplay": ["soapy_connector", "soapy_sdrplay"],
- "hackrf": ["hackrf_transfer"],
+ "hackrf": ["soapy_connector", "soapy_hackrf"],
+ "perseussdr": ["perseustest"],
"airspy": ["soapy_connector", "soapy_airspy"],
"airspyhf": ["soapy_connector", "soapy_airspyhf"],
"lime_sdr": ["soapy_connector", "soapy_lime_sdr"],
- "fifi_sdr": ["alsa"],
+ "fifi_sdr": ["alsa", "rockprog"],
"pluto_sdr": ["soapy_connector", "soapy_pluto_sdr"],
+ "soapy_remote": ["soapy_connector", "soapy_remote"],
+ "uhd": ["soapy_connector", "soapy_uhd"],
+ "red_pitaya": ["soapy_connector", "soapy_red_pitaya"],
+ "radioberry": ["soapy_connector", "soapy_radioberry"],
+ "fcdpp": ["soapy_connector", "soapy_fcdpp"],
# optional features and their requirements
"digital_voice_digiham": ["digiham", "sox"],
"digital_voice_dsd": ["dsd", "sox", "digiham"],
+ "digital_voice_freedv": ["freedv_rx", "sox"],
"wsjt-x": ["wsjtx", "sox"],
"packet": ["direwolf", "sox"],
"pocsag": ["digiham", "sox"],
+ "js8call": ["js8", "sox"],
}
def feature_availability(self):
@@ -93,7 +102,7 @@ class FeatureDetector(object):
return inspect.getdoc(self._get_requirement_method(requirement))
def command_is_runnable(self, command):
- tmp_dir = PropertyManager.getSharedInstance()["temporary_directory"]
+ tmp_dir = Config.get()["temporary_directory"]
cmd = shlex.split(command)
try:
process = subprocess.Popen(cmd, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, cwd=tmp_dir)
@@ -104,7 +113,7 @@ class FeatureDetector(object):
def has_csdr(self):
"""
OpenWebRX uses the demodulator and pipeline tools provided by the csdr project. Please check out [the project
- page on github](https://github.com/simonyiszk/csdr) for further details and installation instructions.
+ page on github](https://github.com/jketterl/csdr) for further details and installation instructions.
"""
return self.command_is_runnable("csdr")
@@ -122,32 +131,25 @@ class FeatureDetector(object):
"""
return self.command_is_runnable("nc --help")
- def has_rtl_sdr(self):
+ def has_perseustest(self):
"""
- The rtl-sdr command is required to read I/Q data from an RTL SDR USB-Stick. It is available in most
- distribution package managers.
- """
- return self.command_is_runnable("rtl_sdr --help")
-
- def has_hackrf_transfer(self):
- """
- To use a HackRF, compile the HackRF host tools from its "stdout" branch:
+ To use a Microtelecom Perseus HF receiver, compile and
+ install the libperseus-sdr:
```
- git clone https://github.com/mossmann/hackrf/
- cd hackrf
- git fetch
- git checkout origin/stdout
- cd host
- mkdir build
- cd build
- cmake .. -DINSTALL_UDEV_RULES=ON
+ sudo apt install libusb-1.0-0-dev
+ cd /tmp
+ wget https://github.com/Microtelecom/libperseus-sdr/releases/download/v0.8.2/libperseus_sdr-0.8.2.tar.gz
+ tar -zxvf libperseus_sdr-0.8.2.tar.gz
+ cd libperseus_sdr-0.8.2/
+ ./configure
make
sudo make install
+ sudo ldconfig
+ perseustest
```
"""
- # TODO i don't have a hackrf, so somebody doublecheck this.
- # TODO also check if it has the stdout feature
- return self.command_is_runnable("hackrf_transfer --help")
+ return self.command_is_runnable("perseustest -h")
+
def has_digiham(self):
"""
@@ -193,7 +195,7 @@ class FeatureDetector(object):
)
def _check_connector(self, command):
- required_version = LooseVersion("0.1")
+ required_version = LooseVersion("0.3")
owrx_connector_version_regex = re.compile("^owrx-connector version (.*)$")
@@ -217,6 +219,15 @@ class FeatureDetector(object):
"""
return self._check_connector("rtl_connector")
+ def has_rtl_tcp_connector(self):
+ """
+ The owrx_connector package offers direct interfacing between your hardware and openwebrx. It allows quicker
+ frequency switching, uses less CPU and can even provide more stability in some cases.
+
+ You can get it [here](https://github.com/jketterl/owrx_connector).
+ """
+ return self._check_connector("rtl_tcp_connector")
+
def has_soapy_connector(self):
"""
The owrx_connector package offers direct interfacing between your hardware and openwebrx. It allows quicker
@@ -229,14 +240,15 @@ class FeatureDetector(object):
def _has_soapy_driver(self, driver):
try:
process = subprocess.Popen(["SoapySDRUtil", "--info"], stdout=subprocess.PIPE, stderr=subprocess.DEVNULL)
- driverRegex = re.compile("^Module found: .*lib(.*)Support.so")
+ factory_regex = re.compile("^Available factories\\.\\.\\. ?(.*)$")
- def matchLine(line):
- matches = driverRegex.match(line.decode())
- return matches is not None and matches.group(1) == driver
+ drivers = []
+ for line in process.stdout:
+ matches = factory_regex.match(line.decode())
+ if matches:
+ drivers = [s.strip() for s in matches.group(1).split(", ")]
- lines = [matchLine(line) for line in process.stdout]
- return reduce(or_, lines, False)
+ return driver in drivers
except FileNotFoundError:
return False
@@ -253,9 +265,9 @@ class FeatureDetector(object):
"""
The SoapySDR module for sdrplay devices is required for interfacing with SDRPlay devices (RSP1*, RSP2*, RSPDuo)
- You can get it [here](https://github.com/pothosware/SoapySDRPlay/wiki).
+ You can get it [here](https://github.com/SDRplay/SoapySDRPlay).
"""
- return self._has_soapy_driver("sdrPlay")
+ return self._has_soapy_driver("sdrplay")
def has_soapy_airspy(self):
"""
@@ -280,7 +292,7 @@ class FeatureDetector(object):
You can get it [here](https://github.com/myriadrf/LimeSuite).
"""
- return self._has_soapy_driver("LMS7")
+ return self._has_soapy_driver("lime")
def has_soapy_pluto_sdr(self):
"""
@@ -288,7 +300,55 @@ class FeatureDetector(object):
You can get it [here](https://github.com/photosware/SoapyPlutoSDR).
"""
- return self._has_soapy_driver("PlutoSDR")
+ return self._has_soapy_driver("plutosdr")
+
+ def has_soapy_remote(self):
+ """
+ The SoapyRemote allows the usage of remote SDR devices using the SoapySDRServer.
+
+ You can get the code and find additional information [here](https://github.com/pothosware/SoapyRemote/wiki).
+ """
+ return self._has_soapy_driver("remote")
+
+ def has_soapy_uhd(self):
+ """
+ The SoapyUHD module allows using UHD / USRP devices with SoapySDR.
+
+ You can get it [here](https://github.com/pothosware/SoapyUHD/wiki).
+ """
+ return self._has_soapy_driver("uhd")
+
+ def has_soapy_red_pitaya(self):
+ """
+ The SoapyRedPitaya allows Red Pitaya deviced to be used with SoapySDR.
+
+ You can get it [here](https://github.com/pothosware/SoapyRedPitaya/wiki).
+ """
+ return self._has_soapy_driver("redpitaya")
+
+ def has_soapy_radioberry(self):
+ """
+ The Radioberry is a SDR hat for the Raspberry Pi.
+
+ You can find more information, along with its SoapySDR module [here](https://github.com/pa3gsb/Radioberry-2.x).
+ """
+ return self._has_soapy_driver("radioberry")
+
+ def has_soapy_hackrf(self):
+ """
+ The SoapyHackRF allows HackRF to be used with SoapySDR.
+
+ You can get it [here](https://github.com/pothosware/SoapyHackRF/wiki).
+ """
+ return self._has_soapy_driver("hackrf")
+
+ def has_soapy_fcdpp(self):
+ """
+ The SoapyFCDPP module allows the use of the Funcube Dongle Pro+.
+
+ You can get it [here](https://github.com/pothosware/SoapyFCDPP).
+ """
+ return self._has_soapy_driver("fcdpp")
def has_dsd(self):
"""
@@ -328,9 +388,37 @@ class FeatureDetector(object):
"""
return reduce(and_, map(self.command_is_runnable, ["jt9", "wsprd"]), True)
+ def has_js8(self):
+ """
+ To decode JS8, you will need to install [JS8Call](http://js8call.com/)
+
+ Please note that the `js8` command line decoder is not made available on $PATH by some JS8Call package builds.
+ You will need to manually make it available by either linking it to `/usr/bin` or by adding its location to
+ $PATH.
+ """
+ return self.command_is_runnable("js8")
+
def has_alsa(self):
"""
Some SDR receivers are identifying themselves as a soundcard. In order to read their data, OpenWebRX relies
on the Alsa library. It is available as a package for most Linux distributions.
"""
return self.command_is_runnable("arecord --help")
+
+ def has_rockprog(self):
+ """
+ The "rockprog" executable is required to send commands to your FiFiSDR. It needs to be installed separately.
+
+ You can find instructions and downloads [here](https://o28.sischa.net/fifisdr/trac/wiki/De%3Arockprog).
+ """
+ return self.command_is_runnable("rockprog")
+
+ def has_freedv_rx(self):
+ """
+ The "freedv\_rx" executable is required to demodulate FreeDV digital transmissions. It comes together with the
+ codec2 library, but it's only a supplemental part and not installed by default or contained in its packages.
+ To install it, you will need to compile codec2 from source and manually install freedv\_rx.
+
+ You can find the codec2 source code [here](https://github.com/drowe67/codec2).
+ """
+ return self.command_is_runnable("freedv_rx")
diff --git a/owrx/fft.py b/owrx/fft.py
index 246f110..cbb98a6 100644
--- a/owrx/fft.py
+++ b/owrx/fft.py
@@ -1,7 +1,8 @@
-from owrx.config import PropertyManager
+from owrx.config import Config
from csdr import csdr
import threading
from owrx.source import SdrSource
+from owrx.property import PropertyStack
import logging
@@ -13,7 +14,10 @@ class SpectrumThread(csdr.output):
self.sdrSource = sdrSource
super().__init__()
- self.props = props = self.sdrSource.props.collect(
+ stack = PropertyStack()
+ stack.addLayer(0, self.sdrSource.props)
+ stack.addLayer(1, Config.get())
+ self.props = props = stack.filter(
"samp_rate",
"fft_size",
"fft_fps",
@@ -23,7 +27,7 @@ class SpectrumThread(csdr.output):
"csdr_print_bufsizes",
"csdr_through",
"temporary_directory",
- ).defaults(PropertyManager.getSharedInstance())
+ )
self.dsp = dsp = csdr.dsp(self)
dsp.nc_port = self.sdrSource.getPort()
@@ -42,12 +46,12 @@ class SpectrumThread(csdr.output):
)
self.subscriptions = [
- props.getProperty("samp_rate").wire(dsp.set_samp_rate),
- props.getProperty("fft_size").wire(dsp.set_fft_size),
- props.getProperty("fft_fps").wire(dsp.set_fft_fps),
- props.getProperty("fft_compression").wire(dsp.set_fft_compression),
- props.getProperty("temporary_directory").wire(dsp.set_temporary_directory),
- props.collect("samp_rate", "fft_size", "fft_fps", "fft_voverlap_factor").wire(set_fft_averages),
+ props.wireProperty("samp_rate", dsp.set_samp_rate),
+ props.wireProperty("fft_size", dsp.set_fft_size),
+ props.wireProperty("fft_fps", dsp.set_fft_fps),
+ props.wireProperty("fft_compression", dsp.set_fft_compression),
+ props.wireProperty("temporary_directory", dsp.set_temporary_directory),
+ props.filter("samp_rate", "fft_size", "fft_fps", "fft_voverlap_factor").wire(set_fft_averages),
]
set_fft_averages(None, None)
@@ -66,10 +70,6 @@ class SpectrumThread(csdr.output):
return t == "audio"
def receive_output(self, type, read_fn):
- if self.props["csdr_dynamic_bufsize"]:
- read_fn(8) # dummy read to skip bufsize & preamble
- logger.debug("Note: CSDR_DYNAMIC_BUFSIZE_ON = 1")
-
threading.Thread(target=self.pump(read_fn, self.sdrSource.writeSpectrumData)).start()
def stop(self):
diff --git a/owrx/form/__init__.py b/owrx/form/__init__.py
new file mode 100644
index 0000000..7f59bf5
--- /dev/null
+++ b/owrx/form/__init__.py
@@ -0,0 +1,238 @@
+from abc import ABC, abstractmethod
+from owrx.modes import Modes
+from owrx.config import Config
+
+
+class Input(ABC):
+ def __init__(self, id, label, infotext=None):
+ self.id = id
+ self.label = label
+ self.infotext = infotext
+
+ def bootstrap_decorate(self, input):
+ infotext = (
+ "{text}".format(text=self.infotext) if self.infotext else ""
+ )
+ return """
+
+ """.format(
+ id=self.id, label=self.label, input=input, infotext=infotext
+ )
+
+ def input_classes(self):
+ return " ".join(["form-control", "form-control-sm"])
+
+ @abstractmethod
+ def render_input(self, value):
+ pass
+
+ def render(self, config):
+ return self.bootstrap_decorate(self.render_input(config[self.id]))
+
+ def parse(self, data):
+ return {self.id: data[self.id][0]} if self.id in data else {}
+
+
+class TextInput(Input):
+ def render_input(self, value):
+ return """
+
+ """.format(
+ id=self.id, label=self.label, classes=self.input_classes(), value=value
+ )
+
+
+class NumberInput(Input):
+ def __init__(self, id, label, infotext=None):
+ super().__init__(id, label, infotext)
+ self.step = None
+
+ def render_input(self, value):
+ return """
+
+ """.format(
+ id=self.id,
+ label=self.label,
+ classes=self.input_classes(),
+ value=value,
+ step='step="{0}"'.format(self.step) if self.step else "",
+ )
+
+ def convert_value(self, v):
+ return int(v)
+
+ def parse(self, data):
+ return {k: self.convert_value(v) for k, v in super().parse(data).items()}
+
+
+class FloatInput(NumberInput):
+ def __init__(self, id, label, infotext=None):
+ super().__init__(id, label, infotext)
+ self.step = "any"
+
+ def convert_value(self, v):
+ return float(v)
+
+
+class LocationInput(Input):
+ def render_input(self, value):
+ return """
+
+ {inputs}
+
+
+ """.format(
+ id=self.id,
+ inputs="".join(self.render_sub_input(value, id) for id in ["lat", "lon"]),
+ key=Config.get()["google_maps_api_key"],
+ )
+
+ def render_sub_input(self, value, id):
+ return """
+
+
+
+ """.format(
+ id="{0}-{1}".format(self.id, id),
+ label=self.label,
+ classes=self.input_classes(),
+ value=value[id],
+ )
+
+ def parse(self, data):
+ return {
+ self.id: {k: float(data["{0}-{1}".format(self.id, k)][0]) for k in ["lat", "lon"]}
+ }
+
+
+class TextAreaInput(Input):
+ def render_input(self, value):
+ return """
+
+ """.format(
+ id=self.id, classes=self.input_classes(), value=value
+ )
+
+
+class CheckboxInput(Input):
+ def __init__(self, id, label, checkboxText, infotext=None):
+ super().__init__(id, label, infotext=infotext)
+ self.checkboxText = checkboxText
+
+ def render_input(self, value):
+ return """
+
+
+
+
+ """.format(
+ id=self.id,
+ classes=self.input_classes(),
+ checked="checked" if value else "",
+ checkboxText=self.checkboxText,
+ )
+
+ def input_classes(self):
+ return " ".join(["form-check", "form-control-sm"])
+
+ def parse(self, data):
+ return {self.id: self.id in data and data[self.id][0] == "on"}
+
+
+class Option(object):
+ # used for both MultiCheckboxInput and DropdownInput
+ def __init__(self, value, text):
+ self.value = value
+ self.text = text
+
+
+class MultiCheckboxInput(Input):
+ def __init__(self, id, label, options, infotext=None):
+ super().__init__(id, label, infotext=infotext)
+ self.options = options
+
+ def render_input(self, value):
+ return "".join(self.render_checkbox(o, value) for o in self.options)
+
+ def checkbox_id(self, option):
+ return "{0}-{1}".format(self.id, option.value)
+
+ def render_checkbox(self, option, value):
+ return """
+
+
+
+
+ """.format(
+ id=self.checkbox_id(option),
+ classes=self.input_classes(),
+ checked="checked" if option.value in value else "",
+ checkboxText=option.text,
+ )
+
+ def parse(self, data):
+ def in_response(option):
+ boxid = self.checkbox_id(option)
+ return boxid in data and data[boxid][0] == "on"
+
+ return {self.id: [o.value for o in self.options if in_response(o)]}
+
+ def input_classes(self):
+ return " ".join(["form-check", "form-control-sm"])
+
+
+class ServicesCheckboxInput(MultiCheckboxInput):
+ def __init__(self, id, label, infotext=None):
+ services = [
+ Option(s.modulation, s.name) for s in Modes.getAvailableServices()
+ ]
+ super().__init__(id, label, services, infotext)
+
+
+class Js8ProfileCheckboxInput(MultiCheckboxInput):
+ def __init__(self, id, label, infotext=None):
+ profiles = [
+ Option("normal", "Normal (15s, 50Hz, ~16WPM)"),
+ Option("slow", "Slow (30s, 25Hz, ~8WPM"),
+ Option("fast", "Fast (10s, 80Hz, ~24WPM"),
+ Option("turbo", "Turbo (6s, 160Hz, ~40WPM"),
+ ]
+ super().__init__(id, label, profiles, infotext)
+
+
+class DropdownInput(Input):
+ def __init__(self, id, label, options, infotext=None):
+ super().__init__(id, label, infotext=infotext)
+ self.options = options
+
+ def render_input(self, value):
+ return """
+
+ """.format(
+ classes=self.input_classes(), id=self.id, options=self.render_options(value)
+ )
+
+ def render_options(self, value):
+ options = [
+ """
+
+ """.format(
+ text=o.text,
+ value=o.value,
+ selected="selected" if o.value == value else "",
+ )
+ for o in self.options
+ ]
+ return "".join(options)
diff --git a/owrx/http.py b/owrx/http.py
index 196c6c4..538bec9 100644
--- a/owrx/http.py
+++ b/owrx/http.py
@@ -1,17 +1,24 @@
-from owrx.controllers import (
- StatusController,
+from owrx.controllers.status import StatusController
+from owrx.controllers.template import (
IndexController,
- OwrxAssetsController,
- WebSocketController,
MapController,
- FeatureController,
- ApiController,
- MetricsController,
- AprsSymbolsController,
+ FeatureController
)
+from owrx.controllers.assets import (
+ OwrxAssetsController,
+ AprsSymbolsController,
+ CompiledAssetsController
+)
+from owrx.controllers.websocket import WebSocketController
+from owrx.controllers.api import ApiController
+from owrx.controllers.metrics import MetricsController
+from owrx.controllers.settings import SettingsController, GeneralSettingsController, SdrSettingsController
+from owrx.controllers.session import SessionController
from http.server import BaseHTTPRequestHandler
from urllib.parse import urlparse, parse_qs
import re
+from abc import ABC, abstractmethod
+from http.cookies import SimpleCookie
import logging
@@ -28,49 +35,96 @@ class RequestHandler(BaseHTTPRequestHandler):
logger.debug("%s - - [%s] %s", self.address_string(), self.log_date_time_string(), format % args)
def do_GET(self):
- self.router.route(self)
+ self.router.route(self, self.get_request("GET"))
+
+ def do_POST(self):
+ self.router.route(self, self.get_request("POST"))
+
+ def get_request(self, method):
+ url = urlparse(self.path)
+ return Request(url, method, self.headers)
class Request(object):
- def __init__(self, query=None, matches=None):
- self.query = query
+ def __init__(self, url, method, headers):
+ self.path = url.path
+ self.query = parse_qs(url.query)
+ self.matches = None
+ self.method = method
+ self.headers = headers
+ self.cookies = SimpleCookie()
+ if "Cookie" in headers:
+ self.cookies.load(headers["Cookie"])
+
+ def setMatches(self, matches):
self.matches = matches
+class Route(ABC):
+ def __init__(self, controller, method="GET", options=None):
+ self.controller = controller
+ self.controllerOptions = options if options is not None else {}
+ self.method = method
+
+ @abstractmethod
+ def matches(self, request):
+ pass
+
+
+class StaticRoute(Route):
+ def __init__(self, route, controller, method="GET", options=None):
+ self.route = route
+ super().__init__(controller, method, options)
+
+ def matches(self, request):
+ return request.path == self.route and self.method == request.method
+
+
+class RegexRoute(Route):
+ def __init__(self, regex, controller, method="GET", options=None):
+ self.regex = re.compile(regex)
+ super().__init__(controller, method, options)
+
+ def matches(self, request):
+ matches = self.regex.match(request.path)
+ # this is probably not the cleanest way to do it...
+ request.setMatches(matches)
+ return matches is not None and self.method == request.method
+
+
class Router(object):
- mappings = [
- {"route": "/", "controller": IndexController},
- {"route": "/status", "controller": StatusController},
- {"regex": "/static/(.+)", "controller": OwrxAssetsController},
- {"regex": "/aprs-symbols/(.+)", "controller": AprsSymbolsController},
- {"route": "/ws/", "controller": WebSocketController},
- {"regex": "(/favicon.ico)", "controller": OwrxAssetsController},
- # backwards compatibility for the sdr.hu portal
- {"regex": "/(gfx/openwebrx-avatar.png)", "controller": OwrxAssetsController},
- {"route": "/map", "controller": MapController},
- {"route": "/features", "controller": FeatureController},
- {"route": "/api/features", "controller": ApiController},
- {"route": "/metrics", "controller": MetricsController},
- ]
+ def __init__(self):
+ self.routes = [
+ StaticRoute("/", IndexController),
+ StaticRoute("/status.json", StatusController),
+ RegexRoute("/static/(.+)", OwrxAssetsController),
+ RegexRoute("/compiled/(.+)", CompiledAssetsController),
+ RegexRoute("/aprs-symbols/(.+)", AprsSymbolsController),
+ StaticRoute("/ws/", WebSocketController),
+ RegexRoute("(/favicon.ico)", OwrxAssetsController),
+ StaticRoute("/map", MapController),
+ StaticRoute("/features", FeatureController),
+ StaticRoute("/api/features", ApiController),
+ StaticRoute("/api/receiverdetails", ApiController, options={"action": "receiverDetails"}),
+ StaticRoute("/metrics", MetricsController),
+ StaticRoute("/settings", SettingsController),
+ StaticRoute("/generalsettings", GeneralSettingsController),
+ StaticRoute("/generalsettings", GeneralSettingsController, method="POST", options={"action": "processFormData"}),
+ StaticRoute("/sdrsettings", SdrSettingsController),
+ StaticRoute("/login", SessionController, options={"action": "loginAction"}),
+ StaticRoute("/login", SessionController, method="POST", options={"action": "processLoginAction"}),
+ StaticRoute("/logout", SessionController, options={"action": "logoutAction"}),
+ ]
- def find_controller(self, path):
- for m in Router.mappings:
- if "route" in m:
- if m["route"] == path:
- return (m["controller"], None)
- if "regex" in m:
- regex = re.compile(m["regex"])
- matches = regex.match(path)
- if matches:
- return (m["controller"], matches)
+ def find_route(self, request):
+ for r in self.routes:
+ if r.matches(request):
+ return r
- def route(self, handler):
- url = urlparse(handler.path)
- res = self.find_controller(url.path)
- if res is not None:
- (controller, matches) = res
- query = parse_qs(url.query)
- request = Request(query, matches)
- controller(handler, request).handle_request()
+ def route(self, handler, request):
+ route = self.find_route(request)
+ if route is not None:
+ controller = route.controller
+ controller(handler, request, route.controllerOptions).handle_request()
else:
handler.send_error(404, "Not Found", "The page you requested could not be found.")
diff --git a/owrx/js8.py b/owrx/js8.py
new file mode 100644
index 0000000..7d3c474
--- /dev/null
+++ b/owrx/js8.py
@@ -0,0 +1,132 @@
+from .audio import AudioChopperProfile
+from .parser import Parser
+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
+
+import logging
+
+logger = logging.getLogger(__name__)
+
+
+class Js8Profiles(object):
+ @staticmethod
+ def getEnabledProfiles():
+ config = Config.get()
+ profiles = config["js8_enabled_profiles"] if "js8_enabled_profiles" in config else []
+ return [Js8Profiles.loadProfile(p) for p in profiles]
+
+ @staticmethod
+ def loadProfile(profileName):
+ className = "Js8{0}Profile".format(profileName[0].upper() + profileName[1:].lower())
+ return globals()[className]()
+
+
+class Js8Profile(AudioChopperProfile, metaclass=ABCMeta):
+ def decoding_depth(self, mode):
+ pm = Config.get()
+ # return global default
+ if "js8_decoding_depth" in pm:
+ return pm["js8_decoding_depth"]
+ # default when no setting is provided
+ return 3
+
+ def getFileTimestampFormat(self):
+ return "%y%m%d_%H%M%S"
+
+ def decoder_commandline(self, file):
+ return ["js8", "--js8", "-b", self.get_sub_mode(), "-d", str(self.decoding_depth("js8")), file]
+
+ @abstractmethod
+ def get_sub_mode(self):
+ pass
+
+
+class Js8NormalProfile(Js8Profile):
+ def getInterval(self):
+ return 15
+
+ def get_sub_mode(self):
+ return "A"
+
+
+class Js8SlowProfile(Js8Profile):
+ def getInterval(self):
+ return 30
+
+ def get_sub_mode(self):
+ return "E"
+
+
+class Js8FastProfile(Js8Profile):
+ def getInterval(self):
+ return 10
+
+ def get_sub_mode(self):
+ return "B"
+
+
+class Js8TurboProfile(Js8Profile):
+ def getInterval(self):
+ return 6
+
+ def get_sub_mode(self):
+ return "C"
+
+
+class Js8Parser(Parser):
+ decoderRegex = re.compile(" ?")
+
+ def parse(self, messages):
+ for raw in messages:
+ try:
+ freq, raw_msg = raw
+ self.setDialFrequency(freq)
+ msg = raw_msg.decode().rstrip()
+ if Js8Parser.decoderRegex.match(msg):
+ return
+ if msg.startswith(" EOF on input file"):
+ return
+
+ frame = Js8().parse_message(msg)
+ self.handler.write_js8_message(frame, self.dial_freq)
+
+ self.pushDecode()
+
+ if (isinstance(frame, Js8FrameHeartbeat) or isinstance(frame, Js8FrameCompound)) and frame.grid:
+ Map.getSharedInstance().updateLocation(
+ frame.callsign, LocatorLocation(frame.grid), "JS8", self.band
+ )
+ PskReporter.getSharedInstance().spot({
+ "callsign": frame.callsign,
+ "mode": "JS8",
+ "locator": frame.grid,
+ "freq": self.dial_freq + frame.freq,
+ "db": frame.db,
+ "timestamp": frame.timestamp,
+ "msg": str(frame)
+ })
+
+ except Exception:
+ logger.exception("error while parsing js8 message")
+
+ def pushDecode(self):
+ metrics = Metrics.getSharedInstance()
+ band = "unknown"
+ if self.band is not None:
+ band = self.band.getName()
+ if band is None:
+ band = "unknown"
+
+ name = "js8call.decodes.{band}.JS8".format(band=band)
+ metric = metrics.getMetric(name)
+ if metric is None:
+ metric = CounterMetric()
+ metrics.addMetric(name, metric)
+
+ metric.inc()
diff --git a/owrx/kiss.py b/owrx/kiss.py
index 1ea9408..440cdab 100644
--- a/owrx/kiss.py
+++ b/owrx/kiss.py
@@ -2,7 +2,7 @@ import socket
import time
import logging
import random
-from owrx.config import PropertyManager
+from owrx.config import Config
logger = logging.getLogger(__name__)
@@ -14,10 +14,11 @@ TFESC = 0xDD
class DirewolfConfig(object):
def getConfig(self, port, is_service):
- pm = PropertyManager.getSharedInstance()
+ pm = Config.get()
config = """
ACHANNELS 1
+ADEVICE stdin null
CHANNEL 0
MYCALL {callsign}
@@ -38,9 +39,14 @@ IGLOGIN {callsign} {password}
)
if pm["aprs_igate_beacon"]:
- (lat, lon) = pm["receiver_gps"]
- lat = "{0}^{1:.2f}{2}".format(int(lat), (lat - int(lat)) * 60, "N" if lat > 0 else "S")
- lon = "{0}^{1:.2f}{2}".format(int(lon), (lon - int(lon)) * 60, "E" if lon > 0 else "W")
+ lat = pm["receiver_gps"]["lat"]
+ lon = pm["receiver_gps"]["lon"]
+ direction_ns = "N" if lat > 0 else "S"
+ direction_we = "E" if lon > 0 else "W"
+ lat = abs(lat)
+ lon = abs(lon)
+ lat = "{0:02d}^{1:05.2f}{2}".format(int(lat), (lat - int(lat)) * 60, direction_ns)
+ lon = "{0:03d}^{1:05.2f}{2}".format(int(lon), (lon - int(lon)) * 60, direction_we)
config += """
PBEACON sendto=IG delay=0:30 every=60:00 symbol="igate" overlay=R lat={lat} long={lon} comment="OpenWebRX APRS gateway"
@@ -68,9 +74,19 @@ class KissClient(object):
pass
def __init__(self, port):
- time.sleep(1)
- self.socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
- self.socket.connect(("localhost", port))
+ delay = .5
+ retries = 0
+ while True:
+ try:
+ self.socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
+ self.socket.connect(("localhost", port))
+ break
+ except ConnectionError:
+ if retries > 20:
+ logger.error("maximum number of connection attempts reached. did direwolf start up correctly?")
+ raise
+ retries += 1
+ time.sleep(delay)
def read(self):
return self.socket.recv(1)
diff --git a/owrx/locator.py b/owrx/locator.py
index ec80b03..52c37e5 100644
--- a/owrx/locator.py
+++ b/owrx/locator.py
@@ -2,7 +2,8 @@ class Locator(object):
@staticmethod
def fromCoordinates(coordinates, depth=3):
- lat, lon = coordinates
+ lat = coordinates["lat"]
+ lon = coordinates["lon"]
lon = lon + 180
lat = lat + 90
diff --git a/owrx/map.py b/owrx/map.py
index f7a0d5d..8c95ea5 100644
--- a/owrx/map.py
+++ b/owrx/map.py
@@ -1,12 +1,14 @@
from datetime import datetime, timedelta
-import threading, time
-from owrx.config import PropertyManager
+from owrx.config import Config
from owrx.bands import Band
+import threading
+import time
import sys
import logging
logger = logging.getLogger(__name__)
+logger.setLevel(logging.INFO)
class Location(object):
@@ -47,7 +49,7 @@ class Map(object):
loops = 0
time.sleep(60)
- threading.Thread(target=removeLoop, daemon=True).start()
+ threading.Thread(target=removeLoop, daemon=True, name="map_removeloop").start()
super().__init__()
def broadcast(self, update):
@@ -105,7 +107,7 @@ class Map(object):
# TODO broadcast removal to clients
def removeOldPositions(self):
- pm = PropertyManager.getSharedInstance()
+ pm = Config.get()
retention = timedelta(seconds=pm["map_position_retention_time"])
cutoff = datetime.now() - retention
diff --git a/owrx/meta.py b/owrx/meta.py
index 3d1b3d9..c7e9c1d 100644
--- a/owrx/meta.py
+++ b/owrx/meta.py
@@ -1,11 +1,10 @@
-from owrx.config import PropertyManager
+from owrx.config import Config
from urllib import request
import json
from datetime import datetime, timedelta
import logging
import threading
from owrx.map import Map, LatLngLocation
-from owrx.bands import Bandplan
from owrx.parser import Parser
logger = logging.getLogger(__name__)
@@ -55,7 +54,7 @@ class DmrMetaEnricher(object):
del self.threads[id]
def enrich(self, meta):
- if not PropertyManager.getSharedInstance()["digital_voice_dmr_id_lookup"]:
+ if not Config.get()["digital_voice_dmr_id_lookup"]:
return None
if not "source" in meta:
return None
diff --git a/owrx/metrics.py b/owrx/metrics.py
index 7055449..48e0db0 100644
--- a/owrx/metrics.py
+++ b/owrx/metrics.py
@@ -1,4 +1,5 @@
import threading
+from owrx.client import ClientRegistry
class Metric(object):
@@ -38,6 +39,7 @@ class Metrics(object):
def __init__(self):
self.metrics = {}
+ self.addMetric("openwebrx.users", DirectMetric(ClientRegistry.getSharedInstance().clientCount))
def addMetric(self, name, metric):
self.metrics[name] = metric
diff --git a/owrx/modes.py b/owrx/modes.py
new file mode 100644
index 0000000..2b70b39
--- /dev/null
+++ b/owrx/modes.py
@@ -0,0 +1,92 @@
+from owrx.feature import FeatureDetector
+from functools import reduce
+
+
+class Bandpass(object):
+ def __init__(self, low_cut, high_cut):
+ self.low_cut = low_cut
+ self.high_cut = high_cut
+
+
+class Mode(object):
+ def __init__(self, modulation, name, bandpass: Bandpass = None, requirements=None, service=False, squelch=True):
+ self.modulation = modulation
+ self.name = name
+ self.requirements = requirements if requirements is not None else []
+ self.service = service
+ self.bandpass = bandpass
+ self.squelch = squelch
+
+ def is_available(self):
+ fd = FeatureDetector()
+ return reduce(lambda a, b: a and b, [fd.is_available(r) for r in self.requirements], True)
+
+ def is_service(self):
+ return self.service
+
+
+class AnalogMode(Mode):
+ pass
+
+
+class DigitalMode(Mode):
+ def __init__(
+ self, modulation, name, underlying, bandpass: Bandpass = None, requirements=None, service=False, squelch=True
+ ):
+ super().__init__(modulation, name, bandpass, requirements, service, squelch)
+ self.underlying = underlying
+
+
+class Modes(object):
+ mappings = [
+ AnalogMode("nfm", "FM", bandpass=Bandpass(-4000, 4000)),
+ AnalogMode("wfm", "WFM", bandpass=Bandpass(-50000, 50000)),
+ AnalogMode("am", "AM", bandpass=Bandpass(-4000, 4000)),
+ AnalogMode("lsb", "LSB", bandpass=Bandpass(-3000, -300)),
+ AnalogMode("usb", "USB", bandpass=Bandpass(300, 3000)),
+ AnalogMode("cw", "CW", bandpass=Bandpass(700, 900)),
+ AnalogMode("dmr", "DMR", bandpass=Bandpass(-4000, 4000), requirements=["digital_voice_digiham"], squelch=False),
+ AnalogMode("dstar", "D-Star", bandpass=Bandpass(-3250, 3250), requirements=["digital_voice_dsd"], squelch=False),
+ AnalogMode("nxdn", "NXDN", bandpass=Bandpass(-3250, 3250), requirements=["digital_voice_dsd"], squelch=False),
+ AnalogMode("ysf", "YSF", bandpass=Bandpass(-4000, 4000), requirements=["digital_voice_digiham"], squelch=False),
+ AnalogMode("freedv", "FreeDV", bandpass=Bandpass(300, 3000), requirements=["digital_voice_freedv"], squelch=False),
+ DigitalMode("bpsk31", "BPSK31", underlying=["usb"]),
+ DigitalMode("bpsk63", "BPSK63", underlying=["usb"]),
+ DigitalMode("ft8", "FT8", underlying=["usb"], requirements=["wsjt-x"], service=True),
+ DigitalMode("ft4", "FT4", underlying=["usb"], requirements=["wsjt-x"], service=True),
+ DigitalMode("jt65", "JT65", underlying=["usb"], requirements=["wsjt-x"], service=True),
+ DigitalMode("jt9", "JT9", underlying=["usb"], requirements=["wsjt-x"], service=True),
+ DigitalMode(
+ "wspr", "WSPR", underlying=["usb"], bandpass=Bandpass(1350, 1650), requirements=["wsjt-x"], service=True
+ ),
+ DigitalMode("js8", "JS8Call", underlying=["usb"], requirements=["js8call"], service=True),
+ DigitalMode(
+ "packet", "Packet", underlying=["nfm", "usb", "lsb"], requirements=["packet"], service=True, squelch=False
+ ),
+ DigitalMode(
+ "pocsag",
+ "Pocsag",
+ underlying=["nfm"],
+ bandpass=Bandpass(-6000, 6000),
+ requirements=["pocsag"],
+ squelch=False,
+ ),
+ ]
+
+ @staticmethod
+ def getModes():
+ return Modes.mappings
+
+ @staticmethod
+ def getAvailableModes():
+ return [m for m in Modes.getModes() if m.is_available()]
+
+ @staticmethod
+ def getAvailableServices():
+ return [m for m in Modes.getAvailableModes() if m.is_service()]
+
+ @staticmethod
+ def findByModulation(modulation):
+ modes = [m for m in Modes.getAvailableModes() if m.modulation == modulation]
+ if modes:
+ return modes[0]
diff --git a/owrx/property/__init__.py b/owrx/property/__init__.py
new file mode 100644
index 0000000..f3560fa
--- /dev/null
+++ b/owrx/property/__init__.py
@@ -0,0 +1,246 @@
+from abc import ABC, abstractmethod
+import logging
+
+logger = logging.getLogger(__name__)
+
+
+class Subscription(object):
+ def __init__(self, subscriptee, name, subscriber):
+ self.subscriptee = subscriptee
+ self.name = name
+ self.subscriber = subscriber
+
+ def getName(self):
+ return self.name
+
+ def call(self, *args, **kwargs):
+ self.subscriber(*args, **kwargs)
+
+ def cancel(self):
+ self.subscriptee.unwire(self)
+
+
+class PropertyManager(ABC):
+ def __init__(self):
+ self.subscribers = []
+
+ @abstractmethod
+ def __getitem__(self, item):
+ pass
+
+ @abstractmethod
+ def __setitem__(self, key, value):
+ pass
+
+ @abstractmethod
+ def __contains__(self, item):
+ pass
+
+ @abstractmethod
+ def __dict__(self):
+ pass
+
+ @abstractmethod
+ def __delitem__(self, key):
+ pass
+
+ @abstractmethod
+ def keys(self):
+ pass
+
+ def filter(self, *props):
+ return PropertyFilter(self, *props)
+
+ def wire(self, callback):
+ sub = Subscription(self, None, callback)
+ self.subscribers.append(sub)
+ return sub
+
+ def wireProperty(self, name, callback):
+ sub = Subscription(self, name, callback)
+ self.subscribers.append(sub)
+ if name in self:
+ sub.call(self[name])
+ return sub
+
+ def unwire(self, sub):
+ try:
+ self.subscribers.remove(sub)
+ except ValueError:
+ # happens when already removed before
+ pass
+ return self
+
+ def _fireCallbacks(self, name, value):
+ for c in self.subscribers:
+ try:
+ if c.getName() is None:
+ c.call(name, value)
+ elif c.getName() == name:
+ c.call(value)
+ except Exception as e:
+ logger.exception(e)
+
+
+class PropertyLayer(PropertyManager):
+ def __init__(self):
+ super().__init__()
+ self.properties = {}
+
+ def __contains__(self, name):
+ return name in self.properties
+
+ def __getitem__(self, name):
+ return self.properties[name]
+
+ def __setitem__(self, name, value):
+ if name in self.properties and self.properties[name] == value:
+ return
+ self.properties[name] = value
+ self._fireCallbacks(name, value)
+
+ def __dict__(self):
+ return {k: v for k, v in self.properties.items()}
+
+ def __delitem__(self, key):
+ return self.properties.__delitem__(key)
+
+ def keys(self):
+ return self.properties.keys()
+
+
+class PropertyFilter(PropertyManager):
+ def __init__(self, pm: PropertyManager, *props: str):
+ super().__init__()
+ self.pm = pm
+ self.props = props
+ self.pm.wire(self.receiveEvent)
+
+ def receiveEvent(self, name, value):
+ if name not in self.props:
+ return
+ self._fireCallbacks(name, value)
+
+ def __getitem__(self, item):
+ if item not in self.props:
+ raise KeyError(item)
+ return self.pm.__getitem__(item)
+
+ def __setitem__(self, key, value):
+ if key not in self.props:
+ raise KeyError(key)
+ return self.pm.__setitem__(key, value)
+
+ def __contains__(self, item):
+ if item not in self.props:
+ return False
+ return self.pm.__contains__(item)
+
+ def __dict__(self):
+ return {k: v for k, v in self.pm.__dict__().items() if k in self.props}
+
+ def __delitem__(self, key):
+ if key not in self.props:
+ raise KeyError(key)
+ return self.pm.__delitem__(key)
+
+ def keys(self):
+ return [k for k in self.pm.keys() if k in self.props]
+
+
+class PropertyStack(PropertyManager):
+ def __init__(self):
+ super().__init__()
+ self.layers = []
+
+ def addLayer(self, priority: int, pm: PropertyManager):
+ """
+ highest priority = 0
+ """
+ self._fireChanges(self._addLayer(priority, pm))
+
+ def _addLayer(self, priority: int, pm: PropertyManager):
+ changes = {}
+ for key in pm.keys():
+ if key not in self or self[key] != pm[key]:
+ changes[key] = pm[key]
+
+ def eventClosure(name, value):
+ self.receiveEvent(pm, name, value)
+
+ sub = pm.wire(eventClosure)
+
+ self.layers.append({"priority": priority, "props": pm, "sub": sub})
+
+ return changes
+
+ def removeLayer(self, pm: PropertyManager):
+ for layer in self.layers:
+ if layer["props"] == pm:
+ self._fireChanges(self._removeLayer(layer))
+
+ def _removeLayer(self, layer):
+ layer["sub"].cancel()
+ self.layers.remove(layer)
+ changes = {}
+ pm = layer["props"]
+ for key in pm.keys():
+ if key in self:
+ if self[key] != pm[key]:
+ changes[key] = self[key]
+ else:
+ changes[key] = None
+ return changes
+
+ def replaceLayer(self, priority: int, pm: PropertyManager):
+ layers = [x for x in self.layers if x["priority"] == priority]
+
+ originalState = self.__dict__()
+
+ changes = self._removeLayer(layers[0]) if layers else {}
+ changes = {**changes, **self._addLayer(priority, pm)}
+ changes = {k: v for k, v in changes.items() if k not in originalState or originalState[k] != v}
+
+ self._fireChanges(changes)
+
+ def _fireChanges(self, changes):
+ for k, v in changes.items():
+ self._fireCallbacks(k, v)
+
+ def receiveEvent(self, layer, name, value):
+ if layer != self._getTopLayer(name):
+ return
+ self._fireCallbacks(name, value)
+
+ def _getTopLayer(self, item):
+ layers = [la["props"] for la in sorted(self.layers, key=lambda l: l["priority"])]
+ for m in layers:
+ if item in m:
+ return m
+ # return top layer by default
+ if layers:
+ return layers[0]
+
+ def __getitem__(self, item):
+ layer = self._getTopLayer(item)
+ return layer.__getitem__(item)
+
+ def __setitem__(self, key, value):
+ layer = self._getTopLayer(key)
+ return layer.__setitem__(key, value)
+
+ def __contains__(self, item):
+ layer = self._getTopLayer(item)
+ if layer:
+ return layer.__contains__(item)
+ return False
+
+ def __dict__(self):
+ return {k: self.__getitem__(k) for k in self.keys()}
+
+ def __delitem__(self, key):
+ for layer in self.layers:
+ layer["props"].__delitem__(key)
+
+ def keys(self):
+ return set([key for l in self.layers for key in l["props"].keys()])
diff --git a/owrx/pskreporter.py b/owrx/pskreporter.py
index da580f0..981ecc8 100644
--- a/owrx/pskreporter.py
+++ b/owrx/pskreporter.py
@@ -5,7 +5,7 @@ import random
import socket
from functools import reduce
from operator import and_
-from owrx.config import PropertyManager
+from owrx.config import Config
from owrx.version import openwebrx_version
from owrx.locator import Locator
from owrx.metrics import Metrics, CounterMetric
@@ -30,13 +30,13 @@ class PskReporter(object):
sharedInstance = None
creationLock = threading.Lock()
interval = 300
- supportedModes = ["FT8", "FT4", "JT9", "JT65"]
+ supportedModes = ["FT8", "FT4", "JT9", "JT65", "JS8"]
@staticmethod
def getSharedInstance():
with PskReporter.creationLock:
if PskReporter.sharedInstance is None:
- if PropertyManager.getSharedInstance()["pskreporter_enabled"]:
+ if Config.get()["pskreporter_enabled"]:
PskReporter.sharedInstance = PskReporter()
else:
PskReporter.sharedInstance = PskReporterDummy()
@@ -154,7 +154,7 @@ class Uploader(object):
def encodeSpot(self, spot):
return bytes(
self.encodeString(spot["callsign"])
- + list(spot["freq"].to_bytes(4, "big"))
+ + list(int(spot["freq"]).to_bytes(4, "big"))
+ list(int(spot["db"]).to_bytes(1, "big", signed=True))
+ self.encodeString(spot["mode"])
+ self.encodeString(spot["locator"])
@@ -181,7 +181,7 @@ class Uploader(object):
)
def getReceiverInformation(self):
- pm = PropertyManager.getSharedInstance()
+ pm = Config.get()
callsign = pm["pskreporter_callsign"]
locator = Locator.fromCoordinates(pm["receiver_gps"])
decodingSoftware = "OpenWebRX " + openwebrx_version
diff --git a/owrx/receiverid.py b/owrx/receiverid.py
new file mode 100644
index 0000000..847bb34
--- /dev/null
+++ b/owrx/receiverid.py
@@ -0,0 +1,94 @@
+import re
+import logging
+import hashlib
+import hmac
+from datetime import datetime, timezone
+from owrx.config import Config
+
+logger = logging.getLogger(__name__)
+
+
+keyRegex = re.compile("^([a-zA-Z]+)-([0-9a-f]{32})-([0-9a-f]{64})$")
+keyChallengeRegex = re.compile("^([a-zA-Z]+)-([0-9a-f]{32})-([0-9a-f]{32})$")
+headerRegex = re.compile("^ReceiverId (.*)$")
+
+
+class KeyException(Exception):
+ pass
+
+
+class Key(object):
+ def __init__(self, keyString):
+ matches = keyRegex.match(keyString)
+ if not matches:
+ raise KeyException("invalid key format")
+ self.source = matches.group(1)
+ self.id = matches.group(2)
+ self.secret = matches.group(3)
+
+
+class KeyChallenge(object):
+ def __init__(self, challengeString):
+ matches = keyChallengeRegex.match(challengeString)
+ if not matches:
+ raise KeyException("invalid key challenge format")
+ self.source = matches.group(1)
+ self.id = matches.group(2)
+ self.challenge = matches.group(3)
+
+
+class KeyResponse(object):
+ def __init__(self, source, id, time, signature):
+ self.source = source
+ self.id = id
+ self.time = time
+ self.signature = signature
+
+ def __str__(self):
+ return "{source}-{id}-{time}-{signature}".format(
+ source=self.source,
+ id=self.id,
+ time=self.time,
+ signature=self.signature,
+ )
+
+
+class ReceiverId(object):
+ @staticmethod
+ def getResponseHeader(requestHeader):
+ matches = headerRegex.match(requestHeader)
+ if not matches:
+ raise KeyException("invalid authorization header")
+ challenges = [KeyChallenge(i) for i in matches.group(1).split(",")]
+
+ def signChallenge(challenge):
+ key = ReceiverId.findKey(challenge)
+ if key is None:
+ return
+ return ReceiverId.signChallenge(challenge, key)
+
+ responses = [signChallenge(c) for c in challenges]
+ return ",".join(str(r) for r in responses if r is not None)
+
+ @staticmethod
+ def findKey(challenge):
+ def parseKey(keyString):
+ try:
+ return Key(keyString)
+ except KeyException as e:
+ logger.error(e)
+ keys = [parseKey(keyString) for keyString in Config.get()['receiver_keys']]
+ keys = [key for key in keys if key is not None]
+ matching_keys = [key for key in keys if key.source == challenge.source and key.id == challenge.id]
+ if matching_keys:
+ return matching_keys[0]
+ return None
+
+ @staticmethod
+ def signChallenge(challenge, key):
+ now = datetime.utcnow().replace(microsecond=0, tzinfo=timezone.utc)
+ now_bytes = int(now.timestamp()).to_bytes(4, byteorder="big")
+ m = hmac.new(bytes.fromhex(key.secret), digestmod=hashlib.sha256)
+ m.update(bytes.fromhex(challenge.challenge))
+ m.update(now_bytes)
+ return KeyResponse(challenge.source, challenge.id, now_bytes.hex(), m.hexdigest())
diff --git a/owrx/sdr.py b/owrx/sdr.py
index 878b24d..c52406f 100644
--- a/owrx/sdr.py
+++ b/owrx/sdr.py
@@ -1,4 +1,5 @@
-from owrx.config import PropertyManager
+from owrx.config import Config
+from owrx.property import PropertyLayer
from owrx.feature import FeatureDetector, UnknownFeatureException
import logging
@@ -14,11 +15,11 @@ class SdrService(object):
@staticmethod
def loadProps():
if SdrService.sdrProps is None:
- pm = PropertyManager.getSharedInstance()
+ pm = Config.get()
featureDetector = FeatureDetector()
def loadIntoPropertyManager(dict: dict):
- propertyManager = PropertyManager()
+ propertyManager = PropertyLayer()
for (name, value) in dict.items():
propertyManager[name] = value
return propertyManager
@@ -27,7 +28,7 @@ class SdrService(object):
try:
if not featureDetector.is_available(value["type"]):
logger.error(
- 'The RTL source type "{0}" is not available. please check requirements.'.format(
+ 'The SDR source type "{0}" is not available. please check requirements.'.format(
value["type"]
)
)
@@ -35,7 +36,7 @@ class SdrService(object):
return True
except UnknownFeatureException:
logger.error(
- 'The RTL source type "{0}" is invalid. Please check your configuration'.format(value["type"])
+ 'The SDR source type "{0}" is invalid. Please check your configuration'.format(value["type"])
)
return False
@@ -44,7 +45,7 @@ class SdrService(object):
name: loadIntoPropertyManager(value) for (name, value) in pm["sdrs"].items() if sdrTypeAvailable(value)
}
logger.info(
- "SDR sources loaded. Availables SDRs: {0}".format(
+ "SDR sources loaded. Available SDRs: {0}".format(
", ".join(map(lambda x: x["name"], SdrService.sdrProps.values()))
)
)
diff --git a/owrx/sdrhu.py b/owrx/sdrhu.py
deleted file mode 100644
index c84a2f5..0000000
--- a/owrx/sdrhu.py
+++ /dev/null
@@ -1,39 +0,0 @@
-import threading
-import subprocess
-import time
-from owrx.config import PropertyManager
-
-import logging
-
-logger = logging.getLogger(__name__)
-
-
-class SdrHuUpdater(threading.Thread):
- def __init__(self):
- self.doRun = True
- super().__init__(daemon=True)
-
- def update(self):
- pm = PropertyManager.getSharedInstance()
- cmd = 'wget --timeout=15 -4qO- https://sdr.hu/update --post-data "url=http://{server_hostname}:{web_port}&apikey={sdrhu_key}" 2>&1'.format(
- **pm.__dict__()
- )
- logger.debug(cmd)
- returned = subprocess.Popen(cmd, shell=True, stdout=subprocess.PIPE).communicate()
- returned = returned[0].decode("utf-8")
- if "UPDATE:" in returned:
- retrytime_mins = 20
- value = returned.split("UPDATE:")[1].split("\n", 1)[0]
- if value.startswith("SUCCESS"):
- logger.info("Update succeeded!")
- else:
- logger.warning("Update failed, your receiver cannot be listed on sdr.hu! Reason: %s", value)
- else:
- retrytime_mins = 2
- logger.warning("wget failed while updating, your receiver cannot be listed on sdr.hu!")
- return retrytime_mins
-
- def run(self):
- while self.doRun:
- retrytime_mins = self.update()
- time.sleep(60 * retrytime_mins)
diff --git a/owrx/service.py b/owrx/service.py
deleted file mode 100644
index 5936f8f..0000000
--- a/owrx/service.py
+++ /dev/null
@@ -1,408 +0,0 @@
-import threading
-from datetime import datetime, timezone, timedelta
-from owrx.source import SdrSource
-from owrx.sdr import SdrService
-from owrx.bands import Bandplan
-from csdr.csdr import dsp, output
-from owrx.wsjt import WsjtParser
-from owrx.aprs import AprsParser
-from owrx.config import PropertyManager
-from owrx.source.resampler import Resampler
-from owrx.feature import FeatureDetector
-
-import logging
-
-logger = logging.getLogger(__name__)
-
-
-class ServiceOutput(output):
- def __init__(self, frequency):
- self.frequency = frequency
-
- def getParser(self):
- # abstract method; implement in subclasses
- pass
-
- def receive_output(self, t, read_fn):
- parser = self.getParser()
- parser.setDialFrequency(self.frequency)
- target = self.pump(read_fn, parser.parse)
- threading.Thread(target=target).start()
-
-
-class WsjtServiceOutput(ServiceOutput):
- def getParser(self):
- return WsjtParser(WsjtHandler())
-
- def supports_type(self, t):
- return t == "wsjt_demod"
-
-
-class AprsServiceOutput(ServiceOutput):
- def getParser(self):
- return AprsParser(AprsHandler())
-
- def supports_type(self, t):
- return t == "packet_demod"
-
-
-class ScheduleEntry(object):
- def __init__(self, startTime, endTime, profile):
- self.startTime = startTime
- self.endTime = endTime
- self.profile = profile
-
- def isCurrent(self, time):
- if self.startTime < self.endTime:
- return self.startTime <= time < self.endTime
- else:
- return self.startTime <= time or time < self.endTime
-
- def getProfile(self):
- return self.profile
-
- def getScheduledEnd(self):
- now = datetime.utcnow()
- end = now.combine(date=now.date(), time=self.endTime)
- while end < now:
- end += timedelta(days=1)
- return end
-
- def getNextActivation(self):
- now = datetime.utcnow()
- start = now.combine(date=now.date(), time=self.startTime)
- while start < now:
- start += timedelta(days=1)
- return start
-
-
-class Schedule(object):
- @staticmethod
- def parse(scheduleDict):
- entries = []
- for time, profile in scheduleDict.items():
- if len(time) != 9:
- logger.warning("invalid schedule spec: %s", time)
- continue
-
- startTime = datetime.strptime(time[0:4], "%H%M").replace(tzinfo=timezone.utc).time()
- endTime = datetime.strptime(time[5:9], "%H%M").replace(tzinfo=timezone.utc).time()
- entries.append(ScheduleEntry(startTime, endTime, profile))
- return Schedule(entries)
-
- def __init__(self, entries):
- self.entries = entries
-
- def getCurrentEntry(self):
- current = [p for p in self.entries if p.isCurrent(datetime.utcnow().time())]
- if current:
- return current[0]
- return None
-
- def getNextEntry(self):
- s = sorted(self.entries, key=lambda e: e.getNextActivation())
- if s:
- return s[0]
- return None
-
-
-class ServiceScheduler(object):
- def __init__(self, source, schedule):
- self.source = source
- self.selectionTimer = None
- self.schedule = Schedule.parse(schedule)
- self.source.addClient(self)
- self.source.getProps().collect("center_freq", "samp_rate").wire(self.onFrequencyChange)
- self.scheduleSelection()
-
- def shutdown(self):
- self.cancelTimer()
- self.source.removeClient(self)
-
- def scheduleSelection(self, time=None):
- if self.source.getState() == SdrSource.STATE_FAILED:
- return
- seconds = 10
- if time is not None:
- delta = time - datetime.utcnow()
- seconds = delta.total_seconds()
- self.cancelTimer()
- self.selectionTimer = threading.Timer(seconds, self.selectProfile)
- self.selectionTimer.start()
-
- def cancelTimer(self):
- if self.selectionTimer:
- self.selectionTimer.cancel()
-
- def getClientClass(self):
- return SdrSource.CLIENT_BACKGROUND
-
- def onStateChange(self, state):
- if state == SdrSource.STATE_STOPPING:
- self.scheduleSelection()
- elif state == SdrSource.STATE_FAILED:
- self.cancelTimer()
-
- def onBusyStateChange(self, state):
- if state == SdrSource.BUSYSTATE_IDLE:
- self.scheduleSelection()
-
- def onFrequencyChange(self, name, value):
- self.scheduleSelection()
-
- def selectProfile(self):
- if self.source.hasClients(SdrSource.CLIENT_USER):
- logger.debug("source has active users; not touching")
- return
- logger.debug("source seems to be idle, selecting profile for background services")
- entry = self.schedule.getCurrentEntry()
-
- if entry is None:
- logger.debug("schedule did not return a profile. checking next entry...")
- nextEntry = self.schedule.getNextEntry()
- if nextEntry is not None:
- self.scheduleSelection(nextEntry.getNextActivation())
- return
-
- logger.debug("scheduling end for current profile: %s", entry.getScheduledEnd())
- self.scheduleSelection(entry.getScheduledEnd())
-
- try:
- self.source.activateProfile(entry.getProfile())
- self.source.start()
- except KeyError:
- pass
-
-
-class ServiceHandler(object):
- def __init__(self, source):
- self.lock = threading.Lock()
- self.services = []
- self.source = source
- self.startupTimer = None
- self.source.addClient(self)
- props = self.source.getProps()
- props.collect("center_freq", "samp_rate").wire(self.onFrequencyChange)
- if self.source.isAvailable():
- self.scheduleServiceStartup()
- self.scheduler = None
- if "schedule" in props:
- self.scheduler = ServiceScheduler(self.source, props["schedule"])
-
- def getClientClass(self):
- return SdrSource.CLIENT_INACTIVE
-
- def onStateChange(self, state):
- if state == SdrSource.STATE_RUNNING:
- self.scheduleServiceStartup()
- elif state == SdrSource.STATE_STOPPING:
- logger.debug("sdr source becoming unavailable; stopping services.")
- self.stopServices()
- elif state == SdrSource.STATE_FAILED:
- logger.debug("sdr source failed; stopping services.")
- self.stopServices()
- if self.scheduler:
- self.scheduler.shutdown()
-
- def onBusyStateChange(self, state):
- pass
-
- def isSupported(self, mode):
- # TODO this should be in a more central place (the frontend also needs this)
- requirements = {
- "ft8": "wsjt-x",
- "ft4": "wsjt-x",
- "jt65": "wsjt-x",
- "jt9": "wsjt-x",
- "wspr": "wsjt-x",
- "packet": "packet",
- }
- fd = FeatureDetector()
-
- # this looks overly complicated... but i'd like modes with no requirements to be always available without
- # being listed in the hash above
- unavailable = [mode for mode, req in requirements.items() if not fd.is_available(req)]
- configured = PropertyManager.getSharedInstance()["services_decoders"]
- available = [mode for mode in configured if mode not in unavailable]
-
- return mode in available
-
- def shutdown(self):
- self.stopServices()
- self.source.removeClient(self)
- if self.scheduler:
- self.scheduler.shutdown()
-
- def stopServices(self):
- with self.lock:
- services = self.services
- self.services = []
-
- for service in services:
- service.stop()
-
- def onFrequencyChange(self, key, value):
- self.stopServices()
- if not self.source.isAvailable():
- return
- self.scheduleServiceStartup()
-
- def scheduleServiceStartup(self):
- if self.startupTimer:
- self.startupTimer.cancel()
- self.startupTimer = threading.Timer(10, self.updateServices)
- self.startupTimer.start()
-
- def updateServices(self):
- logger.debug("re-scheduling services due to sdr changes")
- self.stopServices()
- if not self.source.isAvailable():
- logger.debug("sdr source is unavailable")
- return
- cf = self.source.getProps()["center_freq"]
- sr = self.source.getProps()["samp_rate"]
- srh = sr / 2
- frequency_range = (cf - srh, cf + srh)
-
- dials = [
- dial
- for dial in Bandplan.getSharedInstance().collectDialFrequencies(frequency_range)
- if self.isSupported(dial["mode"])
- ]
-
- if not dials:
- logger.debug("no services available")
- return
-
- with self.lock:
- self.services = []
-
- groups = self.optimizeResampling(dials, sr)
- if groups is None:
- for dial in dials:
- self.services.append(self.setupService(dial["mode"], dial["frequency"], self.source))
- else:
- for group in groups:
- frequencies = sorted([f["frequency"] for f in group])
- min = frequencies[0]
- max = frequencies[-1]
- cf = (min + max) / 2
- bw = max - min
- logger.debug("group center frequency: {0}, bandwidth: {1}".format(cf, bw))
- resampler_props = PropertyManager()
- resampler_props["center_freq"] = cf
- # TODO the + 24000 is a temporary fix since the resampling optimizer does not account for required bandwidths
- resampler_props["samp_rate"] = bw + 24000
- resampler = Resampler(resampler_props, self.source)
- resampler.start()
-
- for dial in group:
- self.services.append(self.setupService(dial["mode"], dial["frequency"], resampler))
-
- # resampler goes in after the services since it must not be shutdown as long as the services are still running
- self.services.append(resampler)
-
- def optimizeResampling(self, freqs, bandwidth):
- freqs = sorted(freqs, key=lambda f: f["frequency"])
- distances = [
- {"frequency": freqs[i]["frequency"], "distance": freqs[i + 1]["frequency"] - freqs[i]["frequency"]}
- for i in range(0, len(freqs) - 1)
- ]
-
- distances = [d for d in distances if d["distance"] > 0]
-
- distances = sorted(distances, key=lambda f: f["distance"], reverse=True)
-
- def calculate_usage(num_splits):
- splits = sorted([f["frequency"] for f in distances[0:num_splits]])
- previous = 0
- groups = []
- for split in splits:
- groups.append([f for f in freqs if previous < f["frequency"] <= split])
- previous = split
- groups.append([f for f in freqs if previous < f["frequency"]])
-
- def get_bandwitdh(group):
- freqs = sorted([f["frequency"] for f in group])
- # the group will process the full BW once, plus the reduced BW once for each group member
- return bandwidth + len(group) * (freqs[-1] - freqs[0] + 24000)
-
- total_bandwidth = sum([get_bandwitdh(group) for group in groups])
- return {"num_splits": num_splits, "total_bandwidth": total_bandwidth, "groups": groups}
-
- usages = [calculate_usage(i) for i in range(0, len(freqs))]
- # another possible outcome might be that it's best not to resample at all. this is a special case.
- usages += [{"num_splits": None, "total_bandwidth": bandwidth * len(freqs), "groups": [freqs]}]
- results = sorted(usages, key=lambda f: f["total_bandwidth"])
-
- for r in results:
- logger.debug("splits: {0}, total: {1}".format(r["num_splits"], r["total_bandwidth"]))
-
- best = results[0]
- if best["num_splits"] is None:
- return None
- return best["groups"]
-
- def setupService(self, mode, frequency, source):
- logger.debug("setting up service {0} on frequency {1}".format(mode, frequency))
- # TODO selecting outputs will need some more intelligence here
- if mode == "packet":
- output = AprsServiceOutput(frequency)
- else:
- output = WsjtServiceOutput(frequency)
- d = dsp(output)
- d.nc_port = source.getPort()
- d.set_offset_freq(frequency - source.getProps()["center_freq"])
- if mode == "packet":
- d.set_demodulator("nfm")
- d.set_bpf(-4000, 4000)
- elif mode == "wspr":
- d.set_demodulator("usb")
- # WSPR only samples between 1400 and 1600 Hz
- d.set_bpf(1350, 1650)
- else:
- d.set_demodulator("usb")
- d.set_bpf(0, 3000)
- d.set_secondary_demodulator(mode)
- d.set_audio_compression("none")
- d.set_samp_rate(source.getProps()["samp_rate"])
- d.set_service()
- d.start()
- return d
-
-
-class WsjtHandler(object):
- def write_wsjt_message(self, msg):
- pass
-
-
-class AprsHandler(object):
- def write_aprs_data(self, data):
- pass
-
-
-class Services(object):
- handlers = []
-
- @staticmethod
- def start():
- if not PropertyManager.getSharedInstance()["services_enabled"]:
- return
- for source in SdrService.getSources().values():
- props = source.getProps()
- if "services" not in props or props["services"] != False:
- Services.handlers.append(ServiceHandler(source))
-
- @staticmethod
- def stop():
- for handler in Services.handlers:
- handler.shutdown()
- Services.handlers = []
-
-
-class Service(object):
- pass
-
-
-class WsjtService(Service):
- pass
diff --git a/owrx/service/__init__.py b/owrx/service/__init__.py
new file mode 100644
index 0000000..a8e60d8
--- /dev/null
+++ b/owrx/service/__init__.py
@@ -0,0 +1,303 @@
+import threading
+from owrx.source import SdrSource
+from owrx.sdr import SdrService
+from owrx.bands import Bandplan
+from csdr.csdr import dsp, output
+from owrx.wsjt import WsjtParser
+from owrx.aprs import AprsParser
+from owrx.js8 import Js8Parser
+from owrx.config import Config
+from owrx.source.resampler import Resampler
+from owrx.property import PropertyLayer
+from js8py import Js8Frame
+from abc import ABCMeta, abstractmethod
+from .schedule import ServiceScheduler
+from owrx.modes import Modes
+
+import logging
+
+logger = logging.getLogger(__name__)
+
+
+class ServiceOutput(output, metaclass=ABCMeta):
+ def __init__(self, frequency):
+ self.frequency = frequency
+
+ @abstractmethod
+ def getParser(self):
+ # abstract method; implement in subclasses
+ pass
+
+ def receive_output(self, t, read_fn):
+ parser = self.getParser()
+ parser.setDialFrequency(self.frequency)
+ target = self.pump(read_fn, parser.parse)
+ threading.Thread(target=target, name="service_output_receive").start()
+
+
+class WsjtServiceOutput(ServiceOutput):
+ def getParser(self):
+ return WsjtParser(WsjtHandler())
+
+ def supports_type(self, t):
+ return t == "wsjt_demod"
+
+
+class AprsServiceOutput(ServiceOutput):
+ def getParser(self):
+ return AprsParser(AprsHandler())
+
+ def supports_type(self, t):
+ return t == "packet_demod"
+
+
+class Js8ServiceOutput(ServiceOutput):
+ def getParser(self):
+ return Js8Parser(Js8Handler())
+
+ def supports_type(self, t):
+ return t == "js8_demod"
+
+
+class ServiceHandler(object):
+ def __init__(self, source):
+ self.lock = threading.RLock()
+ self.services = []
+ self.source = source
+ self.startupTimer = None
+ self.source.addClient(self)
+ props = self.source.getProps()
+ props.filter("center_freq", "samp_rate").wire(self.onFrequencyChange)
+ if self.source.isAvailable():
+ self.scheduleServiceStartup()
+ self.scheduler = None
+ if "schedule" in props or "scheduler" in props:
+ self.scheduler = ServiceScheduler(self.source)
+
+ def getClientClass(self):
+ return SdrSource.CLIENT_INACTIVE
+
+ def onStateChange(self, state):
+ if state == SdrSource.STATE_RUNNING:
+ self.scheduleServiceStartup()
+ elif state == SdrSource.STATE_STOPPING:
+ logger.debug("sdr source becoming unavailable; stopping services.")
+ self.stopServices()
+ elif state == SdrSource.STATE_FAILED:
+ logger.debug("sdr source failed; stopping services.")
+ self.stopServices()
+ if self.scheduler:
+ self.scheduler.shutdown()
+
+ def onBusyStateChange(self, state):
+ pass
+
+ def isSupported(self, mode):
+ configured = Config.get()["services_decoders"]
+ available = [m.modulation for m in Modes.getAvailableServices()]
+ return mode in configured and mode in available
+
+ def shutdown(self):
+ self.stopServices()
+ self.source.removeClient(self)
+ if self.scheduler:
+ self.scheduler.shutdown()
+
+ def stopServices(self):
+ with self.lock:
+ services = self.services
+ self.services = []
+
+ for service in services:
+ service.stop()
+
+ def onFrequencyChange(self, key, value):
+ self.stopServices()
+ if not self.source.isAvailable():
+ return
+ self.scheduleServiceStartup()
+
+ def scheduleServiceStartup(self):
+ if self.startupTimer:
+ self.startupTimer.cancel()
+ self.startupTimer = threading.Timer(10, self.updateServices)
+ self.startupTimer.start()
+
+ def updateServices(self):
+ with self.lock:
+ logger.debug("re-scheduling services due to sdr changes")
+ self.stopServices()
+ if not self.source.isAvailable():
+ logger.debug("sdr source is unavailable")
+ return
+ cf = self.source.getProps()["center_freq"]
+ sr = self.source.getProps()["samp_rate"]
+ srh = sr / 2
+ frequency_range = (cf - srh, cf + srh)
+
+ dials = [
+ dial
+ for dial in Bandplan.getSharedInstance().collectDialFrequencies(
+ frequency_range
+ )
+ if self.isSupported(dial["mode"])
+ ]
+
+ if not dials:
+ logger.debug("no services available")
+ return
+
+ groups = self.optimizeResampling(dials, sr)
+ if groups is None:
+ for dial in dials:
+ self.services.append(
+ self.setupService(dial["mode"], dial["frequency"], self.source)
+ )
+ else:
+ for group in groups:
+ frequencies = sorted([f["frequency"] for f in group])
+ min = frequencies[0]
+ max = frequencies[-1]
+ cf = (min + max) / 2
+ bw = max - min
+ logger.debug(
+ "group center frequency: {0}, bandwidth: {1}".format(cf, bw)
+ )
+ resampler_props = PropertyLayer()
+ resampler_props["center_freq"] = cf
+ # TODO the + 24000 is a temporary fix since the resampling optimizer does not account for required bandwidths
+ resampler_props["samp_rate"] = bw + 24000
+ resampler = Resampler(resampler_props, self.source)
+ resampler.start()
+
+ for dial in group:
+ self.services.append(
+ self.setupService(
+ dial["mode"], dial["frequency"], resampler
+ )
+ )
+
+ # resampler goes in after the services since it must not be shutdown as long as the services are still running
+ self.services.append(resampler)
+
+ def optimizeResampling(self, freqs, bandwidth):
+ freqs = sorted(freqs, key=lambda f: f["frequency"])
+ distances = [
+ {
+ "frequency": freqs[i]["frequency"],
+ "distance": freqs[i + 1]["frequency"] - freqs[i]["frequency"],
+ }
+ for i in range(0, len(freqs) - 1)
+ ]
+
+ distances = [d for d in distances if d["distance"] > 0]
+
+ distances = sorted(distances, key=lambda f: f["distance"], reverse=True)
+
+ def calculate_usage(num_splits):
+ splits = sorted([f["frequency"] for f in distances[0:num_splits]])
+ previous = 0
+ groups = []
+ for split in splits:
+ groups.append([f for f in freqs if previous < f["frequency"] <= split])
+ previous = split
+ groups.append([f for f in freqs if previous < f["frequency"]])
+
+ def get_bandwitdh(group):
+ freqs = sorted([f["frequency"] for f in group])
+ # the group will process the full BW once, plus the reduced BW once for each group member
+ return bandwidth + len(group) * (freqs[-1] - freqs[0] + 24000)
+
+ total_bandwidth = sum([get_bandwitdh(group) for group in groups])
+ return {
+ "num_splits": num_splits,
+ "total_bandwidth": total_bandwidth,
+ "groups": groups,
+ }
+
+ usages = [calculate_usage(i) for i in range(0, len(freqs))]
+ # another possible outcome might be that it's best not to resample at all. this is a special case.
+ usages += [
+ {
+ "num_splits": None,
+ "total_bandwidth": bandwidth * len(freqs),
+ "groups": [freqs],
+ }
+ ]
+ results = sorted(usages, key=lambda f: f["total_bandwidth"])
+
+ for r in results:
+ logger.debug(
+ "splits: {0}, total: {1}".format(r["num_splits"], r["total_bandwidth"])
+ )
+
+ best = results[0]
+ if best["num_splits"] is None:
+ return None
+ return best["groups"]
+
+ def setupService(self, mode, frequency, source):
+ logger.debug("setting up service {0} on frequency {1}".format(mode, frequency))
+ # TODO selecting outputs will need some more intelligence here
+ if mode == "packet":
+ output = AprsServiceOutput(frequency)
+ elif mode == "js8":
+ output = Js8ServiceOutput(frequency)
+ else:
+ output = WsjtServiceOutput(frequency)
+ d = dsp(output)
+ d.nc_port = source.getPort()
+ center_freq = source.getProps()["center_freq"]
+ d.set_offset_freq(frequency - center_freq)
+ d.set_center_freq(center_freq)
+ if mode == "packet":
+ d.set_demodulator("nfm")
+ d.set_bpf(-4000, 4000)
+ elif mode == "wspr":
+ d.set_demodulator("usb")
+ # WSPR only samples between 1400 and 1600 Hz
+ d.set_bpf(1350, 1650)
+ else:
+ d.set_demodulator("usb")
+ d.set_bpf(0, 3000)
+ d.set_secondary_demodulator(mode)
+ d.set_audio_compression("none")
+ d.set_samp_rate(source.getProps()["samp_rate"])
+ d.set_temporary_directory(Config.get()['temporary_directory'])
+ d.set_service()
+ d.start()
+ return d
+
+
+class WsjtHandler(object):
+ def write_wsjt_message(self, msg):
+ pass
+
+
+class AprsHandler(object):
+ def write_aprs_data(self, data):
+ pass
+
+
+class Js8Handler(object):
+ def write_js8_message(self, frame: Js8Frame, freq: int):
+ pass
+
+
+class Services(object):
+ handlers = []
+
+ @staticmethod
+ def start():
+ if not Config.get()["services_enabled"]:
+ return
+ for source in SdrService.getSources().values():
+ props = source.getProps()
+ if "services" not in props or props["services"] is not False:
+ Services.handlers.append(ServiceHandler(source))
+
+ @staticmethod
+ def stop():
+ for handler in Services.handlers:
+ handler.shutdown()
+ Services.handlers = []
diff --git a/owrx/service/schedule.py b/owrx/service/schedule.py
new file mode 100644
index 0000000..992757e
--- /dev/null
+++ b/owrx/service/schedule.py
@@ -0,0 +1,273 @@
+from datetime import datetime, timezone, timedelta
+from owrx.source import SdrSource
+from owrx.config import Config
+import threading
+import math
+from abc import ABC, ABCMeta, abstractmethod
+
+import logging
+
+logger = logging.getLogger(__name__)
+
+
+class ScheduleEntry(ABC):
+ def __init__(self, startTime, endTime, profile):
+ self.startTime = startTime
+ self.endTime = endTime
+ self.profile = profile
+
+ def getProfile(self):
+ return self.profile
+
+ def __str__(self):
+ return "{0} - {1}: {2}".format(self.startTime, self.endTime, self.profile)
+
+ @abstractmethod
+ def isCurrent(self, dt):
+ pass
+
+ @abstractmethod
+ def getScheduledEnd(self):
+ pass
+
+ @abstractmethod
+ def getNextActivation(self):
+ pass
+
+
+class TimeScheduleEntry(ScheduleEntry):
+ def isCurrent(self, dt):
+ time = dt.time()
+ if self.startTime < self.endTime:
+ return self.startTime <= time < self.endTime
+ else:
+ return self.startTime <= time or time < self.endTime
+
+ def getScheduledEnd(self):
+ now = datetime.utcnow()
+ end = now.combine(date=now.date(), time=self.endTime)
+ while end < now:
+ end += timedelta(days=1)
+ return end
+
+ def getNextActivation(self):
+ now = datetime.utcnow()
+ start = now.combine(date=now.date(), time=self.startTime)
+ while start < now:
+ start += timedelta(days=1)
+ return start
+
+
+class DatetimeScheduleEntry(ScheduleEntry):
+ def isCurrent(self, dt):
+ return self.startTime <= dt < self.endTime
+
+ def getScheduledEnd(self):
+ return self.endTime
+
+ def getNextActivation(self):
+ return self.startTime
+
+class Schedule(ABC):
+ @staticmethod
+ def parse(props):
+ # downwards compatibility
+ if "schedule" in props:
+ return StaticSchedule(props["schedule"])
+ elif "scheduler" in props:
+ sc = props["scheduler"]
+ t = sc["type"] if "type" in sc else "static"
+ if t == "static":
+ return StaticSchedule(sc["schedule"])
+ elif t == "daylight":
+ return DaylightSchedule(sc["schedule"])
+ else:
+ logger.warning("Invalid scheduler type: %s", t)
+
+ @abstractmethod
+ def getCurrentEntry(self):
+ pass
+
+ @abstractmethod
+ def getNextEntry(self):
+ pass
+
+
+class TimerangeSchedule(Schedule, metaclass=ABCMeta):
+ @abstractmethod
+ def getEntries(self):
+ pass
+
+ def getCurrentEntry(self):
+ current = [p for p in self.getEntries() if p.isCurrent(datetime.utcnow())]
+ if current:
+ return current[0]
+ return None
+
+ def getNextEntry(self):
+ s = sorted(self.getEntries(), key=lambda e: e.getNextActivation())
+ if s:
+ return s[0]
+ return None
+
+
+class StaticSchedule(TimerangeSchedule):
+ def __init__(self, scheduleDict):
+ self.entries = []
+ for time, profile in scheduleDict.items():
+ if len(time) != 9:
+ logger.warning("invalid schedule spec: %s", time)
+ continue
+
+ startTime = datetime.strptime(time[0:4], "%H%M").replace(tzinfo=timezone.utc).time()
+ endTime = datetime.strptime(time[5:9], "%H%M").replace(tzinfo=timezone.utc).time()
+ self.entries.append(TimeScheduleEntry(startTime, endTime, profile))
+
+ def getEntries(self):
+ return self.entries
+
+
+class DaylightSchedule(TimerangeSchedule):
+ greyLineTime = timedelta(hours=1)
+
+ def __init__(self, scheduleDict):
+ self.schedule = scheduleDict
+
+ def getSunTimes(self, date):
+ pm = Config.get()
+ lat = pm["receiver_gps"]["lat"]
+ lng = pm["receiver_gps"]["lon"]
+ degtorad = math.pi / 180
+ radtodeg = 180 / math.pi
+
+ #Number of days since 01/01
+ days = date.timetuple().tm_yday
+
+ # Longitudinal correction
+ longCorr = 4 * lng
+
+ # calibrate for solstice
+ b = 2 * math.pi * (days - 81) / 365
+
+ # Equation of Time Correction
+ eoTCorr = 9.87 * math.sin(2 * b) - 7.53 * math.cos(b) - 1.5 * math.sin(b)
+
+ # Solar correction
+ solarCorr = longCorr + eoTCorr
+
+ # Solar declination
+ declination = math.asin(math.sin(23.45 * degtorad) * math.sin(b))
+
+ sunrise = 12 - math.acos(-math.tan(lat * degtorad) * math.tan(declination)) * radtodeg / 15 - solarCorr / 60
+ sunset = 12 + math.acos(-math.tan(lat * degtorad) * math.tan(declination)) * radtodeg / 15 - solarCorr / 60
+
+ midnight = datetime.combine(date, datetime.min.time())
+ sunrise = midnight + timedelta(hours=sunrise)
+ sunset = midnight + timedelta(hours=sunset)
+ logger.debug("for {date} sunrise: {sunrise} sunset {sunset}".format(date=date, sunrise=sunrise, sunset=sunset))
+
+ return sunrise, sunset
+
+ def getEntries(self):
+ now = datetime.utcnow()
+ date = now.date()
+ # greyline is optional, it its set it will shorten the other profiles
+ useGreyline = "greyline" in self.schedule
+ entries = []
+
+ delta = DaylightSchedule.greyLineTime if useGreyline else timedelta()
+ events = []
+ # we need to start yesterday for longitudes close to the date line
+ offset = -1
+ while len(events) < 1:
+ sunrise, sunset = self.getSunTimes(date + timedelta(days=offset))
+ offset += 1
+ events += [{"type": "sunrise", "time": sunrise}, {"type": "sunset", "time": sunset}]
+ # keep only events in the future
+ events = [v for v in events if v["time"] + delta > now]
+ events.sort(key=lambda e: e["time"])
+
+ previousEvent = None
+ for event in events:
+ # night profile _until_ sunrise, day profile _until_ sunset
+ stype = "night" if event["type"] == "sunrise" else "day"
+ if previousEvent is not None or event["time"] - delta > now:
+ start = now if previousEvent is None else previousEvent
+ entries.append(DatetimeScheduleEntry(start, event["time"] - delta, self.schedule[stype]))
+ if useGreyline:
+ entries.append(
+ DatetimeScheduleEntry(event["time"] - delta, event["time"] + delta, self.schedule["greyline"])
+ )
+ previousEvent = event["time"] + delta
+
+ logger.debug([str(e) for e in entries])
+ return entries
+
+
+class ServiceScheduler(object):
+ def __init__(self, source):
+ self.source = source
+ self.selectionTimer = None
+ self.source.addClient(self)
+ props = self.source.getProps()
+ self.schedule = Schedule.parse(props)
+ props.filter("center_freq", "samp_rate").wire(self.onFrequencyChange)
+ self.scheduleSelection()
+
+ def shutdown(self):
+ self.cancelTimer()
+ self.source.removeClient(self)
+
+ def scheduleSelection(self, time=None):
+ if self.source.getState() == SdrSource.STATE_FAILED:
+ return
+ seconds = 10
+ if time is not None:
+ delta = time - datetime.utcnow()
+ seconds = delta.total_seconds()
+ self.cancelTimer()
+ self.selectionTimer = threading.Timer(seconds, self.selectProfile)
+ self.selectionTimer.start()
+
+ def cancelTimer(self):
+ if self.selectionTimer:
+ self.selectionTimer.cancel()
+
+ def getClientClass(self):
+ return SdrSource.CLIENT_BACKGROUND
+
+ def onStateChange(self, state):
+ if state == SdrSource.STATE_STOPPING:
+ self.scheduleSelection()
+ elif state == SdrSource.STATE_FAILED:
+ self.cancelTimer()
+
+ def onBusyStateChange(self, state):
+ if state == SdrSource.BUSYSTATE_IDLE:
+ self.scheduleSelection()
+
+ def onFrequencyChange(self, name, value):
+ self.scheduleSelection()
+
+ def selectProfile(self):
+ if self.source.hasClients(SdrSource.CLIENT_USER):
+ logger.debug("source has active users; not touching")
+ return
+ logger.debug("source seems to be idle, selecting profile for background services")
+ entry = self.schedule.getCurrentEntry()
+
+ if entry is None:
+ logger.debug("schedule did not return a profile. checking next entry...")
+ nextEntry = self.schedule.getNextEntry()
+ if nextEntry is not None:
+ self.scheduleSelection(nextEntry.getNextActivation())
+ return
+
+ logger.debug("selected profile %s until %s", entry.getProfile(), entry.getScheduledEnd())
+ self.scheduleSelection(entry.getScheduledEnd())
+
+ try:
+ self.source.activateProfile(entry.getProfile())
+ self.source.start()
+ except KeyError:
+ pass
diff --git a/owrx/source/__init__.py b/owrx/source/__init__.py
index cb050f2..8720882 100644
--- a/owrx/source/__init__.py
+++ b/owrx/source/__init__.py
@@ -1,4 +1,4 @@
-from owrx.config import PropertyManager
+from owrx.config import Config
import threading
import subprocess
import os
@@ -9,6 +9,7 @@ import signal
from abc import ABC, abstractmethod
from owrx.command import CommandMapper
from owrx.socket import getAvailablePort
+from owrx.property import PropertyStack, PropertyLayer
import logging
@@ -32,12 +33,18 @@ class SdrSource(ABC):
def __init__(self, id, props):
self.id = id
- self.props = props
+
+ self.commandMapper = None
+
+ self.props = PropertyStack()
+ # layer 0 reserved for profile properties
+ self.props.addLayer(1, props)
+ self.props.addLayer(2, Config.get())
+ self.sdrProps = self.props.filter(*self.getEventNames())
+
self.profile_id = None
self.activateProfile()
- self.rtlProps = self.props.collect(*self.getEventNames()).defaults(PropertyManager.getSharedInstance())
self.wireEvents()
- self.commandMapper = None
if "port" in props and props["port"] is not None:
self.port = props["port"]
@@ -66,7 +73,7 @@ class SdrSource(ABC):
"ppm",
"rf_gain",
"lfo_offset",
- ]
+ ] + list(self.getCommandMapper().keys())
def getCommandMapper(self):
if self.commandMapper is None:
@@ -78,7 +85,7 @@ class SdrSource(ABC):
pass
def wireEvents(self):
- self.rtlProps.wire(self.onPropertyChange)
+ self.sdrProps.wire(self.onPropertyChange)
def getCommand(self):
return [self.getCommandMapper().map(self.getCommandValues())]
@@ -93,14 +100,17 @@ class SdrSource(ABC):
if profile_id == self.profile_id:
return
logger.debug("activating profile {0}".format(profile_id))
- self.profile_id = profile_id
- profile = profiles[profile_id]
self.props["profile_id"] = profile_id
+ 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
- self.props[key] = value
+ layer[key] = value
+ self.props.replaceLayer(0, layer)
def getId(self):
return self.id
@@ -121,7 +131,7 @@ class SdrSource(ABC):
return self.port
def getCommandValues(self):
- dict = self.rtlProps.collect(*self.getEventNames()).__dict__()
+ dict = self.sdrProps.__dict__()
if "lfo_offset" in dict and dict["lfo_offset"] is not None:
dict["tuner_freq"] = dict["center_freq"] + dict["lfo_offset"]
else:
@@ -161,7 +171,7 @@ class SdrSource(ABC):
logger.debug("shut down with RC={0}".format(rc))
self.monitor = None
- self.monitor = threading.Thread(target=wait_for_process_to_end)
+ self.monitor = threading.Thread(target=wait_for_process_to_end, name="source_monitor")
self.monitor.start()
retries = 1000
@@ -241,13 +251,14 @@ class SdrSource(ABC):
except ValueError:
pass
+ hasUsers = self.hasClients(SdrSource.CLIENT_USER)
+ self.setBusyState(SdrSource.BUSYSTATE_BUSY if hasUsers else SdrSource.BUSYSTATE_IDLE)
+
# no need to check for users if we are always-on
if self.isAlwaysOn():
return
- hasUsers = self.hasClients(SdrSource.CLIENT_USER)
hasBackgroundTasks = self.hasClients(SdrSource.CLIENT_BACKGROUND)
- self.setBusyState(SdrSource.BUSYSTATE_BUSY if hasUsers else SdrSource.BUSYSTATE_IDLE)
if not hasUsers and not hasBackgroundTasks:
self.stop()
diff --git a/owrx/source/airspy.py b/owrx/source/airspy.py
index 4453da9..97221a3 100644
--- a/owrx/source/airspy.py
+++ b/owrx/source/airspy.py
@@ -3,11 +3,15 @@ from .soapy import SoapyConnectorSource
class AirspySource(SoapyConnectorSource):
- def getCommandMapper(self):
- return super().getCommandMapper().setMappings({"bias_tee": Flag("-t biastee=true")})
+ def getSoapySettingsMappings(self):
+ mappings = super().getSoapySettingsMappings()
+ mappings.update(
+ {
+ "bias_tee": "biastee",
+ "bitpack": "bitpack",
+ }
+ )
+ return mappings
def getDriver(self):
return "airspy"
-
- def getEventNames(self):
- return super().getEventNames() + ["bias_tee"]
diff --git a/owrx/source/connector.py b/owrx/source/connector.py
index c626506..f28da98 100644
--- a/owrx/source/connector.py
+++ b/owrx/source/connector.py
@@ -29,13 +29,6 @@ class ConnectorSource(SdrSource):
}
)
- def getEventNames(self):
- return super().getEventNames() + [
- "device",
- "iqswap",
- "rtltcp_compat",
- ]
-
def sendControlMessage(self, prop, value):
logger.debug("sending property change over control socket: {0} changed to {1}".format(prop, value))
self.controlSocket.sendall("{prop}:{value}\n".format(prop=prop, value=value).encode())
@@ -45,10 +38,10 @@ class ConnectorSource(SdrSource):
return
if (
(prop == "center_freq" or prop == "lfo_offset")
- and "lfo_offset" in self.rtlProps
- and self.rtlProps["lfo_offset"] is not None
+ and "lfo_offset" in self.sdrProps
+ and self.sdrProps["lfo_offset"] is not None
):
- freq = self.rtlProps["center_freq"] + self.rtlProps["lfo_offset"]
+ freq = self.sdrProps["center_freq"] + self.sdrProps["lfo_offset"]
self.sendControlMessage("center_freq", freq)
else:
self.sendControlMessage(prop, value)
diff --git a/owrx/source/direct.py b/owrx/source/direct.py
index a2b26ec..904d2ea 100644
--- a/owrx/source/direct.py
+++ b/owrx/source/direct.py
@@ -23,7 +23,7 @@ class DirectSource(SdrSource, metaclass=ABCMeta):
]
def getNmuxCommand(self):
- props = self.rtlProps
+ props = self.sdrProps
nmux_bufcnt = nmux_bufsize = 0
while nmux_bufsize < props["samp_rate"] / 4:
diff --git a/owrx/source/fcdpp.py b/owrx/source/fcdpp.py
new file mode 100644
index 0000000..bb121ee
--- /dev/null
+++ b/owrx/source/fcdpp.py
@@ -0,0 +1,6 @@
+from owrx.source.soapy import SoapyConnectorSource
+
+
+class FcdppSource(SoapyConnectorSource):
+ def getDriver(self):
+ return "fcdpp"
diff --git a/owrx/source/fifi_sdr.py b/owrx/source/fifi_sdr.py
index f591b52..69babf4 100644
--- a/owrx/source/fifi_sdr.py
+++ b/owrx/source/fifi_sdr.py
@@ -11,16 +11,16 @@ class FifiSdrSource(DirectSource):
def getCommandMapper(self):
return super().getCommandMapper().setBase("arecord").setMappings(
{"device": Option("-D"), "samp_rate": Option("-r")}
- ).setStatic("-f S16_LE -c2 -")
+ ).setStatic("-t raw -f S16_LE -c2 -")
def getEventNames(self):
return super().getEventNames() + ["device"]
def getFormatConversion(self):
- return ["csdr convert_s16_f", "csdr gain_ff 30"]
+ return ["csdr convert_s16_f", "csdr gain_ff 5"]
def sendRockProgFrequency(self, frequency):
- process = Popen(["rockprog", "--vco", "-w", "--", "freq={}".format(frequency / 1E6)])
+ process = Popen(["rockprog", "--vco", "-w", "--freq={}".format(frequency / 1E6)])
process.communicate()
rc = process.wait()
if rc != 0:
diff --git a/owrx/source/hackrf.py b/owrx/source/hackrf.py
index 50001ae..a103218 100644
--- a/owrx/source/hackrf.py
+++ b/owrx/source/hackrf.py
@@ -1,24 +1,11 @@
-from .direct import DirectSource
-from owrx.command import Flag, Option
+from .soapy import SoapyConnectorSource
-class HackrfSource(DirectSource):
- def getCommandMapper(self):
- return super().getCommandMapper().setBase("hackrf_transfer").setMappings(
- {
- "samp_rate": Option("-s"),
- "tuner_freq": Option("-f"),
- "rf_gain": Option("-g"),
- "lna_gain": Option("-l"),
- "rf_amp": Option("-a"),
- }
- ).setStatic("-r-")
+class HackrfSource(SoapyConnectorSource):
+ def getSoapySettingsMappings(self):
+ mappings = super().getSoapySettingsMappings()
+ mappings.update({"bias_tee": "bias_tx"})
+ return mappings
- def getEventNames(self):
- return super().getEventNames() + [
- "lna_gain",
- "rf_amp",
- ]
-
- def getFormatConversion(self):
- return ["csdr convert_s8_f"]
+ def getDriver(self):
+ return "hackrf"
\ No newline at end of file
diff --git a/owrx/source/perseussdr.py b/owrx/source/perseussdr.py
new file mode 100644
index 0000000..bad4f38
--- /dev/null
+++ b/owrx/source/perseussdr.py
@@ -0,0 +1,31 @@
+from .direct import DirectSource
+from owrx.command import Flag, Option
+
+
+#
+# In order to interface Perseus hardware, we resolve to use the
+# perseustest utility that comes with libperseus-sdr support package.
+# Below the base options used are shown:
+#
+# -p output I/Q samples as 32 bits floating point
+# -d -1 suppress debug messages
+# -a don't test attenuators on startup
+# -t 0 runs indefinitely
+# -o - output samples on stdout
+#
+# As we are already returning I/Q samples as pairs of 32 bits
+# floating points (option -p),no need for further conversions,
+# so the method getFormatConversion(self) is not implemented at all.
+
+class PerseussdrSource(DirectSource):
+ def getCommandMapper(self):
+ return super().getCommandMapper().setBase("perseustest -p -d -1 -a -t 0 -o - ").setMappings(
+ {
+ "samp_rate": Option("-s"),
+ "tuner_freq": Option("-f"),
+ "attenuator": Option("-u"),
+ "adc_preamp": Option("-m"),
+ "adc_dither": Option("-x"),
+ "wideband": Option("-w"),
+ }
+ )
diff --git a/owrx/source/radioberry.py b/owrx/source/radioberry.py
new file mode 100644
index 0000000..5bb95c7
--- /dev/null
+++ b/owrx/source/radioberry.py
@@ -0,0 +1,6 @@
+from .soapy import SoapyConnectorSource
+
+
+class RadioberrySource(SoapyConnectorSource):
+ def getDriver(self):
+ return "radioberry"
diff --git a/owrx/source/red_pitaya.py b/owrx/source/red_pitaya.py
new file mode 100644
index 0000000..06431f0
--- /dev/null
+++ b/owrx/source/red_pitaya.py
@@ -0,0 +1,6 @@
+from .soapy import SoapyConnectorSource
+
+
+class RedPitayaSource(SoapyConnectorSource):
+ def getDriver(self):
+ return "redpitaya"
diff --git a/owrx/source/resampler.py b/owrx/source/resampler.py
index 6afe50c..1f6a4e1 100644
--- a/owrx/source/resampler.py
+++ b/owrx/source/resampler.py
@@ -1,10 +1,4 @@
from .direct import DirectSource
-from . import SdrSource
-import subprocess
-import threading
-import os
-import socket
-import time
import logging
@@ -29,7 +23,7 @@ class Resampler(DirectSource):
def getCommand(self):
return [
"nc -v 127.0.0.1 {nc_port}".format(nc_port=self.sdr.getPort()),
- "csdr shift_addition_cc {shift}".format(shift=self.shift),
+ "csdr shift_addfast_cc {shift}".format(shift=self.shift),
"csdr fir_decimate_cc {decimation} {ddc_transition_bw} HAMMING".format(
decimation=self.decimation, ddc_transition_bw=self.transition_bw
),
diff --git a/owrx/source/rtl_sdr.py b/owrx/source/rtl_sdr.py
index 64badf1..a6ecbc9 100644
--- a/owrx/source/rtl_sdr.py
+++ b/owrx/source/rtl_sdr.py
@@ -1,6 +1,12 @@
from .connector import ConnectorSource
+from owrx.command import Flag, Option
class RtlSdrSource(ConnectorSource):
def getCommandMapper(self):
- return super().getCommandMapper().setBase("rtl_connector")
+ return (
+ super()
+ .getCommandMapper()
+ .setBase("rtl_connector")
+ .setMappings({"bias_tee": Flag("-b"), "direct_sampling": Option("-e")})
+ )
diff --git a/owrx/source/rtl_sdr_soapy.py b/owrx/source/rtl_sdr_soapy.py
index 24fe9f4..55744d5 100644
--- a/owrx/source/rtl_sdr_soapy.py
+++ b/owrx/source/rtl_sdr_soapy.py
@@ -1,13 +1,11 @@
from .soapy import SoapyConnectorSource
-from owrx.command import Option
class RtlSdrSoapySource(SoapyConnectorSource):
- def getCommandMapper(self):
- return super().getCommandMapper().setMappings({"direct_sampling": Option("-t direct_samp").setSpacer("=")})
+ def getSoapySettingsMappings(self):
+ mappings = super().getSoapySettingsMappings()
+ mappings.update({"direct_sampling": "direct_samp", "bias_tee": "biastee"})
+ return mappings
def getDriver(self):
return "rtlsdr"
-
- def getEventNames(self):
- return super().getEventNames() + ["direct_sampling"]
diff --git a/owrx/source/rtl_tcp.py b/owrx/source/rtl_tcp.py
new file mode 100644
index 0000000..03c3109
--- /dev/null
+++ b/owrx/source/rtl_tcp.py
@@ -0,0 +1,16 @@
+from .connector import ConnectorSource
+from owrx.command import Flag, Option, Argument
+
+
+class RtlTcpSource(ConnectorSource):
+ def getCommandMapper(self):
+ return (
+ super()
+ .getCommandMapper()
+ .setBase("rtl_tcp_connector")
+ .setMappings({
+ "bias_tee": Flag("-b"),
+ "direct_sampling": Option("-e"),
+ "remote": Argument(),
+ })
+ )
diff --git a/owrx/source/sdrplay.py b/owrx/source/sdrplay.py
index dac2983..da2398a 100644
--- a/owrx/source/sdrplay.py
+++ b/owrx/source/sdrplay.py
@@ -2,5 +2,17 @@ from .soapy import SoapyConnectorSource
class SdrplaySource(SoapyConnectorSource):
+ def getSoapySettingsMappings(self):
+ mappings = super().getSoapySettingsMappings()
+ mappings.update(
+ {
+ "bias_tee": "biasT_ctrl",
+ "rf_notch": "rfnotch_ctrl",
+ "dab_notch": "dabnotch_ctrl",
+ "if_mode": "if_mode",
+ }
+ )
+ return mappings
+
def getDriver(self):
return "sdrplay"
diff --git a/owrx/source/soapy.py b/owrx/source/soapy.py
index 0810884..e713ba0 100644
--- a/owrx/source/soapy.py
+++ b/owrx/source/soapy.py
@@ -1,12 +1,16 @@
from abc import ABCMeta, abstractmethod
from owrx.command import Option
-
from .connector import ConnectorSource
class SoapyConnectorSource(ConnectorSource, metaclass=ABCMeta):
def getCommandMapper(self):
- return super().getCommandMapper().setBase("soapy_connector").setMappings({"antenna": Option("-a")})
+ return super().getCommandMapper().setBase("soapy_connector").setMappings(
+ {
+ "antenna": Option("-a"),
+ "soapy_settings": Option("-t"),
+ }
+ )
"""
must be implemented by child classes to be able to build a driver-based device selector by default.
@@ -18,9 +22,7 @@ class SoapyConnectorSource(ConnectorSource, metaclass=ABCMeta):
pass
def getEventNames(self):
- return super().getEventNames() + [
- "antenna",
- ]
+ return super().getEventNames() + list(self.getSoapySettingsMappings().keys())
def parseDeviceString(self, dstr):
def decodeComponent(c):
@@ -41,18 +43,46 @@ class SoapyConnectorSource(ConnectorSource, metaclass=ABCMeta):
return ",".join([encodeComponent(c) for c in dobj])
- """
- this method always attempts to inject a driver= part into the soapysdr query, depending on what connector was used.
- this prevents the soapy_connector from using the wrong device in scenarios where there's no same-type sdrs.
- """
+ def buildSoapyDeviceParameters(self, parsed, values):
+ """
+ this method always attempts to inject a driver= part into the soapysdr query, depending on what connector was used.
+ this prevents the soapy_connector from using the wrong device in scenarios where there's no same-type sdrs.
+ """
+ parsed = [v for v in parsed if "driver" not in v]
+ parsed += [{"driver": self.getDriver()}]
+ return parsed
+
+ def getSoapySettingsMappings(self):
+ return {}
+
+ def buildSoapySettings(self, values):
+ settings = {}
+ for k, v in self.getSoapySettingsMappings().items():
+ if k in values and values[k] is not None:
+ settings[v] = self.convertSoapySettingsValue(values[k])
+ return settings
+
+ def convertSoapySettingsValue(self, value):
+ if isinstance(value, bool):
+ return "true" if value else "false"
+ return value
def getCommandValues(self):
values = super().getCommandValues()
if "device" in values and values["device"] is not None:
parsed = self.parseDeviceString(values["device"])
- parsed = [v for v in parsed if "driver" not in v]
- parsed += [{"driver": self.getDriver()}]
- values["device"] = self.encodeDeviceString(parsed)
else:
- values["device"] = "driver={0}".format(self.getDriver())
+ parsed = []
+ modified = self.buildSoapyDeviceParameters(parsed, values)
+ values["device"] = self.encodeDeviceString(modified)
+ settings = ",".join(["{0}={1}".format(k, v) for k, v in self.buildSoapySettings(values).items()])
+ if len(settings):
+ values["soapy_settings"] = settings
return values
+
+ def onPropertyChange(self, prop, value):
+ mappings = self.getSoapySettingsMappings()
+ if prop in mappings.keys():
+ value = "{0}={1}".format(mappings[prop], self.convertSoapySettingsValue(value))
+ prop = "settings"
+ super().onPropertyChange(prop, value)
diff --git a/owrx/source/soapy_remote.py b/owrx/source/soapy_remote.py
new file mode 100644
index 0000000..53a3196
--- /dev/null
+++ b/owrx/source/soapy_remote.py
@@ -0,0 +1,17 @@
+from .soapy import SoapyConnectorSource
+
+
+class SoapyRemoteSource(SoapyConnectorSource):
+ def getEventNames(self):
+ return super().getEventNames() + ["remote", "remote_driver"]
+
+ def getDriver(self):
+ return "remote"
+
+ def buildSoapyDeviceParameters(self, parsed, values):
+ params = super().buildSoapyDeviceParameters(parsed, values)
+ params = [v for v in params if not "remote" in params]
+ params += [{"remote": values["remote"]}]
+ if "remote_driver" in values and values["remote_driver"] is not None:
+ params += [{"remote:driver": values["remote_driver"]}]
+ return params
diff --git a/owrx/source/uhd.py b/owrx/source/uhd.py
new file mode 100644
index 0000000..29e2909
--- /dev/null
+++ b/owrx/source/uhd.py
@@ -0,0 +1,6 @@
+from .soapy import SoapyConnectorSource
+
+
+class UhdSource(SoapyConnectorSource):
+ def getDriver(self):
+ return "uhd"
diff --git a/owrx/users.py b/owrx/users.py
new file mode 100644
index 0000000..43e0865
--- /dev/null
+++ b/owrx/users.py
@@ -0,0 +1,80 @@
+from abc import ABC, abstractmethod
+import json
+
+import logging
+
+logger = logging.getLogger(__name__)
+
+
+class PasswordException(Exception):
+ pass
+
+
+class Password(ABC):
+ @staticmethod
+ def from_dict(d: dict):
+ if "encoding" not in d:
+ raise PasswordException("password encoding not set")
+ if d["encoding"] == "string":
+ return CleartextPassword(d)
+ raise PasswordException("invalid passord encoding: {0}".format(d["type"]))
+
+ def __init__(self, pwinfo: dict):
+ self.pwinfo = pwinfo
+
+ @abstractmethod
+ def is_valid(self, inp: str):
+ pass
+
+
+class CleartextPassword(Password):
+ def is_valid(self, inp: str):
+ return self.pwinfo['value'] == inp
+
+
+class User(object):
+ def __init__(self, name: str, enabled: bool, password: Password):
+ self.name = name
+ self.enabled = enabled
+ self.password = password
+
+
+class UserList(object):
+ sharedInstance = None
+
+ @staticmethod
+ def getSharedInstance():
+ if UserList.sharedInstance is None:
+ UserList.sharedInstance = UserList()
+ return UserList.sharedInstance
+
+ def __init__(self):
+ self.users = self._loadUsers()
+
+ def _loadUsers(self):
+ for file in ["/etc/openwebrx/users.json", "users.json"]:
+ try:
+ f = open(file, "r")
+ users_json = json.load(f)
+ f.close()
+
+ return {u.name: u for u in [self.buildUser(d) for d in users_json]}
+ except FileNotFoundError:
+ pass
+ except json.JSONDecodeError:
+ logger.exception("error while parsing users file %s", file)
+ return {}
+ except Exception:
+ logger.exception("error while processing users from %s", file)
+ return {}
+ return {}
+
+ def buildUser(self, d):
+ if "user" in d and "password" in d and "enabled" in d:
+ return User(d["user"], d["enabled"], Password.from_dict(d["password"]))
+
+ def __getitem__(self, item):
+ return self.users[item]
+
+ def __contains__(self, item):
+ return item in self.users
diff --git a/owrx/version.py b/owrx/version.py
index a040f27..60a4954 100644
--- a/owrx/version.py
+++ b/owrx/version.py
@@ -1,5 +1,5 @@
-from distutils.version import StrictVersion
+from distutils.version import LooseVersion
-_versionstring = "0.18.0"
-strictversion = StrictVersion(_versionstring)
-openwebrx_version = "v{0}".format(strictversion)
+_versionstring = "0.20.0-dev"
+looseversion = LooseVersion(_versionstring)
+openwebrx_version = "v{0}".format(looseversion)
diff --git a/owrx/websocket.py b/owrx/websocket.py
index a5fb188..4908e72 100644
--- a/owrx/websocket.py
+++ b/owrx/websocket.py
@@ -16,7 +16,7 @@ OPCODE_PING = 0x09
OPCODE_PONG = 0x0A
-class WebSocketException(Exception):
+class WebSocketException(IOError):
pass
@@ -146,6 +146,9 @@ class WebSocketConnection(object):
self.close()
def interrupt(self):
+ if self.interruptPipeSend is None:
+ logger.debug("interrupt with closed pipe")
+ return
self.interruptPipeSend.send(bytes(0x00))
def handle(self):
@@ -199,9 +202,15 @@ class WebSocketConnection(object):
data = bytes([b ^ masking_key[index % 4] for (index, b) in enumerate(data)])
if opcode == OPCODE_TEXT_MESSAGE:
message = data.decode("utf-8")
- self.messageHandler.handleTextMessage(self, message)
+ try:
+ self.messageHandler.handleTextMessage(self, message)
+ except Exception:
+ logger.exception("Exception in websocket handler handleTextMessage()")
elif opcode == OPCODE_BINARY_MESSAGE:
- self.messageHandler.handleBinaryMessage(self, data)
+ try:
+ self.messageHandler.handleBinaryMessage(self, data)
+ except Exception:
+ logger.exception("Exception in websocket handler handleBinaryMessage()")
elif opcode == OPCODE_PING:
self.sendPong()
elif opcode == OPCODE_PONG:
@@ -221,6 +230,18 @@ class WebSocketConnection(object):
logger.exception("OSError while reading data; closing connection")
self.open = False
+ self.interruptPipeSend.close()
+ self.interruptPipeSend = None
+ # drain messages left in the queue so that the queue can be successfully closed
+ # this is necessary since python keeps the file descriptors open otherwise
+ try:
+ while True:
+ self.interruptPipeRecv.recv()
+ except EOFError:
+ pass
+ self.interruptPipeRecv.close()
+ self.interruptPipeRecv = None
+
def close(self):
self.open = False
self.interrupt()
diff --git a/owrx/wsjt.py b/owrx/wsjt.py
index 4818201..046ae7d 100644
--- a/owrx/wsjt.py
+++ b/owrx/wsjt.py
@@ -1,199 +1,21 @@
-import threading
-import wave
-from datetime import datetime, timedelta, timezone
-import subprocess
-import os
-from multiprocessing.connection import Pipe
+from datetime import datetime, timezone
from owrx.map import Map, LocatorLocation
import re
-from queue import Queue, Full
-from owrx.config import PropertyManager
-from owrx.bands import Bandplan
-from owrx.metrics import Metrics, CounterMetric, DirectMetric
+from owrx.metrics import Metrics, CounterMetric
from owrx.pskreporter import PskReporter
from owrx.parser import Parser
+from owrx.audio import AudioChopperProfile
+from abc import ABC, ABCMeta, abstractmethod
+from owrx.config import Config
import logging
logger = logging.getLogger(__name__)
-logger.setLevel(logging.INFO)
-class WsjtQueueWorker(threading.Thread):
- def __init__(self, queue):
- self.queue = queue
- self.doRun = True
- super().__init__(daemon=True)
-
- def run(self) -> None:
- while self.doRun:
- (processor, file) = self.queue.get()
- try:
- logger.debug("processing file %s", file)
- processor.decode(file)
- except Exception:
- logger.exception("failed to decode job")
- self.queue.onError()
- self.queue.task_done()
-
-
-class WsjtQueue(Queue):
- sharedInstance = None
- creationLock = threading.Lock()
-
- @staticmethod
- def getSharedInstance():
- with WsjtQueue.creationLock:
- if WsjtQueue.sharedInstance is None:
- pm = PropertyManager.getSharedInstance()
- WsjtQueue.sharedInstance = WsjtQueue(maxsize=pm["wsjt_queue_length"], workers=pm["wsjt_queue_workers"])
- return WsjtQueue.sharedInstance
-
- def __init__(self, maxsize, workers):
- super().__init__(maxsize)
- metrics = Metrics.getSharedInstance()
- metrics.addMetric("wsjt.queue.length", DirectMetric(self.qsize))
- self.inCounter = CounterMetric()
- metrics.addMetric("wsjt.queue.in", self.inCounter)
- self.outCounter = CounterMetric()
- metrics.addMetric("wsjt.queue.out", self.outCounter)
- self.overflowCounter = CounterMetric()
- metrics.addMetric("wsjt.queue.overflow", self.overflowCounter)
- self.errorCounter = CounterMetric()
- metrics.addMetric("wsjt.queue.error", self.errorCounter)
- self.workers = [self.newWorker() for _ in range(0, workers)]
-
- def put(self, item):
- self.inCounter.inc()
- try:
- super(WsjtQueue, self).put(item, block=False)
- except Full:
- self.overflowCounter.inc()
- raise
-
- def get(self, **kwargs):
- # super.get() is blocking, so it would mess up the stats to inc() first
- out = super(WsjtQueue, self).get(**kwargs)
- self.outCounter.inc()
- return out
-
- def newWorker(self):
- worker = WsjtQueueWorker(self)
- worker.start()
- return worker
-
- def onError(self):
- self.errorCounter.inc()
-
-
-class WsjtChopper(threading.Thread):
- def __init__(self, source):
- self.source = source
- self.tmp_dir = PropertyManager.getSharedInstance()["temporary_directory"]
- (self.wavefilename, self.wavefile) = self.getWaveFile()
- self.switchingLock = threading.Lock()
- self.timer = None
- (self.outputReader, self.outputWriter) = Pipe()
- self.doRun = True
- super().__init__()
-
- def getWaveFile(self):
- filename = "{tmp_dir}/openwebrx-wsjtchopper-{id}-{timestamp}.wav".format(
- tmp_dir=self.tmp_dir, id=id(self), timestamp=datetime.utcnow().strftime(self.fileTimestampFormat)
- )
- wavefile = wave.open(filename, "wb")
- wavefile.setnchannels(1)
- wavefile.setsampwidth(2)
- wavefile.setframerate(12000)
- return filename, wavefile
-
- def getNextDecodingTime(self):
- t = datetime.utcnow()
- zeroed = t.replace(minute=0, second=0, microsecond=0)
- delta = t - zeroed
- seconds = (int(delta.total_seconds() / self.interval) + 1) * self.interval
- t = zeroed + timedelta(seconds=seconds)
- logger.debug("scheduling: {0}".format(t))
- return t
-
- def cancelTimer(self):
- if self.timer:
- self.timer.cancel()
-
- def _scheduleNextSwitch(self):
- if self.doRun:
- delta = self.getNextDecodingTime() - datetime.utcnow()
- self.timer = threading.Timer(delta.total_seconds(), self.switchFiles)
- self.timer.start()
-
- def switchFiles(self):
- self.switchingLock.acquire()
- file = self.wavefile
- filename = self.wavefilename
- (self.wavefilename, self.wavefile) = self.getWaveFile()
- self.switchingLock.release()
-
- file.close()
- try:
- WsjtQueue.getSharedInstance().put((self, filename))
- except Full:
- logger.warning("wsjt decoding queue overflow; dropping one file")
- os.unlink(filename)
- self._scheduleNextSwitch()
-
- def decoder_commandline(self, file):
- """
- must be overridden in child classes
- """
- return []
-
- def decode(self, file):
- decoder = subprocess.Popen(
- ["nice", "-n", "10"] + self.decoder_commandline(file),
- stdout=subprocess.PIPE,
- cwd=self.tmp_dir,
- close_fds=True,
- )
- for line in decoder.stdout:
- self.outputWriter.send(line)
- try:
- rc = decoder.wait(timeout=10)
- if rc != 0:
- logger.warning("decoder return code: %i", rc)
- except subprocess.TimeoutExpired:
- logger.warning("subprocess (pid=%i}) did not terminate correctly; sending kill signal.", decoder.pid)
- decoder.kill()
- os.unlink(file)
-
- def run(self) -> None:
- logger.debug("WSJT chopper starting up")
- self._scheduleNextSwitch()
- while self.doRun:
- data = self.source.read(256)
- if data is None or (isinstance(data, bytes) and len(data) == 0):
- self.doRun = False
- else:
- self.switchingLock.acquire()
- self.wavefile.writeframes(data)
- self.switchingLock.release()
-
- logger.debug("WSJT chopper shutting down")
- self.outputReader.close()
- self.outputWriter.close()
- self.cancelTimer()
- try:
- os.unlink(self.wavefilename)
- except Exception:
- logger.exception("error removing undecoded file")
-
- def read(self):
- try:
- return self.outputReader.recv()
- except EOFError:
- return None
-
+class WsjtProfile(AudioChopperProfile, metaclass=ABCMeta):
def decoding_depth(self, mode):
- pm = PropertyManager.getSharedInstance()
+ pm = Config.get()
# mode-specific setting?
if "wsjt_decoding_depths" in pm and mode in pm["wsjt_decoding_depths"]:
return pm["wsjt_decoding_depths"][mode]
@@ -204,21 +26,23 @@ class WsjtChopper(threading.Thread):
return 3
-class Ft8Chopper(WsjtChopper):
- def __init__(self, source):
- self.interval = 15
- self.fileTimestampFormat = "%y%m%d_%H%M%S"
- super().__init__(source)
+class Ft8Profile(WsjtProfile):
+ def getInterval(self):
+ return 15
+
+ def getFileTimestampFormat(self):
+ return "%y%m%d_%H%M%S"
def decoder_commandline(self, file):
return ["jt9", "--ft8", "-d", str(self.decoding_depth("ft8")), file]
-class WsprChopper(WsjtChopper):
- def __init__(self, source):
- self.interval = 120
- self.fileTimestampFormat = "%y%m%d_%H%M"
- super().__init__(source)
+class WsprProfile(WsjtProfile):
+ def getInterval(self):
+ return 120
+
+ def getFileTimestampFormat(self):
+ return "%y%m%d_%H%M"
def decoder_commandline(self, file):
cmd = ["wsprd"]
@@ -228,31 +52,34 @@ class WsprChopper(WsjtChopper):
return cmd
-class Jt65Chopper(WsjtChopper):
- def __init__(self, source):
- self.interval = 60
- self.fileTimestampFormat = "%y%m%d_%H%M"
- super().__init__(source)
+class Jt65Profile(WsjtProfile):
+ def getInterval(self):
+ return 60
+
+ def getFileTimestampFormat(self):
+ return "%y%m%d_%H%M"
def decoder_commandline(self, file):
return ["jt9", "--jt65", "-d", str(self.decoding_depth("jt65")), file]
-class Jt9Chopper(WsjtChopper):
- def __init__(self, source):
- self.interval = 60
- self.fileTimestampFormat = "%y%m%d_%H%M"
- super().__init__(source)
+class Jt9Profile(WsjtProfile):
+ def getInterval(self):
+ return 60
+
+ def getFileTimestampFormat(self):
+ return "%y%m%d_%H%M"
def decoder_commandline(self, file):
return ["jt9", "--jt9", "-d", str(self.decoding_depth("jt9")), file]
-class Ft4Chopper(WsjtChopper):
- def __init__(self, source):
- self.interval = 7.5
- self.fileTimestampFormat = "%y%m%d_%H%M%S"
- super().__init__(source)
+class Ft4Profile(WsjtProfile):
+ def getInterval(self):
+ return 7.5
+
+ def getFileTimestampFormat(self):
+ return "%y%m%d_%H%M%S"
def decoder_commandline(self, file):
return ["jt9", "--ft4", "-d", str(self.decoding_depth("ft4")), file]
@@ -261,32 +88,35 @@ class Ft4Chopper(WsjtChopper):
class WsjtParser(Parser):
modes = {"~": "FT8", "#": "JT65", "@": "JT9", "+": "FT4"}
- def parse(self, data):
- try:
- msg = data.decode().rstrip()
- # known debug messages we know to skip
- if msg.startswith(""):
- return
- if msg.startswith(" EOF on input file"):
- return
+ def parse(self, messages):
+ for data in messages:
+ try:
+ freq, raw_msg = data
+ self.setDialFrequency(freq)
+ msg = raw_msg.decode().rstrip()
+ # known debug messages we know to skip
+ if msg.startswith(""):
+ return
+ if msg.startswith(" EOF on input file"):
+ return
- modes = list(WsjtParser.modes.keys())
- if msg[21] in modes or msg[19] in modes:
- decoder = Jt9Decoder()
- else:
- decoder = WsprDecoder()
- out = decoder.parse(msg, self.dial_freq)
- if "mode" in out:
- self.pushDecode(out["mode"])
- if "callsign" in out and "locator" in out:
- Map.getSharedInstance().updateLocation(
- out["callsign"], LocatorLocation(out["locator"]), out["mode"], self.band
- )
- PskReporter.getSharedInstance().spot(out)
+ modes = list(WsjtParser.modes.keys())
+ if msg[21] in modes or msg[19] in modes:
+ decoder = Jt9Decoder()
+ else:
+ decoder = WsprDecoder()
+ out = decoder.parse(msg, freq)
+ if "mode" in out:
+ self.pushDecode(out["mode"])
+ if "callsign" in out and "locator" in out:
+ Map.getSharedInstance().updateLocation(
+ out["callsign"], LocatorLocation(out["locator"]), out["mode"], self.band
+ )
+ PskReporter.getSharedInstance().spot(out)
- self.handler.write_wsjt_message(out)
- except ValueError:
- logger.exception("error while parsing wsjt message")
+ self.handler.write_wsjt_message(out)
+ except ValueError:
+ logger.exception("error while parsing wsjt message")
def pushDecode(self, mode):
metrics = Metrics.getSharedInstance()
@@ -308,13 +138,17 @@ class WsjtParser(Parser):
metric.inc()
-class Decoder(object):
+class Decoder(ABC):
def parse_timestamp(self, instring, dateformat):
ts = datetime.strptime(instring, dateformat)
return int(
datetime.combine(datetime.utcnow().date(), ts.time()).replace(tzinfo=timezone.utc).timestamp() * 1000
)
+ @abstractmethod
+ def parse(self, msg, dial_freq):
+ pass
+
class Jt9Decoder(Decoder):
locator_pattern = re.compile("[A-Z0-9]+\\s([A-Z0-9]+)\\s([A-R]{2}[0-9]{2})$")
diff --git a/push.sh b/push.sh
index 1bb5fde..bca5cca 100755
--- a/push.sh
+++ b/push.sh
@@ -1,26 +1,7 @@
#!/bin/bash
set -euxo pipefail
-
-ARCH=$(uname -m)
-
-ALL_ARCHS="x86_64 armv7l aarch64"
-TAG="latest"
-ARCHTAG="$TAG-$ARCH"
-
-IMAGES="openwebrx-rtlsdr openwebrx-sdrplay openwebrx-hackrf openwebrx-airspy openwebrx-rtlsdr-soapy openwebrx-full openwebrx"
+. docker/env
for image in ${IMAGES}; do
docker push jketterl/$image:$ARCHTAG
-done
-
-for image in ${IMAGES}; do
- # there's no docker manifest rm command, and the create --amend does not work, so we have to clean up manually
- rm -rf "${HOME}/.docker/manifests/docker.io_jketterl_${image}-${TAG}"
- IMAGE_LIST=""
- for a in $ALL_ARCHS; do
- IMAGE_LIST="$IMAGE_LIST jketterl/$image:$TAG-$a"
- done
- docker manifest create jketterl/$image:$TAG $IMAGE_LIST
- docker manifest push --purge jketterl/$image:$TAG
- docker pull jketterl/$image:$TAG
-done
+done
\ No newline at end of file
diff --git a/sdrhu.py b/sdrhu.py
deleted file mode 100755
index d74d166..0000000
--- a/sdrhu.py
+++ /dev/null
@@ -1,32 +0,0 @@
-#!/usr/bin/python2
-"""
-
- This file is part of OpenWebRX,
- an open-source SDR receiver software with a web UI.
- Copyright (c) 2013-2015 by Andras Retzler
- Copyright (c) 2019 by Jakob Ketterl
-
- This program is free software: you can redistribute it and/or modify
- it under the terms of the GNU Affero General Public License as
- published by the Free Software Foundation, either version 3 of the
- License, or (at your option) any later version.
-
- This program is distributed in the hope that it will be useful,
- but WITHOUT ANY WARRANTY; without even the implied warranty of
- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- GNU Affero General Public License for more details.
-
- You should have received a copy of the GNU Affero General Public License
- along with this program. If not, see .
-
-"""
-
-from owrx.sdrhu import SdrHuUpdater
-from owrx.config import PropertyManager
-
-if __name__ == "__main__":
- pm = PropertyManager.getSharedInstance().loadConfig()
-
- if not "sdrhu_key" in pm:
- exit(1)
- SdrHuUpdater().update()
diff --git a/setup.py b/setup.py
index 03c0a30..e8d7524 100644
--- a/setup.py
+++ b/setup.py
@@ -1,6 +1,6 @@
from glob import glob
from setuptools import setup
-from owrx.version import strictversion
+from owrx.version import looseversion
try:
from setuptools import find_namespace_packages
@@ -10,12 +10,11 @@ except ImportError:
setup(
name="OpenWebRX",
- version=str(strictversion),
- packages=find_namespace_packages(include=["owrx", "owrx.source", "csdr", "htdocs"]),
+ version=str(looseversion),
+ packages=find_namespace_packages(include=["owrx", "owrx.source", "owrx.service", "owrx.controllers", "owrx.property", "owrx.form", "csdr", "htdocs"]),
package_data={"htdocs": [f[len("htdocs/") :] for f in glob("htdocs/**/*", recursive=True)]},
entry_points={"console_scripts": ["openwebrx=owrx.__main__:main"]},
- # use the github page for now
- url="https://github.com/jketterl/openwebrx",
+ url="https://www.openwebrx.de/",
author="Jakob Ketterl",
author_email="jakob.ketterl@gmx.de",
maintainer="Jakob Ketterl",
diff --git a/systemd/openwebrx.service b/systemd/openwebrx.service
index 5865022..ab4ad9f 100644
--- a/systemd/openwebrx.service
+++ b/systemd/openwebrx.service
@@ -4,10 +4,10 @@ After=multi-user.target
[Service]
Type=simple
-User=root
-Group=root
+User=openwebrx
+Group=openwebrx
ExecStart=/usr/bin/openwebrx
-Restart=on-failure
+Restart=always
[Install]
WantedBy=multi-user.target
diff --git a/test/__init__.py b/test/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/test/property/__init__.py b/test/property/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/test/property/test_property_filter.py b/test/property/test_property_filter.py
new file mode 100644
index 0000000..66eff3b
--- /dev/null
+++ b/test/property/test_property_filter.py
@@ -0,0 +1,51 @@
+from unittest import TestCase
+from unittest.mock import Mock
+from owrx.property import PropertyLayer, PropertyFilter
+
+
+class PropertyFilterTest(TestCase):
+
+ def testPassesProperty(self):
+ pm = PropertyLayer()
+ pm["testkey"] = "testvalue"
+ pf = PropertyFilter(pm, "testkey")
+ self.assertEqual(pf["testkey"], "testvalue")
+
+ def testMissesPropert(self):
+ pm = PropertyLayer()
+ pm["testkey"] = "testvalue"
+ pf = PropertyFilter(pm, "other_key")
+ self.assertFalse("testkey" in pf)
+ with self.assertRaises(KeyError):
+ x = pf["testkey"]
+
+ def testForwardsEvent(self):
+ pm = PropertyLayer()
+ pf = PropertyFilter(pm, "testkey")
+ mock = Mock()
+ pf.wire(mock.method)
+ pm["testkey"] = "testvalue"
+ mock.method.assert_called_once_with("testkey", "testvalue")
+
+ def testForwardsPropertyEvent(self):
+ pm = PropertyLayer()
+ pf = PropertyFilter(pm, "testkey")
+ mock = Mock()
+ pf.wireProperty("testkey", mock.method)
+ pm["testkey"] = "testvalue"
+ mock.method.assert_called_once_with("testvalue")
+
+ def testForwardsWrite(self):
+ pm = PropertyLayer()
+ pf = PropertyFilter(pm, "testkey")
+ pf["testkey"] = "testvalue"
+ self.assertTrue("testkey" in pm)
+ self.assertEqual(pm["testkey"], "testvalue")
+
+ def testOverwrite(self):
+ pm = PropertyLayer()
+ pm["testkey"] = "old value"
+ pf = PropertyFilter(pm, "testkey")
+ pf["testkey"] = "new value"
+ self.assertEqual(pm["testkey"], "new value")
+ self.assertEqual(pf["testkey"], "new value")
diff --git a/test/property/test_property_layer.py b/test/property/test_property_layer.py
new file mode 100644
index 0000000..472c14f
--- /dev/null
+++ b/test/property/test_property_layer.py
@@ -0,0 +1,60 @@
+from owrx.property import PropertyLayer
+from unittest import TestCase
+from unittest.mock import Mock
+
+
+class PropertyLayerTest(TestCase):
+ def testKeyIsset(self):
+ pm = PropertyLayer()
+ self.assertFalse("some_key" in pm)
+
+ def testKeyError(self):
+ pm = PropertyLayer()
+ with self.assertRaises(KeyError):
+ x = pm["some_key"]
+
+ def testSubscription(self):
+ pm = PropertyLayer()
+ pm["testkey"] = "before"
+ mock = Mock()
+ pm.wire(mock.method)
+ pm["testkey"] = "after"
+ mock.method.assert_called_once_with("testkey", "after")
+
+ def testUnsubscribe(self):
+ pm = PropertyLayer()
+ pm["testkey"] = "before"
+ mock = Mock()
+ sub = pm.wire(mock.method)
+ pm["testkey"] = "between"
+ mock.method.assert_called_once_with("testkey", "between")
+
+ mock.reset_mock()
+ pm.unwire(sub)
+ pm["testkey"] = "after"
+ mock.method.assert_not_called()
+
+ def testContains(self):
+ pm = PropertyLayer()
+ pm["testkey"] = "value"
+ self.assertTrue("testkey" in pm)
+
+ def testDoesNotContain(self):
+ pm = PropertyLayer()
+ self.assertFalse("testkey" in pm)
+
+ def testSubscribeBeforeSet(self):
+ pm = PropertyLayer()
+ mock = Mock()
+ pm.wireProperty("testkey", mock.method)
+ mock.method.assert_not_called()
+ pm["testkey"] = "newvalue"
+ mock.method.assert_called_once_with("newvalue")
+
+ def testEventPreventedWhenValueUnchanged(self):
+ pm = PropertyLayer()
+ pm["testkey"] = "testvalue"
+ mock = Mock()
+ pm.wire(mock.method)
+ pm["testkey"] = "testvalue"
+ mock.method.assert_not_called()
diff --git a/test/property/test_property_stack.py b/test/property/test_property_stack.py
new file mode 100644
index 0000000..31e3b36
--- /dev/null
+++ b/test/property/test_property_stack.py
@@ -0,0 +1,187 @@
+from unittest import TestCase
+from unittest.mock import Mock
+from owrx.property import PropertyLayer, PropertyStack
+
+
+class PropertyStackTest(TestCase):
+ def testLayer(self):
+ om = PropertyStack()
+ pm = PropertyLayer()
+ pm["testkey"] = "testvalue"
+ om.addLayer(1, pm)
+ self.assertEqual(om["testkey"], "testvalue")
+
+ def testHighPriority(self):
+ om = PropertyStack()
+ low_pm = PropertyLayer()
+ high_pm = PropertyLayer()
+ low_pm["testkey"] = "low value"
+ high_pm["testkey"] = "high value"
+ om.addLayer(1, low_pm)
+ om.addLayer(0, high_pm)
+ self.assertEqual(om["testkey"], "high value")
+
+ def testPriorityFallback(self):
+ om = PropertyStack()
+ low_pm = PropertyLayer()
+ high_pm = PropertyLayer()
+ low_pm["testkey"] = "low value"
+ om.addLayer(1, low_pm)
+ om.addLayer(0, high_pm)
+ self.assertEqual(om["testkey"], "low value")
+
+ def testLayerRemoval(self):
+ om = PropertyStack()
+ low_pm = PropertyLayer()
+ high_pm = PropertyLayer()
+ low_pm["testkey"] = "low value"
+ high_pm["testkey"] = "high value"
+ om.addLayer(1, low_pm)
+ om.addLayer(0, high_pm)
+ self.assertEqual(om["testkey"], "high value")
+ om.removeLayer(high_pm)
+ self.assertEqual(om["testkey"], "low value")
+
+ def testPropertyChange(self):
+ layer = PropertyLayer()
+ stack = PropertyStack()
+ stack.addLayer(0, layer)
+ mock = Mock()
+ stack.wire(mock.method)
+ layer["testkey"] = "testvalue"
+ mock.method.assert_called_once_with("testkey", "testvalue")
+
+ def testPropertyChangeEventPriority(self):
+ low_layer = PropertyLayer()
+ high_layer = PropertyLayer()
+ low_layer["testkey"] = "initial low value"
+ high_layer["testkey"] = "initial high value"
+ stack = PropertyStack()
+ stack.addLayer(1, low_layer)
+ stack.addLayer(0, high_layer)
+ mock = Mock()
+ stack.wire(mock.method)
+ low_layer["testkey"] = "modified low value"
+ mock.method.assert_not_called()
+ high_layer["testkey"] = "modified high value"
+ mock.method.assert_called_once_with("testkey", "modified high value")
+
+ def testPropertyEventOnLayerAdd(self):
+ low_layer = PropertyLayer()
+ low_layer["testkey"] = "low value"
+ stack = PropertyStack()
+ stack.addLayer(1, low_layer)
+ mock = Mock()
+ stack.wireProperty("testkey", mock.method)
+ mock.reset_mock()
+ high_layer = PropertyLayer()
+ high_layer["testkey"] = "high value"
+ stack.addLayer(0, high_layer)
+ mock.method.assert_called_once_with("high value")
+
+ def testNoEventOnExistingValue(self):
+ low_layer = PropertyLayer()
+ low_layer["testkey"] = "same value"
+ stack = PropertyStack()
+ stack.addLayer(1, low_layer)
+ mock = Mock()
+ stack.wireProperty("testkey", mock.method)
+ mock.reset_mock()
+ high_layer = PropertyLayer()
+ high_layer["testkey"] = "same value"
+ stack.addLayer(0, high_layer)
+ mock.method.assert_not_called()
+
+ def testEventOnLayerWithNewProperty(self):
+ low_layer = PropertyLayer()
+ low_layer["existingkey"] = "existing value"
+ stack = PropertyStack()
+ stack.addLayer(1, low_layer)
+ mock = Mock()
+ stack.wireProperty("newkey", mock.method)
+ high_layer = PropertyLayer()
+ high_layer["newkey"] = "new value"
+ stack.addLayer(0, high_layer)
+ mock.method.assert_called_once_with("new value")
+
+ def testEventOnLayerRemoval(self):
+ low_layer = PropertyLayer()
+ high_layer = PropertyLayer()
+ stack = PropertyStack()
+ stack.addLayer(1, low_layer)
+ stack.addLayer(0, high_layer)
+ low_layer["testkey"] = "low value"
+ high_layer["testkey"] = "high value"
+
+ mock = Mock()
+ stack.wireProperty("testkey", mock.method)
+ mock.method.assert_called_once_with("high value")
+ mock.reset_mock()
+ stack.removeLayer(high_layer)
+ mock.method.assert_called_once_with("low value")
+
+ def testNoneOnKeyRemoval(self):
+ low_layer = PropertyLayer()
+ high_layer = PropertyLayer()
+ stack = PropertyStack()
+ stack.addLayer(1, low_layer)
+ stack.addLayer(0, high_layer)
+ low_layer["testkey"] = "low value"
+ high_layer["testkey"] = "high value"
+ high_layer["unique key"] = "unique value"
+
+ mock = Mock()
+ stack.wireProperty("unique key", mock.method)
+ mock.method.assert_called_once_with("unique value")
+ mock.reset_mock()
+ stack.removeLayer(high_layer)
+ mock.method.assert_called_once_with(None)
+
+ def testReplaceLayer(self):
+ first_layer = PropertyLayer()
+ first_layer["testkey"] = "old value"
+ second_layer = PropertyLayer()
+ second_layer["testkey"] = "new value"
+
+ stack = PropertyStack()
+ stack.addLayer(0, first_layer)
+
+ mock = Mock()
+ stack.wireProperty("testkey", mock.method)
+ mock.method.assert_called_once_with("old value")
+ mock.reset_mock()
+
+ stack.replaceLayer(0, second_layer)
+ mock.method.assert_called_once_with("new value")
+
+ def testUnwiresEventsOnRemoval(self):
+ layer = PropertyLayer()
+ layer["testkey"] = "before"
+ stack = PropertyStack()
+ stack.addLayer(0, layer)
+ mock = Mock()
+ stack.wire(mock.method)
+ stack.removeLayer(layer)
+ mock.method.assert_called_once_with("testkey", None)
+ mock.reset_mock()
+
+ layer["testkey"] = "after"
+ mock.method.assert_not_called()
+
+ def testReplaceLayerNoEventWhenValueUnchanged(self):
+ fixed = PropertyLayer()
+ fixed["testkey"] = "fixed value"
+ first_layer = PropertyLayer()
+ first_layer["testkey"] = "same value"
+ second_layer = PropertyLayer()
+ second_layer["testkey"] = "same value"
+
+ stack = PropertyStack()
+ stack.addLayer(1, fixed)
+ stack.addLayer(0, first_layer)
+ mock = Mock()
+ stack.wire(mock.method)
+ mock.method.assert_not_called()
+
+ stack.replaceLayer(0, second_layer)
+ mock.method.assert_not_called()
diff --git a/users.json b/users.json
new file mode 100644
index 0000000..298d7f2
--- /dev/null
+++ b/users.json
@@ -0,0 +1,11 @@
+[
+ {
+ "user": "admin",
+ "password": {
+ "encoding": "string",
+ "value": "password",
+ "force_change": true
+ },
+ "enabled": true
+ }
+]
\ No newline at end of file