Compare commits

..

26 Commits

Author SHA1 Message Date
Jakob Ketterl
72aad99d20 Merge branch 'develop' into csdr++ 2021-08-03 22:22:43 +02:00
Jakob Ketterl
38cc900b1c Merge branch 'develop' into csdr++ 2021-07-15 12:55:43 +02:00
Jakob Ketterl
db0efc14f5 use csdr++ timingrecovery 2021-07-10 23:03:14 +02:00
Jakob Ketterl
72e05f5276 use csdr++ bpsk & varicode 2021-07-09 22:00:01 +02:00
Jakob Ketterl
e088006b4d use new csdr++ bandpass filters 2021-07-09 20:25:07 +02:00
Jakob Ketterl
483d954b95 Merge branch 'develop' into csdr++ 2021-07-09 13:53:25 +02:00
Jakob Ketterl
a92f587734 use csdr++ gain 2021-07-06 21:42:29 +02:00
Jakob Ketterl
8f0b33eb83 activate adpcm sync flag 2021-07-06 21:20:33 +02:00
Jakob Ketterl
14eb71c8de implement adpcdm synchronization, refs #203 2021-07-06 19:40:06 +02:00
Jakob Ketterl
1a9bcdeb07 use new csdr++ deemphasis filters 2021-07-06 16:26:16 +02:00
Jakob Ketterl
6cce1ccdaa use new csdr++ squelch and power; disable squelch when unused 2021-07-06 00:05:24 +02:00
Jakob Ketterl
f0933472c9 use cxdr++ limit 2021-07-05 17:41:11 +02:00
Jakob Ketterl
fc5fb9166e use new adpcm methods in csdr++ 2021-07-05 17:20:57 +02:00
Jakob Ketterl
59de2628c3 Merge branch 'develop' into csdr++ 2021-07-05 13:06:06 +02:00
Jakob Ketterl
44c1e00509 use csdr++ fractionaldecimator 2021-07-05 12:42:35 +02:00
Jakob Ketterl
4a68c9d3da use new csdr++ firdecimate 2021-07-02 19:15:10 +02:00
Jakob Ketterl
2d183ffeac use new csdr++ shift 2021-07-01 15:52:30 +02:00
Jakob Ketterl
808418c723 use csdr++ realpart 2021-06-30 23:17:37 +02:00
Jakob Ketterl
c3cac092bd use new fft functions in csdr++ 2021-06-30 23:06:28 +02:00
Jakob Ketterl
8797615720 use new fft 2021-06-30 21:23:25 +02:00
Jakob Ketterl
b3cdc568d9 use new csdr++ converter 2021-06-30 14:22:26 +02:00
Jakob Ketterl
666c286485 use new dcblock 2021-06-30 13:53:56 +02:00
Jakob Ketterl
d431e37d7b use am and fm demodulator 2021-06-29 11:52:17 +02:00
Jakob Ketterl
566b747928 Merge branch 'develop' into csdr++ 2021-06-28 15:14:24 +02:00
Jakob Ketterl
22f0d90896 use new agc in all the spots 2021-06-28 13:01:26 +02:00
Jakob Ketterl
6b4432982e use new csdr++; first stage: agc 2021-06-25 00:29:56 +02:00
115 changed files with 2784 additions and 3992 deletions

View File

@ -1,17 +1,4 @@
**unreleased** **unreleased**
- SDR device log messages are now available in the web configuration to simplify troubleshooting
- Added support for the MSK144 digimode
**1.2.1**
- FifiSDR support fixed (pipeline formats now line up correctly)
- Added "Device" input for FifiSDR devices for sound card selection
**1.2.0**
- Major rewrite of all demodulation components to make use of the new csdr/pycsdr and digiham/pydigiham demodulator
modules
- Preliminary display of M17 callsign information
- New devices supported:
- Blade RF
**1.1.0** **1.1.0**
- Reworked most graphical elements as SVGs for faster loadtimes and crispier display on hi-dpi displays - Reworked most graphical elements as SVGs for faster loadtimes and crispier display on hi-dpi displays

View File

@ -14,7 +14,7 @@ It has the following features:
- supports a wide range of [SDR hardware](https://github.com/jketterl/openwebrx/wiki/Supported-Hardware#sdr-devices) - supports a wide range of [SDR hardware](https://github.com/jketterl/openwebrx/wiki/Supported-Hardware#sdr-devices)
- Multiple SDR devices can be used simultaneously - Multiple SDR devices can be used simultaneously
- [digiham](https://github.com/jketterl/digiham) based demodularors (DMR, YSF, Pocsag, D-Star, NXDN) - [digiham](https://github.com/jketterl/digiham) based demodularors (DMR, YSF, Pocsag, D-Star, NXDN)
- [wsjt-x](https://wsjt.sourceforge.io/) based demodulators (FT8, FT4, WSPR, JT65, JT9, FST4, - [wsjt-x](https://physics.princeton.edu/pulsar/k1jt/wsjtx.html) based demodulators (FT8, FT4, WSPR, JT65, JT9, FST4,
FST4W) FST4W)
- [direwolf](https://github.com/wb2osz/direwolf) based demodulation of APRS packets - [direwolf](https://github.com/wb2osz/direwolf) based demodulation of APRS packets
- [JS8Call](http://js8call.com/) support - [JS8Call](http://js8call.com/) support

View File

@ -42,7 +42,7 @@
"frequencies": { "frequencies": {
"bpsk31": 3580000, "bpsk31": 3580000,
"ft8": 3573000, "ft8": 3573000,
"wspr": 3568600, "wspr": 3592600,
"jt65": 3570000, "jt65": 3570000,
"jt9": 3572000, "jt9": 3572000,
"ft4": [3568000, 3575000], "ft4": [3568000, 3575000],
@ -56,7 +56,7 @@
"upper_bound": 5366500, "upper_bound": 5366500,
"frequencies": { "frequencies": {
"ft8": 5357000, "ft8": 5357000,
"wspr": [5287200, 5364700] "wspr": 5364700
}, },
"tags": ["hamradio"] "tags": ["hamradio"]
}, },
@ -177,8 +177,7 @@
"jt9": 50312000, "jt9": 50312000,
"ft4": 50318000, "ft4": 50318000,
"js8": 50318000, "js8": 50318000,
"q65": [50211000, 50275000], "q65": [50211000, 50275000]
"msk144": 50260000
}, },
"tags": ["hamradio"] "tags": ["hamradio"]
}, },
@ -187,8 +186,7 @@
"lower_bound": 70150000, "lower_bound": 70150000,
"upper_bound": 70200000, "upper_bound": 70200000,
"frequencies": { "frequencies": {
"wspr": 70091000, "wspr": 70091000
"msk144": 70230000
}, },
"tags": ["hamradio"] "tags": ["hamradio"]
}, },
@ -202,8 +200,7 @@
"ft4": 144170000, "ft4": 144170000,
"jt65": 144120000, "jt65": 144120000,
"packet": 144800000, "packet": 144800000,
"q65": 144116000, "q65": 144116000
"msk144": 144360000
}, },
"tags": ["hamradio"] "tags": ["hamradio"]
}, },
@ -213,8 +210,7 @@
"upper_bound": 440000000, "upper_bound": 440000000,
"frequencies": { "frequencies": {
"pocsag": 439987500, "pocsag": 439987500,
"q65": 432065000, "q65": 432065000
"msk144": 432360000
}, },
"tags": ["hamradio"] "tags": ["hamradio"]
}, },

386
config_webrx.py Normal file
View File

@ -0,0 +1,386 @@
# -*- coding: utf-8 -*-
"""
config_webrx: configuration options for OpenWebRX
This file is part of OpenWebRX,
an open-source SDR receiver software with a web UI.
Copyright (c) 2013-2015 by Andras Retzler <randras@sdr.hu>
Copyright (c) 2019-2021 by Jakob Ketterl <dd5jfk@darc.de>
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 <http://www.gnu.org/licenses/>.
++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
In addition, as a special exception, the copyright holders
state that config_rtl.py and config_webrx.py are not part of the
Corresponding Source defined in GNU AGPL version 3 section 1.
(It means that you do not have to redistribute config_rtl.py and
config_webrx.py if you make any changes to these two configuration files,
and use them for running your web service with OpenWebRX.)
"""
"""
DEPRECATION notice
As of OpenWebRX 0.21, the configuration system has been completely overhauled.
The configuration of OpenWebRX should now be done in the new web-based
configuration interface exclusively.
Existing configurations can still be used, but their values will be migrated
to the new storage infrastructure as soon as the web configuration is used to
edit them.
The new configuration storage is not intended to be edited manually.
"""
# configuration version. please only modify if you're able to perform the associated migration steps.
version = 7
# NOTE: you can find additional information about configuring OpenWebRX in the Wiki:
# https://github.com/jketterl/openwebrx/wiki/Configuration-guide
# ==== Server settings ====
#max_clients = 20
# ==== Web GUI configuration ====
#receiver_name = "[Callsign]"
#receiver_location = "Budapest, Hungary"
#receiver_asl = 200
#receiver_admin = "example@example.com"
#receiver_gps = {"lat": 47.000000, "lon": 19.000000}
#photo_title = "Panorama of Budapest from Schönherz Zoltán Dormitory"
# photo_desc allows you to put pretty much any HTML you like into the receiver description.
# The lines below should give you some examples of what's possible.
#photo_desc = """
#You can add your own background photo and receiver information.<br />
#Receiver is operated by: <a href="mailto:openwebrx@localhost" target="_blank">Receiver Operator</a><br/>
#Device: Receiver Device<br />
#Antenna: Receiver Antenna<br />
#Website: <a href="http://localhost" target="_blank">http://localhost</a>
#"""
# ==== Public receiver listings ====
# You can publish your receiver on online receiver directories, like https://www.receiverbook.de
# You will receive a receiver key from the directory that will authenticate you as the operator of this receiver.
# Please note that you not share your receiver keys publicly since anyone that obtains your receiver key can take over
# your public listing.
# Your receiver keys should be placed into this array:
#receiver_keys = []
# If you list your receiver on multiple sites, you can place all your keys into the array above, or you can append
# keys to the arraylike this:
# receiver_keys += ["my-receiver-key"]
# If you're not sure, simply copy & paste the code you received from your listing site below this line:
# ==== DSP/RX settings ====
#fft_fps = 9
#fft_size = 4096 # Should be power of 2
#fft_voverlap_factor = (
# 0.3 # If fft_voverlap_factor is above 0, multiple FFTs will be used for creating a line on the diagram.
#)
#audio_compression = "adpcm" # valid values: "adpcm", "none"
#fft_compression = "adpcm" # valid values: "adpcm", "none"
# Tau setting for WFM (broadcast FM) deemphasis\
# Quote from wikipedia https://en.wikipedia.org/wiki/FM_broadcasting#Pre-emphasis_and_de-emphasis
# "In most of the world a 50 µs time constant is used. In the Americas and South Korea, 75 µs is used"
# Enable one of the following lines, depending on your location:
# wfm_deemphasis_tau = 75e-6 # for US and South Korea
#wfm_deemphasis_tau = 50e-6 # for the rest of the world
#digimodes_fft_size = 2048
# enables lookup of DMR ids using the radioid api
#digital_voice_dmr_id_lookup = True
"""
Note: if you experience audio underruns while CPU usage is 100%, you can:
- decrease `samp_rate`,
- set `fft_voverlap_factor` to 0,
- decrease `fft_fps` and `fft_size`,
- limit the number of users by decreasing `max_clients`.
"""
# ==== I/Q sources ====
# (Uncomment the appropriate by removing # characters at the beginning of the corresponding lines.)
###############################################################################
# Is my SDR hardware supported? #
# Check here: https://github.com/jketterl/openwebrx/wiki/Supported-Hardware #
###############################################################################
# Currently supported types of sdr receivers:
# "rtl_sdr", "rtl_sdr_soapy", "sdrplay", "hackrf", "airspy", "airspyhf", "fifi_sdr",
# "perseussdr", "lime_sdr", "pluto_sdr", "soapy_remote", "hpsdr", "uhd",
# "radioberry", "fcdpp", "rtl_tcp", "sddc", "runds"
# For more details on specific types, please checkout the wiki:
# https://github.com/jketterl/openwebrx/wiki/Supported-Hardware#sdr-devices
#sdrs = {
# "rtlsdr": {
# "name": "RTL-SDR USB Stick",
# "type": "rtl_sdr",
# "ppm": 0,
# # you can change this if you use an upconverter. formula is:
# # center_freq + lfo_offset = actual frequency on the sdr
# # "lfo_offset": 0,
# "profiles": {
# "70cm": {
# "name": "70cm Relais",
# "center_freq": 438800000,
# "rf_gain": 29,
# "samp_rate": 2400000,
# "start_freq": 439275000,
# "start_mod": "nfm",
# },
# "2m": {
# "name": "2m komplett",
# "center_freq": 145000000,
# "rf_gain": 29,
# "samp_rate": 2048000,
# "start_freq": 145725000,
# "start_mod": "nfm",
# },
# },
# },
# "airspy": {
# "name": "Airspy HF+",
# "type": "airspyhf",
# "ppm": 0,
# "rf_gain": "auto",
# "profiles": {
# "20m": {
# "name": "20m",
# "center_freq": 14150000,
# "samp_rate": 384000,
# "start_freq": 14070000,
# "start_mod": "usb",
# },
# "30m": {
# "name": "30m",
# "center_freq": 10125000,
# "samp_rate": 192000,
# "start_freq": 10142000,
# "start_mod": "usb",
# },
# "40m": {
# "name": "40m",
# "center_freq": 7100000,
# "samp_rate": 256000,
# "start_freq": 7070000,
# "start_mod": "lsb",
# },
# "80m": {
# "name": "80m",
# "center_freq": 3650000,
# "samp_rate": 384000,
# "start_freq": 3570000,
# "start_mod": "lsb",
# },
# "49m": {
# "name": "49m Broadcast",
# "center_freq": 6050000,
# "samp_rate": 384000,
# "start_freq": 6070000,
# "start_mod": "am",
# },
# },
# },
# "sdrplay": {
# "name": "SDRPlay RSP2",
# "type": "sdrplay",
# "ppm": 0,
# "antenna": "Antenna A",
# "profiles": {
# "20m": {
# "name": "20m",
# "center_freq": 14150000,
# "rf_gain": 0,
# "samp_rate": 500000,
# "start_freq": 14070000,
# "start_mod": "usb",
# },
# "30m": {
# "name": "30m",
# "center_freq": 10125000,
# "rf_gain": 0,
# "samp_rate": 250000,
# "start_freq": 10142000,
# "start_mod": "usb",
# },
# "40m": {
# "name": "40m",
# "center_freq": 7100000,
# "rf_gain": 0,
# "samp_rate": 500000,
# "start_freq": 7070000,
# "start_mod": "lsb",
# },
# "80m": {
# "name": "80m",
# "center_freq": 3650000,
# "rf_gain": 0,
# "samp_rate": 500000,
# "start_freq": 3570000,
# "start_mod": "lsb",
# },
# "49m": {
# "name": "49m Broadcast",
# "center_freq": 6000000,
# "rf_gain": 0,
# "samp_rate": 500000,
# "start_freq": 6070000,
# "start_mod": "am",
# },
# },
# },
#}
# ==== Color themes ====
### google turbo colormap (see: https://ai.googleblog.com/2019/08/turbo-improved-rainbow-colormap-for.html)
#waterfall_scheme = "GoogleTurboWaterfall"
### original theme by teejez:
#waterfall_scheme = "TeejeezWaterfall"
### old theme by HA7ILM:
#waterfall_scheme = "Ha7ilmWaterfall"
##For the old colors, you might also want to set [fft_voverlap_factor] to 0.
### custom waterfall schemes can be configured like this:
#waterfall_scheme = "CustomWaterfall"
#waterfall_colors = [0x0000FF, 0x00FF00, 0xFF0000]
### Waterfall calibration
#waterfall_levels = {"min": -88, "max": -20} # in dB
#waterfall_auto_levels = {"min": 3, "max": 10}
#waterfall_auto_min_range = 50
# Note: When the auto waterfall level button is clicked, the following happens:
# [waterfall_levels.min] = [current_min_power_level] - [waterfall_auto_levels["min"]]
# [waterfall_levels.max] = [current_max_power_level] + [waterfall_auto_levels["max"]]
#
# ___|__________________________________|____________________________________|__________________________________|___> signal power
# \_waterfall_auto_levels["min"]_/ |__ current_min_power_level | \_waterfall_auto_levels["max"]_/
# current_max_power_level __|
# This setting allows you to modify the precision of the frequency displays in OpenWebRX.
# Set this to exponent of 10 to select the most precise digit in Hz you'd like to see
# examples:
# a value of 2 selects 10^2 = 100Hz tuning precision (default):
#tuning_precision = 2
# a value of 1 selects 10^1 = 10Hz tuning precision:
#tuning_precision = 1
# This setting tells the auto-squelch the offset to add to the current signal level to use as the new squelch level.
# Lowering this setting will give you a more sensitive squelch, but it may also cause unwanted squelch openings when
# using the auto squelch.
#squelch_auto_margin = 10 # in dB
#google_maps_api_key = ""
# how long should positions be visible on the map?
# they will start fading out after half of that
# in seconds; default: 2 hours
#map_position_retention_time = 2 * 60 * 60
# decoder queue configuration
# due to the nature of some operating modes (ft8, ft8, jt9, jt65, wspr and js8), the data is recorded for a given amount
# of time (6 seconds up to 2 minutes) and decoded at the end. this can lead to very high peak loads.
# to mitigate this, the recordings will be queued and processed in sequence.
# the number of workers will limit the total amount of work (one worker will losely occupy one cpu / thread)
#decoding_queue_workers = 2
# the maximum queue length will cause decodes to be dumped if the workers cannot keep up
# if you are running background services, make sure this number is high enough to accept the task influx during peaks
# i.e. this should be higher than the number of decoding services running at the same time
#decoding_queue_length = 10
# wsjt decoding depth will allow more results, but will also consume more cpu
#wsjt_decoding_depth = 3
# can also be set for each mode separately
# jt65 seems to be somewhat prone to erroneous decodes, this setting handles that to some extent
#wsjt_decoding_depths = {"jt65": 1}
# FST4 can be transmitted in different intervals. This setting determines which intervals will be decoded.
# available values (in seconds): 15, 30, 60, 120, 300, 900, 1800
#fst4_enabled_intervals = [15, 30]
# FST4W can be transmitted in different intervals. This setting determines which intervals will be decoded.
# available values (in seconds): 120, 300, 900, 1800
#fst4w_enabled_intervals = [120, 300]
# Q65 allows many combinations of intervals and submodes. This setting determines which combinations will be decoded.
# Please use the mode letter followed by the decode interval in seconds to specify the combinations. For example:
#q65_enabled_combinations = ["A30", "E120", "C60"]
# JS8 comes in different speeds: normal, slow, fast, turbo. This setting controls which ones are enabled.
#js8_enabled_profiles = ["normal", "slow"]
# JS8 decoding depth; higher value will get more results, but will also consume more cpu
#js8_decoding_depth = 3
# Enable background service for decoding digital data. You can find more information at:
# https://github.com/jketterl/openwebrx/wiki/Background-decoding
#services_enabled = False
#services_decoders = ["ft8", "ft4", "wspr", "packet"]
# === aprs igate settings ===
# If you want to share your APRS decodes with the aprs network, configure these settings accordingly.
# Make sure that you have set services_enabled to true and customize services_decoders to your needs.
#aprs_callsign = "N0CALL"
#aprs_igate_enabled = False
#aprs_igate_server = "euro.aprs2.net"
#aprs_igate_password = ""
# beacon uses the receiver_gps setting, so if you enable this, make sure the location is correct there
#aprs_igate_beacon = False
# Uncomment the following to customize gateway beacon details reported to the aprs network
# Plese see Dire Wolf's documentation on PBEACON configuration for complete details:
# https://github.com/wb2osz/direwolf/raw/master/doc/User-Guide.pdf
# Symbol in its two-character form as specified by the APRS spec at http://www.aprs.org/symbols/symbols-new.txt
# Default: Receive only IGate (do not send msgs back to RF)
# aprs_igate_symbol = "R&"
# Custom comment about igate
# Default: OpenWebRX APRS gateway
# aprs_igate_comment = "OpenWebRX APRS gateway"
# Antenna Height and Gain details
# Unspecified by default
# Antenna height above average terrain (HAAT) in meters
# aprs_igate_height = "5"
# Antenna gain in dBi
# aprs_igate_gain = "0"
# Antenna direction (N, NE, E, SE, S, SW, W, NW). Omnidirectional by default
# aprs_igate_dir = "NE"
# === PSK Reporter settings ===
# enable this if you want to upload all ft8, ft4 etc spots to pskreporter.info
# this also uses the receiver_gps setting from above, so make sure it contains a correct locator
#pskreporter_enabled = False
#pskreporter_callsign = "N0CALL"
# optional antenna information, uncomment to enable
#pskreporter_antenna_information = "Dipole"
# === WSPRNet reporting settings
# enable this if you want to upload WSPR spots to wsprnet.ort
# in addition to these settings also make sure that receiver_gps contains your correct location
#wsprnet_enabled = False
#wsprnet_callsign = "N0CALL"

View File

@ -0,0 +1,835 @@
"""
OpenWebRX csdr plugin: do the signal processing with csdr
This file is part of OpenWebRX,
an open-source SDR receiver software with a web UI.
Copyright (c) 2013-2015 by Andras Retzler <randras@sdr.hu>
Copyright (c) 2019-2021 by Jakob Ketterl <dd5jfk@darc.de>
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 <http://www.gnu.org/licenses/>.
"""
import subprocess
import os
import signal
import threading
import math
from functools import partial
from csdr.output import Output
from owrx.kiss import KissClient, DirewolfConfig, DirewolfConfigSubscriber
from owrx.audio.chopper import AudioChopper
from csdr.pipe import Pipe
import logging
logger = logging.getLogger(__name__)
class Dsp(DirewolfConfigSubscriber):
def __init__(self, output: Output):
self.samp_rate = 250000
self.output_rate = 11025
self.hd_output_rate = 44100
self.fft_size = 1024
self.fft_fps = 5
self.center_freq = 0
self.offset_freq = 0
self.low_cut = -4000
self.high_cut = 4000
self.bpf_transition_bw = 320 # Hz, and this is a constant
self.ddc_transition_bw_rate = 0.15 # of the IF sample rate
self.running = False
self.secondary_processes_running = False
self.audio_compression = "none"
self.fft_compression = "none"
self.demodulator = "nfm"
self.name = "csdr"
self.decimation = None
self.last_decimation = None
self.nc_port = None
self.squelch_level = -150
self.fft_averages = 50
self.wfm_deemphasis_tau = 50e-6
self.iqtee = False
self.iqtee2 = False
self.secondary_demodulator = None
self.secondary_fft_size = 1024
self.secondary_process_fft = None
self.secondary_process_demod = None
self.pipe_names = {
"bpf_pipe": Pipe.WRITE,
"shift_pipe": Pipe.WRITE,
"squelch_pipe": Pipe.WRITE,
"smeter_pipe": Pipe.READ,
"meta_pipe": Pipe.READ,
"iqtee_pipe": Pipe.NONE,
"iqtee2_pipe": Pipe.NONE,
"dmr_control_pipe": Pipe.WRITE,
}
self.pipes = {}
self.secondary_pipe_names = {"secondary_shift_pipe": Pipe.WRITE}
self.secondary_offset_freq = 1000
self.codecserver = None
self.modification_lock = threading.Lock()
self.output = output
self.temporary_directory = None
self.pipe_base_path = None
self.set_temporary_directory("/tmp")
self.is_service = False
self.direwolf_config = None
self.direwolf_config_path = None
self.process = None
def set_service(self, flag=True):
self.is_service = flag
def set_temporary_directory(self, what):
self.temporary_directory = what
self.pipe_base_path = "{tmp_dir}/openwebrx_pipe_".format(tmp_dir=self.temporary_directory)
def chain(self, which):
chain = ["nc -v 127.0.0.1 {nc_port}"]
if which == "fft":
chain += [
"csdr++ fft {fft_size} {fft_block_size}",
"csdr++ logpower -70"
if self.fft_averages == 0
else "csdr++ logaveragepower {fft_size} {fft_averages} --add -70",
"csdr++ fftswap {fft_size}",
]
if self.fft_compression == "adpcm":
chain += ["csdr++ fftadpcm {fft_size}"]
return chain
chain += ["csdr++ shift --fifo {shift_pipe}"]
if self.decimation > 1:
chain += ["csdr++ firdecimate {decimation} {ddc_transition_bw} --window hamming"]
chain += ["csdr++ bandpass --fft --fifo {bpf_pipe} {bpf_transition_bw} --window hamming"]
if self.output.supports_type("smeter"):
if self.isSquelchActive():
chain += ["csdr++ squelch --fifo {squelch_pipe} --outfifo {smeter_pipe} 5 {smeter_report_every}"]
else:
chain += ["csdr++ power --outfifo {smeter_pipe} 5 {smeter_report_every}"]
if self.secondary_demodulator:
if self.output.supports_type("secondary_fft"):
chain += ["csdr tee {iqtee_pipe}"]
chain += ["csdr tee {iqtee2_pipe}"]
# early exit if we don't want audio
if not self.output.supports_type("audio"):
return chain
# safe some cpu cycles... no need to decimate if decimation factor is 1
last_decimation_block = []
if self.last_decimation >= 2.0:
# activate prefilter if signal has been oversampled, e.g. WFM
last_decimation_block = ["csdr++ fractionaldecimator --format float {last_decimation} --prefilter"]
elif self.last_decimation != 1.0:
last_decimation_block = ["csdr++ fractionaldecimator --format float {last_decimation}"]
if which == "nfm":
chain += ["csdr++ fmdemod", "csdr++ limit"]
chain += last_decimation_block
chain += [
"csdr++ deemphasis --nfm {audio_rate}",
"csdr++ agc --format float --profile slow --max 3",
]
if self.get_audio_rate() != self.get_output_rate():
chain += [
"sox -t raw -r {audio_rate} -e floating-point -b 32 -c 1 --buffer 32 - -t raw -r {output_rate} -e signed-integer -b 16 -c 1 - "
]
else:
chain += ["csdr++ convert -i float -o s16"]
elif which == "wfm":
chain += [
"csdr++ fmdemod",
"csdr++ limit",
]
chain += last_decimation_block
chain += ["csdr++ deemphasis --wfm {audio_rate} {wfm_deemphasis_tau}", "csdr++ convert -i float -o s16"]
elif self.isDigitalVoice(which):
chain += ["csdr++ fmdemod"]
chain += last_decimation_block
chain += ["dc_block"]
# m17
if which == "m17":
chain += [
"csdr++ limit",
"csdr++ convert -i float -o s16",
"m17-demod",
]
else:
# digiham modes
if which == "dstar":
chain += [
"fsk_demodulator -s 10",
"dstar_decoder --fifo {meta_pipe}",
"mbe_synthesizer -d {codecserver_arg}",
]
elif which == "nxdn":
chain += [
"rrc_filter --narrow",
"gfsk_demodulator --samples 20",
"nxdn_decoder --fifo {meta_pipe}",
"mbe_synthesizer {codecserver_arg}",
]
else:
chain += ["rrc_filter", "gfsk_demodulator"]
if which == "dmr":
chain += [
"dmr_decoder --fifo {meta_pipe} --control-fifo {dmr_control_pipe}",
"mbe_synthesizer {codecserver_arg}",
]
elif which == "ysf":
chain += ["ysf_decoder --fifo {meta_pipe}", "mbe_synthesizer -y {codecserver_arg}"]
chain += ["digitalvoice_filter"]
chain += [
"csdr++ agc --format s16 --max 30 --initial 3",
"sox --buffer 320 -t raw -r 8000 -e signed-integer -b 16 -c 1 - -t raw -r {output_rate} -e signed-integer -b 16 -c 1 - ",
]
elif which == "am":
chain += ["csdr++ amdemod", "csdr++ dcblock"]
chain += last_decimation_block
chain += [
"csdr++ agc --format float --profile slow --initial 200",
"csdr++ convert -i float -o s16",
]
elif self.isFreeDV(which):
chain += ["csdr++ realpart"]
chain += last_decimation_block
chain += [
"csdr++ agc --format float",
"csdr++ convert -i float -o s16",
"freedv_rx 1600 - -",
"csdr++ agc --format s16 --max 30 --initial 3",
"sox -t raw -r 8000 -e signed-integer -b 16 -c 1 --buffer 32 - -t raw -r {output_rate} -e signed-integer -b 16 -c 1 - ",
]
elif self.isDrm(which):
if self.last_decimation != 1.0:
# we are still dealing with complex samples here, so the regular last_decimation_block doesn't fit
chain += ["csdr++ fractionaldecimator --format complex {last_decimation}"]
chain += [
"csdr++ convert -i float -o s16",
"dream -c 6 --sigsrate 48000 --audsrate 48000 -I - -O -",
"sox -t raw -r 48000 -e signed-integer -b 16 -c 2 - -t raw -r {output_rate} -e signed-integer -b 16 -c 1 - ",
]
elif which == "ssb":
chain += ["csdr++ realpart"]
chain += last_decimation_block
chain += ["csdr++ agc --format float"]
# fixed sample rate necessary for the wsjt-x tools. fix with sox...
if self.get_audio_rate() != self.get_output_rate():
chain += [
"sox -t raw -r {audio_rate} -e floating-point -b 32 -c 1 --buffer 32 - -t raw -r {output_rate} -e signed-integer -b 16 -c 1 - "
]
else:
chain += ["csdr++ convert -i float -o s16"]
if self.audio_compression == "adpcm":
chain += ["csdr++ adpcm -e --sync"]
return chain
def secondary_chain(self, which):
chain = ["cat {input_pipe}"]
if which == "fft":
chain += [
"csdr++ fft {secondary_fft_input_size} {secondary_fft_block_size}",
"csdr++ logpower -70"
if self.fft_averages == 0
else "csdr++ logaveragepower {secondary_fft_size} {fft_averages} --add -70",
"csdr++ fftswap {secondary_fft_input_size}",
]
if self.fft_compression == "adpcm":
chain += ["csdr++ fftadpcm {secondary_fft_size}"]
return chain
elif which == "bpsk31" or which == "bpsk63":
return chain + [
"csdr++ shift --fifo {secondary_shift_pipe}",
"csdr++ bandpass --low -{secondary_bpf_cutoff} --high {secondary_bpf_cutoff} {secondary_bpf_cutoff}",
"csdr++ agc --format complex",
"csdr++ timingrecovery --algorithm gardner {secondary_samples_per_bits} 0.5 2 --add_q",
"csdr++ dbpskdecode",
"csdr++ varicodedecode",
]
elif self.isWsjtMode(which) or self.isJs8(which):
chain += ["csdr++ realpart"]
if self.last_decimation != 1.0:
chain += ["csdr++ fractionaldecimator --format float {last_decimation}"]
return chain + ["csdr++ agc --format float", "csdr++ convert -i float -o s16"]
elif which == "packet":
chain += ["csdr++ fmdemod"]
if self.last_decimation != 1.0:
chain += ["csdr++ fractionaldecimator --format float {last_decimation}"]
return chain + ["csdr++ convert -i float -o s16", "direwolf -c {direwolf_config} -r {audio_rate} -t 0 -q d -q h 1>&2"]
elif which == "pocsag":
chain += ["csdr++ fmdemod"]
if self.last_decimation != 1.0:
chain += ["csdr++ fractionaldecimator --format float {last_decimation}"]
return chain + ["fsk_demodulator -i", "pocsag_decoder"]
def set_secondary_demodulator(self, what):
if self.get_secondary_demodulator() == what:
return
self.secondary_demodulator = what
self.calculate_decimation()
self.restart()
def secondary_fft_block_size(self):
base = (self.samp_rate / self.decimation) / (self.fft_fps * 2)
if self.fft_averages == 0:
return round(base)
return round(base / self.fft_averages)
def secondary_decimation(self):
return 1 # currently unused
def secondary_bpf_cutoff(self):
if self.secondary_demodulator == "bpsk31":
return 31.25 / self.if_samp_rate()
elif self.secondary_demodulator == "bpsk63":
return 62.5 / self.if_samp_rate()
return 0
def secondary_bpf_transition_bw(self):
if self.secondary_demodulator == "bpsk31":
return 31.25 / self.if_samp_rate()
elif self.secondary_demodulator == "bpsk63":
return 62.5 / self.if_samp_rate()
return 0
def secondary_samples_per_bits(self):
if self.secondary_demodulator == "bpsk31":
return int(round(self.if_samp_rate() / 31.25)) & ~3
elif self.secondary_demodulator == "bpsk63":
return int(round(self.if_samp_rate() / 62.5)) & ~3
return 0
def secondary_bw(self):
if self.secondary_demodulator == "bpsk31":
return 31.25
elif self.secondary_demodulator == "bpsk63":
return 62.5
def start_secondary_demodulator(self):
if not self.secondary_demodulator:
return
logger.debug("starting secondary demodulator from IF input sampled at %d" % self.if_samp_rate())
secondary_command_demod = " | ".join(self.secondary_chain(self.secondary_demodulator))
self.try_create_pipes(self.secondary_pipe_names, secondary_command_demod)
self.try_create_configs(secondary_command_demod)
secondary_command_demod = secondary_command_demod.format(
input_pipe=self.pipes["iqtee2_pipe"],
secondary_shift_pipe=self.pipes["secondary_shift_pipe"],
secondary_decimation=self.secondary_decimation(),
secondary_samples_per_bits=self.secondary_samples_per_bits(),
secondary_bpf_cutoff=self.secondary_bpf_cutoff(),
secondary_bpf_transition_bw=self.secondary_bpf_transition_bw(),
if_samp_rate=self.if_samp_rate(),
last_decimation=self.last_decimation,
audio_rate=self.get_audio_rate(),
direwolf_config=self.direwolf_config_path,
)
logger.debug("secondary command (demod) = %s", secondary_command_demod)
if self.output.supports_type("secondary_fft"):
secondary_command_fft = " | ".join(self.secondary_chain("fft"))
secondary_command_fft = secondary_command_fft.format(
input_pipe=self.pipes["iqtee_pipe"],
secondary_fft_input_size=self.secondary_fft_size,
secondary_fft_size=self.secondary_fft_size,
secondary_fft_block_size=self.secondary_fft_block_size(),
fft_averages=self.fft_averages,
)
logger.debug("secondary command (fft) = %s", secondary_command_fft)
self.secondary_process_fft = subprocess.Popen(
secondary_command_fft, stdout=subprocess.PIPE, shell=True, start_new_session=True
)
self.output.send_output(
"secondary_fft",
partial(self.secondary_process_fft.stdout.read, int(self.get_secondary_fft_bytes_to_read())),
)
# direwolf does not provide any meaningful data on stdout
# more specifically, it doesn't provide any data. if however, for any strange reason, it would start to do so,
# it would block if not read. by piping it to devnull, we avoid a potential pitfall here.
secondary_output = subprocess.DEVNULL if self.isPacket() else subprocess.PIPE
self.secondary_process_demod = subprocess.Popen(
secondary_command_demod, stdout=secondary_output, shell=True, start_new_session=True
)
self.secondary_processes_running = True
if self.isWsjtMode() or self.isJs8():
chopper = AudioChopper(self, self.get_secondary_demodulator())
chopper.send_output("audio", self.secondary_process_demod.stdout.read)
output_type = "js8_demod" if self.isJs8() else "wsjt_demod"
self.output.send_output(output_type, chopper.read)
elif self.isPacket():
# we best get the ax25 packets from the kiss socket
kiss = KissClient(self.direwolf_config.getPort())
self.output.send_output("packet_demod", kiss.read)
elif self.isPocsag():
self.output.send_output("pocsag_demod", self.secondary_process_demod.stdout.readline)
else:
self.output.send_output("secondary_demod", partial(self.secondary_process_demod.stdout.read, 1))
# open control pipes for csdr and send initialization data
if self.has_pipe("secondary_shift_pipe"): # TODO digimodes
self.set_secondary_offset_freq(self.secondary_offset_freq) # TODO digimodes
def set_secondary_offset_freq(self, value):
self.secondary_offset_freq = value
if self.secondary_processes_running and self.has_pipe("secondary_shift_pipe"):
self.pipes["secondary_shift_pipe"].write(
"%g\n" % (-float(self.secondary_offset_freq) / self.if_samp_rate())
)
def stop_secondary_demodulator(self):
if not self.secondary_processes_running:
return
self.try_delete_pipes(self.secondary_pipe_names)
self.try_delete_configs()
if self.secondary_process_fft:
try:
os.killpg(os.getpgid(self.secondary_process_fft.pid), signal.SIGTERM)
# drain any leftover data to free file descriptors
self.secondary_process_fft.communicate()
self.secondary_process_fft = None
except ProcessLookupError:
# been killed by something else, ignore
pass
if self.secondary_process_demod:
try:
os.killpg(os.getpgid(self.secondary_process_demod.pid), signal.SIGTERM)
# drain any leftover data to free file descriptors
self.secondary_process_demod.communicate()
self.secondary_process_demod = None
except ProcessLookupError:
# been killed by something else, ignore
pass
self.secondary_processes_running = False
def get_secondary_demodulator(self):
return self.secondary_demodulator
def set_secondary_fft_size(self, secondary_fft_size):
if self.secondary_fft_size == secondary_fft_size:
return
self.secondary_fft_size = secondary_fft_size
self.restart()
def set_audio_compression(self, what):
if self.audio_compression == what:
return
self.audio_compression = what
self.restart()
def get_audio_bytes_to_read(self):
# desired latency: 5ms
# uncompressed audio has 16 bits = 2 bytes per sample
base = self.output_rate * 0.005 * 2
# adpcm compresses the bitstream by 4
if self.audio_compression == "adpcm":
base = base / 4
return int(base)
def set_fft_compression(self, what):
if self.fft_compression == what:
return
self.fft_compression = what
self.restart()
def get_fft_bytes_to_read(self):
if self.fft_compression == "none":
return self.fft_size * 4
if self.fft_compression == "adpcm":
return int((self.fft_size / 2) + (10 / 2))
def get_secondary_fft_bytes_to_read(self):
if self.fft_compression == "none":
return self.secondary_fft_size * 4
if self.fft_compression == "adpcm":
return (self.secondary_fft_size / 2) + (10 / 2)
def set_samp_rate(self, samp_rate):
self.samp_rate = samp_rate
self.calculate_decimation()
if self.running:
self.restart()
def calculate_decimation(self):
(self.decimation, self.last_decimation) = self.get_decimation(self.samp_rate, self.get_audio_rate())
def get_decimation(self, input_rate, output_rate):
if output_rate <= 0:
raise ValueError("invalid output rate: {rate}".format(rate=output_rate))
decimation = 1
target_rate = output_rate
# wideband fm has a much higher frequency deviation (75kHz).
# we cannot cover this if we immediately decimate to the sample rate the audio will have later on, so we need
# to compensate here.
if self.get_demodulator() == "wfm" and output_rate < 200000:
target_rate = 200000
while input_rate / (decimation + 1) >= target_rate:
decimation += 1
fraction = float(input_rate / decimation) / output_rate
return decimation, fraction
def if_samp_rate(self):
return self.samp_rate / self.decimation
def get_name(self):
return self.name
def get_output_rate(self):
return self.output_rate
def get_hd_output_rate(self):
return self.hd_output_rate
def get_audio_rate(self):
if self.isDigitalVoice() or self.isPacket() or self.isPocsag() or self.isDrm():
return 48000
elif self.isWsjtMode() or self.isJs8():
return 12000
elif self.isFreeDV():
return 8000
elif self.isHdAudio():
return self.get_hd_output_rate()
return self.get_output_rate()
def isDigitalVoice(self, demodulator=None):
if demodulator is None:
demodulator = self.get_demodulator()
return demodulator in ["dmr", "dstar", "nxdn", "ysf", "m17"]
def isWsjtMode(self, demodulator=None):
if demodulator is None:
demodulator = self.get_secondary_demodulator()
return demodulator in ["ft8", "wspr", "jt65", "jt9", "ft4", "fst4", "fst4w", "q65"]
def isJs8(self, demodulator=None):
if demodulator is None:
demodulator = self.get_secondary_demodulator()
return demodulator == "js8"
def isPacket(self, demodulator=None):
if demodulator is None:
demodulator = self.get_secondary_demodulator()
return demodulator == "packet"
def isPocsag(self, demodulator=None):
if demodulator is None:
demodulator = self.get_secondary_demodulator()
return demodulator == "pocsag"
def isFreeDV(self, demodulator=None):
if demodulator is None:
demodulator = self.get_demodulator()
return demodulator == "freedv"
def isHdAudio(self, demodulator=None):
if demodulator is None:
demodulator = self.get_demodulator()
return demodulator == "wfm"
def isDrm(self, demodulator=None):
if demodulator is None:
demodulator = self.get_demodulator()
return demodulator == "drm"
def set_output_rate(self, output_rate):
if self.output_rate == output_rate:
return
self.output_rate = output_rate
self.calculate_decimation()
self.restart()
def set_hd_output_rate(self, hd_output_rate):
if self.hd_output_rate == hd_output_rate:
return
self.hd_output_rate = hd_output_rate
self.calculate_decimation()
self.restart()
def set_demodulator(self, demodulator):
if demodulator in ["usb", "lsb", "cw"]:
demodulator = "ssb"
if self.demodulator == demodulator:
return
self.demodulator = demodulator
self.calculate_decimation()
self.restart()
def get_demodulator(self):
return self.demodulator
def set_fft_size(self, fft_size):
if self.fft_size == fft_size:
return
self.fft_size = fft_size
self.restart()
def set_fft_fps(self, fft_fps):
self.fft_fps = fft_fps
self.restart()
def set_fft_averages(self, fft_averages):
self.fft_averages = fft_averages
self.restart()
def fft_block_size(self):
if self.fft_averages == 0:
return round(self.samp_rate / self.fft_fps)
else:
return round(self.samp_rate / self.fft_fps / self.fft_averages)
def set_offset_freq(self, offset_freq):
if offset_freq is None:
return
self.offset_freq = offset_freq
if self.running:
self.pipes["shift_pipe"].write("%g\n" % (-float(self.offset_freq) / self.samp_rate))
def set_center_freq(self, center_freq):
# dsp only needs to know this to be able to pass it to decoders in the form of get_operating_freq()
self.center_freq = center_freq
def get_operating_freq(self):
return self.center_freq + self.offset_freq
def set_bandpass(self, bandpass):
self.set_bpf(bandpass.low_cut, bandpass.high_cut)
def set_bpf(self, low_cut, high_cut):
self.low_cut = low_cut
self.high_cut = high_cut
if self.running:
self.pipes["bpf_pipe"].write(
"%g %g\n" % (float(self.low_cut) / self.if_samp_rate(), float(self.high_cut) / self.if_samp_rate())
)
def get_bpf(self):
return [self.low_cut, self.high_cut]
def convertToLinear(self, db):
return float(math.pow(10, db / 10))
def isSquelchActive(self):
return not self.isDigitalVoice() and not self.isPacket() and not self.isPocsag() and not self.isFreeDV() and not self.isDrm()
def set_squelch_level(self, squelch_level):
self.squelch_level = squelch_level
# no squelch required on digital voice modes
actual_squelch = self.squelch_level if self.isSquelchActive() else -150
if self.running and "squelch_pipe" in self.pipes:
self.pipes["squelch_pipe"].write("%g\n" % (self.convertToLinear(actual_squelch)))
def set_codecserver(self, s):
if self.codecserver == s:
return
self.codecserver = s
self.restart()
def get_codecserver_arg(self):
return "-s {}".format(self.codecserver) if self.codecserver else ""
def set_dmr_filter(self, filter):
if self.has_pipe("dmr_control_pipe"):
self.pipes["dmr_control_pipe"].write("{0}\n".format(filter))
def set_wfm_deemphasis_tau(self, tau):
if self.wfm_deemphasis_tau == tau:
return
self.wfm_deemphasis_tau = tau
self.restart()
def ddc_transition_bw(self):
return self.ddc_transition_bw_rate * (self.if_samp_rate() / float(self.samp_rate))
def try_create_pipes(self, pipe_names, command_base):
for pipe_name, pipe_type in pipe_names.items():
if self.has_pipe(pipe_name):
logger.warning("{pipe_name} is still in use", pipe_name=pipe_name)
self.pipes[pipe_name].close()
if "{" + pipe_name + "}" in command_base:
p = self.pipe_base_path + pipe_name
encoding = None
# TODO make digiham output unicode and then change this here
# the whole pipe enoding feature onlye exists because of this
if pipe_name == "meta_pipe":
encoding = "cp437"
self.pipes[pipe_name] = Pipe.create(p, pipe_type, encoding=encoding)
else:
self.pipes[pipe_name] = None
def has_pipe(self, name):
return name in self.pipes and self.pipes[name] is not None
def try_delete_pipes(self, pipe_names):
for pipe_name in pipe_names:
if self.has_pipe(pipe_name):
self.pipes[pipe_name].close()
self.pipes[pipe_name] = None
def try_create_configs(self, command):
if "{direwolf_config}" in command:
self.direwolf_config_path = "{tmp_dir}/openwebrx_direwolf_{myid}.conf".format(
tmp_dir=self.temporary_directory, myid=id(self)
)
self.direwolf_config = DirewolfConfig()
self.direwolf_config.wire(self)
file = open(self.direwolf_config_path, "w")
file.write(self.direwolf_config.getConfig(self.is_service))
file.close()
else:
self.direwolf_config = None
self.direwolf_config_path = None
def try_delete_configs(self):
if self.direwolf_config is not None:
self.direwolf_config.unwire(self)
self.direwolf_config = None
if self.direwolf_config_path is not None:
try:
os.unlink(self.direwolf_config_path)
except FileNotFoundError:
# result suits our expectations. fine :)
pass
except Exception:
logger.exception("try_delete_configs()")
self.direwolf_config_path = None
def onConfigChanged(self):
self.restart()
def start(self):
with self.modification_lock:
if self.running:
return
self.running = True
command_base = " | ".join(self.chain(self.demodulator))
# create control pipes for csdr
self.try_create_pipes(self.pipe_names, command_base)
# send initial config through the pipes
if self.has_pipe("bpf_pipe"):
self.set_bpf(self.low_cut, self.high_cut)
if self.has_pipe("shift_pipe"):
self.set_offset_freq(self.offset_freq)
if self.has_pipe("squelch_pipe"):
self.set_squelch_level(self.squelch_level)
if self.has_pipe("dmr_control_pipe"):
self.set_dmr_filter(3)
# run the command
command = command_base.format(
bpf_pipe=self.pipes["bpf_pipe"],
shift_pipe=self.pipes["shift_pipe"],
squelch_pipe=self.pipes["squelch_pipe"],
smeter_pipe=self.pipes["smeter_pipe"],
meta_pipe=self.pipes["meta_pipe"],
iqtee_pipe=self.pipes["iqtee_pipe"],
iqtee2_pipe=self.pipes["iqtee2_pipe"],
dmr_control_pipe=self.pipes["dmr_control_pipe"],
decimation=self.decimation,
last_decimation=self.last_decimation,
fft_size=self.fft_size,
fft_block_size=self.fft_block_size(),
fft_averages=self.fft_averages,
bpf_transition_bw=float(self.bpf_transition_bw) / self.if_samp_rate(),
ddc_transition_bw=self.ddc_transition_bw(),
flowcontrol=int(self.samp_rate * 2),
nc_port=self.nc_port,
output_rate=self.get_output_rate(),
smeter_report_every=int(self.if_samp_rate() / 6000),
codecserver_arg=self.get_codecserver_arg(),
audio_rate=self.get_audio_rate(),
wfm_deemphasis_tau=self.wfm_deemphasis_tau,
)
logger.debug("Command = %s", command)
out = subprocess.PIPE if self.output.supports_type("audio") else subprocess.DEVNULL
self.process = subprocess.Popen(command, stdout=out, shell=True, start_new_session=True, bufsize=128)
def watch_thread():
rc = self.process.wait()
logger.debug("dsp thread ended with rc=%d", rc)
if rc == 0 and self.running and not self.modification_lock.locked():
logger.debug("restarting since rc = 0, self.running = true, and no modification")
self.restart()
threading.Thread(target=watch_thread, name="csdr_watch_thread").start()
audio_type = "hd_audio" if self.isHdAudio() else "audio"
if self.output.supports_type(audio_type):
self.output.send_output(
audio_type,
partial(
self.process.stdout.read,
self.get_fft_bytes_to_read() if self.demodulator == "fft" else self.get_audio_bytes_to_read(),
),
)
self.start_secondary_demodulator()
if self.has_pipe("smeter_pipe"):
def read_smeter():
raw = self.pipes["smeter_pipe"].readline()
if len(raw) == 0:
return None
else:
return float(raw.rstrip("\n"))
self.output.send_output("smeter", read_smeter)
if self.has_pipe("meta_pipe"):
def read_meta():
raw = self.pipes["meta_pipe"].readline()
if len(raw) == 0:
return None
else:
return raw.rstrip("\n")
self.output.send_output("meta", read_meta)
def stop(self):
with self.modification_lock:
self.running = False
if self.process is not None:
try:
os.killpg(os.getpgid(self.process.pid), signal.SIGTERM)
# drain any leftover data to free file descriptors
self.process.communicate()
self.process = None
except ProcessLookupError:
# been killed by something else, ignore
pass
self.stop_secondary_demodulator()
self.try_delete_pipes(self.pipe_names)
self.try_delete_configs()
def restart(self):
if not self.running:
return
self.stop()
self.start()

View File

@ -1,142 +0,0 @@
from csdr.module import Module
from pycsdr.modules import Buffer
from pycsdr.types import Format
from typing import Union, Callable, Optional
class Chain(Module):
def __init__(self, workers):
super().__init__()
self.workers = workers
for i in range(1, len(self.workers)):
self._connect(self.workers[i - 1], self.workers[i])
def empty(self):
return not self.workers
def _connect(self, w1, w2, buffer: Optional[Buffer] = None) -> None:
if buffer is None:
buffer = Buffer(w1.getOutputFormat())
w1.setWriter(buffer)
w2.setReader(buffer.getReader())
def setReader(self, reader):
if self.reader is reader:
return
super().setReader(reader)
if self.workers:
self.workers[0].setReader(reader)
def setWriter(self, writer):
if self.writer is writer:
return
super().setWriter(writer)
if self.workers:
self.workers[-1].setWriter(writer)
def indexOf(self, search: Union[Callable, object]) -> int:
def searchFn(x):
if callable(search):
return search(x)
else:
return x is search
try:
return next(i for i, v in enumerate(self.workers) if searchFn(v))
except StopIteration:
return -1
def replace(self, index, newWorker):
if index >= len(self.workers):
raise IndexError("Index {} does not exist".format(index))
self.workers[index].stop()
self.workers[index] = newWorker
error = None
if index == 0:
if self.reader is not None:
newWorker.setReader(self.reader)
else:
try:
previousWorker = self.workers[index - 1]
self._connect(previousWorker, newWorker)
except ValueError as e:
# store error for later raising, but still attempt the second connection
error = e
if index == len(self.workers) - 1:
if self.writer is not None:
newWorker.setWriter(self.writer)
else:
try:
nextWorker = self.workers[index + 1]
self._connect(newWorker, nextWorker)
except ValueError as e:
error = e
if error is not None:
raise error
def append(self, newWorker):
previousWorker = None
if self.workers:
previousWorker = self.workers[-1]
self.workers.append(newWorker)
if previousWorker:
self._connect(previousWorker, newWorker)
elif self.reader is not None:
newWorker.setReader(self.reader)
if self.writer is not None:
newWorker.setWriter(self.writer)
def insert(self, newWorker):
nextWorker = None
if self.workers:
nextWorker = self.workers[0]
self.workers.insert(0, newWorker)
if nextWorker:
self._connect(newWorker, nextWorker)
elif self.writer is not None:
newWorker.setWriter(self.writer)
if self.reader is not None:
newWorker.setReader(self.reader)
def remove(self, index):
removedWorker = self.workers[index]
self.workers.remove(removedWorker)
removedWorker.stop()
if index == 0:
if self.reader is not None and len(self.workers):
self.workers[0].setReader(self.reader)
elif index == len(self.workers):
if self.writer is not None:
self.workers[-1].setWriter(self.writer)
else:
previousWorker = self.workers[index - 1]
nextWorker = self.workers[index]
self._connect(previousWorker, nextWorker)
def stop(self):
for w in self.workers:
w.stop()
def getInputFormat(self) -> Format:
if self.workers:
return self.workers[0].getInputFormat()
else:
raise BufferError("getInputFormat on empty chain")
def getOutputFormat(self) -> Format:
if self.workers:
return self.workers[-1].getOutputFormat()
else:
raise BufferError("getOutputFormat on empty chain")

View File

@ -1,76 +0,0 @@
from csdr.chain.demodulator import BaseDemodulatorChain, FixedIfSampleRateChain, HdAudio, DeemphasisTauChain
from pycsdr.modules import AmDemod, DcBlock, FmDemod, Limit, NfmDeemphasis, Agc, WfmDeemphasis, FractionalDecimator, RealPart
from pycsdr.types import Format, AgcProfile
class Am(BaseDemodulatorChain):
def __init__(self):
agc = Agc(Format.FLOAT)
agc.setProfile(AgcProfile.SLOW)
agc.setInitialGain(200)
workers = [
AmDemod(),
DcBlock(),
agc,
]
super().__init__(workers)
class NFm(BaseDemodulatorChain):
def __init__(self, sampleRate: int):
self.sampleRate = sampleRate
agc = Agc(Format.FLOAT)
agc.setProfile(AgcProfile.SLOW)
agc.setMaxGain(3)
workers = [
FmDemod(),
Limit(),
NfmDeemphasis(sampleRate),
agc,
]
super().__init__(workers)
def setSampleRate(self, sampleRate: int) -> None:
if sampleRate == self.sampleRate:
return
self.sampleRate = sampleRate
self.replace(2, NfmDeemphasis(sampleRate))
class WFm(BaseDemodulatorChain, FixedIfSampleRateChain, DeemphasisTauChain, HdAudio):
def __init__(self, sampleRate: int, tau: float):
self.sampleRate = sampleRate
self.tau = tau
workers = [
FmDemod(),
Limit(),
FractionalDecimator(Format.FLOAT, 200000.0 / self.sampleRate, prefilter=True),
WfmDeemphasis(self.sampleRate, self.tau),
]
super().__init__(workers)
def getFixedIfSampleRate(self):
return 200000
def setDeemphasisTau(self, tau: float) -> None:
if tau == self.tau:
return
self.tau = tau
self.replace(3, WfmDeemphasis(self.sampleRate, self.tau))
def setSampleRate(self, sampleRate: int) -> None:
if sampleRate == self.sampleRate:
return
self.sampleRate = sampleRate
self.replace(2, FractionalDecimator(Format.FLOAT, 200000.0 / self.sampleRate, prefilter=True))
self.replace(3, WfmDeemphasis(self.sampleRate, self.tau))
class Ssb(BaseDemodulatorChain):
def __init__(self):
workers = [
RealPart(),
Agc(Format.FLOAT),
]
super().__init__(workers)

View File

@ -1,72 +0,0 @@
from csdr.chain import Chain
from pycsdr.modules import AudioResampler, Convert, AdpcmEncoder, Limit
from pycsdr.types import Format
class Converter(Chain):
def __init__(self, format: Format, inputRate: int, clientRate: int):
workers = []
if inputRate != clientRate:
# we only have an audio resampler for float ATM so if we need to resample, we need to convert
if format != Format.FLOAT:
workers += [Convert(format, Format.FLOAT)]
workers += [AudioResampler(inputRate, clientRate), Limit(), Convert(Format.FLOAT, Format.SHORT)]
elif format != Format.SHORT:
workers += [Convert(format, Format.SHORT)]
super().__init__(workers)
class ClientAudioChain(Chain):
def __init__(self, format: Format, inputRate: int, clientRate: int, compression: str):
self.format = format
self.inputRate = inputRate
self.clientRate = clientRate
workers = []
converter = self._buildConverter()
if not converter.empty():
workers += [converter]
if compression == "adpcm":
workers += [AdpcmEncoder(sync=True)]
super().__init__(workers)
def _buildConverter(self):
return Converter(self.format, self.inputRate, self.clientRate)
def _updateConverter(self):
converter = self._buildConverter()
index = self.indexOf(lambda x: isinstance(x, Converter))
if converter.empty():
if index >= 0:
self.remove(index)
else:
if index >= 0:
self.replace(index, converter)
else:
self.insert(converter)
def setFormat(self, format: Format) -> None:
if format == self.format:
return
self.format = format
self._updateConverter()
def setInputRate(self, inputRate: int) -> None:
if inputRate == self.inputRate:
return
self.inputRate = inputRate
self._updateConverter()
def setClientRate(self, clientRate: int) -> None:
if clientRate == self.clientRate:
return
self.clientRate = clientRate
self._updateConverter()
def setAudioCompression(self, compression: str) -> None:
index = self.indexOf(lambda x: isinstance(x, AdpcmEncoder))
if compression == "adpcm":
if index < 0:
self.append(AdpcmEncoder(sync=True))
else:
if index >= 0:
self.remove(index)

View File

@ -1,73 +0,0 @@
from csdr.chain import Chain
from abc import ABC, ABCMeta, abstractmethod
from pycsdr.modules import Writer
class FixedAudioRateChain(ABC):
@abstractmethod
def getFixedAudioRate(self) -> int:
pass
class FixedIfSampleRateChain(ABC):
@abstractmethod
def getFixedIfSampleRate(self) -> int:
pass
class DialFrequencyReceiver(ABC):
@abstractmethod
def setDialFrequency(self, frequency: int) -> None:
pass
# marker interface
class HdAudio:
pass
class MetaProvider(ABC):
@abstractmethod
def setMetaWriter(self, writer: Writer) -> None:
pass
class SlotFilterChain(ABC):
@abstractmethod
def setSlotFilter(self, filter: int) -> None:
pass
class SecondarySelectorChain(ABC):
def getBandwidth(self) -> float:
pass
class DeemphasisTauChain(ABC):
@abstractmethod
def setDeemphasisTau(self, tau: float) -> None:
pass
class BaseDemodulatorChain(Chain):
def supportsSquelch(self) -> bool:
return True
def setSampleRate(self, sampleRate: int) -> None:
pass
class SecondaryDemodulator(Chain):
def supportsSquelch(self) -> bool:
return True
def setSampleRate(self, sampleRate: int) -> None:
pass
class ServiceDemodulator(SecondaryDemodulator, FixedAudioRateChain, metaclass=ABCMeta):
pass
class DemodulatorError(Exception):
pass

View File

@ -1,133 +0,0 @@
from csdr.chain.demodulator import BaseDemodulatorChain, FixedAudioRateChain, FixedIfSampleRateChain, DialFrequencyReceiver, MetaProvider, SlotFilterChain, DemodulatorError, ServiceDemodulator
from pycsdr.modules import FmDemod, Agc, Writer, Buffer
from pycsdr.types import Format
from digiham.modules import DstarDecoder, DcBlock, FskDemodulator, GfskDemodulator, DigitalVoiceFilter, MbeSynthesizer, NarrowRrcFilter, NxdnDecoder, DmrDecoder, WideRrcFilter, YsfDecoder, PocsagDecoder
from digiham.ambe import Modes, ServerError
from owrx.meta import MetaParser
from owrx.pocsag import PocsagParser
class DigihamChain(BaseDemodulatorChain, FixedIfSampleRateChain, FixedAudioRateChain, DialFrequencyReceiver, MetaProvider):
def __init__(self, fskDemodulator, decoder, mbeMode, filter=None, codecserver: str = ""):
self.decoder = decoder
if codecserver is None:
codecserver = ""
agc = Agc(Format.SHORT)
agc.setMaxGain(30)
agc.setInitialGain(3)
workers = [FmDemod(), DcBlock()]
if filter is not None:
workers += [filter]
try:
mbeSynthesizer = MbeSynthesizer(mbeMode, codecserver)
except ConnectionError as ce:
raise DemodulatorError("Connection to codecserver failed: {}".format(ce))
except ServerError as se:
raise DemodulatorError("Codecserver error: {}".format(se))
workers += [
fskDemodulator,
decoder,
mbeSynthesizer,
DigitalVoiceFilter(),
agc
]
self.metaParser = None
self.dialFrequency = None
super().__init__(workers)
def getFixedIfSampleRate(self):
return 48000
def getFixedAudioRate(self):
return 8000
def setMetaWriter(self, writer: Writer) -> None:
if self.metaParser is None:
self.metaParser = MetaParser()
buffer = Buffer(Format.CHAR)
self.decoder.setMetaWriter(buffer)
self.metaParser.setReader(buffer.getReader())
if self.dialFrequency is not None:
self.metaParser.setDialFrequency(self.dialFrequency)
self.metaParser.setWriter(writer)
def supportsSquelch(self):
return False
def setDialFrequency(self, frequency: int) -> None:
self.dialFrequency = frequency
if self.metaParser is None:
return
self.metaParser.setDialFrequency(frequency)
def stop(self):
if self.metaParser is not None:
self.metaParser.stop()
super().stop()
class Dstar(DigihamChain):
def __init__(self, codecserver: str = ""):
super().__init__(
fskDemodulator=FskDemodulator(samplesPerSymbol=10),
decoder=DstarDecoder(),
mbeMode=Modes.DStarMode,
codecserver=codecserver
)
class Nxdn(DigihamChain):
def __init__(self, codecserver: str = ""):
super().__init__(
fskDemodulator=GfskDemodulator(samplesPerSymbol=20),
decoder=NxdnDecoder(),
mbeMode=Modes.NxdnMode,
filter=NarrowRrcFilter(),
codecserver=codecserver
)
class Dmr(DigihamChain, SlotFilterChain):
def __init__(self, codecserver: str = ""):
super().__init__(
fskDemodulator=GfskDemodulator(samplesPerSymbol=10),
decoder=DmrDecoder(),
mbeMode=Modes.DmrMode,
filter=WideRrcFilter(),
codecserver=codecserver,
)
def setSlotFilter(self, slotFilter: int) -> None:
self.decoder.setSlotFilter(slotFilter)
class Ysf(DigihamChain):
def __init__(self, codecserver: str = ""):
super().__init__(
fskDemodulator=GfskDemodulator(samplesPerSymbol=10),
decoder=YsfDecoder(),
mbeMode=Modes.YsfMode,
filter=WideRrcFilter(),
codecserver=codecserver
)
class PocsagDemodulator(ServiceDemodulator, DialFrequencyReceiver):
def __init__(self):
self.parser = PocsagParser()
workers = [
FmDemod(),
FskDemodulator(samplesPerSymbol=40, invert=True),
PocsagDecoder(),
self.parser,
]
super().__init__(workers)
def supportsSquelch(self) -> bool:
return False
def getFixedAudioRate(self) -> int:
return 48000
def setDialFrequency(self, frequency: int) -> None:
self.parser.setDialFrequency(frequency)

View File

@ -1,86 +0,0 @@
from csdr.chain.demodulator import ServiceDemodulator, SecondaryDemodulator, DialFrequencyReceiver, SecondarySelectorChain
from csdr.module.msk144 import Msk144Module, ParserAdapter
from owrx.audio.chopper import AudioChopper, AudioChopperParser
from owrx.aprs.kiss import KissDeframer
from owrx.aprs import Ax25Parser, AprsParser
from pycsdr.modules import Convert, FmDemod, Agc, TimingRecovery, DBPskDecoder, VaricodeDecoder
from pycsdr.types import Format
from owrx.aprs.module import DirewolfModule
class AudioChopperDemodulator(ServiceDemodulator, DialFrequencyReceiver):
def __init__(self, mode: str, parser: AudioChopperParser):
self.chopper = AudioChopper(mode, parser)
workers = [Convert(Format.FLOAT, Format.SHORT), self.chopper]
super().__init__(workers)
def getFixedAudioRate(self):
return 12000
def setDialFrequency(self, frequency: int) -> None:
self.chopper.setDialFrequency(frequency)
class Msk144Demodulator(ServiceDemodulator, DialFrequencyReceiver):
def __init__(self):
self.parser = ParserAdapter()
workers = [
Convert(Format.FLOAT, Format.SHORT),
Msk144Module(),
self.parser,
]
super().__init__(workers)
def getFixedAudioRate(self) -> int:
return 12000
def setDialFrequency(self, frequency: int) -> None:
self.parser.setDialFrequency(frequency)
class PacketDemodulator(ServiceDemodulator, DialFrequencyReceiver):
def __init__(self, service: bool = False):
self.parser = AprsParser()
workers = [
FmDemod(),
Convert(Format.FLOAT, Format.SHORT),
DirewolfModule(service=service),
KissDeframer(),
Ax25Parser(),
self.parser,
]
super().__init__(workers)
def supportsSquelch(self) -> bool:
return False
def getFixedAudioRate(self) -> int:
return 48000
def setDialFrequency(self, frequency: int) -> None:
self.parser.setDialFrequency(frequency)
class PskDemodulator(SecondaryDemodulator, SecondarySelectorChain):
def __init__(self, baudRate: float):
self.baudRate = baudRate
# this is an assumption, we will adjust in setSampleRate
self.sampleRate = 12000
secondary_samples_per_bits = int(round(self.sampleRate / self.baudRate)) & ~3
workers = [
Agc(Format.COMPLEX_FLOAT),
TimingRecovery(secondary_samples_per_bits, 0.5, 2, useQ=True),
DBPskDecoder(),
VaricodeDecoder(),
]
super().__init__(workers)
def getBandwidth(self):
return self.baudRate
def setSampleRate(self, sampleRate: int) -> None:
if sampleRate == self.sampleRate:
return
self.sampleRate = sampleRate
secondary_samples_per_bits = int(round(self.sampleRate / self.baudRate)) & ~3
self.replace(1, TimingRecovery(secondary_samples_per_bits, 0.5, 2, useQ=True))

View File

@ -1,19 +0,0 @@
from csdr.chain.demodulator import BaseDemodulatorChain, FixedIfSampleRateChain, FixedAudioRateChain
from pycsdr.modules import Convert, Downmix
from pycsdr.types import Format
from csdr.module.drm import DrmModule
class Drm(BaseDemodulatorChain, FixedIfSampleRateChain, FixedAudioRateChain):
def __init__(self):
workers = [Convert(Format.COMPLEX_FLOAT, Format.COMPLEX_SHORT), DrmModule(), Downmix()]
super().__init__(workers)
def supportsSquelch(self) -> bool:
return False
def getFixedIfSampleRate(self) -> int:
return 48000
def getFixedAudioRate(self) -> int:
return 48000

View File

@ -1,14 +0,0 @@
from pycsdr.types import Format
from csdr.chain import Module
class DummyDemodulator(Module):
def __init__(self, outputFormat: Format):
self.outputFormat = outputFormat
super().__init__()
def getInputFormat(self) -> Format:
return Format.COMPLEX_FLOAT
def getOutputFormat(self) -> Format:
return self.outputFormat

View File

@ -1,96 +0,0 @@
from csdr.chain import Chain
from pycsdr.modules import Fft, LogPower, LogAveragePower, FftSwap, FftAdpcm
class FftAverager(Chain):
def __init__(self, fft_size, fft_averages):
self.fftSize = fft_size
self.fftAverages = fft_averages
workers = [self._getWorker()]
super().__init__(workers)
def setFftAverages(self, fft_averages):
if self.fftAverages == fft_averages:
return
self.fftAverages = fft_averages
self.replace(0, self._getWorker())
def _getWorker(self):
if self.fftAverages == 0:
return LogPower(add_db=-70)
else:
return LogAveragePower(add_db=-70, fft_size=self.fftSize, avg_number=self.fftAverages)
class FftChain(Chain):
def __init__(self, samp_rate, fft_size, fft_v_overlap_factor, fft_fps, fft_compression):
self.sampleRate = samp_rate
self.vOverlapFactor = fft_v_overlap_factor
self.fps = fft_fps
self.size = fft_size
self.blockSize = 0
self.fft = Fft(size=self.size, every_n_samples=self.blockSize)
self.averager = FftAverager(fft_size=self.size, fft_averages=10)
self.fftExchangeSides = FftSwap(fft_size=self.size)
workers = [
self.fft,
self.averager,
self.fftExchangeSides,
]
self.compressFftAdpcm = None
if fft_compression == "adpcm":
self.compressFftAdpcm = FftAdpcm(fft_size=self.size)
workers += [self.compressFftAdpcm]
self._updateParameters()
super().__init__(workers)
def _setBlockSize(self, fft_block_size):
if self.blockSize == int(fft_block_size):
return
self.blockSize = int(fft_block_size)
self.fft.setEveryNSamples(self.blockSize)
def setVOverlapFactor(self, fft_v_overlap_factor):
if self.vOverlapFactor == fft_v_overlap_factor:
return
self.vOverlapFactor = fft_v_overlap_factor
self._updateParameters()
def setFps(self, fft_fps):
if self.fps == fft_fps:
return
self.fps = fft_fps
self._updateParameters()
def setSampleRate(self, samp_rate):
if self.sampleRate == samp_rate:
return
self.sampleRate = samp_rate
self._updateParameters()
def _updateParameters(self):
fftAverages = 0
if self.vOverlapFactor > 0:
fftAverages = int(round(1.0 * self.sampleRate / self.size / self.fps / (1.0 - self.vOverlapFactor)))
self.averager.setFftAverages(fftAverages)
if fftAverages == 0:
self._setBlockSize(self.sampleRate / self.fps)
else:
self._setBlockSize(self.sampleRate / self.fps / fftAverages)
def setCompression(self, compression: str) -> None:
if compression == "adpcm" and not self.compressFftAdpcm:
self.compressFftAdpcm = FftAdpcm(self.size)
# should always be at the end
self.append(self.compressFftAdpcm)
elif compression == "none" and self.compressFftAdpcm:
self.compressFftAdpcm.stop()
self.compressFftAdpcm = None
# should always be at that position (right?)
self.remove(3)

View File

@ -1,28 +0,0 @@
from csdr.chain.demodulator import BaseDemodulatorChain, FixedIfSampleRateChain, FixedAudioRateChain
from csdr.module.freedv import FreeDVModule
from pycsdr.modules import RealPart, Agc, Convert
from pycsdr.types import Format
class FreeDV(BaseDemodulatorChain, FixedIfSampleRateChain, FixedAudioRateChain):
def __init__(self):
agc = Agc(Format.SHORT)
agc.setMaxGain(30)
agc.setInitialGain(3)
workers = [
RealPart(),
Agc(Format.FLOAT),
Convert(Format.FLOAT, Format.SHORT),
FreeDVModule(),
agc,
]
super().__init__(workers)
def getFixedIfSampleRate(self) -> int:
return 8000
def getFixedAudioRate(self) -> int:
return 8000
def supportsSquelch(self) -> bool:
return False

View File

@ -1,30 +0,0 @@
from csdr.chain.demodulator import BaseDemodulatorChain, FixedIfSampleRateChain, FixedAudioRateChain, MetaProvider
from csdr.module.m17 import M17Module
from pycsdr.modules import FmDemod, Limit, Convert, Writer
from pycsdr.types import Format
from digiham.modules import DcBlock
class M17(BaseDemodulatorChain, FixedIfSampleRateChain, FixedAudioRateChain, MetaProvider):
def __init__(self):
self.module = M17Module()
workers = [
FmDemod(),
DcBlock(),
Limit(),
Convert(Format.FLOAT, Format.SHORT),
self.module,
]
super().__init__(workers)
def getFixedIfSampleRate(self) -> int:
return 48000
def getFixedAudioRate(self) -> int:
return 8000
def supportsSquelch(self) -> bool:
return False
def setMetaWriter(self, writer: Writer) -> None:
self.module.setMetaWriter(writer)

View File

@ -1,160 +0,0 @@
from csdr.chain import Chain
from pycsdr.modules import Shift, FirDecimate, Bandpass, Squelch, FractionalDecimator, Writer
from pycsdr.types import Format
import math
class Decimator(Chain):
def __init__(self, inputRate: int, outputRate: int):
if outputRate > inputRate:
raise ValueError("impossible decimation: cannot upsample {} to {}".format(inputRate, outputRate))
self.inputRate = inputRate
self.outputRate = outputRate
decimation, fraction = self._getDecimation(outputRate)
transition = 0.15 * (outputRate / float(self.inputRate))
# set the cutoff on the fist decimation stage lower so that the resulting output
# is already prepared for the second (fractional) decimation stage.
# this spares us a second filter.
cutoff = 0.5 * decimation / (self.inputRate / outputRate)
workers = [
FirDecimate(decimation, transition, cutoff),
]
if fraction != 1.0:
workers += [FractionalDecimator(Format.COMPLEX_FLOAT, fraction)]
super().__init__(workers)
def _getDecimation(self, outputRate: int) -> (int, float):
d = self.inputRate / outputRate
dInt = int(d)
dFloat = float(self.inputRate / dInt) / outputRate
return dInt, dFloat
def _reconfigure(self):
decimation, fraction = self._getDecimation(self.outputRate)
transition = 0.15 * (self.outputRate / float(self.inputRate))
cutoff = 0.5 * decimation / (self.inputRate / self.outputRate)
self.replace(0, FirDecimate(decimation, transition, cutoff))
index = self.indexOf(lambda x: isinstance(x, FractionalDecimator))
if fraction != 1.0:
decimator = FractionalDecimator(Format.COMPLEX_FLOAT, fraction)
if index >= 0:
self.replace(index, decimator)
else:
self.append(decimator)
elif index >= 0:
self.remove(index)
def setOutputRate(self, outputRate: int) -> None:
if outputRate == self.outputRate:
return
self.outputRate = outputRate
self._reconfigure()
def setInputRate(self, inputRate: int) -> None:
if inputRate == self.inputRate:
return
self.inputRate = inputRate
self._reconfigure()
class Selector(Chain):
def __init__(self, inputRate: int, outputRate: int, withSquelch: bool = True):
self.inputRate = inputRate
self.outputRate = outputRate
self.frequencyOffset = 0
self.shift = Shift(0.0)
self.decimation = Decimator(inputRate, outputRate)
self.bandpass = self._buildBandpass()
self.bandpassCutoffs = None
self.setBandpass(-4000, 4000)
workers = [self.shift, self.decimation, self.bandpass]
if withSquelch:
self.readings_per_second = 4
# s-meter readings are available every 1024 samples
# the reporting interval is measured in those 1024-sample blocks
self.squelch = Squelch(5, int(outputRate / (self.readings_per_second * 1024)))
workers += [self.squelch]
super().__init__(workers)
def _buildBandpass(self) -> Bandpass:
bp_transition = 320.0 / self.outputRate
return Bandpass(transition=bp_transition, use_fft=True)
def setFrequencyOffset(self, offset: int) -> None:
if offset == self.frequencyOffset:
return
self.frequencyOffset = offset
self._updateShift()
def _updateShift(self):
shift = -self.frequencyOffset / self.inputRate
self.shift.setRate(shift)
def _convertToLinear(self, db: float) -> float:
return float(math.pow(10, db / 10))
def setSquelchLevel(self, level: float) -> None:
self.squelch.setSquelchLevel(self._convertToLinear(level))
def setBandpass(self, lowCut: float, highCut: float) -> None:
self.bandpassCutoffs = [lowCut, highCut]
scaled = [x / self.outputRate for x in self.bandpassCutoffs]
self.bandpass.setBandpass(*scaled)
def setLowCut(self, lowCut: float) -> None:
self.bandpassCutoffs[0] = lowCut
self.setBandpass(*self.bandpassCutoffs)
def setHighCut(self, highCut: float) -> None:
self.bandpassCutoffs[1] = highCut
self.setBandpass(*self.bandpassCutoffs)
def setPowerWriter(self, writer: Writer) -> None:
self.squelch.setPowerWriter(writer)
def setOutputRate(self, outputRate: int) -> None:
if outputRate == self.outputRate:
return
self.outputRate = outputRate
self.decimation.setOutputRate(outputRate)
self.squelch.setReportInterval(int(outputRate / (self.readings_per_second * 1024)))
self.bandpass = self._buildBandpass()
self.setBandpass(*self.bandpassCutoffs)
self.replace(2, self.bandpass)
def setInputRate(self, inputRate: int) -> None:
if inputRate == self.inputRate:
return
self.inputRate = inputRate
self.decimation.setInputRate(inputRate)
self._updateShift()
class SecondarySelector(Chain):
def __init__(self, sampleRate: int, bandwidth: float):
self.sampleRate = sampleRate
self.frequencyOffset = 0
self.shift = Shift(0.0)
cutoffRate = bandwidth / sampleRate
self.bandpass = Bandpass(-cutoffRate, cutoffRate, cutoffRate, use_fft=True)
workers = [self.shift, self.bandpass]
super().__init__(workers)
def setFrequencyOffset(self, offset: int) -> None:
if offset == self.frequencyOffset:
return
self.frequencyOffset = offset
if self.frequencyOffset is None:
return
self.shift.setRate(-offset / self.sampleRate)

View File

@ -1,136 +0,0 @@
from pycsdr.modules import Module as BaseModule
from pycsdr.modules import Reader, Writer
from pycsdr.types import Format
from abc import ABCMeta, abstractmethod
from threading import Thread
from io import BytesIO
from subprocess import Popen, PIPE
from functools import partial
import pickle
class Module(BaseModule, metaclass=ABCMeta):
def __init__(self):
self.reader = None
self.writer = None
super().__init__()
def setReader(self, reader: Reader) -> None:
self.reader = reader
def setWriter(self, writer: Writer) -> None:
self.writer = writer
@abstractmethod
def getInputFormat(self) -> Format:
pass
@abstractmethod
def getOutputFormat(self) -> Format:
pass
def pump(self, read, write):
def copy():
while True:
data = None
try:
data = read()
except ValueError:
pass
except BrokenPipeError:
break
if data is None or isinstance(data, bytes) and len(data) == 0:
break
write(data)
return copy
class AutoStartModule(Module, metaclass=ABCMeta):
def _checkStart(self) -> None:
if self.reader is not None and self.writer is not None:
self.start()
def setReader(self, reader: Reader) -> None:
super().setReader(reader)
self._checkStart()
def setWriter(self, writer: Writer) -> None:
super().setWriter(writer)
self._checkStart()
@abstractmethod
def start(self):
pass
class ThreadModule(AutoStartModule, Thread, metaclass=ABCMeta):
def __init__(self):
self.doRun = True
super().__init__()
Thread.__init__(self)
@abstractmethod
def run(self):
pass
def stop(self):
self.doRun = False
self.reader.stop()
def start(self):
Thread.start(self)
class PickleModule(ThreadModule):
def getInputFormat(self) -> Format:
return Format.CHAR
def getOutputFormat(self) -> Format:
return Format.CHAR
def run(self):
while self.doRun:
data = self.reader.read()
if data is None:
self.doRun = False
break
io = BytesIO(data.tobytes())
try:
while True:
output = self.process(pickle.load(io))
if output is not None:
self.writer.write(pickle.dumps(output))
except EOFError:
pass
@abstractmethod
def process(self, input):
pass
class PopenModule(AutoStartModule, metaclass=ABCMeta):
def __init__(self):
self.process = None
super().__init__()
@abstractmethod
def getCommand(self):
pass
def _getProcess(self):
return Popen(self.getCommand(), stdin=PIPE, stdout=PIPE)
def start(self):
self.process = self._getProcess()
# resume in case the reader has been stop()ed before
self.reader.resume()
Thread(target=self.pump(self.reader.read, self.process.stdin.write)).start()
Thread(target=self.pump(partial(self.process.stdout.read1, 1024), self.writer.write)).start()
def stop(self):
if self.process is not None:
self.process.terminate()
self.process.wait()
self.process = None
self.reader.stop()

View File

@ -1,14 +0,0 @@
from csdr.module import PopenModule
from pycsdr.types import Format
class DrmModule(PopenModule):
def getInputFormat(self) -> Format:
return Format.COMPLEX_FLOAT
def getOutputFormat(self) -> Format:
return Format.SHORT
def getCommand(self):
# dream -c 6 --sigsrate 48000 --audsrate 48000 -I - -O -
return ["dream", "-c", "6", "--sigsrate", "48000", "--audsrate", "48000", "-I", "-", "-O", "-"]

View File

@ -1,13 +0,0 @@
from pycsdr.types import Format
from csdr.module import PopenModule
class FreeDVModule(PopenModule):
def getInputFormat(self) -> Format:
return Format.SHORT
def getOutputFormat(self) -> Format:
return Format.SHORT
def getCommand(self):
return ["freedv_rx", "1600", "-", "-"]

View File

@ -1,58 +0,0 @@
from csdr.module import PopenModule
from pycsdr.types import Format
from pycsdr.modules import Writer
from subprocess import Popen, PIPE
from threading import Thread
import re
import pickle
class M17Module(PopenModule):
lsfRegex = re.compile("SRC: ([a-zA-Z0-9]+), DEST: ([a-zA-Z0-9]+)")
def __init__(self):
super().__init__()
self.metawriter = None
def getInputFormat(self) -> Format:
return Format.SHORT
def getOutputFormat(self) -> Format:
return Format.SHORT
def getCommand(self):
return ["m17-demod", "-l"]
def _getProcess(self):
return Popen(self.getCommand(), stdin=PIPE, stdout=PIPE, stderr=PIPE)
def start(self):
super().start()
Thread(target=self._readOutput).start()
def _readOutput(self):
while True:
line = self.process.stderr.readline()
if not line:
break
self.parseOutput(line.decode())
def parseOutput(self, line):
if self.metawriter is None:
return
matches = self.lsfRegex.match(line)
msg = {"protocol": "M17"}
if matches:
# fake sync
msg["sync"] = "voice"
msg["source"] = matches.group(1)
msg["destination"] = matches.group(2)
elif line.startswith("EOS"):
pass
else:
return
self.metawriter.write(pickle.dumps(msg))
def setMetaWriter(self, writer: Writer) -> None:
self.metawriter = writer

View File

@ -1,57 +0,0 @@
from pycsdr.types import Format
from csdr.module import PopenModule, ThreadModule
from owrx.wsjt import WsjtParser, Msk144Profile
import pickle
import logging
logger = logging.getLogger(__name__)
class Msk144Module(PopenModule):
def getCommand(self):
return ["msk144decoder"]
def getInputFormat(self) -> Format:
return Format.SHORT
def getOutputFormat(self) -> Format:
return Format.CHAR
class ParserAdapter(ThreadModule):
def __init__(self):
self.retained = bytes()
self.parser = WsjtParser()
self.dialFrequency = 0
super().__init__()
def run(self):
profile = Msk144Profile()
while self.doRun:
data = self.reader.read()
if data is None:
self.doRun = False
else:
self.retained += data
lines = self.retained.split(b"\n")
# keep the last line
# this should either be empty if the last char was \n
# or an incomplete line if the read returned early
self.retained = lines[-1]
# parse all completed lines
for line in lines[0:-1]:
# actual messages from msk144decoder should start with "*** "
if line[0:4] == b"*** ":
self.writer.write(pickle.dumps(self.parser.parse(profile, self.dialFrequency, line[4:])))
def getInputFormat(self) -> Format:
return Format.CHAR
def getOutputFormat(self) -> Format:
return Format.CHAR
def setDialFrequency(self, frequency: int) -> None:
self.dialFrequency = frequency

36
csdr/output.py Normal file
View File

@ -0,0 +1,36 @@
import threading
import logging
logger = logging.getLogger(__name__)
class Output(object):
def send_output(self, t, read_fn):
if not self.supports_type(t):
# TODO rewrite the output mechanism in a way that avoids producing unnecessary data
logger.warning("dumping output of type %s since it is not supported.", t)
threading.Thread(target=self.pump(read_fn, lambda x: None), name="csdr_pump_thread").start()
return
self.receive_output(t, read_fn)
def receive_output(self, t, read_fn):
pass
def pump(self, read, write):
def copy():
run = True
while run:
data = None
try:
data = read()
except ValueError:
pass
if data is None or (isinstance(data, bytes) and len(data) == 0):
run = False
else:
write(data)
return copy
def supports_type(self, t):
return True

156
csdr/pipe.py Normal file
View File

@ -0,0 +1,156 @@
import os
import select
import time
import threading
import logging
logger = logging.getLogger(__name__)
class Pipe(object):
READ = "r"
WRITE = "w"
NONE = None
@staticmethod
def create(path, t, encoding=None):
if t == Pipe.READ:
return ReadingPipe(path, encoding=encoding)
elif t == Pipe.WRITE:
return WritingPipe(path, encoding=encoding)
elif t == Pipe.NONE:
return Pipe(path, None, encoding=encoding)
def __init__(self, path, direction, encoding=None):
self.doOpen = True
self.path = "{base}_{myid}".format(base=path, myid=id(self))
self.direction = direction
self.encoding = encoding
self.file = None
os.mkfifo(self.path)
def open(self):
"""
this method opens the file descriptor with an added O_NONBLOCK flag. This gives us a special behaviour for
FIFOS, when they are not opened by the opposing side:
- opening a pipe for writing will throw an OSError with errno = 6 (ENXIO). This is handled specially in the
WritingPipe class.
- opening a pipe for reading will pass through this method instantly, even if the opposing end has not been
opened yet, but the resulting file descriptor will behave as if O_NONBLOCK is set (even if we remove it
immediately here), resulting in empty reads until data is available. This is handled specially in the
ReadingPipe class.
"""
def opener(path, flags):
fd = os.open(path, flags | os.O_NONBLOCK)
os.set_blocking(fd, True)
return fd
self.file = open(self.path, self.direction, encoding=self.encoding, opener=opener)
def close(self):
self.doOpen = False
try:
if self.file is not None:
self.file.close()
os.unlink(self.path)
except FileNotFoundError:
# it seems like we keep calling this twice. no idea why, but we don't need the resulting error.
pass
except Exception:
logger.exception("Pipe.close()")
def __str__(self):
return self.path
class WritingPipe(Pipe):
def __init__(self, path, encoding=None):
self.queue = []
self.queueLock = threading.Lock()
super().__init__(path, "w", encoding=encoding)
self.open()
def open_and_dequeue(self):
"""
This method implements a retry loop that can be interrupted in case the Pipe gets shutdown before actually
being connected.
After the pipe is opened successfully, all data that has been queued is sent in the order it was passed into
write().
"""
retries = 0
while self.file is None and self.doOpen and retries < 10:
try:
super().open()
except OSError as error:
# ENXIO = FIFO has not been opened for reading
if error.errno == 6:
time.sleep(0.1)
retries += 1
else:
raise
# if doOpen is false, opening has been canceled, so no warning in that case.
if self.file is None:
if self.doOpen:
logger.warning("could not open FIFO %s", self.path)
return
with self.queueLock:
for i in self.queue:
self.file.write(i)
self.file.flush()
self.queue = None
def open(self):
"""
This sends the opening operation off to a background thread. If we were to block the thread here, another pipe
may be waiting in the queue to be opened on the opposing side, resulting in a deadlock
"""
threading.Thread(target=self.open_and_dequeue, name="csdr_pipe_thread").start()
def write(self, data):
"""
This method queues all data to be written until the file is actually opened. As soon as a file is available,
it becomes a passthrough.
"""
if self.file is None:
with self.queueLock:
self.queue.append(data)
return
r = self.file.write(data)
self.file.flush()
return r
class ReadingPipe(Pipe):
def __init__(self, path, encoding=None):
super().__init__(path, "r", encoding=encoding)
def open(self):
"""
This method implements an interruptible loop that waits for the file descriptor to be opened and the first
batch of data coming in using repeated select() calls.
:return:
"""
if not self.doOpen:
return
super().open()
while self.doOpen:
(read, _, _) = select.select([self.file], [], [], 1)
if self.file in read:
break
def read(self):
if self.file is None:
self.open()
return self.file.read()
def readline(self):
if self.file is None:
self.open()
return self.file.readline()

24
debian/changelog vendored
View File

@ -1,26 +1,6 @@
openwebrx (1.3.0) UNRELEASED; urgency=low openwebrx (1.2.0) UNRELEASED; urgency=low
* SDR device log messages are now available in the web configuration to
simplify troubleshooting
* Added support for the MSK144 digimode
-- Jakob Ketterl <jakob.ketterl@gmx.de> Fri, 30 Sep 2022 16:47:00 +0000 -- Jakob Ketterl <jakob.ketterl@gmx.de> Tue, 03 Aug 2021 13:54:00 +0000
openwebrx (1.2.1) bullseye jammy; urgency=low
* FifiSDR support fixed (pipeline formats now line up correctly)
* Added "Device" input for FifiSDR devices for sound card selection
-- Jakob Ketterl <jakob.ketterl@gmx.de> Tue, 20 Sep 2022 16:01:00 +0000
openwebrx (1.2.0) bullseye jammy; urgency=low
* Major rewrite of all demodulation components to make use of the new
csdr/pycsdr and digiham/pydigiham demodulator modules
* Preliminary display of M17 callsign information
* New devices supported:
- Blade RF
-- Jakob Ketterl <jakob.ketterl@gmx.de> Wed, 15 Jun 2022 16:20:00 +0000
openwebrx (1.1.0) buster hirsute; urgency=low openwebrx (1.1.0) buster hirsute; urgency=low

4
debian/control vendored
View File

@ -10,7 +10,7 @@ Vcs-Git: https://github.com/jketterl/openwebrx.git
Package: openwebrx Package: openwebrx
Architecture: all Architecture: all
Depends: adduser, python3 (>= 3.5), python3-pkg-resources, owrx-connector (>= 0.5), soapysdr-tools, python3-csdr (>= 0.18), ${python3:Depends}, ${misc:Depends} Depends: adduser, python3 (>= 3.5), python3-pkg-resources, csdr (>= 0.17), netcat, owrx-connector (>= 0.5), soapysdr-tools, python3-js8py (>= 0.1), ${python3:Depends}, ${misc:Depends}
Recommends: python3-digiham (>= 0.6), direwolf (>= 1.4), wsjtx, js8call, runds-connector (>= 0.2), hpsdrconnector, aprs-symbols, m17-demod, js8call, python3-js8py (>= 0.2), nmux (>= 0.18), codecserver (>= 0.1), msk144decoder Recommends: digiham (>= 0.5), sox, direwolf (>= 1.4), wsjtx, runds-connector (>= 0.2), hpsdrconnector, aprs-symbols, m17-demod, js8call
Description: multi-user web sdr Description: multi-user web sdr
Open source, multi-user SDR receiver with a web interface Open source, multi-user SDR receiver with a web interface

View File

@ -2,7 +2,7 @@
set -euo pipefail set -euo pipefail
ARCH=$(uname -m) ARCH=$(uname -m)
IMAGES="openwebrx-rtlsdr openwebrx-sdrplay openwebrx-hackrf openwebrx-airspy openwebrx-rtlsdr-soapy openwebrx-plutosdr openwebrx-limesdr openwebrx-soapyremote openwebrx-perseus openwebrx-fcdpp openwebrx-radioberry openwebrx-uhd openwebrx-rtltcp openwebrx-runds openwebrx-hpsdr openwebrx-bladerf openwebrx-full openwebrx" IMAGES="openwebrx-rtlsdr openwebrx-sdrplay openwebrx-hackrf openwebrx-airspy openwebrx-rtlsdr-soapy openwebrx-plutosdr openwebrx-limesdr openwebrx-soapyremote openwebrx-perseus openwebrx-fcdpp openwebrx-radioberry openwebrx-uhd openwebrx-rtltcp openwebrx-runds openwebrx-hpsdr openwebrx-full openwebrx"
ALL_ARCHS="x86_64 armv7l aarch64" ALL_ARCHS="x86_64 armv7l aarch64"
TAG=${TAG:-"latest"} TAG=${TAG:-"latest"}
ARCHTAG="${TAG}-${ARCH}" ARCHTAG="${TAG}-${ARCH}"

View File

@ -1,4 +1,4 @@
FROM debian:bullseye-slim FROM debian:buster-slim
COPY docker/files/js8call/js8call-hamlib.patch \ COPY docker/files/js8call/js8call-hamlib.patch \
docker/files/wsjtx/wsjtx.patch \ docker/files/wsjtx/wsjtx.patch \

View File

@ -1,8 +0,0 @@
ARG ARCHTAG
FROM openwebrx-soapysdr-base:$ARCHTAG
COPY docker/scripts/install-dependencies-bladerf.sh /
RUN /install-dependencies-bladerf.sh &&\
rm /install-dependencies-bladerf.sh
COPY . /opt/openwebrx

View File

@ -19,7 +19,6 @@ RUN /install-dependencies-rtlsdr.sh &&\
/install-dependencies-radioberry.sh &&\ /install-dependencies-radioberry.sh &&\
/install-dependencies-uhd.sh &&\ /install-dependencies-uhd.sh &&\
/install-dependencies-hpsdr.sh &&\ /install-dependencies-hpsdr.sh &&\
/install-dependencies-bladerf.sh &&\
/install-connectors.sh &&\ /install-connectors.sh &&\
/install-dependencies-runds.sh &&\ /install-dependencies-runds.sh &&\
rm /install-dependencies-*.sh &&\ rm /install-dependencies-*.sh &&\

View File

@ -1,5 +1,5 @@
--- CMakeLists.txt.orig 2021-09-28 14:33:14.329598412 +0200 --- CMakeLists.txt.orig 2021-03-30 15:28:36.956587995 +0200
+++ CMakeLists.txt 2021-09-28 14:34:23.052345270 +0200 +++ CMakeLists.txt 2021-03-30 15:29:45.719326832 +0200
@@ -106,24 +106,6 @@ @@ -106,24 +106,6 @@

View File

@ -1,6 +1,15 @@
diff -ur wsjtx-orig/CMake/Modules/Findhamlib.cmake wsjtx/CMake/Modules/Findhamlib.cmake
--- wsjtx-orig/CMake/Modules/Findhamlib.cmake 2021-05-31 18:56:20.657682124 +0200
+++ wsjtx/CMake/Modules/Findhamlib.cmake 2021-05-31 18:57:03.963994898 +0200
@@ -85,4 +85,4 @@
# Handle the QUIETLY and REQUIRED arguments and set HAMLIB_FOUND to
# TRUE if all listed variables are TRUE
include (FindPackageHandleStandardArgs)
-find_package_handle_standard_args (hamlib DEFAULT_MSG hamlib_INCLUDE_DIRS hamlib_LIBRARIES hamlib_LIBRARY_DIRS)
+find_package_handle_standard_args (hamlib DEFAULT_MSG hamlib_INCLUDE_DIRS hamlib_LIBRARIES)
diff -ur wsjtx-orig/CMakeLists.txt wsjtx/CMakeLists.txt diff -ur wsjtx-orig/CMakeLists.txt wsjtx/CMakeLists.txt
--- wsjtx-orig/CMakeLists.txt 2023-01-28 17:43:05.586124507 +0100 --- wsjtx-orig/CMakeLists.txt 2021-05-31 18:56:20.657682124 +0200
+++ wsjtx/CMakeLists.txt 2023-01-28 17:56:07.108634912 +0100 +++ wsjtx/CMakeLists.txt 2021-05-31 19:08:02.768474060 +0200
@@ -122,7 +122,7 @@ @@ -122,7 +122,7 @@
option (WSJT_QDEBUG_TO_FILE "Redirect Qt debuging messages to a trace file.") option (WSJT_QDEBUG_TO_FILE "Redirect Qt debuging messages to a trace file.")
option (WSJT_SOFT_KEYING "Apply a ramp to CW keying envelope to reduce transients." ON) option (WSJT_SOFT_KEYING "Apply a ramp to CW keying envelope to reduce transients." ON)
@ -10,11 +19,10 @@ diff -ur wsjtx-orig/CMakeLists.txt wsjtx/CMakeLists.txt
option (WSJT_RIG_NONE_CAN_SPLIT "Allow split operation with \"None\" as rig.") option (WSJT_RIG_NONE_CAN_SPLIT "Allow split operation with \"None\" as rig.")
option (WSJT_TRACE_UDP "Debugging option that turns on UDP message protocol diagnostics.") option (WSJT_TRACE_UDP "Debugging option that turns on UDP message protocol diagnostics.")
option (WSJT_BUILD_UTILS "Build simulators and code demonstrators." ON) option (WSJT_BUILD_UTILS "Build simulators and code demonstrators." ON)
@@ -170,77 +170,7 @@ @@ -169,74 +169,7 @@
) )
set (wsjt_qt_CXXSRCS set (wsjt_qt_CXXSRCS
- helper_functions.cpp
- qt_helpers.cpp - qt_helpers.cpp
- widgets/MessageBox.cpp - widgets/MessageBox.cpp
- MetaDataRegistry.cpp - MetaDataRegistry.cpp
@ -34,7 +42,6 @@ diff -ur wsjtx-orig/CMakeLists.txt wsjtx/CMakeLists.txt
- widgets/FrequencyDeltaLineEdit.cpp - widgets/FrequencyDeltaLineEdit.cpp
- item_delegates/CandidateKeyFilter.cpp - item_delegates/CandidateKeyFilter.cpp
- item_delegates/ForeignKeyDelegate.cpp - item_delegates/ForeignKeyDelegate.cpp
- item_delegates/MessageItemDelegate.cpp
- validators/LiveFrequencyValidator.cpp - validators/LiveFrequencyValidator.cpp
- GetUserId.cpp - GetUserId.cpp
- Audio/AudioDevice.cpp - Audio/AudioDevice.cpp
@ -84,11 +91,19 @@ diff -ur wsjtx-orig/CMakeLists.txt wsjtx/CMakeLists.txt
- Network/NetworkAccessManager.cpp - Network/NetworkAccessManager.cpp
- widgets/LazyFillComboBox.cpp - widgets/LazyFillComboBox.cpp
- widgets/CheckableItemComboBox.cpp - widgets/CheckableItemComboBox.cpp
- widgets/BandComboBox.cpp
) )
set (wsjt_qtmm_CXXSRCS set (wsjt_qtmm_CXXSRCS
@@ -1089,9 +1019,6 @@ @@ -857,7 +790,7 @@
#
# libhamlib setup
#
-set (hamlib_STATIC 1)
+set (hamlib_STATIC 0)
find_package (hamlib 3 REQUIRED)
find_program (RIGCTL_EXE rigctl)
find_program (RIGCTLD_EXE rigctld)
@@ -895,9 +828,6 @@
if (WSJT_GENERATE_DOCS) if (WSJT_GENERATE_DOCS)
add_subdirectory (doc) add_subdirectory (doc)
endif (WSJT_GENERATE_DOCS) endif (WSJT_GENERATE_DOCS)
@ -96,23 +111,11 @@ diff -ur wsjtx-orig/CMakeLists.txt wsjtx/CMakeLists.txt
- add_subdirectory (tests) - add_subdirectory (tests)
-endif () -endif ()
# build a library of package functionality (without and optionally with OpenMP support) #
add_library (wsjt_cxx STATIC ${wsjt_CSRCS} ${wsjt_CXXSRCS}) # Library building setup
@@ -1357,10 +1284,7 @@ @@ -1380,60 +1310,6 @@
add_library (wsjt_qt STATIC ${wsjt_qt_CXXSRCS} ${wsjt_qt_GENUISRCS} ${GENAXSRCS}) target_link_libraries (jt9 wsjt_fort wsjt_cxx fort_qt)
# set wsjtx_udp exports to static variants endif (${OPENMP_FOUND} OR APPLE)
target_compile_definitions (wsjt_qt PUBLIC UDP_STATIC_DEFINE)
-target_link_libraries (wsjt_qt Hamlib::Hamlib Boost::log qcp Qt5::Widgets Qt5::Network Qt5::Sql)
-if (WIN32)
- target_link_libraries (wsjt_qt Qt5::AxContainer Qt5::AxBase)
-endif (WIN32)
+target_link_libraries (wsjt_qt Qt5::Core)
# build a library of package Qt functionality used in Fortran utilities
add_library (fort_qt STATIC ${fort_qt_CXXSRCS})
@@ -1425,90 +1349,6 @@
add_subdirectory (map65)
endif ()
-# build the main application -# build the main application
-generate_version_info (wsjtx_VERSION_RESOURCES -generate_version_info (wsjtx_VERSION_RESOURCES
@ -147,7 +150,7 @@ diff -ur wsjtx-orig/CMakeLists.txt wsjtx/CMakeLists.txt
- ) - )
- -
-target_include_directories (wsjtx PRIVATE ${FFTW3_INCLUDE_DIRS}) -target_include_directories (wsjtx PRIVATE ${FFTW3_INCLUDE_DIRS})
-if ((NOT ${OPENMP_FOUND}) OR APPLE) -if (APPLE)
- target_link_libraries (wsjtx wsjt_fort) - target_link_libraries (wsjtx wsjt_fort)
-else () -else ()
- target_link_libraries (wsjtx wsjt_fort_omp) - target_link_libraries (wsjtx wsjt_fort_omp)
@ -166,42 +169,12 @@ diff -ur wsjtx-orig/CMakeLists.txt wsjtx/CMakeLists.txt
- ) - )
- endif () - endif ()
-endif () -endif ()
-target_link_libraries (wsjtx Qt5::SerialPort wsjt_cxx wsjt_qt wsjt_qtmm ${FFTW3_LIBRARIES} ${LIBM_LIBRARIES}) -target_link_libraries (wsjtx Qt5::SerialPort wsjt_cxx wsjt_qt wsjt_qtmm ${hamlib_LIBRARIES} ${FFTW3_LIBRARIES} ${LIBM_LIBRARIES})
- -
-# make a library for WSJT-X UDP servers # make a library for WSJT-X UDP servers
-# add_library (wsjtx_udp SHARED ${UDP_library_CXXSRCS}) # add_library (wsjtx_udp SHARED ${UDP_library_CXXSRCS})
-add_library (wsjtx_udp-static STATIC ${UDP_library_CXXSRCS}) add_library (wsjtx_udp-static STATIC ${UDP_library_CXXSRCS})
-#target_include_directories (wsjtx_udp @@ -1473,47 +1349,9 @@
-# INTERFACE
-# $<INSTALL_INTERFACE:${CMAKE_INSTALL_INCLUDEDIR}/wsjtx>
-# )
-target_include_directories (wsjtx_udp-static
- INTERFACE
- $<INSTALL_INTERFACE:${CMAKE_INSTALL_INCLUDEDIR}/wsjtx>
- )
-#set_target_properties (wsjtx_udp PROPERTIES
-# PUBLIC_HEADER "${UDP_library_HEADERS}"
-# )
-set_target_properties (wsjtx_udp-static PROPERTIES
- OUTPUT_NAME wsjtx_udp
- )
-target_compile_definitions (wsjtx_udp-static PUBLIC UDP_STATIC_DEFINE)
-target_link_libraries (wsjtx_udp-static Qt5::Network Qt5::Gui)
-generate_export_header (wsjtx_udp-static BASE_NAME udp)
-
-generate_version_info (udp_daemon_VERSION_RESOURCES
- NAME udp_daemon
- BUNDLE ${PROJECT_BUNDLE_NAME}
- ICON ${WSJTX_ICON_FILE}
- FILE_DESCRIPTION "Example WSJT-X UDP Message Protocol daemon"
- )
-add_executable (udp_daemon UDPExamples/UDPDaemon.cpp ${udp_daemon_VERSION_RESOURCES})
-target_link_libraries (udp_daemon wsjtx_udp-static)
-
generate_version_info (wsjtx_app_version_VERSION_RESOURCES
NAME wsjtx_app_version
BUNDLE ${PROJECT_BUNDLE_NAME}
@@ -1518,47 +1358,9 @@
add_executable (wsjtx_app_version AppVersion/AppVersion.cpp ${wsjtx_app_version_VERSION_RESOURCES}) add_executable (wsjtx_app_version AppVersion/AppVersion.cpp ${wsjtx_app_version_VERSION_RESOURCES})
target_link_libraries (wsjtx_app_version wsjt_qt) target_link_libraries (wsjtx_app_version wsjt_qt)
@ -249,7 +222,7 @@ diff -ur wsjtx-orig/CMakeLists.txt wsjtx/CMakeLists.txt
# install (TARGETS wsjtx_udp EXPORT udp # install (TARGETS wsjtx_udp EXPORT udp
# RUNTIME DESTINATION ${CMAKE_INSTALL_BINDIR} # RUNTIME DESTINATION ${CMAKE_INSTALL_BINDIR}
@@ -1577,12 +1379,7 @@ @@ -1532,12 +1370,7 @@
# DESTINATION ${CMAKE_INSTALL_LIBDIR}/cmake/wsjtx # DESTINATION ${CMAKE_INSTALL_LIBDIR}/cmake/wsjtx
# ) # )
@ -263,7 +236,7 @@ diff -ur wsjtx-orig/CMakeLists.txt wsjtx/CMakeLists.txt
RUNTIME DESTINATION ${CMAKE_INSTALL_BINDIR} COMPONENT runtime RUNTIME DESTINATION ${CMAKE_INSTALL_BINDIR} COMPONENT runtime
BUNDLE DESTINATION ${CMAKE_INSTALL_BINDIR} COMPONENT runtime BUNDLE DESTINATION ${CMAKE_INSTALL_BINDIR} COMPONENT runtime
) )
@@ -1595,38 +1392,6 @@ @@ -1549,38 +1382,6 @@
) )
endif(WSJT_BUILD_UTILS) endif(WSJT_BUILD_UTILS)
@ -302,7 +275,7 @@ diff -ur wsjtx-orig/CMakeLists.txt wsjtx/CMakeLists.txt
install (FILES install (FILES
cty.dat cty.dat
cty.dat_copyright.txt cty.dat_copyright.txt
@@ -1635,13 +1400,6 @@ @@ -1589,13 +1390,6 @@
#COMPONENT runtime #COMPONENT runtime
) )
@ -316,11 +289,10 @@ diff -ur wsjtx-orig/CMakeLists.txt wsjtx/CMakeLists.txt
# #
# Mac installer files # Mac installer files
# #
@@ -1693,22 +1451,6 @@ @@ -1648,22 +1442,6 @@
"${CMAKE_CURRENT_BINARY_DIR}/wsjtx_config.h"
) )
-
-if (NOT WIN32 AND NOT APPLE) -if (NOT WIN32 AND NOT APPLE)
- # install a desktop file so wsjtx appears in the application start - # install a desktop file so wsjtx appears in the application start
- # menu with an icon - # menu with an icon
@ -336,6 +308,9 @@ diff -ur wsjtx-orig/CMakeLists.txt wsjtx/CMakeLists.txt
- ) - )
-endif (NOT WIN32 AND NOT APPLE) -endif (NOT WIN32 AND NOT APPLE)
- -
if (APPLE) -
set (CMAKE_POSTFLIGHT_SCRIPT #
"${wsjtx_BINARY_DIR}/postflight.sh") # bundle fixup only done in non-Debug configurations
#
Only in wsjtx/: CMakeLists.txt.orig
Only in wsjtx/: .idea

View File

@ -18,14 +18,13 @@ function cmakebuild() {
cd /tmp cd /tmp
BUILD_PACKAGES="git cmake make gcc g++ libsamplerate-dev libfftw3-dev" BUILD_PACKAGES="git cmake make gcc g++"
apt-get update apt-get update
apt-get -y install --no-install-recommends $BUILD_PACKAGES apt-get -y install --no-install-recommends $BUILD_PACKAGES
git clone https://github.com/jketterl/owrx_connector.git git clone https://github.com/jketterl/owrx_connector.git
# latest develop as of 2022-12-11 (std::endl implicit flushing) cmakebuild owrx_connector 0.5.0
cmakebuild owrx_connector bca362707131289f91441c8080fd368fdc067b6d
apt-get -y purge --autoremove $BUILD_PACKAGES apt-get -y purge --autoremove $BUILD_PACKAGES
apt-get clean apt-get clean

View File

@ -1,36 +0,0 @@
#!/bin/bash
set -euxo pipefail
export MAKEFLAGS="-j4"
function cmakebuild() {
cd $1
if [[ ! -z "${2:-}" ]]; then
git checkout $2
fi
mkdir build
cd build
cmake ..
make
make install
cd ../..
rm -rf $1
}
cd /tmp
STATIC_PACKAGES="libusb-1.0-0"
BUILD_PACKAGES="git cmake make gcc g++ libusb-1.0-0-dev"
apt-get update
apt-get -y install --no-install-recommends $STATIC_PACKAGES $BUILD_PACKAGES
git clone https://github.com/Nuand/bladeRF.git
cmakebuild bladeRF 2021.10
git clone https://github.com/pothosware/SoapyBladeRF.git
# latest from master as of 2022-01-12
cmakebuild SoapyBladeRF 70505a5cdf8c9deabc4af3eb3384aa82a7b6f021
apt-get -y purge --autoremove $BUILD_PACKAGES
apt-get clean
rm -rf /var/lib/apt/lists/*

View File

@ -29,7 +29,7 @@ tar xfz $PACKAGE
git clone https://github.com/jancona/hpsdrconnector.git git clone https://github.com/jancona/hpsdrconnector.git
pushd hpsdrconnector pushd hpsdrconnector
git checkout v0.6.1 git checkout v0.4.2
/tmp/go/bin/go build /tmp/go/bin/go build
install -m 0755 hpsdrconnector /usr/local/bin install -m 0755 hpsdrconnector /usr/local/bin

View File

@ -25,8 +25,7 @@ apt-get update
apt-get -y install --no-install-recommends $STATIC_PACKAGES $BUILD_PACKAGES apt-get -y install --no-install-recommends $STATIC_PACKAGES $BUILD_PACKAGES
git clone https://github.com/jketterl/runds_connector.git git clone https://github.com/jketterl/runds_connector.git
# latest develop as of 2022-12-11 (std::endl implicit flushing) cmakebuild runds_connector 0.2.0
cmakebuild runds_connector 06ca993a3c81ddb0a2581b1474895da07752a9e1
apt-get -y purge --autoremove $BUILD_PACKAGES apt-get -y purge --autoremove $BUILD_PACKAGES
apt-get clean apt-get clean

View File

@ -38,7 +38,7 @@ case $ARCH in
;; ;;
esac esac
wget --no-http-keep-alive https://www.sdrplay.com/software/$BINARY wget https://www.sdrplay.com/software/$BINARY
sh $BINARY --noexec --target sdrplay sh $BINARY --noexec --target sdrplay
patch --verbose -Np0 < /install-lib.$ARCH.patch patch --verbose -Np0 < /install-lib.$ARCH.patch

View File

@ -18,16 +18,17 @@ function cmakebuild() {
cd /tmp cd /tmp
STATIC_PACKAGES="libusb-1.0.0 libboost-chrono1.74.0 libboost-date-time1.74.0 libboost-filesystem1.74.0 libboost-program-options1.74.0 libboost-regex1.74.0 libboost-test1.74.0 libboost-serialization1.74.0 libboost-thread1.74.0 libboost-system1.74.0 python3-numpy python3-mako" STATIC_PACKAGES="libusb-1.0.0 libboost-chrono1.67.0 libboost-date-time1.67.0 libboost-filesystem1.67.0 libboost-program-options1.67.0 libboost-regex1.67.0 libboost-test1.67.0 libboost-serialization1.67.0 libboost-thread1.67.0 libboost-system1.67.0 python3-numpy python3-mako"
BUILD_PACKAGES="git cmake make gcc g++ libusb-1.0-0-dev libboost-dev libboost-chrono-dev libboost-date-time-dev libboost-filesystem-dev libboost-program-options-dev libboost-regex-dev libboost-test-dev libboost-serialization-dev libboost-thread-dev libboost-system-dev" BUILD_PACKAGES="git cmake make gcc g++ libusb-1.0-0-dev libboost-dev libboost-chrono-dev libboost-date-time-dev libboost-filesystem-dev libboost-program-options-dev libboost-regex-dev libboost-test-dev libboost-serialization-dev libboost-thread-dev libboost-system-dev"
apt-get update apt-get update
apt-get -y install --no-install-recommends $STATIC_PACKAGES $BUILD_PACKAGES apt-get -y install --no-install-recommends $STATIC_PACKAGES $BUILD_PACKAGES
git clone https://github.com/EttusResearch/uhd.git git clone https://github.com/EttusResearch/uhd.git
# 3.15.0.0 Release
mkdir -p uhd/host/build mkdir -p uhd/host/build
cd uhd/host/build cd uhd/host/build
git checkout v4.1.0.4 git checkout v3.15.0.0
# see https://github.com/EttusResearch/uhd/issues/350 # see https://github.com/EttusResearch/uhd/issues/350
case `uname -m` in case `uname -m` in
arm*) arm*)

View File

@ -7,9 +7,6 @@ function cmakebuild() {
if [[ ! -z "${2:-}" ]]; then if [[ ! -z "${2:-}" ]]; then
git checkout $2 git checkout $2
fi fi
if [[ -f ".gitmodules" ]]; then
git submodule update --init
fi
mkdir build mkdir build
cd build cd build
cmake ${CMAKE_ARGS:-} .. cmake ${CMAKE_ARGS:-} ..
@ -21,8 +18,8 @@ function cmakebuild() {
cd /tmp cd /tmp
STATIC_PACKAGES="libfftw3-bin python3 python3-setuptools netcat-openbsd libsndfile1 liblapack3 libusb-1.0-0 libqt5core5a libreadline8 libgfortran5 libgomp1 libasound2 libudev1 ca-certificates libpulse0 libfaad2 libopus0 libboost-program-options1.74.0 libboost-log1.74.0 libcurl4" STATIC_PACKAGES="sox libfftw3-bin python3 python3-setuptools netcat-openbsd libsndfile1 liblapack3 libusb-1.0-0 libqt5core5a libreadline7 libgfortran4 libgomp1 libasound2 libudev1 ca-certificates libqt5gui5 libqt5sql5 libqt5printsupport5 libpulse0 libfaad2 libopus0 libboost-program-options1.67.0 libboost-log1.67.0"
BUILD_PACKAGES="wget git libsndfile1-dev libfftw3-dev cmake make gcc g++ liblapack-dev texinfo gfortran libusb-1.0-0-dev qtbase5-dev qtmultimedia5-dev qttools5-dev libqt5serialport5-dev qttools5-dev-tools asciidoctor asciidoc libasound2-dev libudev-dev libhamlib-dev patch xsltproc qt5-qmake libfaad-dev libopus-dev libboost-dev libboost-program-options-dev libboost-log-dev libboost-regex-dev libpulse-dev libcurl4-openssl-dev" BUILD_PACKAGES="wget git libsndfile1-dev libfftw3-dev cmake make gcc g++ liblapack-dev texinfo gfortran libusb-1.0-0-dev qtbase5-dev qtmultimedia5-dev qttools5-dev libqt5serialport5-dev qttools5-dev-tools asciidoctor asciidoc libasound2-dev libudev-dev libhamlib-dev patch xsltproc qt5-default libfaad-dev libopus-dev libboost-dev libboost-program-options-dev libboost-log-dev libboost-regex-dev"
apt-get update apt-get update
apt-get -y install auto-apt-proxy apt-get -y install auto-apt-proxy
apt-get -y install --no-install-recommends $STATIC_PACKAGES $BUILD_PACKAGES apt-get -y install --no-install-recommends $STATIC_PACKAGES $BUILD_PACKAGES
@ -51,22 +48,18 @@ tar xfz ${JS8CALL_TGZ}
# patch allows us to build against the packaged hamlib # patch allows us to build against the packaged hamlib
patch -Np1 -d ${JS8CALL_DIR} < /js8call-hamlib.patch patch -Np1 -d ${JS8CALL_DIR} < /js8call-hamlib.patch
rm /js8call-hamlib.patch rm /js8call-hamlib.patch
cmakebuild ${JS8CALL_DIR} CMAKE_ARGS="-D CMAKE_CXX_FLAGS=-DJS8_USE_HAMLIB_THREE" cmakebuild ${JS8CALL_DIR}
rm ${JS8CALL_TGZ} rm ${JS8CALL_TGZ}
WSJT_DIR=wsjtx-2.6.1 WSJT_DIR=wsjtx-2.4.0
WSJT_TGZ=${WSJT_DIR}.tgz WSJT_TGZ=${WSJT_DIR}.tgz
wget https://downloads.sourceforge.net/project/wsjt/${WSJT_DIR}/${WSJT_TGZ} wget http://physics.princeton.edu/pulsar/k1jt/${WSJT_TGZ}
tar xfz ${WSJT_TGZ} tar xfz ${WSJT_TGZ}
patch -Np0 -d ${WSJT_DIR} < /wsjtx-hamlib.patch patch -Np0 -d ${WSJT_DIR} < /wsjtx-hamlib.patch
mv /wsjtx.patch ${WSJT_DIR} mv /wsjtx.patch ${WSJT_DIR}
cmakebuild ${WSJT_DIR} cmakebuild ${WSJT_DIR}
rm ${WSJT_TGZ} rm ${WSJT_TGZ}
git clone https://github.com/alexander-sholohov/msk144decoder.git
# latest from main as of 2023-02-21
MAKEFLAGS="" cmakebuild msk144decoder fe2991681e455636e258e83c29fd4b2a72d16095
git clone --depth 1 -b 1.6 https://github.com/wb2osz/direwolf.git git clone --depth 1 -b 1.6 https://github.com/wb2osz/direwolf.git
cd direwolf cd direwolf
# hamlib is present (necessary for the wsjt-x and js8call builds) and would be used, but there's no real need. # hamlib is present (necessary for the wsjt-x and js8call builds) and would be used, but there's no real need.
@ -109,7 +102,7 @@ rm -rf dream
rm dream-2.1.1-svn808.tar.gz rm dream-2.1.1-svn808.tar.gz
git clone https://github.com/mobilinkd/m17-cxx-demod.git git clone https://github.com/mobilinkd/m17-cxx-demod.git
cmakebuild m17-cxx-demod v2.3 cmakebuild m17-cxx-demod v2.2
git clone https://github.com/hessu/aprs-symbols /usr/share/aprs-symbols git clone https://github.com/hessu/aprs-symbols /usr/share/aprs-symbols
pushd /usr/share/aprs-symbols pushd /usr/share/aprs-symbols

View File

@ -18,43 +18,35 @@ function cmakebuild() {
cd /tmp cd /tmp
STATIC_PACKAGES="libfftw3-bin libprotobuf23 libsamplerate0 libicu67 libudev1" STATIC_PACKAGES="libfftw3-bin libprotobuf17"
BUILD_PACKAGES="git autoconf automake libtool libfftw3-dev pkg-config cmake make gcc g++ libprotobuf-dev protobuf-compiler libsamplerate-dev libicu-dev libpython3-dev libudev-dev" BUILD_PACKAGES="git autoconf automake libtool libfftw3-dev pkg-config cmake make gcc g++ libprotobuf-dev protobuf-compiler"
apt-get update apt-get update
apt-get -y install --no-install-recommends $STATIC_PACKAGES $BUILD_PACKAGES apt-get -y install --no-install-recommends $STATIC_PACKAGES $BUILD_PACKAGES
git clone https://github.com/jketterl/js8py.git git clone https://github.com/jketterl/js8py.git
pushd js8py pushd js8py
# latest develop as of 2022-11-30 (structured callsign data) git checkout 0.1.0
git checkout f7e394b7892d26cbdcce5d43c0b4081a2a6a48f6
python3 setup.py install python3 setup.py install
popd popd
rm -rf js8py rm -rf js8py
git clone https://github.com/jketterl/csdr.git git clone https://github.com/jketterl/csdr.git
cmakebuild csdr 0.18.1 cd csdr
git checkout 0.17.0
git clone https://github.com/jketterl/pycsdr.git autoreconf -i
cd pycsdr ./configure
git checkout 0.18.1 make
./setup.py install install_headers make install
cd .. cd ..
rm -rf pycsdr rm -rf csdr
git clone https://github.com/jketterl/codecserver.git git clone https://github.com/jketterl/codecserver.git
mkdir -p /usr/local/etc/codecserver mkdir -p /usr/local/etc/codecserver
cp codecserver/conf/codecserver.conf /usr/local/etc/codecserver cp codecserver/conf/codecserver.conf /usr/local/etc/codecserver
cmakebuild codecserver 0.2.0 cmakebuild codecserver 0.1.0
git clone https://github.com/jketterl/digiham.git git clone https://github.com/jketterl/digiham.git
cmakebuild digiham 0.6.1 cmakebuild digiham 0.5.0
git clone https://github.com/jketterl/pydigiham.git
cd pydigiham
git checkout 0.6.1
./setup.py install
cd ..
rm -rf pydigiham
apt-get -y purge --autoremove $BUILD_PACKAGES apt-get -y purge --autoremove $BUILD_PACKAGES
apt-get clean apt-get clean

View File

@ -160,7 +160,3 @@ h1 {
.imageupload.is-invalid ~ .invalid-feedback { .imageupload.is-invalid ~ .invalid-feedback {
display: block; display: block;
} }
.device-log-messages {
max-height: 500px;
}

View File

@ -917,6 +917,33 @@ img.openwebrx-mirror-img
z-index: 10; z-index: 10;
} }
#openwebrx-digimode-content .part
{
perspective: 700px;
}
#openwebrx-digimode-content .part
{
animation: new-digimode-data-3d 100ms;
animation-timing-function: linear;
display: inline-block;
perspective-origin: 50% 50%;
transform-origin: 0% 50%;
}
@keyframes new-digimode-data
{
0%{ opacity: 0; }
100%{ opacity: 1; }
}
@keyframes new-digimode-data-3d
{
0%{ transform: rotateX(0deg) rotateY(-90deg) translateX(-5px) scale(1.3); }
100%{ transform: rotateX(0deg) rotateY(0deg) translateX(0) scale(1); }
}
#openwebrx-digimode-select-channel #openwebrx-digimode-select-channel
{ {
transition: all 500ms; transition: all 500ms;
@ -1025,8 +1052,7 @@ img.openwebrx-mirror-img
.openwebrx-meta-slot.active.direct .openwebrx-meta-user-image .directcall, .openwebrx-meta-slot.active.direct .openwebrx-meta-user-image .directcall,
.openwebrx-meta-slot.active.individual .openwebrx-meta-user-image .directcall, .openwebrx-meta-slot.active.individual .openwebrx-meta-user-image .directcall,
#openwebrx-panel-metadata-ysf .openwebrx-meta-slot.active .openwebrx-meta-user-image .directcall, #openwebrx-panel-metadata-ysf .openwebrx-meta-slot.active .openwebrx-meta-user-image .directcall,
#openwebrx-panel-metadata-dstar .openwebrx-meta-slot.active .openwebrx-meta-user-image .directcall, #openwebrx-panel-metadata-dstar .openwebrx-meta-slot.active .openwebrx-meta-user-image .directcall {
#openwebrx-panel-metadata-m17 .openwebrx-meta-slot.active .openwebrx-meta-user-image .directcall {
display: initial; display: initial;
} }
@ -1060,14 +1086,6 @@ img.openwebrx-mirror-img
content: "Down: "; content: "Down: ";
} }
.openwebrx-m17-source:not(:empty):before {
content: "SRC: ";
}
.openwebrx-m17-destination:not(:empty):before {
content: "DEST: ";
}
.openwebrx-dstar-yourcall:not(:empty):before { .openwebrx-dstar-yourcall:not(:empty):before {
content: "UR: "; content: "UR: ";
} }
@ -1265,7 +1283,6 @@ img.openwebrx-mirror-img
#openwebrx-panel-digimodes[data-mode="fst4"] #openwebrx-digimode-content-container, #openwebrx-panel-digimodes[data-mode="fst4"] #openwebrx-digimode-content-container,
#openwebrx-panel-digimodes[data-mode="fst4w"] #openwebrx-digimode-content-container, #openwebrx-panel-digimodes[data-mode="fst4w"] #openwebrx-digimode-content-container,
#openwebrx-panel-digimodes[data-mode="q65"] #openwebrx-digimode-content-container, #openwebrx-panel-digimodes[data-mode="q65"] #openwebrx-digimode-content-container,
#openwebrx-panel-digimodes[data-mode="msk144"] #openwebrx-digimode-content-container,
#openwebrx-panel-digimodes[data-mode="ft8"] #openwebrx-digimode-select-channel, #openwebrx-panel-digimodes[data-mode="ft8"] #openwebrx-digimode-select-channel,
#openwebrx-panel-digimodes[data-mode="wspr"] #openwebrx-digimode-select-channel, #openwebrx-panel-digimodes[data-mode="wspr"] #openwebrx-digimode-select-channel,
#openwebrx-panel-digimodes[data-mode="jt65"] #openwebrx-digimode-select-channel, #openwebrx-panel-digimodes[data-mode="jt65"] #openwebrx-digimode-select-channel,
@ -1276,8 +1293,7 @@ img.openwebrx-mirror-img
#openwebrx-panel-digimodes[data-mode="js8"] #openwebrx-digimode-select-channel, #openwebrx-panel-digimodes[data-mode="js8"] #openwebrx-digimode-select-channel,
#openwebrx-panel-digimodes[data-mode="fst4"] #openwebrx-digimode-select-channel, #openwebrx-panel-digimodes[data-mode="fst4"] #openwebrx-digimode-select-channel,
#openwebrx-panel-digimodes[data-mode="fst4w"] #openwebrx-digimode-select-channel, #openwebrx-panel-digimodes[data-mode="fst4w"] #openwebrx-digimode-select-channel,
#openwebrx-panel-digimodes[data-mode="q65"] #openwebrx-digimode-select-channel, #openwebrx-panel-digimodes[data-mode="q65"] #openwebrx-digimode-select-channel
#openwebrx-panel-digimodes[data-mode="msk144"] #openwebrx-digimode-select-channel
{ {
display: none; display: none;
} }
@ -1292,8 +1308,7 @@ img.openwebrx-mirror-img
#openwebrx-panel-digimodes[data-mode="js8"] #openwebrx-digimode-canvas-container, #openwebrx-panel-digimodes[data-mode="js8"] #openwebrx-digimode-canvas-container,
#openwebrx-panel-digimodes[data-mode="fst4"] #openwebrx-digimode-canvas-container, #openwebrx-panel-digimodes[data-mode="fst4"] #openwebrx-digimode-canvas-container,
#openwebrx-panel-digimodes[data-mode="fst4w"] #openwebrx-digimode-canvas-container, #openwebrx-panel-digimodes[data-mode="fst4w"] #openwebrx-digimode-canvas-container,
#openwebrx-panel-digimodes[data-mode="q65"] #openwebrx-digimode-canvas-container, #openwebrx-panel-digimodes[data-mode="q65"] #openwebrx-digimode-canvas-container
#openwebrx-panel-digimodes[data-mode="msk144"] #openwebrx-digimode-canvas-container
{ {
height: 200px; height: 200px;
margin: -10px; margin: -10px;

View File

@ -19,7 +19,7 @@
<div class="webrx-rx-photo-desc">${photo_desc}</div> <div class="webrx-rx-photo-desc">${photo_desc}</div>
</div> </div>
<a class="openwebrx-rx-details-arrow openwebrx-rx-details-arrow--down openwebrx-photo-trigger"> <a class="openwebrx-rx-details-arrow openwebrx-rx-details-arrow--down openwebrx-photo-trigger">
<svg class="down" viewBox="0 0 43 12"><use xlink:href="${document_root}static/gfx/svg-defs.svg#rx-details-arrow-down"></use></svg> <svg class="down" viewBox="0 0 43 12"><use xlink:href="static/gfx/svg-defs.svg#rx-details-arrow-down"></use></svg>
<svg class="up" viewBox="0 0 43 12"><use xlink:href="${document_root}static/gfx/svg-defs.svg#rx-details-arrow-up"></use></svg> <svg class="up" viewBox="0 0 43 12"><use xlink:href="static/gfx/svg-defs.svg#rx-details-arrow-up"></use></svg>
</a> </a>
</div> </div>

View File

@ -74,15 +74,6 @@
<div class="openwebrx-panel openwebrx-message-panel" id="openwebrx-panel-js8-message" style="display:none; width: 619px;" data-panel-name="js8-message"></div> <div class="openwebrx-panel openwebrx-message-panel" id="openwebrx-panel-js8-message" style="display:none; width: 619px;" data-panel-name="js8-message"></div>
<div class="openwebrx-panel openwebrx-message-panel" id="openwebrx-panel-packet-message" style="display: none; width: 619px;" data-panel-name="aprs-message"></div> <div class="openwebrx-panel openwebrx-message-panel" id="openwebrx-panel-packet-message" style="display: none; width: 619px;" data-panel-name="aprs-message"></div>
<div class="openwebrx-panel openwebrx-message-panel" id="openwebrx-panel-pocsag-message" style="display: none; width: 619px;" data-panel-name="pocsag-message"></div> <div class="openwebrx-panel openwebrx-message-panel" id="openwebrx-panel-pocsag-message" style="display: none; width: 619px;" data-panel-name="pocsag-message"></div>
<div class="openwebrx-panel openwebrx-meta-panel" id="openwebrx-panel-metadata-m17" style="display: none;" data-panel-name="metadata-m17">
<div class="openwebrx-meta-slot">
<div class="openwebrx-meta-user-image">
<img class="directcall" src="static/gfx/openwebrx-directcall.svg">
</div>
<div class="openwebrx-m17-source"></div>
<div class="openwebrx-m17-destination"></div>
</div>
</div>
<div class="openwebrx-panel openwebrx-meta-panel" id="openwebrx-panel-metadata-ysf" style="display: none;" data-panel-name="metadata-ysf"> <div class="openwebrx-panel openwebrx-meta-panel" id="openwebrx-panel-metadata-ysf" style="display: none;" data-panel-name="metadata-ysf">
<div class="openwebrx-meta-slot"> <div class="openwebrx-meta-slot">
<div class="openwebrx-ysf-mode"></div> <div class="openwebrx-ysf-mode"></div>
@ -124,7 +115,7 @@
<img class="directcall" src="static/gfx/openwebrx-directcall.svg"> <img class="directcall" src="static/gfx/openwebrx-directcall.svg">
<img class="groupcall" src="static/gfx/openwebrx-groupcall.svg"> <img class="groupcall" src="static/gfx/openwebrx-groupcall.svg">
</div> </div>
<div class="openwebrx-dmr-id"><span class="location"></span><span class="dmr-id"></span></div> <div class="openwebrx-dmr-id"></div>
<div class="openwebrx-dmr-name"></div> <div class="openwebrx-dmr-name"></div>
<div class="openwebrx-dmr-target"></div> <div class="openwebrx-dmr-target"></div>
<div class="mute"> <div class="mute">
@ -137,7 +128,7 @@
<img class="directcall" src="static/gfx/openwebrx-directcall.svg"> <img class="directcall" src="static/gfx/openwebrx-directcall.svg">
<img class="groupcall" src="static/gfx/openwebrx-groupcall.svg"> <img class="groupcall" src="static/gfx/openwebrx-groupcall.svg">
</div> </div>
<div class="openwebrx-dmr-id"><span class="location"></span><span class="dmr-id"></span></div> <div class="openwebrx-dmr-id"></div>
<div class="openwebrx-dmr-name"></div> <div class="openwebrx-dmr-name"></div>
<div class="openwebrx-dmr-target"></div> <div class="openwebrx-dmr-target"></div>
<div class="mute"> <div class="mute">

View File

@ -70,7 +70,7 @@ AprsMarker.prototype.onAdd = function() {
div.appendChild(overlay); div.appendChild(overlay);
var self = this; var self = this;
div.addEventListener("click", function(event) { google.maps.event.addDomListener(div, "click", function(event) {
event.stopPropagation(); event.stopPropagation();
google.maps.event.trigger(self, "click", event); google.maps.event.trigger(self, "click", event);
}); });

View File

@ -331,9 +331,7 @@ ImaAdpcmCodec.prototype.reset = function() {
this.synchronized = 0; this.synchronized = 0;
this.syncWord = "SYNC"; this.syncWord = "SYNC";
this.syncCounter = 0; this.syncCounter = 0;
this.phase = 0; this.skip = 0;
this.syncBuffer = new Uint8Array(4);
this.syncBufferIndex = 0;
}; };
ImaAdpcmCodec.imaIndexTable = [ -1, -1, -1, -1, 2, 4, 6, 8, -1, -1, -1, -1, 2, 4, 6, 8 ]; ImaAdpcmCodec.imaIndexTable = [ -1, -1, -1, -1, 2, 4, 6, 8, -1, -1, -1, -1, 2, 4, 6, 8 ];
@ -361,45 +359,38 @@ ImaAdpcmCodec.prototype.decode = function(data) {
ImaAdpcmCodec.prototype.decodeWithSync = function(data) { ImaAdpcmCodec.prototype.decodeWithSync = function(data) {
var output = new Int16Array(data.length * 2); var output = new Int16Array(data.length * 2);
var index = this.skip;
var oi = 0; var oi = 0;
for (var index = 0; index < data.length; index++) { while (index < data.length) {
switch (this.phase) { while (this.synchronized < 4 && index < data.length) {
case 0: if (data[index] === this.syncWord.charCodeAt(this.synchronized)) {
// search for sync word this.synchronized++;
if (data[index] !== this.syncWord.charCodeAt(this.synchronized++)) { } else {
// reset if data is unexpected
this.synchronized = 0; this.synchronized = 0;
} }
// if sync word has been found pass on to next phase index++;
if (this.synchronized === 4) { if (this.synchronized === 4) {
this.syncBufferIndex = 0; if (index + 4 < data.length) {
this.phase = 1; var syncData = new Int16Array(data.buffer.slice(index, index + 4));
}
break;
case 1:
// read codec runtime data from stream
this.syncBuffer[this.syncBufferIndex++] = data[index];
// if data is complete, apply and pass on to next phase
if (this.syncBufferIndex === 4) {
var syncData = new Int16Array(this.syncBuffer.buffer);
this.stepIndex = syncData[0]; this.stepIndex = syncData[0];
this.predictor = syncData[1]; this.predictor = syncData[1];
this.syncCounter = 1000;
this.phase = 2;
} }
this.syncCounter = 1000;
index += 4;
break; break;
case 2: }
// decode actual audio data }
while (index < data.length) {
if (this.syncCounter-- < 0) {
this.synchronized = 0;
break;
}
output[oi++] = this.decodeNibble(data[index] & 0x0F); output[oi++] = this.decodeNibble(data[index] & 0x0F);
output[oi++] = this.decodeNibble(data[index] >> 4); output[oi++] = this.decodeNibble(data[index] >> 4);
// if the next sync keyword is due, reset and return to phase 0 index++;
if (this.syncCounter-- === 0) {
this.synchronized = 0;
this.phase = 0;
}
break;
} }
} }
this.skip = index - data.length;
return output.slice(0, oi); return output.slice(0, oi);
}; };

View File

@ -13,8 +13,6 @@ Filter.prototype.getLimits = function() {
max_bw = 100000; max_bw = 100000;
} else if (this.demodulator.get_modulation() === 'drm') { } else if (this.demodulator.get_modulation() === 'drm') {
max_bw = 50000; max_bw = 50000;
} else if (this.demodulator.get_modulation() === "freedv") {
max_bw = 4000;
} else { } else {
max_bw = (audioEngine.getOutputRate() / 2) - 1; max_bw = (audioEngine.getOutputRate() / 2) - 1;
} }
@ -81,12 +79,6 @@ Envelope.prototype.draw = function(visible_range){
scale_ctx.fill(); scale_ctx.fill();
scale_ctx.globalAlpha = 1; scale_ctx.globalAlpha = 1;
scale_ctx.stroke(); scale_ctx.stroke();
scale_ctx.lineWidth = 1;
scale_ctx.textAlign = "left";
scale_ctx.fillText(this.demodulator.high_cut.toString(), to_px + env_att_w, env_h2);
scale_ctx.textAlign = "right";
scale_ctx.fillText(this.demodulator.low_cut.toString(), from_px - env_att_w, env_h2);
scale_ctx.lineWidth = 3;
} }
if (typeof line !== "undefined") // out of screen? if (typeof line !== "undefined") // out of screen?
{ {

View File

@ -89,7 +89,7 @@ DemodulatorPanel.prototype.setMode = function(requestedModulation) {
return; return;
} }
if (!mode.isAvailable()) { if (!mode.isAvailable()) {
divlog('Modulation "' + mode.name + '" not supported. Please check the feature report', true); divlog('Modulation "' + mode.name + '" not supported. Please check requirements', true);
return; return;
} }
@ -158,8 +158,8 @@ DemodulatorPanel.prototype.updatePanels = function() {
var modulation = this.getDemodulator().get_secondary_demod(); var modulation = this.getDemodulator().get_secondary_demod();
$('#openwebrx-panel-digimodes').attr('data-mode', modulation); $('#openwebrx-panel-digimodes').attr('data-mode', modulation);
toggle_panel("openwebrx-panel-digimodes", !!modulation); toggle_panel("openwebrx-panel-digimodes", !!modulation);
toggle_panel("openwebrx-panel-wsjt-message", ['ft8', 'wspr', 'jt65', 'jt9', 'ft4', 'fst4', 'fst4w', "q65", "msk144"].indexOf(modulation) >= 0); toggle_panel("openwebrx-panel-wsjt-message", ['ft8', 'wspr', 'jt65', 'jt9', 'ft4', 'fst4', 'fst4w', "q65"].indexOf(modulation) >= 0);
toggle_panel("openwebrx-panel-js8-message", modulation === "js8"); toggle_panel("openwebrx-panel-js8-message", modulation == "js8");
toggle_panel("openwebrx-panel-packet-message", modulation === "packet"); toggle_panel("openwebrx-panel-packet-message", modulation === "packet");
toggle_panel("openwebrx-panel-pocsag-message", modulation === "pocsag"); toggle_panel("openwebrx-panel-pocsag-message", modulation === "pocsag");

View File

@ -98,13 +98,8 @@ TuneableFrequencyDisplay.prototype.setupEvents = function() {
if (index < 0) return; if (index < 0) return;
var delta = 10 ** (Math.floor(Math.max(me.exponent, Math.log10(me.frequency))) - index); var delta = 10 ** (Math.floor(Math.max(me.exponent, Math.log10(me.frequency))) - index);
var newFrequency;
if ('deltaMode' in e.originalEvent && e.originalEvent.deltaMode === 0) {
newFrequency = me.frequency - delta * (e.originalEvent.deltaY / 50);
} else {
if (e.originalEvent.deltaY > 0) delta *= -1; if (e.originalEvent.deltaY > 0) delta *= -1;
newFrequency = me.frequency + delta; var newFrequency = me.frequency + delta;
}
me.element.trigger('frequencychange', newFrequency); me.element.trigger('frequencychange', newFrequency);
}); });

View File

@ -70,7 +70,7 @@ Js8Thread.prototype.getMessageDuration = function() {
Js8Thread.prototype.getMode = function() { Js8Thread.prototype.getMode = function() {
// we filter messages by mode, so the first one is as good as any // we filter messages by mode, so the first one is as good as any
if (!this.messages.length) return; if (!this.messages.length) return;
return this.messages[0].js8mode; return this.messages[0].mode;
}; };
Js8Thread.prototype.acceptsMode = function(mode) { Js8Thread.prototype.acceptsMode = function(mode) {
@ -117,10 +117,6 @@ Js8Threader = function(el){
Js8Threader.prototype = new MessagePanel(); Js8Threader.prototype = new MessagePanel();
Js8Threader.prototype.supportsMessage = function(message) {
return message['mode'] === 'JS8';
};
Js8Threader.prototype.render = function() { Js8Threader.prototype.render = function() {
$(this.el).append($( $(this.el).append($(
'<table>' + '<table>' +
@ -162,7 +158,7 @@ Js8Threader.prototype.pushMessage = function(message) {
var thread; var thread;
// only look for exising threads if the message is not a starting message // only look for exising threads if the message is not a starting message
if ((message.thread_type & 1) === 0) { if ((message.thread_type & 1) === 0) {
thread = this.findThread(message.freq, message.js8mode); thread = this.findThread(message.freq, message.mode);
} }
if (!thread) { if (!thread) {
var line = $("<tr></tr>"); var line = $("<tr></tr>");

View File

@ -4,10 +4,6 @@ function MessagePanel(el) {
this.initClearButton(); this.initClearButton();
} }
MessagePanel.prototype.supportsMessage = function(message) {
return false;
};
MessagePanel.prototype.render = function() { MessagePanel.prototype.render = function() {
}; };
@ -50,17 +46,10 @@ MessagePanel.prototype.initClearButton = function() {
function WsjtMessagePanel(el) { function WsjtMessagePanel(el) {
MessagePanel.call(this, el); MessagePanel.call(this, el);
this.initClearTimer(); this.initClearTimer();
this.qsoModes = ['FT8', 'JT65', 'JT9', 'FT4', 'FST4', 'Q65', 'MSK144'];
this.beaconModes = ['WSPR', 'FST4W'];
this.modes = [].concat(this.qsoModes, this.beaconModes);
} }
WsjtMessagePanel.prototype = new MessagePanel(); WsjtMessagePanel.prototype = new MessagePanel();
WsjtMessagePanel.prototype.supportsMessage = function(message) {
return this.modes.indexOf(message['mode']) >= 0;
};
WsjtMessagePanel.prototype.render = function() { WsjtMessagePanel.prototype.render = function() {
$(this.el).append($( $(this.el).append($(
'<table>' + '<table>' +
@ -89,14 +78,14 @@ WsjtMessagePanel.prototype.pushMessage = function(msg) {
return $('<div/>').text(input).html() return $('<div/>').text(input).html()
}; };
if (this.qsoModes.indexOf(msg['mode']) >= 0) { if (['FT8', 'JT65', 'JT9', 'FT4', 'FST4', 'Q65'].indexOf(msg['mode']) >= 0) {
matches = linkedmsg.match(/(.*\s[A-Z0-9]+\s)([A-R]{2}[0-9]{2})$/); matches = linkedmsg.match(/(.*\s[A-Z0-9]+\s)([A-R]{2}[0-9]{2})$/);
if (matches && matches[2] !== 'RR73') { if (matches && matches[2] !== 'RR73') {
linkedmsg = html_escape(matches[1]) + '<a href="map?locator=' + matches[2] + '" target="openwebrx-map">' + matches[2] + '</a>'; linkedmsg = html_escape(matches[1]) + '<a href="map?locator=' + matches[2] + '" target="openwebrx-map">' + matches[2] + '</a>';
} else { } else {
linkedmsg = html_escape(linkedmsg); linkedmsg = html_escape(linkedmsg);
} }
} else if (this.beaconModes.indexOf(msg['mode']) >= 0) { } else if (['WSPR', 'FST4W'].indexOf(msg['mode']) >= 0) {
matches = linkedmsg.match(/([A-Z0-9]*\s)([A-R]{2}[0-9]{2})(\s[0-9]+)/); matches = linkedmsg.match(/([A-Z0-9]*\s)([A-R]{2}[0-9]{2})(\s[0-9]+)/);
if (matches) { if (matches) {
linkedmsg = html_escape(matches[1]) + '<a href="map?locator=' + matches[2] + '" target="openwebrx-map">' + matches[2] + '</a>' + html_escape(matches[3]); linkedmsg = html_escape(matches[1]) + '<a href="map?locator=' + matches[2] + '" target="openwebrx-map">' + matches[2] + '</a>' + html_escape(matches[3]);
@ -119,7 +108,7 @@ WsjtMessagePanel.prototype.pushMessage = function(msg) {
$.fn.wsjtMessagePanel = function(){ $.fn.wsjtMessagePanel = function(){
if (!this.data('panel')) { if (!this.data('panel')) {
this.data('panel', new WsjtMessagePanel(this)); this.data('panel', new WsjtMessagePanel(this));
} };
return this.data('panel'); return this.data('panel');
}; };
@ -130,10 +119,6 @@ function PacketMessagePanel(el) {
PacketMessagePanel.prototype = new MessagePanel(); PacketMessagePanel.prototype = new MessagePanel();
PacketMessagePanel.prototype.supportsMessage = function(message) {
return message['mode'] === 'APRS';
};
PacketMessagePanel.prototype.render = function() { PacketMessagePanel.prototype.render = function() {
$(this.el).append($( $(this.el).append($(
'<table>' + '<table>' +
@ -157,17 +142,13 @@ PacketMessagePanel.prototype.pushMessage = function(msg) {
if (msg.type && msg.type === 'thirdparty' && msg.data) { if (msg.type && msg.type === 'thirdparty' && msg.data) {
msg = msg.data; msg = msg.data;
} }
var source = msg.source; var source = msg.source;
var callsign; if (msg.type) {
if ('object' in source) { if (msg.type === 'item') {
callsign = source.object; source = msg.item;
} else if ('item' in source) { }
callsign = source.item; if (msg.type === 'object') {
} else { source = msg.object;
callsign = source.callsign;
if ('ssid' in source) {
callsign += '-' + source.ssid;
} }
} }
@ -206,7 +187,7 @@ PacketMessagePanel.prototype.pushMessage = function(msg) {
'style="' + stylesToString(styles) + '"' 'style="' + stylesToString(styles) + '"'
].join(' '); ].join(' ');
if (msg.lat && msg.lon) { if (msg.lat && msg.lon) {
link = '<a ' + attrs + ' href="map?' + new URLSearchParams(source).toString() + '" target="openwebrx-map">' + overlay + '</a>'; link = '<a ' + attrs + ' href="map?callsign=' + encodeURIComponent(source) + '" target="openwebrx-map">' + overlay + '</a>';
} else { } else {
link = '<div ' + attrs + '>' + overlay + '</div>' link = '<div ' + attrs + '>' + overlay + '</div>'
} }
@ -214,7 +195,7 @@ PacketMessagePanel.prototype.pushMessage = function(msg) {
$b.append($( $b.append($(
'<tr>' + '<tr>' +
'<td>' + timestamp + '</td>' + '<td>' + timestamp + '</td>' +
'<td class="callsign">' + callsign + '</td>' + '<td class="callsign">' + source + '</td>' +
'<td class="coord">' + link + '</td>' + '<td class="coord">' + link + '</td>' +
'<td class="message">' + (msg.comment || msg.message || '') + '</td>' + '<td class="message">' + (msg.comment || msg.message || '') + '</td>' +
'</tr>' '</tr>'
@ -225,7 +206,7 @@ PacketMessagePanel.prototype.pushMessage = function(msg) {
$.fn.packetMessagePanel = function() { $.fn.packetMessagePanel = function() {
if (!this.data('panel')) { if (!this.data('panel')) {
this.data('panel', new PacketMessagePanel(this)); this.data('panel', new PacketMessagePanel(this));
} };
return this.data('panel'); return this.data('panel');
}; };
@ -236,10 +217,6 @@ PocsagMessagePanel = function(el) {
PocsagMessagePanel.prototype = new MessagePanel(); PocsagMessagePanel.prototype = new MessagePanel();
PocsagMessagePanel.prototype.supportsMessage = function(message) {
return message['mode'] === 'Pocsag';
};
PocsagMessagePanel.prototype.render = function() { PocsagMessagePanel.prototype.render = function() {
$(this.el).append($( $(this.el).append($(
'<table>' + '<table>' +
@ -266,6 +243,6 @@ PocsagMessagePanel.prototype.pushMessage = function(msg) {
$.fn.pocsagMessagePanel = function() { $.fn.pocsagMessagePanel = function() {
if (!this.data('panel')) { if (!this.data('panel')) {
this.data('panel', new PocsagMessagePanel(this)); this.data('panel', new PocsagMessagePanel(this));
} };
return this.data('panel'); return this.data('panel');
}; };

View File

@ -22,31 +22,20 @@ function DmrMetaSlot(el) {
DmrMetaSlot.prototype.update = function(data) { DmrMetaSlot.prototype.update = function(data) {
this.el[data['sync'] ? "addClass" : "removeClass"]("sync"); this.el[data['sync'] ? "addClass" : "removeClass"]("sync");
if (data['sync'] && data['sync'] === "voice") { if (data['sync'] && data['sync'] === "voice") {
this.setId(data['additional'] && data['additional']['callsign'] || data['talkeralias'] || data['source']); this.setId(data['additional'] && data['additional']['callsign'] || data['source']);
this.setName(data['additional'] && data['additional']['fname']); this.setName(data['additional'] && data['additional']['fname']);
this.setMode(['group', 'direct'].includes(data['type']) ? data['type'] : undefined); this.setMode(['group', 'direct'].includes(data['type']) ? data['type'] : undefined);
this.setTarget(data['target']); this.setTarget(data['target']);
this.setLocation(data['lat'], data['lon'], this.getCallsign(data));
this.el.addClass("active"); this.el.addClass("active");
} else { } else {
this.clear(); this.clear();
} }
}; };
DmrMetaSlot.prototype.getCallsign = function(data) {
if ('additional' in data) {
return data['additional']['callsign'];
}
if ('talkeralias' in data) {
var matches = /^([A-Z0-9]+)(\s.*)?$/.exec(data['talkeralias']);
if (matches) return matches[1];
}
};
DmrMetaSlot.prototype.setId = function(id) { DmrMetaSlot.prototype.setId = function(id) {
if (this.id === id) return; if (this.id === id) return;
this.id = id; this.id = id;
this.el.find('.openwebrx-dmr-id .dmr-id').text(id || ''); this.el.find('.openwebrx-dmr-id').text(id || '');
} }
DmrMetaSlot.prototype.setName = function(name) { DmrMetaSlot.prototype.setName = function(name) {
@ -70,23 +59,11 @@ DmrMetaSlot.prototype.setTarget = function(target) {
this.el.find('.openwebrx-dmr-target').text(target || ''); this.el.find('.openwebrx-dmr-target').text(target || '');
} }
DmrMetaSlot.prototype.setLocation = function(lat, lon, callsign) {
var hasLocation = lat && lon && callsign && callsign != '';
if (hasLocation === this.hasLocation && this.callsign === callsign) return;
this.hasLocation = hasLocation; this.callsign = callsign;
var html = '';
if (hasLocation) {
html = '<a class="openwebrx-maps-pin" href="map?callsign=' + encodeURIComponent(callsign) + '" target="_blank"><svg viewBox="0 0 20 35"><use xlink:href="static/gfx/svg-defs.svg#maps-pin"></use></svg></a>';
}
this.el.find('.openwebrx-dmr-id .location').html(html);
}
DmrMetaSlot.prototype.clear = function() { DmrMetaSlot.prototype.clear = function() {
this.setId(); this.setId();
this.setName(); this.setName();
this.setMode(); this.setMode();
this.setTarget(); this.setTarget();
this.setLocation();
this.el.removeClass("active"); this.el.removeClass("active");
}; };
@ -135,9 +112,7 @@ YsfMetaPanel.prototype.update = function(data) {
this.setLocation(data['lat'], data['lon'], data['source']); this.setLocation(data['lat'], data['lon'], data['source']);
this.setUp(data['up']); this.setUp(data['up']);
this.setDown(data['down']); this.setDown(data['down']);
if (data['mode'].indexOf('data') < 0) {
this.el.find(".openwebrx-meta-slot").addClass("active"); this.el.find(".openwebrx-meta-slot").addClass("active");
}
} else { } else {
this.clear(); this.clear();
} }
@ -273,7 +248,7 @@ NxdnMetaPanel.prototype = new MetaPanel();
NxdnMetaPanel.prototype.update = function(data) { NxdnMetaPanel.prototype.update = function(data) {
if (!this.isSupported(data)) return; if (!this.isSupported(data)) return;
if (data['sync'] && data['sync'] === 'voice') { if (data['sync'] && data['sync'] == 'voice') {
this.el.find(".openwebrx-meta-slot").addClass("active"); this.el.find(".openwebrx-meta-slot").addClass("active");
this.setSource(data['additional'] && data['additional']['callsign'] || data['source']); this.setSource(data['additional'] && data['additional']['callsign'] || data['source']);
this.setName(data['additional'] && data['additional']['fname']); this.setName(data['additional'] && data['additional']['fname']);
@ -321,57 +296,18 @@ NxdnMetaPanel.prototype.clear = function() {
this.setDestination(); this.setDestination();
}; };
function M17MetaPanel(el) {
MetaPanel.call(this, el);
this.modes = ['M17'];
this.clear();
}
M17MetaPanel.prototype = new MetaPanel();
M17MetaPanel.prototype.update = function(data) {
if (!this.isSupported(data)) return;
if (data['sync'] && data['sync'] === 'voice') {
this.el.find(".openwebrx-meta-slot").addClass("active");
this.setSource(data['source']);
this.setDestination(data['destination']);
} else {
this.clear();
}
};
M17MetaPanel.prototype.setSource = function(source) {
if (this.source === source) return;
this.source = source;
this.el.find('.openwebrx-m17-source').text(source || '');
};
M17MetaPanel.prototype.setDestination = function(destination) {
if (this.destination === destination) return;
this.destination = destination;
this.el.find('.openwebrx-m17-destination').text(destination || '');
};
M17MetaPanel.prototype.clear = function() {
MetaPanel.prototype.clear.call(this);
this.setSource();
this.setDestination();
};
MetaPanel.types = { MetaPanel.types = {
dmr: DmrMetaPanel, dmr: DmrMetaPanel,
ysf: YsfMetaPanel, ysf: YsfMetaPanel,
dstar: DStarMetaPanel, dstar: DStarMetaPanel,
nxdn: NxdnMetaPanel, nxdn: NxdnMetaPanel,
m17: M17MetaPanel,
}; };
$.fn.metaPanel = function() { $.fn.metaPanel = function() {
return this.map(function() { return this.map(function() {
var $self = $(this); var $self = $(this);
if (!$self.data('metapanel')) { if (!$self.data('metapanel')) {
var matches = /^openwebrx-panel-metadata-([a-z0-9]+)$/.exec($self.prop('id')); var matches = /^openwebrx-panel-metadata-([a-z]+)$/.exec($self.prop('id'));
var constructor = matches && MetaPanel.types[matches[1]] || MetaPanel; var constructor = matches && MetaPanel.types[matches[1]] || MetaPanel;
$self.data('metapanel', new constructor($self)); $self.data('metapanel', new constructor($self));
} }

View File

@ -1,4 +1,2 @@
/* Taken from https://github.com/cyphercodes/location-picker under GPLv3 license */ /* Taken from https://github.com/cyphercodes/location-picker under GPLv3 license */
/* Contains https://github.com/cyphercodes/location-picker/pull/11 to allow latitude and longitude to be 0 */ !function(e,t){"object"==typeof exports&&"undefined"!=typeof module?module.exports=t():"function"==typeof define&&define.amd?define(t):e.locationPicker=t()}(this,function(){"use strict";return function(e,t){void 0===t&&(t={});var n=t.insertAt;if(e&&"undefined"!=typeof document){var o=document.head||document.getElementsByTagName("head")[0],i=document.createElement("style");i.type="text/css","top"===n&&o.firstChild?o.insertBefore(i,o.firstChild):o.appendChild(i),i.styleSheet?i.styleSheet.cssText=e:i.appendChild(document.createTextNode(e))}}('.location-picker .centerMarker{position:absolute;background:url("data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAADYAAABWCAYAAACEsWWHAAAGLElEQVR4AdXcA2xsXReH8f3Ztm3btm2br23btm3btm2rd257b835vVnJStL0sj3Tds6TrOTkzN5r/5925qgorQRPwQewDHbHebgXDQxlNXLfeTlmmZzzlNJOpMyXsA86TJ2O7PGl6DnbQr/CncYx1tNj4JzTde+0la5V/6/x+x+Z85Ov6/jWZ0TFduyL12JMjI05E4iev5pxQXwFV0lGOx7Xf/xRulb5n45vflrH1z4xqYo5MTd6RC9JrvGVmRB6C04dLzR/yw10fP2TGbJyRa/oOVHw1Fh7uqS+jAY0e3uaPXvupOM7n48w01LRO9aItSDX/nKrpf6FYRi85AJzfvy1WHxGKtaKNZPhyNIKoadjZ0nfIfvFYrNSsXYiMz2tilg00Bweas7fZO1YYFYrMkQWKVfl7ScadS7z12jcFhVZxsn9ayoHimHI71RbVWRKhiPrZA7pjZn+TFX4zDWW6lSAU/PoFw3auiJjcuqSryjyPJWH9LauyBhZEXxlcdd+VyFOjDGxFhVZk6vCoUwEv5KXSXlFUYuKrJE5+dXCvlt3Ia7TYkKtKjIndxr/XcOXIMzzgrZWFZkje/Kl8WL7QP/xR7Z0wWs/9X5HvutNdnjdq2z6qpdFxXbsi9daulZkT/YZ/zbsQNwTtWSRuz7/YXu9+bU2ePlLFlcxJsa2ZM3InnSEU8EH5J1v3iRWqhs+9QGbv+plS5TKirExp/K6kT0ckg8ULIO4RW/Jd2q81GTkWvGdC4dkmYLdoXvHLSs3zrffVCrmVl4/HJLdC86r/vmKA8X7ImCVqnxACYfkvIL7EE+OKjWNI11FsehRKUM4JPcVNBCPxSo1jcN4RbHoUSlDOCSNgiHEM79KTeMcVVEselTKEA7JUMEA4pqr9mLhkAwUPAqNX36n9m/Fxi+/K3mk4GaY+/df1/7gEQ7JzQUnwPwN16h8XTjbh/twSE4oWBf6jjio9ifocEjWLfgeDN14be0vqcIh+V7BCzFobKw556ffqO1FcGQPh3AJpxLgROjebrPa3rZE9uTEEgT4IwzffENtbzQje/LH8WLPRQPycXatKjInjXAp48GGMHjx+bUTi8zJhmUieAX60ez8359rIxVZ0czsrygLA9uM+6zVoiJrsnVZFHg55sP8jddqe6nImMyP7GVxYA0Yffyxtn4iHNkiY7J6WRJ4Fu6G3n13a1uxyJbcHZnL0oDvQHNwsJmPDNqqIlNkg8haJgOOgKGrL287sciUHFEmC16FTpi/2bptIxVZks7IWKYC/grN+fOa+bBnVisyRBaIbKUKOBsGzjp11sUiQ3J2qQreij7oWn3ZWZOKtZO+yFRaAVaAsSceb3Z874szLhVrxtoQWUqrwFNxeeWfoVX/2dflkaW0ErwPQ2h2Lvf3GZOKtdDMtd9XpgOsByMP3q/j25+bdqlYI9ZK1ivTBZ6JW6D3kH2nXSzWSG6Jtct0gs+iaWSkOfcvv5g2qehtdLSJqM+WmQC7wND110ybWPROdikzBV6Ih2H+5uu1XCp6Jg/HWmUmwa+gOa+rOedHX22ZVPSKngh+VWYDnAP9Jx3TMrHolZxTZgu8B8PxBHbun39eWSp6RC8MR+8ym2AbGLzo3Mpi0SPZpsw2eCm6oPP/f5myVMxNuqJnaQewKgzdMPXDf8xNVi3tAp6Nh2DemstPWirmJNHj2aWdwDIwfNstkxaLOckypd3Ac/AEdP73T0stFWOTJ6JHaUewBgycecpSi8XYZI3SruBlGGwODy/VT0djTIzFYMwt7QwOhZ69d16iWIxJDi3tDr5CPh/5xqcWKRWvxRgEX6mDWP5GOF0r/muRYl0r/ltyV8wpdQCbQ98xhy1SLF5LNi91AV+A0ccfXaRYvJZ8oU5iT0cXNP7wExOlYl/SFWNLncARMH+L9U0Ui33JEaVuYGXoP/lYE8ViX7JyHcW+uahrx9iXfLOOYq+Bse75JorFvuQ1pY6gE8b/kV1sJ52lruCuib+9GtvJXXUWu2ri72fFdnJVncXOhvgXFykmtpOz6yx2MMzfdB0pJraTg+sstiwM33mbxm9/qPG7H4ntZNk6iz0Hd5hA7ntOqTN4OfbDo1mx/fIyzTwJJedUPgRWtocAAAAASUVORK5CYII=") no-repeat;background-size:100%;top:50%;left:50%;z-index:1;margin-left:-14px;margin-top:-43px;height:44px;width:28px;cursor:pointer}'),function(){function e(e,t,n){void 0===t&&(t={}),void 0===n&&(n={});var o={setCurrentPosition:!0};Object.assign(o,t);var i={center:new google.maps.LatLng(o.lat?o.lat:34.4346,o.lng?o.lng:35.8362),zoom:15};Object.assign(i,n),e instanceof HTMLElement?this.element=e:this.element=document.getElementById(e),this.map=new google.maps.Map(this.element,i);var r=document.createElement("div");r.classList.add("centerMarker"),this.element&&(this.element.classList.add("location-picker"),this.element.children[0].appendChild(r)),!o.setCurrentPosition||o.lat||o.lng||this.setCurrentPosition()}return e.prototype.getMarkerPosition=function(){var e=this.map.getCenter();return{lat:e.lat(),lng:e.lng()}},e.prototype.setLocation=function(e,t){this.map.setCenter(new google.maps.LatLng(e,t))},e.prototype.setCurrentPosition=function(){var e=this;navigator.geolocation?navigator.geolocation.getCurrentPosition(function(t){var n={lat:t.coords.latitude,lng:t.coords.longitude};e.map.setCenter(n)},function(){console.log("Could not determine your location...")}):console.log("Your browser does not support Geolocation.")},e}()});
!function(e,t){"object"==typeof exports&&"undefined"!=typeof module?module.exports=t():"function"==typeof define&&define.amd?define(t):e.locationPicker=t()}(this,function(){"use strict";return function(e,t){void 0===t&&(t={});var n=t.insertAt;if(e&&"undefined"!=typeof document){var o=document.head||document.getElementsByTagName("head")[0],i=document.createElement("style");i.type="text/css","top"===n&&o.firstChild?o.insertBefore(i,o.firstChild):o.appendChild(i),i.styleSheet?i.styleSheet.cssText=e:i.appendChild(document.createTextNode(e))}}('.location-picker .centerMarker{position:absolute;background:url("data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAADYAAABWCAYAAACEsWWHAAAGLElEQVR4AdXcA2xsXReH8f3Ztm3btm2br23btm3btm2rd257b835vVnJStL0sj3Tds6TrOTkzN5r/5925qgorQRPwQewDHbHebgXDQxlNXLfeTlmmZzzlNJOpMyXsA86TJ2O7PGl6DnbQr/CncYx1tNj4JzTde+0la5V/6/x+x+Z85Ov6/jWZ0TFduyL12JMjI05E4iev5pxQXwFV0lGOx7Xf/xRulb5n45vflrH1z4xqYo5MTd6RC9JrvGVmRB6C04dLzR/yw10fP2TGbJyRa/oOVHw1Fh7uqS+jAY0e3uaPXvupOM7n48w01LRO9aItSDX/nKrpf6FYRi85AJzfvy1WHxGKtaKNZPhyNIKoadjZ0nfIfvFYrNSsXYiMz2tilg00Bweas7fZO1YYFYrMkQWKVfl7ScadS7z12jcFhVZxsn9ayoHimHI71RbVWRKhiPrZA7pjZn+TFX4zDWW6lSAU/PoFw3auiJjcuqSryjyPJWH9LauyBhZEXxlcdd+VyFOjDGxFhVZk6vCoUwEv5KXSXlFUYuKrJE5+dXCvlt3Ia7TYkKtKjIndxr/XcOXIMzzgrZWFZkje/Kl8WL7QP/xR7Z0wWs/9X5HvutNdnjdq2z6qpdFxXbsi9daulZkT/YZ/zbsQNwTtWSRuz7/YXu9+bU2ePlLFlcxJsa2ZM3InnSEU8EH5J1v3iRWqhs+9QGbv+plS5TKirExp/K6kT0ckg8ULIO4RW/Jd2q81GTkWvGdC4dkmYLdoXvHLSs3zrffVCrmVl4/HJLdC86r/vmKA8X7ImCVqnxACYfkvIL7EE+OKjWNI11FsehRKUM4JPcVNBCPxSo1jcN4RbHoUSlDOCSNgiHEM79KTeMcVVEselTKEA7JUMEA4pqr9mLhkAwUPAqNX36n9m/Fxi+/K3mk4GaY+/df1/7gEQ7JzQUnwPwN16h8XTjbh/twSE4oWBf6jjio9ifocEjWLfgeDN14be0vqcIh+V7BCzFobKw556ffqO1FcGQPh3AJpxLgROjebrPa3rZE9uTEEgT4IwzffENtbzQje/LH8WLPRQPycXatKjInjXAp48GGMHjx+bUTi8zJhmUieAX60ez8359rIxVZ0czsrygLA9uM+6zVoiJrsnVZFHg55sP8jddqe6nImMyP7GVxYA0Yffyxtn4iHNkiY7J6WRJ4Fu6G3n13a1uxyJbcHZnL0oDvQHNwsJmPDNqqIlNkg8haJgOOgKGrL287sciUHFEmC16FTpi/2bptIxVZks7IWKYC/grN+fOa+bBnVisyRBaIbKUKOBsGzjp11sUiQ3J2qQreij7oWn3ZWZOKtZO+yFRaAVaAsSceb3Z874szLhVrxtoQWUqrwFNxeeWfoVX/2dflkaW0ErwPQ2h2Lvf3GZOKtdDMtd9XpgOsByMP3q/j25+bdqlYI9ZK1ivTBZ6JW6D3kH2nXSzWSG6Jtct0gs+iaWSkOfcvv5g2qehtdLSJqM+WmQC7wND110ybWPROdikzBV6Ih2H+5uu1XCp6Jg/HWmUmwa+gOa+rOedHX22ZVPSKngh+VWYDnAP9Jx3TMrHolZxTZgu8B8PxBHbun39eWSp6RC8MR+8ym2AbGLzo3Mpi0SPZpsw2eCm6oPP/f5myVMxNuqJnaQewKgzdMPXDf8xNVi3tAp6Nh2DemstPWirmJNHj2aWdwDIwfNstkxaLOckypd3Ac/AEdP73T0stFWOTJ6JHaUewBgycecpSi8XYZI3SruBlGGwODy/VT0djTIzFYMwt7QwOhZ69d16iWIxJDi3tDr5CPh/5xqcWKRWvxRgEX6mDWP5GOF0r/muRYl0r/ltyV8wpdQCbQ98xhy1SLF5LNi91AV+A0ccfXaRYvJZ8oU5iT0cXNP7wExOlYl/SFWNLncARMH+L9U0Ui33JEaVuYGXoP/lYE8ViX7JyHcW+uahrx9iXfLOOYq+Bse75JorFvuQ1pY6gE8b/kV1sJ52lruCuib+9GtvJXXUWu2ri72fFdnJVncXOhvgXFykmtpOz6yx2MMzfdB0pJraTg+sstiwM33mbxm9/qPG7H4ntZNk6iz0Hd5hA7ntOqTN4OfbDo1mx/fIyzTwJJedUPgRWtocAAAAASUVORK5CYII=") no-repeat;background-size:100%;top:50%;left:50%;z-index:1;margin-left:-14px;margin-top:-43px;height:44px;width:28px;cursor:pointer}'),function(){function e(e,t,n){void 0===t&&(t={}),void 0===n&&(n={});var o={setCurrentPosition:!0};Object.assign(o,t);var i={center:new google.maps.LatLng("number"==typeof o.lat?o.lat:34.4346,"number"==typeof o.lng?o.lng:35.8362),zoom:15};Object.assign(i,n),e instanceof HTMLElement?this.element=e:this.element=document.getElementById(e),this.map=new google.maps.Map(this.element,i);var r=document.createElement("div");r.classList.add("centerMarker"),this.element&&(this.element.classList.add("location-picker"),this.element.children[0].appendChild(r)),!o.setCurrentPosition||o.lat||o.lng||this.setCurrentPosition()}return e.prototype.getMarkerPosition=function(){var e=this.map.getCenter();return{lat:e.lat(),lng:e.lng()}},e.prototype.setLocation=function(e,t){this.map.setCenter(new google.maps.LatLng(e,t))},e.prototype.setCurrentPosition=function(){var e=this;navigator.geolocation?navigator.geolocation.getCurrentPosition(function(t){var n={lat:t.coords.latitude,lng:t.coords.longitude};e.map.setCenter(n)},function(){console.log("Could not determine your location...")}):console.log("Your browser does not support Geolocation.")},e}()});

View File

@ -1,5 +0,0 @@
$.fn.logMessages = function() {
$.each(this, function(){
$(this).scrollTop(this.scrollHeight);
});
};

View File

@ -1,12 +1,17 @@
$(function(){ $(function(){
var query = new URLSearchParams(window.location.search); var query = window.location.search.replace(/^\?/, '').split('&').map(function(v){
var s = v.split('=');
var r = {};
r[s[0]] = s.slice(1).join('=');
return r;
}).reduce(function(a, b){
return a.assign(b);
});
var expectedCallsign; var expectedCallsign;
if (query.has('callsign')) { if (query.callsign) expectedCallsign = decodeURIComponent(query.callsign);
expectedCallsign = Object.fromEntries(query.entries());
}
var expectedLocator; var expectedLocator;
if (query.has('locator')) expectedLocator = query.get('locator'); if (query.locator) expectedLocator = query.locator;
var protocol = window.location.protocol.match(/https/) ? 'wss' : 'ws'; var protocol = window.location.protocol.match(/https/) ? 'wss' : 'ws';
@ -32,7 +37,6 @@ $(function(){
var retention_time = 2 * 60 * 60 * 1000; var retention_time = 2 * 60 * 60 * 1000;
var strokeOpacity = 0.8; var strokeOpacity = 0.8;
var fillOpacity = 0.35; var fillOpacity = 0.35;
var callsign_service;
var colorKeys = {}; var colorKeys = {};
var colorScale = chroma.scale(['red', 'blue', 'green']).mode('hsl'); var colorScale = chroma.scale(['red', 'blue', 'green']).mode('hsl');
@ -97,11 +101,6 @@ $(function(){
return '<li class="square' + disabled + '" data-selector="' + key + '"><span class="illustration" style="background-color:' + chroma(value).alpha(fillOpacity) + ';border-color:' + chroma(value).alpha(strokeOpacity) + ';"></span>' + key + '</li>'; return '<li class="square' + disabled + '" data-selector="' + key + '"><span class="illustration" style="background-color:' + chroma(value).alpha(fillOpacity) + ';border-color:' + chroma(value).alpha(strokeOpacity) + ';"></span>' + key + '</li>';
}); });
$(".openwebrx-map-legend .content").html('<ul>' + lis.join('') + '</ul>'); $(".openwebrx-map-legend .content").html('<ul>' + lis.join('') + '</ul>');
};
var shallowEquals = function(obj1, obj2) {
// basic shallow object comparison
return Object.entries(obj1).sort().toString() === Object.entries(obj2).sort().toString();
} }
var processUpdates = function(updates) { var processUpdates = function(updates) {
@ -110,7 +109,6 @@ $(function(){
return; return;
} }
updates.forEach(function(update){ updates.forEach(function(update){
var key = sourceToKey(update.source);
switch (update.location.type) { switch (update.location.type) {
case 'latlon': case 'latlon':
@ -124,33 +122,33 @@ $(function(){
aprsOptions.course = update.location.course; aprsOptions.course = update.location.course;
aprsOptions.speed = update.location.speed; aprsOptions.speed = update.location.speed;
} }
if (markers[key]) { if (markers[update.callsign]) {
marker = markers[key]; marker = markers[update.callsign];
} else { } else {
marker = new markerClass(); marker = new markerClass();
marker.addListener('click', function(){ marker.addListener('click', function(){
showMarkerInfoWindow(update.source, pos); showMarkerInfoWindow(update.callsign, pos);
}); });
markers[key] = marker; markers[update.callsign] = marker;
} }
marker.setOptions($.extend({ marker.setOptions($.extend({
position: pos, position: pos,
map: map, map: map,
title: sourceToString(update.source) title: update.callsign
}, aprsOptions, getMarkerOpacityOptions(update.lastseen) )); }, aprsOptions, getMarkerOpacityOptions(update.lastseen) ));
marker.lastseen = update.lastseen; marker.lastseen = update.lastseen;
marker.mode = update.mode; marker.mode = update.mode;
marker.band = update.band; marker.band = update.band;
marker.comment = update.location.comment; marker.comment = update.location.comment;
if (expectedCallsign && shallowEquals(expectedCallsign, update.source)) { if (expectedCallsign && expectedCallsign == update.callsign) {
map.panTo(pos); map.panTo(pos);
showMarkerInfoWindow(update.source, pos); showMarkerInfoWindow(update.callsign, pos);
expectedCallsign = false; expectedCallsign = false;
} }
if (infowindow && infowindow.source && shallowEquals(infowindow.source, update.source)) { if (infowindow && infowindow.callsign && infowindow.callsign == update.callsign) {
showMarkerInfoWindow(infowindow.source, pos); showMarkerInfoWindow(infowindow.callsign, pos);
} }
break; break;
case 'locator': case 'locator':
@ -161,16 +159,15 @@ $(function(){
var rectangle; var rectangle;
// the accessor is designed to work on the rectangle... but it should work on the update object, too // the accessor is designed to work on the rectangle... but it should work on the update object, too
var color = getColor(colorAccessor(update)); var color = getColor(colorAccessor(update));
if (rectangles[key]) { if (rectangles[update.callsign]) {
rectangle = rectangles[key]; rectangle = rectangles[update.callsign];
} else { } else {
rectangle = new google.maps.Rectangle(); rectangle = new google.maps.Rectangle();
rectangle.addListener('click', function(){ rectangle.addListener('click', function(){
showLocatorInfoWindow(this.locator, this.center); showLocatorInfoWindow(this.locator, this.center);
}); });
rectangles[key] = rectangle; rectangles[update.callsign] = rectangle;
} }
rectangle.source = update.source;
rectangle.lastseen = update.lastseen; rectangle.lastseen = update.lastseen;
rectangle.locator = update.location.locator; rectangle.locator = update.location.locator;
rectangle.mode = update.mode; rectangle.mode = update.mode;
@ -190,13 +187,13 @@ $(function(){
} }
}, getRectangleOpacityOptions(update.lastseen) )); }, getRectangleOpacityOptions(update.lastseen) ));
if (expectedLocator && expectedLocator === update.location.locator) { if (expectedLocator && expectedLocator == update.location.locator) {
map.panTo(center); map.panTo(center);
showLocatorInfoWindow(expectedLocator, center); showLocatorInfoWindow(expectedLocator, center);
expectedLocator = false; expectedLocator = false;
} }
if (infowindow && infowindow.locator && infowindow.locator === update.location.locator) { if (infowindow && infowindow.locator && infowindow.locator == update.location.locator) {
showLocatorInfoWindow(infowindow.locator, center); showLocatorInfoWindow(infowindow.locator, center);
} }
break; break;
@ -205,7 +202,7 @@ $(function(){
}; };
var clearMap = function(){ var clearMap = function(){
var reset = function(_, item) { item.setMap(); }; var reset = function(callsign, item) { item.setMap(); };
$.each(markers, reset); $.each(markers, reset);
$.each(rectangles, reset); $.each(rectangles, reset);
receiverMarker.setMap(); receiverMarker.setMap();
@ -289,9 +286,6 @@ $(function(){
if ('map_position_retention_time' in config) { if ('map_position_retention_time' in config) {
retention_time = config.map_position_retention_time * 1000; retention_time = config.map_position_retention_time * 1000;
} }
if ('callsign_service' in config) {
callsign_service = config['callsign_service'];
}
break; break;
case "update": case "update":
processUpdates(json.value); processUpdates(json.value);
@ -338,77 +332,32 @@ $(function(){
infowindow = new google.maps.InfoWindow(); infowindow = new google.maps.InfoWindow();
google.maps.event.addListener(infowindow, 'closeclick', function() { google.maps.event.addListener(infowindow, 'closeclick', function() {
delete infowindow.locator; delete infowindow.locator;
delete infowindow.source; delete infowindow.callsign;
}); });
} }
delete infowindow.locator; delete infowindow.locator;
delete infowindow.source; delete infowindow.callsign;
return infowindow; return infowindow;
};
var sourceToKey = function(source) {
// special treatment for special entities
// not just for display but also in key treatment in order not to overlap with other locations sent by the same callsign
if ('item' in source) return source['item'];
if ('object' in source) return source['object'];
var key = source.callsign;
if ('ssid' in source) key += '-' + source.ssid;
return key;
};
// we can reuse the same logic for displaying and indexing
var sourceToString = sourceToKey;
var linkifySource = function(source) {
var callsignString = sourceToString(source);
switch (callsign_service) {
case "qrzcq":
return '<a target="callsign_info" href="https://www.qrzcq.com/call/' + source.callsign + '">' + callsignString + '</a>';
case "qrz":
return '<a target="callsign_info" href="https://www.qrz.com/db/' + source.callsign + '">' + callsignString + '</a>';
case 'aprsfi':
var callWithSsid = sourceToKey(source);
return '<a target="callsign_info" href="https://aprs.fi/info/a/' + callWithSsid + '">' + callsignString + '</a>';
default:
return callsignString;
} }
};
var distanceKm = function(p1, p2) {
// Earth radius in km
var R = 6371.0;
// Convert degrees to radians
var rlat1 = p1.lat() * (Math.PI/180);
var rlat2 = p2.lat() * (Math.PI/180);
// Compute difference in radians
var difflat = rlat2-rlat1;
var difflon = (p2.lng()-p1.lng()) * (Math.PI/180);
// Compute distance
d = 2 * R * Math.asin(Math.sqrt(
Math.sin(difflat/2) * Math.sin(difflat/2) +
Math.cos(rlat1) * Math.cos(rlat2) * Math.sin(difflon/2) * Math.sin(difflon/2)
));
return Math.round(d);
};
var infowindow; var infowindow;
var showLocatorInfoWindow = function(locator, pos) { var showLocatorInfoWindow = function(locator, pos) {
var infowindow = getInfoWindow(); var infowindow = getInfoWindow();
infowindow.locator = locator; infowindow.locator = locator;
var inLocator = Object.values(rectangles).filter(rectangleFilter).filter(function(d) { var inLocator = $.map(rectangles, function(r, callsign) {
return d.locator === locator; return {callsign: callsign, locator: r.locator, lastseen: r.lastseen, mode: r.mode, band: r.band}
}).filter(rectangleFilter).filter(function(d) {
return d.locator == locator;
}).sort(function(a, b){ }).sort(function(a, b){
return b.lastseen - a.lastseen; return b.lastseen - a.lastseen;
}); });
var distance = receiverMarker?
" at " + distanceKm(receiverMarker.position, pos) + " km" : "";
infowindow.setContent( infowindow.setContent(
'<h3>Locator: ' + locator + distance + '</h3>' + '<h3>Locator: ' + locator + '</h3>' +
'<div>Active Callsigns:</div>' + '<div>Active Callsigns:</div>' +
'<ul>' + '<ul>' +
inLocator.map(function(i){ inLocator.map(function(i){
var timestring = moment(i.lastseen).fromNow(); var timestring = moment(i.lastseen).fromNow();
var message = linkifySource(i.source) + ' (' + timestring + ' using ' + i.mode; var message = i.callsign + ' (' + timestring + ' using ' + i.mode;
if (i.band) message += ' on ' + i.band; if (i.band) message += ' on ' + i.band;
message += ')'; message += ')';
return '<li>' + message + '</li>' return '<li>' + message + '</li>'
@ -419,26 +368,22 @@ $(function(){
infowindow.open(map); infowindow.open(map);
}; };
var showMarkerInfoWindow = function(source, pos) { var showMarkerInfoWindow = function(callsign, pos) {
var infowindow = getInfoWindow(); var infowindow = getInfoWindow();
infowindow.source = source; infowindow.callsign = callsign;
var marker = markers[sourceToKey(source)]; var marker = markers[callsign];
var timestring = moment(marker.lastseen).fromNow(); var timestring = moment(marker.lastseen).fromNow();
var commentString = ""; var commentString = "";
var distance = "";
if (marker.comment) { if (marker.comment) {
commentString = '<div>' + marker.comment + '</div>'; commentString = '<div>' + marker.comment + '</div>';
} }
if (receiverMarker) {
distance = " at " + distanceKm(receiverMarker.position, marker.position) + " km";
}
infowindow.setContent( infowindow.setContent(
'<h3>' + linkifySource(source) + distance + '</h3>' + '<h3>' + callsign + '</h3>' +
'<div>' + timestring + ' using ' + marker.mode + ( marker.band ? ' on ' + marker.band : '' ) + '</div>' + '<div>' + timestring + ' using ' + marker.mode + ( marker.band ? ' on ' + marker.band : '' ) + '</div>' +
commentString commentString
); );
infowindow.open(map, marker); infowindow.open(map, marker);
}; }
var showReceiverInfoWindow = function(marker) { var showReceiverInfoWindow = function(marker) {
var infowindow = getInfoWindow() var infowindow = getInfoWindow()
@ -447,7 +392,7 @@ $(function(){
'<div>Receiver location</div>' '<div>Receiver location</div>'
); );
infowindow.open(map, marker); infowindow.open(map, marker);
}; }
var getScale = function(lastseen) { var getScale = function(lastseen) {
var age = new Date().getTime() - lastseen; var age = new Date().getTime() - lastseen;
@ -476,19 +421,19 @@ $(function(){
// fade out / remove positions after time // fade out / remove positions after time
setInterval(function(){ setInterval(function(){
var now = new Date().getTime(); var now = new Date().getTime();
Object.values(rectangles).forEach(function(m){ $.each(rectangles, function(callsign, m) {
var age = now - m.lastseen; var age = now - m.lastseen;
if (age > retention_time) { if (age > retention_time) {
delete rectangles[sourceToKey(m.source)]; delete rectangles[callsign];
m.setMap(); m.setMap();
return; return;
} }
m.setOptions(getRectangleOpacityOptions(m.lastseen)); m.setOptions(getRectangleOpacityOptions(m.lastseen));
}); });
Object.values(markers).forEach(function(m) { $.each(markers, function(callsign, m) {
var age = now - m.lastseen; var age = now - m.lastseen;
if (age > retention_time) { if (age > retention_time) {
delete markers[sourceToKey(m.source)]; delete markers[callsign];
m.setMap(); m.setMap();
return; return;
} }

View File

@ -60,7 +60,7 @@ function zoomOutOneStep() {
} }
function zoomInTotal() { function zoomInTotal() {
zoom_set(zoom_levels_count); zoom_set(zoom_levels.length - 1);
} }
function zoomOutTotal() { function zoomOutTotal() {
@ -317,7 +317,7 @@ function scale_px_from_freq(f, range) {
function get_visible_freq_range() { function get_visible_freq_range() {
if (!bandwidth) return false; if (!bandwidth) return false;
var fcalc = function (x) { var fcalc = function (x) {
var canvasWidth = waterfallWidth() * get_zoom(zoom_level); var canvasWidth = waterfallWidth() * zoom_levels[zoom_level];
return Math.round(((-zoom_offset_px + x) / canvasWidth) * bandwidth) + (center_freq - bandwidth / 2); return Math.round(((-zoom_offset_px + x) / canvasWidth) * bandwidth) + (center_freq - bandwidth / 2);
}; };
var out = { var out = {
@ -565,7 +565,7 @@ function canvas_mousemove(evt) {
) { ) {
zoom_center_rel += dpx; zoom_center_rel += dpx;
} }
resize_canvases(); resize_canvases(false);
canvas_drag_last_x = evt.pageX; canvas_drag_last_x = evt.pageX;
canvas_drag_last_y = evt.pageY; canvas_drag_last_y = evt.pageY;
mkscale(); mkscale();
@ -616,14 +616,9 @@ function get_relative_x(evt) {
function canvas_mousewheel(evt) { function canvas_mousewheel(evt) {
if (!waterfall_setup_done) return; if (!waterfall_setup_done) return;
var delta = -evt.deltaY;
// deltaMode 0 means pixels instead of lines
if ('deltaMode' in evt && evt.deltaMode === 0) {
delta /= 50;
}
var relativeX = get_relative_x(evt); var relativeX = get_relative_x(evt);
zoom_step(delta, relativeX, zoom_center_where_calc(evt.pageX)); var dir = (evt.deltaY / Math.abs(evt.deltaY)) > 0;
zoom_step(dir, relativeX, zoom_center_where_calc(evt.pageX));
evt.preventDefault(); evt.preventDefault();
} }
@ -636,6 +631,7 @@ function get_zoom_coeff_from_hps(hps) {
return bandwidth / shown_bw; return bandwidth / shown_bw;
} }
var zoom_levels = [1];
var zoom_level = 0; var zoom_level = 0;
var zoom_offset_px = 0; var zoom_offset_px = 0;
var zoom_center_rel = 0; var zoom_center_rel = 0;
@ -643,48 +639,45 @@ var zoom_center_where = 0;
var smeter_level = 0; var smeter_level = 0;
function get_zoom(level) { function mkzoomlevels() {
zoom_levels = [1];
var maxc = get_zoom_coeff_from_hps(zoom_max_level_hps); var maxc = get_zoom_coeff_from_hps(zoom_max_level_hps);
if (maxc < 1) return; if (maxc < 1) return;
// logarithmic interpolation // logarithmic interpolation
var zoom_ratio = Math.pow(maxc, 1 / zoom_levels_count); var zoom_ratio = Math.pow(maxc, 1 / zoom_levels_count);
return Math.pow(zoom_ratio, level); for (var i = 1; i < zoom_levels_count; i++)
zoom_levels.push(Math.pow(zoom_ratio, i));
} }
function zoom_step(delta, where, onscreen) { function zoom_step(out, where, onscreen) {
zoom_level += delta; if ((out && zoom_level === 0) || (!out && zoom_level >= zoom_levels_count - 1)) return;
if (zoom_level < 0) { if (out) --zoom_level;
zoom_level = 0; else ++zoom_level;
} else if (zoom_level > zoom_levels_count) {
zoom_level = zoom_levels_count;
}
zoom_center_rel = canvas_get_freq_offset(where); zoom_center_rel = canvas_get_freq_offset(where);
//console.log("zoom_step || zlevel: "+zoom_level.toString()+" zlevel_val: "+zoom_levels[zoom_level].toString()+" zoom_center_rel: "+zoom_center_rel.toString());
zoom_center_where = onscreen; zoom_center_where = onscreen;
resize_canvases(); //console.log(zoom_center_where, zoom_center_rel, where);
resize_canvases(true);
mkscale(); mkscale();
bookmarks.position(); bookmarks.position();
} }
function zoom_set(level) { function zoom_set(level) {
if (level < 0) { if (!(level >= 0 && level <= zoom_levels.length - 1)) return;
zoom_level = 0; level = parseInt(level);
} else if (level > zoom_levels_count) { zoom_level = level;
zoom_level = zoom_levels_count;
} else {
zoom_level = parseFloat(level);
}
//zoom_center_rel=canvas_get_freq_offset(-canvases[0].offsetLeft+waterfallWidth()/2); //zoom to screen center instead of demod envelope //zoom_center_rel=canvas_get_freq_offset(-canvases[0].offsetLeft+waterfallWidth()/2); //zoom to screen center instead of demod envelope
zoom_center_rel = $('#openwebrx-panel-receiver').demodulatorPanel().getDemodulator().get_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 zoom_center_where = 0.5 + (zoom_center_rel / bandwidth); //this is a kind of hack
resize_canvases(); resize_canvases(true);
mkscale(); mkscale();
bookmarks.position(); bookmarks.position();
} }
function zoom_calc() { function zoom_calc() {
var winsize = waterfallWidth(); var winsize = waterfallWidth();
var canvases_new_width = winsize * get_zoom(zoom_level); var canvases_new_width = winsize * zoom_levels[zoom_level];
zoom_offset_px = -((canvases_new_width * (0.5 + zoom_center_rel / bandwidth)) - (winsize * zoom_center_where)); zoom_offset_px = -((canvases_new_width * (0.5 + zoom_center_rel / bandwidth)) - (winsize * zoom_center_where));
if (zoom_offset_px > 0) zoom_offset_px = 0; if (zoom_offset_px > 0) zoom_offset_px = 0;
if (zoom_offset_px < winsize - canvases_new_width) if (zoom_offset_px < winsize - canvases_new_width)
@ -778,7 +771,6 @@ function on_ws_recv(evt) {
$('#openwebrx-sdr-profiles-listbox').val(currentprofile.toString()); $('#openwebrx-sdr-profiles-listbox').val(currentprofile.toString());
waterfall_clear(); waterfall_clear();
zoom_set(0);
} }
if ('tuning_precision' in config) if ('tuning_precision' in config)
@ -787,10 +779,13 @@ function on_ws_recv(evt) {
break; break;
case "secondary_config": case "secondary_config":
var s = json['value']; var s = json['value'];
secondary_fft_size = s['secondary_fft_size'] || secondary_fft_size; if ('secondary_fft_size' in s)
secondary_bw = s['secondary_bw'] || secondary_bw; window.secondary_fft_size = s['secondary_fft_size'];
if_samp_rate = s['if_samp_rate'] || if_samp_rate; if ('secondary_bw' in s)
if (if_samp_rate) secondary_demod_init_canvases(); window.secondary_bw = s['secondary_bw'];
if ('if_samp_rate' in s)
window.if_samp_rate = s['if_samp_rate'];
secondary_demod_init_canvases();
break; break;
case "receiver_details": case "receiver_details":
$('.webrx-top-container').header().setDetails(json['value']); $('.webrx-top-container').header().setDetails(json['value']);
@ -826,6 +821,12 @@ function on_ws_recv(evt) {
this.update(json['value']); this.update(json['value']);
}); });
break; break;
case "js8_message":
$("#openwebrx-panel-js8-message").js8().pushMessage(json['value']);
break;
case "wsjt_message":
$("#openwebrx-panel-wsjt-message").wsjtMessagePanel().pushMessage(json['value']);
break;
case "dial_frequencies": case "dial_frequencies":
var as_bookmarks = json['value'].map(function (d) { var as_bookmarks = json['value'].map(function (d) {
return { return {
@ -836,6 +837,9 @@ function on_ws_recv(evt) {
}); });
bookmarks.replace_bookmarks(as_bookmarks, 'dial_frequencies'); bookmarks.replace_bookmarks(as_bookmarks, 'dial_frequencies');
break; break;
case "aprs_data":
$('#openwebrx-panel-packet-message').packetMessagePanel().pushMessage(json['value']);
break;
case "bookmarks": case "bookmarks":
bookmarks.replace_bookmarks(json['value'], "server"); bookmarks.replace_bookmarks(json['value'], "server");
break; break;
@ -846,28 +850,15 @@ function on_ws_recv(evt) {
$overlay.show(); $overlay.show();
$("#openwebrx-panel-receiver").demodulatorPanel().stopDemodulator(); $("#openwebrx-panel-receiver").demodulatorPanel().stopDemodulator();
break; break;
case "demodulator_error":
divlog(json['value'], true);
break;
case 'secondary_demod': case 'secondary_demod':
var value = json['value']; secondary_demod_push_data(json['value']);
var panels = [
$("#openwebrx-panel-wsjt-message").wsjtMessagePanel(),
$('#openwebrx-panel-packet-message').packetMessagePanel(),
$('#openwebrx-panel-pocsag-message').pocsagMessagePanel(),
$("#openwebrx-panel-js8-message").js8()
];
if (!panels.some(function(panel) {
if (!panel.supportsMessage(value)) return false;
panel.pushMessage(value);
return true;
})) {
secondary_demod_push_data(value);
}
break; break;
case 'log_message': case 'log_message':
divlog(json['value'], true); divlog(json['value'], true);
break; break;
case 'pocsag_data':
$('#openwebrx-panel-pocsag-message').pocsagMessagePanel().pushMessage(json['value']);
break;
case 'backoff': case 'backoff':
divlog("Server is currently busy: " + json['reason'], true); divlog("Server is currently busy: " + json['reason'], true);
var $overlay = $('#openwebrx-error-overlay'); var $overlay = $('#openwebrx-error-overlay');
@ -943,15 +934,9 @@ var waterfall_measure_minmax_now = false;
var waterfall_measure_minmax_continuous = false; var waterfall_measure_minmax_continuous = false;
function waterfall_measure_minmax_do(what) { function waterfall_measure_minmax_do(what) {
// Get visible range
var range = get_visible_freq_range();
var start = center_freq - bandwidth / 2;
// this is based on an oversampling factor of about 1,25 // this is based on an oversampling factor of about 1,25
range.start = Math.max(0.1, (range.start - start) / bandwidth); var ignored = .1 * what.length;
range.end = Math.min(0.9, (range.end - start) / bandwidth); var data = what.slice(ignored, -ignored);
var data = what.slice(range.start * what.length, range.end * what.length);
return { return {
min: Math.min.apply(Math, data), min: Math.min.apply(Math, data),
max: Math.max.apply(Math, data) max: Math.max.apply(Math, data)
@ -1123,9 +1108,8 @@ function init_canvas_container() {
canvas_container.addEventListener("mouseup", canvas_mouseup, false); canvas_container.addEventListener("mouseup", canvas_mouseup, false);
canvas_container.addEventListener("mousedown", canvas_mousedown, false); canvas_container.addEventListener("mousedown", canvas_mousedown, false);
canvas_container.addEventListener("wheel", canvas_mousewheel, false); canvas_container.addEventListener("wheel", canvas_mousewheel, false);
$("#openwebrx-frequency-container").each(function(){ var frequency_container = $("#openwebrx-frequency-container");
this.addEventListener("wheel", canvas_mousewheel, false); frequency_container.on("wheel", canvas_mousewheel, false);
});
} }
canvas_maxshift = 0; canvas_maxshift = 0;
@ -1137,10 +1121,12 @@ function shift_canvases() {
canvas_maxshift++; canvas_maxshift++;
} }
function resize_canvases() { function resize_canvases(zoom) {
if (typeof zoom === "undefined") zoom = false;
if (!zoom) mkzoomlevels();
zoom_calc(); zoom_calc();
$('#webrx-canvas-container').css({ $('#webrx-canvas-container').css({
width: waterfallWidth() * get_zoom(zoom_level) + 'px', width: waterfallWidth() * zoom_levels[zoom_level] + 'px',
left: zoom_offset_px + "px" left: zoom_offset_px + "px"
}); });
} }
@ -1149,6 +1135,7 @@ function waterfall_init() {
init_canvas_container(); init_canvas_container();
resize_canvases(); resize_canvases();
scale_setup(); scale_setup();
mkzoomlevels();
waterfall_setup_done = 1; waterfall_setup_done = 1;
} }
@ -1278,9 +1265,6 @@ function digimodes_init() {
$('.openwebrx-dmr-timeslot-panel').click(function (e) { $('.openwebrx-dmr-timeslot-panel').click(function (e) {
$(e.currentTarget).toggleClass("muted"); $(e.currentTarget).toggleClass("muted");
update_dmr_timeslot_filtering(); update_dmr_timeslot_filtering();
// don't mute when the location icon is clicked
}).find('.location').click(function(e) {
e.stopPropagation();
}); });
$('.openwebrx-meta-panel').metaPanel(); $('.openwebrx-meta-panel').metaPanel();
@ -1403,8 +1387,6 @@ var secondary_demod_current_canvas_actual_line;
var secondary_demod_current_canvas_context; var secondary_demod_current_canvas_context;
var secondary_demod_current_canvas_index; var secondary_demod_current_canvas_index;
var secondary_demod_canvases; var secondary_demod_canvases;
var secondary_bw = 31.25;
var if_samp_rate;
function secondary_demod_create_canvas() { function secondary_demod_create_canvas() {
var new_canvas = document.createElement("canvas"); var new_canvas = document.createElement("canvas");
@ -1473,8 +1455,11 @@ function secondary_demod_push_data(x) {
if (y === "<") return "&lt;"; if (y === "<") return "&lt;";
if (y === ">") return "&gt;"; if (y === ">") return "&gt;";
if (y === " ") return "&nbsp;"; if (y === " ") return "&nbsp;";
if (y === "\n") return "<br />";
return y; return y;
}).map(function (y) {
if (y === "\n")
return "<br />";
return "<span class=\"part\">" + y + "</span>";
}).join(""); }).join("");
$("#openwebrx-cursor-blink").before(x); $("#openwebrx-cursor-blink").before(x);
} }

View File

@ -8,5 +8,4 @@ $(function(){
$('.optional-section').optionalSection(); $('.optional-section').optionalSection();
$('#scheduler').schedulerInput(); $('#scheduler').schedulerInput();
$('.exponential-input').exponentialInput(); $('.exponential-input').exponentialInput();
$('.device-log-messages').logMessages();
}); });

View File

@ -1,7 +1,6 @@
[core] [core]
data_directory = /var/lib/openwebrx data_directory = /var/lib/openwebrx
temporary_directory = /tmp temporary_directory = /tmp
log_level = INFO
[web] [web]
port = 8073 port = 8073

View File

@ -1,7 +1,6 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
import sys
from owrx.__main__ import main from owrx.__main__ import main
if __name__ == "__main__": if __name__ == "__main__":
sys.exit(main()) main()

View File

@ -1,8 +1,7 @@
import logging import logging
# the linter will complain about this, but the logging must be configured before importing all the other modules # the linter will complain about this, but the logging must be configured before importing all the other modules
# loglevel will be adjusted later, INFO is just for the startup logging.basicConfig(level=logging.DEBUG, format="%(asctime)s - %(name)s - %(levelname)s - %(message)s")
logging.basicConfig(level=logging.INFO, format="%(asctime)s - %(name)s - %(levelname)s - %(message)s")
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
from http.server import HTTPServer from http.server import HTTPServer
@ -52,23 +51,21 @@ def main():
args = parser.parse_args() args = parser.parse_args()
if args.debug: # set loglevel to info for CLI commands
logging.getLogger().setLevel(logging.DEBUG) if args.module is not None and not args.debug:
logging.getLogger().setLevel(logging.INFO)
if args.version: if args.version:
print("OpenWebRX version {version}".format(version=openwebrx_version)) print("OpenWebRX version {version}".format(version=openwebrx_version))
return 0 elif args.module == "admin":
run_admin_action(adminparser, args)
if args.module == "admin": elif args.module == "config":
return run_admin_action(adminparser, args) run_admin_action(configparser, args)
else:
if args.module == "config": start_receiver()
return run_admin_action(configparser, args)
return start_receiver(loglevel=logging.DEBUG if args.debug else None)
def start_receiver(loglevel=None): def start_receiver():
print( print(
""" """
@ -87,27 +84,18 @@ Support and info: https://groups.io/g/openwebrx
for sig in [signal.SIGINT, signal.SIGTERM]: for sig in [signal.SIGINT, signal.SIGTERM]:
signal.signal(sig, handleSignal) signal.signal(sig, handleSignal)
coreConfig = CoreConfig()
# passed loglevel takes priority (used for the --debug argument)
logging.getLogger().setLevel(coreConfig.get_log_level() if loglevel is None else loglevel)
# config warmup # config warmup
Config.validateConfig() Config.validateConfig()
coreConfig = CoreConfig()
featureDetector = FeatureDetector() featureDetector = FeatureDetector()
failed = featureDetector.get_failed_requirements("core") if not featureDetector.is_available("core"):
if failed:
logger.error( logger.error(
"you are missing required dependencies to run openwebrx. " "you are missing required dependencies to run openwebrx. "
"please check that the following core requirements are installed and up to date: %s", "please check that the following core requirements are installed and up to date:"
", ".join(failed)
) )
for f in failed: logger.error(", ".join(featureDetector.get_requirements("core")))
description = featureDetector.get_requirement_description(f) return
if description:
logger.error("description for %s:\n%s", f, description)
return 1
# Get error messages about unknown / unavailable features as soon as possible # Get error messages about unknown / unavailable features as soon as possible
# start up "always-on" sources right away # start up "always-on" sources right away
@ -123,8 +111,5 @@ Support and info: https://groups.io/g/openwebrx
WebSocketConnection.closeAll() WebSocketConnection.closeAll()
Services.stop() Services.stop()
SdrService.stopAllSources()
ReportingEngine.stopAll() ReportingEngine.stopAll()
DecoderQueue.stopAll() DecoderQueue.stopAll()
return 0

View File

@ -46,14 +46,15 @@ def run_admin_action(parser, args):
else: else:
if not hasattr(args, "silent") or not args.silent: if not hasattr(args, "silent") or not args.silent:
parser.print_help() parser.print_help()
return 1 sys.exit(1)
return 0 sys.exit(0)
try: try:
return command.run(args) command.run(args)
except Exception: except Exception:
if not hasattr(args, "silent") or not args.silent: if not hasattr(args, "silent") or not args.silent:
print("Error running command:") print("Error running command:")
traceback.print_exc() traceback.print_exc()
return 1 sys.exit(1)
return 0 sys.exit(0)

View File

@ -30,7 +30,8 @@ class UserCommand(Command, metaclass=ABCMeta):
password = getpass("Please enter the new password for {username}: ".format(username=username)) password = getpass("Please enter the new password for {username}: ".format(username=username))
confirm = getpass("Please confirm the new password: ") confirm = getpass("Please confirm the new password: ")
if password != confirm: if password != confirm:
raise ValueError("Password mismatch") print("ERROR: Password mismatch.")
sys.exit(1)
generated = False generated = False
return password, generated return password, generated
@ -107,9 +108,8 @@ class HasUser(Command):
if args.user in userList: if args.user in userList:
if not args.silent: if not args.silent:
print('User "{name}" exists.'.format(name=args.user)) print('User "{name}" exists.'.format(name=args.user))
return 0
else: else:
if not args.silent: if not args.silent:
print('User "{name}" does not exist.'.format(name=args.user)) print('User "{name}" does not exist.'.format(name=args.user))
# in bash, a return code > 0 is interpreted as "false" # in bash, a return code > 0 is interpreted as "false"
return 1 sys.exit(1)

View File

@ -1,8 +1,9 @@
from owrx.kiss import KissDeframer
from owrx.map import Map, LatLngLocation from owrx.map import Map, LatLngLocation
from owrx.metrics import Metrics, CounterMetric
from owrx.bands import Bandplan from owrx.bands import Bandplan
from owrx.metrics import Metrics, CounterMetric
from owrx.parser import Parser
from datetime import datetime, timezone from datetime import datetime, timezone
from csdr.module import PickleModule
import re import re
import logging import logging
@ -33,7 +34,7 @@ thirdpartyeRegex = re.compile("^([a-zA-Z0-9-]+)>((([a-zA-Z0-9-]+\\*?,)*)([a-zA-Z
messageIdRegex = re.compile("^(.*){([0-9]{1,5})$") messageIdRegex = re.compile("^(.*){([0-9]{1,5})$")
# regex to filter pseudo "WIDE" path elements # regex to filter pseudo "WIDE" path elements
widePattern = re.compile("^WIDE[0-9]$") widePattern = re.compile("^WIDE[0-9]-[0-9]$")
def decodeBase91(input): def decodeBase91(input):
@ -45,8 +46,8 @@ def getSymbolData(symbol, table):
return {"symbol": symbol, "table": table, "index": ord(symbol) - 33, "tableindex": ord(table) - 33} return {"symbol": symbol, "table": table, "index": ord(symbol) - 33, "tableindex": ord(table) - 33}
class Ax25Parser(PickleModule): class Ax25Parser(object):
def process(self, ax25frame): def parse(self, ax25frame):
control_pid = ax25frame.find(bytes([0x03, 0xF0])) control_pid = ax25frame.find(bytes([0x03, 0xF0]))
if control_pid % 7 > 0: if control_pid % 7 > 0:
logger.warning("aprs packet framing error: control/pid position not aligned with 7-octet callsign data") logger.warning("aprs packet framing error: control/pid position not aligned with 7-octet callsign data")
@ -56,23 +57,19 @@ class Ax25Parser(PickleModule):
for i in range(0, len(l), n): for i in range(0, len(l), n):
yield l[i : i + n] yield l[i : i + n]
try:
return { return {
"destination": self.extractCallsign(ax25frame[0:7]), "destination": self.extractCallsign(ax25frame[0:7]),
"source": self.extractCallsign(ax25frame[7:14]), "source": self.extractCallsign(ax25frame[7:14]),
"path": [self.extractCallsign(c) for c in chunks(ax25frame[14:control_pid], 7)], "path": [self.extractCallsign(c) for c in chunks(ax25frame[14:control_pid], 7)],
"data": ax25frame[control_pid + 2 :], "data": ax25frame[control_pid + 2 :],
} }
except (ValueError, IndexError):
logger.exception("error parsing ax25 frame")
def extractCallsign(self, input): def extractCallsign(self, input):
cs = { cs = bytes([b >> 1 for b in input[0:6]]).decode(encoding, "replace").strip()
"callsign": bytes([b >> 1 for b in input[0:6]]).decode(encoding, "replace").strip(),
}
ssid = (input[6] & 0b00011110) >> 1 ssid = (input[6] & 0b00011110) >> 1
if ssid > 0: if ssid > 0:
cs["ssid"] = ssid return "{callsign}-{ssid}".format(callsign=cs, ssid=ssid)
else:
return cs return cs
@ -121,9 +118,9 @@ class WeatherParser(object):
WeatherMapping("s", "snowfall", 3, lambda x: x * 25.4), WeatherMapping("s", "snowfall", 3, lambda x: x * 25.4),
] ]
def __init__(self, data, weather=None): def __init__(self, data, weather={}):
self.data = data self.data = data
self.weather = {} if weather is None else weather self.weather = weather
def getWeather(self): def getWeather(self):
doWork = True doWork = True
@ -155,14 +152,16 @@ class AprsLocation(LatLngLocation):
return res return res
class AprsParser(PickleModule): class AprsParser(Parser):
def __init__(self): def __init__(self, handler):
super().__init__() super().__init__(handler)
self.ax25parser = Ax25Parser()
self.deframer = KissDeframer()
self.metrics = {} self.metrics = {}
self.band = None
def setDialFrequency(self, freq): def setDialFrequency(self, freq):
self.band = Bandplan.getSharedInstance().findBand(freq) super().setDialFrequency(freq)
self.metrics = {}
def getMetric(self, category): def getMetric(self, category):
if category not in self.metrics: if category not in self.metrics:
@ -179,15 +178,18 @@ class AprsParser(PickleModule):
def isDirect(self, aprsData): def isDirect(self, aprsData):
if "path" in aprsData and len(aprsData["path"]) > 0: if "path" in aprsData and len(aprsData["path"]) > 0:
hops = [host for host in aprsData["path"] if widePattern.match(host["callsign"]) is None] hops = [host for host in aprsData["path"] if widePattern.match(host) is None]
if len(hops) > 0: if len(hops) > 0:
return False return False
if "type" in aprsData and aprsData["type"] in ["thirdparty", "item", "object"]: if "type" in aprsData and aprsData["type"] in ["thirdparty", "item", "object"]:
return False return False
return True return True
def process(self, data): def parse(self, raw):
for frame in self.deframer.parse(raw):
try: try:
data = self.ax25parser.parse(frame)
# TODO how can we tell if this is an APRS frame at all? # TODO how can we tell if this is an APRS frame at all?
aprsData = self.parseAprsData(data) aprsData = self.parseAprsData(data)
@ -196,10 +198,7 @@ class AprsParser(PickleModule):
self.getMetric("total").inc() self.getMetric("total").inc()
if self.isDirect(aprsData): if self.isDirect(aprsData):
self.getMetric("direct").inc() self.getMetric("direct").inc()
self.handler.write_aprs_data(aprsData)
# the frontend uses this to distinguis hessages from the different parsers
aprsData["mode"] = "APRS"
return aprsData
except Exception: except Exception:
logger.exception("exception while parsing aprs data") logger.exception("exception while parsing aprs data")
@ -208,13 +207,12 @@ class AprsParser(PickleModule):
mapData = mapData["data"] mapData = mapData["data"]
if "lat" in mapData and "lon" in mapData: if "lat" in mapData and "lon" in mapData:
loc = AprsLocation(mapData) loc = AprsLocation(mapData)
source = mapData["source"].copy() source = mapData["source"]
# these are special packets, sent on behalf of other entities
if "type" in mapData: if "type" in mapData:
if mapData["type"] == "item" and "item" in mapData: if mapData["type"] == "item":
source["item"] = mapData["item"] source = mapData["item"]
elif mapData["type"] == "object" and "object" in mapData: elif mapData["type"] == "object":
source["object"] = mapData["object"] source = mapData["object"]
Map.getSharedInstance().updateLocation(source, loc, "APRS", self.band) Map.getSharedInstance().updateLocation(source, loc, "APRS", self.band)
def hasCompressedCoordinates(self, raw): def hasCompressedCoordinates(self, raw):
@ -347,24 +345,15 @@ class AprsParser(PickleModule):
return result return result
def parseThirdpartyAprsData(self, information): def parseThirdpartyAprsData(self, information):
# in thirdparty packets, the callsign is passed as a string with -SSID suffix...
# this seems to be the only case where parsing is necessary, hence this function is inline
def parseCallsign(callsign):
el = callsign.split('-')
result = {"callsign": el[0]}
if len(el) > 1:
result["ssid"] = int(el[1])
return result
matches = thirdpartyeRegex.match(information) matches = thirdpartyeRegex.match(information)
if matches: if matches:
path = matches.group(2).split(",") path = matches.group(2).split(",")
destination = next((c.strip("*").upper() for c in path if c.endswith("*")), None) destination = next((c.strip("*").upper() for c in path if c.endswith("*")), None)
data = self.parseAprsData( data = self.parseAprsData(
{ {
"source": parseCallsign(matches.group(1).upper()), "source": matches.group(1).upper(),
"destination": parseCallsign(destination), "destination": destination,
"path": [parseCallsign(c) for c in path], "path": path,
"data": matches.group(6).encode(encoding), "data": matches.group(6).encode(encoding),
} }
) )
@ -542,7 +531,7 @@ class MicEParser(object):
def parse(self, data): def parse(self, data):
information = data["data"] information = data["data"]
destination = data["destination"]["callsign"] destination = data["destination"]
rawLatitude = [self.extractNumber(c) for c in destination[0:6]] rawLatitude = [self.extractNumber(c) for c in destination[0:6]]
lat = self.listToNumber(rawLatitude[0:2]) + self.listToNumber(rawLatitude[2:6]) / 6000 lat = self.listToNumber(rawLatitude[0:2]) + self.listToNumber(rawLatitude[2:6]) / 6000

View File

@ -1,54 +0,0 @@
from pycsdr.types import Format
from csdr.module import ThreadModule
import pickle
import logging
logger = logging.getLogger(__name__)
FEND = 0xC0
FESC = 0xDB
TFEND = 0xDC
TFESC = 0xDD
class KissDeframer(ThreadModule):
def __init__(self):
self.escaped = False
self.buf = bytearray()
super().__init__()
def getInputFormat(self) -> Format:
return Format.CHAR
def getOutputFormat(self) -> Format:
return Format.CHAR
def run(self):
while self.doRun:
data = self.reader.read()
if data is None:
self.doRun = False
else:
for frame in self.parse(data):
self.writer.write(pickle.dumps(frame))
def parse(self, input):
for b in input:
if b == FESC:
self.escaped = True
elif self.escaped:
if b == TFEND:
self.buf.append(FEND)
elif b == TFESC:
self.buf.append(FESC)
else:
logger.warning("invalid escape char: %s", str(input[0]))
self.escaped = False
elif b == FEND:
# data frames start with 0x00
if len(self.buf) > 1 and self.buf[0] == 0x00:
yield self.buf[1:]
self.buf = bytearray()
else:
self.buf.append(b)

View File

@ -1,83 +0,0 @@
from csdr.module import AutoStartModule
from pycsdr.types import Format
from pycsdr.modules import Writer, TcpSource
from subprocess import Popen, PIPE
from owrx.aprs.direwolf import DirewolfConfig, DirewolfConfigSubscriber
from owrx.config.core import CoreConfig
import threading
import time
import os
import logging
logger = logging.getLogger(__name__)
class DirewolfModule(AutoStartModule, DirewolfConfigSubscriber):
def __init__(self, service: bool = False):
self.process = None
self.tcpSource = None
self.service = service
self.direwolfConfigPath = "{tmp_dir}/openwebrx_direwolf_{myid}.conf".format(
tmp_dir=CoreConfig().get_temporary_directory(), myid=id(self)
)
self.direwolfConfig = None
super().__init__()
def setWriter(self, writer: Writer) -> None:
super().setWriter(writer)
if self.tcpSource is not None:
self.tcpSource.setWriter(writer)
def getInputFormat(self) -> Format:
return Format.SHORT
def getOutputFormat(self) -> Format:
return Format.CHAR
def start(self):
self.direwolfConfig = DirewolfConfig()
self.direwolfConfig.wire(self)
file = open(self.direwolfConfigPath, "w")
file.write(self.direwolfConfig.getConfig(self.service))
file.close()
# direwolf -c {direwolf_config} -r {audio_rate} -t 0 -q d -q h 1>&2
self.process = Popen(
["direwolf", "-c", self.direwolfConfigPath, "-r", "48000", "-t", "0", "-q", "d", "-q", "h"],
start_new_session=True,
stdin=PIPE,
)
# resume in case the reader has been stop()ed before
self.reader.resume()
threading.Thread(target=self.pump(self.reader.read, self.process.stdin.write)).start()
delay = 0.5
retries = 0
while True:
try:
self.tcpSource = TcpSource(self.direwolfConfig.getPort(), Format.CHAR)
if self.writer:
self.tcpSource.setWriter(self.writer)
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 stop(self):
if self.process is not None:
self.process.terminate()
self.process.wait()
self.process = None
os.unlink(self.direwolfConfigPath)
self.direwolfConfig.unwire(self)
self.direwolfConfig = None
self.reader.stop()
def onConfigChanged(self):
self.stop()
self.start()

View File

@ -1,13 +1,10 @@
from owrx.modes import Modes, AudioChopperMode from owrx.modes import Modes, AudioChopperMode
from owrx.audio import AudioChopperProfile from csdr.output import Output
from itertools import groupby from itertools import groupby
import threading
from owrx.audio import ProfileSourceSubscriber from owrx.audio import ProfileSourceSubscriber
from owrx.audio.wav import AudioWriter from owrx.audio.wav import AudioWriter
from owrx.audio.queue import QueueJob from multiprocessing.connection import Pipe
from csdr.module import ThreadModule
from pycsdr.types import Format
from abc import ABC, abstractmethod
import pickle
import logging import logging
@ -15,30 +12,19 @@ logger = logging.getLogger(__name__)
logger.setLevel(logging.INFO) logger.setLevel(logging.INFO)
class AudioChopperParser(ABC): class AudioChopper(threading.Thread, Output, ProfileSourceSubscriber):
@abstractmethod def __init__(self, active_dsp, mode_str: str):
def parse(self, profile: AudioChopperProfile, frequency: int, line: bytes): self.read_fn = None
pass
class AudioChopper(ThreadModule, ProfileSourceSubscriber):
def __init__(self, mode_str: str, parser: AudioChopperParser):
self.parser = parser
self.dialFrequency = None
self.doRun = True self.doRun = True
self.dsp = active_dsp
self.writers = [] self.writers = []
mode = Modes.findByModulation(mode_str) mode = Modes.findByModulation(mode_str)
if mode is None or not isinstance(mode, AudioChopperMode): if mode is None or not isinstance(mode, AudioChopperMode):
raise ValueError("Mode {} is not an audio chopper mode".format(mode_str)) raise ValueError("Mode {} is not an audio chopper mode".format(mode_str))
self.profile_source = mode.get_profile_source() self.profile_source = mode.get_profile_source()
(self.outputReader, self.outputWriter) = Pipe()
super().__init__() super().__init__()
def getInputFormat(self) -> Format:
return Format.SHORT
def getOutputFormat(self) -> Format:
return Format.CHAR
def stop_writers(self): def stop_writers(self):
while self.writers: while self.writers:
self.writers.pop().stop() self.writers.pop().stop()
@ -48,12 +34,19 @@ class AudioChopper(ThreadModule, ProfileSourceSubscriber):
sorted_profiles = sorted(self.profile_source.getProfiles(), key=lambda p: p.getInterval()) sorted_profiles = sorted(self.profile_source.getProfiles(), key=lambda p: p.getInterval())
groups = {interval: list(group) for interval, group in groupby(sorted_profiles, key=lambda p: p.getInterval())} groups = {interval: list(group) for interval, group in groupby(sorted_profiles, key=lambda p: p.getInterval())}
writers = [ writers = [
AudioWriter(self, interval, profiles) for interval, profiles in groups.items() AudioWriter(self.dsp, self.outputWriter, interval, profiles) for interval, profiles in groups.items()
] ]
for w in writers: for w in writers:
w.start() w.start()
self.writers = writers self.writers = writers
def supports_type(self, t):
return t == "audio"
def receive_output(self, t, read_fn):
self.read_fn = read_fn
self.start()
def run(self) -> None: def run(self) -> None:
logger.debug("Audio chopper starting up") logger.debug("Audio chopper starting up")
self.setup_writers() self.setup_writers()
@ -61,31 +54,37 @@ class AudioChopper(ThreadModule, ProfileSourceSubscriber):
while self.doRun: while self.doRun:
data = None data = None
try: try:
data = self.reader.read() data = self.read_fn(256)
except ValueError: except ValueError:
pass pass
if data is None: if data is None or (isinstance(data, bytes) and len(data) == 0):
self.doRun = False self.doRun = False
else: else:
for w in self.writers: for w in self.writers:
w.write(data.tobytes()) w.write(data)
logger.debug("Audio chopper shutting down") logger.debug("Audio chopper shutting down")
self.profile_source.unsubscribe(self) self.profile_source.unsubscribe(self)
self.stop_writers() self.stop_writers()
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
def onProfilesChanged(self): def onProfilesChanged(self):
logger.debug("profile change received, resetting writers...") logger.debug("profile change received, resetting writers...")
self.setup_writers() self.setup_writers()
def setDialFrequency(self, frequency: int) -> None: def read(self):
self.dialFrequency = frequency try:
return self.outputReader.recv()
def createJob(self, profile, filename): except (EOFError, OSError):
return QueueJob(profile, self.dialFrequency, self, filename) return None
def sendResult(self, result):
for line in result.lines:
data = self.parser.parse(result.profile, result.frequency, line)
if data is not None and self.writer is not None:
self.writer.write(pickle.dumps(data))

View File

@ -12,19 +12,12 @@ logger = logging.getLogger(__name__)
logger.setLevel(logging.INFO) logger.setLevel(logging.INFO)
class QueueJobResult:
def __init__(self, profile, frequency, lines):
self.profile = profile
self.frequency = frequency
self.lines = lines
class QueueJob(object): class QueueJob(object):
def __init__(self, profile, frequency, writer, file): def __init__(self, profile, writer, file, freq):
self.profile = profile self.profile = profile
self.frequency = frequency
self.writer = writer self.writer = writer
self.file = file self.file = file
self.freq = freq
def run(self): def run(self):
logger.debug("processing file %s", self.file) logger.debug("processing file %s", self.file)
@ -35,18 +28,13 @@ class QueueJob(object):
cwd=tmp_dir, cwd=tmp_dir,
close_fds=True, close_fds=True,
) )
lines = None
try: try:
lines = [l for l in decoder.stdout] for line in decoder.stdout:
except OSError: self.writer.send((self.profile, self.freq, line))
except (OSError, AttributeError):
decoder.stdout.flush() decoder.stdout.flush()
# TODO uncouple parsing from the output so that decodes can still go to the map and the spotters # 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.") logger.debug("output has gone away while decoding job.")
# keep this out of the try/except
if lines is not None:
self.writer.sendResult(QueueJobResult(self.profile, self.frequency, lines))
try: try:
rc = decoder.wait(timeout=10) rc = decoder.wait(timeout=10)
if rc != 0: if rc != 0:

View File

@ -1,6 +1,6 @@
from owrx.config.core import CoreConfig from owrx.config.core import CoreConfig
from owrx.audio import AudioChopperProfile from owrx.audio import AudioChopperProfile
from owrx.audio.queue import DecoderQueue from owrx.audio.queue import QueueJob, DecoderQueue
import threading import threading
import wave import wave
import os import os
@ -47,8 +47,9 @@ class WaveFile(object):
class AudioWriter(object): class AudioWriter(object):
def __init__(self, chopper, interval, profiles: List[AudioChopperProfile]): def __init__(self, active_dsp, outputWriter, interval, profiles: List[AudioChopperProfile]):
self.chopper = chopper self.dsp = active_dsp
self.outputWriter = outputWriter
self.interval = interval self.interval = interval
self.profiles = profiles self.profiles = profiles
self.wavefile = None self.wavefile = None
@ -101,7 +102,7 @@ class AudioWriter(object):
logger.exception("Error while linking job files") logger.exception("Error while linking job files")
continue continue
job = self.chopper.createJob(profile, filename) job = QueueJob(profile, self.outputWriter, filename, self.dsp.get_operating_freq())
try: try:
DecoderQueue.getSharedInstance().put(job) DecoderQueue.getSharedInstance().put(job)
except Full: except Full:

View File

@ -9,7 +9,6 @@ class CoreConfig(object):
"core": { "core": {
"data_directory": "/var/lib/openwebrx", "data_directory": "/var/lib/openwebrx",
"temporary_directory": "/tmp", "temporary_directory": "/tmp",
"log_level": "INFO",
}, },
"web": { "web": {
"port": 8073, "port": 8073,
@ -35,7 +34,6 @@ class CoreConfig(object):
CoreConfig.checkDirectory(self.data_directory, "data_directory") CoreConfig.checkDirectory(self.data_directory, "data_directory")
self.temporary_directory = config.get("core", "temporary_directory") self.temporary_directory = config.get("core", "temporary_directory")
CoreConfig.checkDirectory(self.temporary_directory, "temporary_directory") CoreConfig.checkDirectory(self.temporary_directory, "temporary_directory")
self.log_level = config.get("core", "log_level")
self.web_port = config.getint("web", "port") self.web_port = config.getint("web", "port")
self.aprs_symbols_path = config.get("aprs", "symbols_path") self.aprs_symbols_path = config.get("aprs", "symbols_path")
@ -59,6 +57,3 @@ class CoreConfig(object):
def get_aprs_symbols_path(self): def get_aprs_symbols_path(self):
return self.aprs_symbols_path return self.aprs_symbols_path
def get_log_level(self):
return self.log_level

View File

@ -2,7 +2,7 @@ from owrx.property import PropertyLayer
defaultConfig = PropertyLayer( defaultConfig = PropertyLayer(
version=8, version=7,
max_clients=20, max_clients=20,
receiver_name="[Callsign]", receiver_name="[Callsign]",
receiver_location="Budapest, Hungary", receiver_location="Budapest, Hungary",
@ -90,7 +90,7 @@ defaultConfig = PropertyLayer(
), ),
), ),
sdrplay=PropertyLayer( sdrplay=PropertyLayer(
name="SDRPlay device", name="SDRPlay RSP2",
type="sdrplay", type="sdrplay",
antenna="Antenna A", antenna="Antenna A",
profiles=PropertyLayer( profiles=PropertyLayer(

View File

@ -111,21 +111,8 @@ class ConfigMigratorVersion6(ConfigMigrator):
config["version"] = 7 config["version"] = 7
class ConfigMigratorVersion7(ConfigMigrator):
def migrate(self, config):
if "callsign_url" in config:
if "qrzcq.com" in config["callsign_url"]:
config["callsign_service"] = "qrzcq"
elif "qrz.com" in config["callsign_url"]:
config["callsign_service"] = "qrz"
else:
logger.warning("unable to migrate callsign_url! please check settings!")
del config["callsign_url"]
config["version"] = 8
class Migrator(object): class Migrator(object):
currentVersion = 8 currentVersion = 7
migrators = { migrators = {
1: ConfigMigratorVersion1(), 1: ConfigMigratorVersion1(),
2: ConfigMigratorVersion2(), 2: ConfigMigratorVersion2(),
@ -133,7 +120,6 @@ class Migrator(object):
4: ConfigMigratorVersion4(), 4: ConfigMigratorVersion4(),
5: ConfigMigratorVersion5(), 5: ConfigMigratorVersion5(),
6: ConfigMigratorVersion6(), 6: ConfigMigratorVersion6(),
7: ConfigMigratorVersion7(),
} }
@staticmethod @staticmethod

View File

@ -15,10 +15,10 @@ from owrx.config import Config
from owrx.waterfall import WaterfallOptions from owrx.waterfall import WaterfallOptions
from owrx.websocket import Handler from owrx.websocket import Handler
from queue import Queue, Full, Empty from queue import Queue, Full, Empty
from js8py import Js8Frame
from abc import ABCMeta, abstractmethod from abc import ABCMeta, abstractmethod
import json import json
import threading import threading
import struct
import logging import logging
@ -56,10 +56,9 @@ class Client(Handler, metaclass=ABCMeta):
try: try:
self.conn.send(data) self.conn.send(data)
except IOError: except IOError:
logger.exception("error in Client::send()") self.close()
self.close(error=True)
def close(self, error: bool = False): def close(self):
if self.multithreadingQueue is not None: if self.multithreadingQueue is not None:
while True: while True:
try: try:
@ -71,7 +70,7 @@ class Client(Handler, metaclass=ABCMeta):
except Full: except Full:
# this shouldn't happen, we just emptied the queue, but it's not worth risking the exception # this shouldn't happen, we just emptied the queue, but it's not worth risking the exception
logger.exception("impossible queue state: Full after Empty") logger.exception("impossible queue state: Full after Empty")
self.conn.close(socketError=error) self.conn.close()
def mp_send(self, data): def mp_send(self, data):
if self.multithreadingQueue is None: if self.multithreadingQueue is None:
@ -79,7 +78,7 @@ class Client(Handler, metaclass=ABCMeta):
try: try:
self.multithreadingQueue.put(data, block=False) self.multithreadingQueue.put(data, block=False)
except Full: except Full:
self.close(error=True) self.close()
@abstractmethod @abstractmethod
def handleTextMessage(self, conn, message): def handleTextMessage(self, conn, message):
@ -108,9 +107,9 @@ class OpenWebRxClient(Client, metaclass=ABCMeta):
def write_receiver_details(self, details): def write_receiver_details(self, details):
self.send({"type": "receiver_details", "value": details}) self.send({"type": "receiver_details", "value": details})
def close(self, error: bool = False): def close(self):
self._detailsSubscription.cancel() self._detailsSubscription.cancel()
super().close(error) super().close()
class OpenWebRxReceiverClient(OpenWebRxClient, SdrSourceEventClient): class OpenWebRxReceiverClient(OpenWebRxClient, SdrSourceEventClient):
@ -340,7 +339,7 @@ class OpenWebRxReceiverClient(OpenWebRxClient, SdrSourceEventClient):
def handleNoSdrsAvailable(self): def handleNoSdrsAvailable(self):
self.write_sdr_error("No SDR Devices available") self.write_sdr_error("No SDR Devices available")
def close(self, error: bool = False): def close(self):
if self.sdr is not None: if self.sdr is not None:
self.sdr.removeClient(self) self.sdr.removeClient(self)
self.stopDsp() self.stopDsp()
@ -351,7 +350,7 @@ class OpenWebRxReceiverClient(OpenWebRxClient, SdrSourceEventClient):
if self.bookmarkSub is not None: if self.bookmarkSub is not None:
self.bookmarkSub.cancel() self.bookmarkSub.cancel()
self.bookmarkSub = None self.bookmarkSub = None
super().close(error) super().close()
def stopDsp(self): def stopDsp(self):
with self.dspLock: with self.dspLock:
@ -377,8 +376,6 @@ class OpenWebRxReceiverClient(OpenWebRxClient, SdrSourceEventClient):
self.send(bytes([0x04]) + data) self.send(bytes([0x04]) + data)
def write_s_meter_level(self, level): def write_s_meter_level(self, level):
# may contain more than one sample, so only take the last 4 bytes = 1 float
level, = struct.unpack('f', level[-4:])
try: try:
self.send({"type": "smeter", "value": level}) self.send({"type": "smeter", "value": level})
except ValueError: except ValueError:
@ -393,7 +390,8 @@ class OpenWebRxReceiverClient(OpenWebRxClient, SdrSourceEventClient):
def write_secondary_fft(self, data): def write_secondary_fft(self, data):
self.send(bytes([0x03]) + data) self.send(bytes([0x03]) + data)
def write_secondary_demod(self, message): def write_secondary_demod(self, data):
message = data.decode("ascii", "replace")
self.send({"type": "secondary_demod", "value": message}) self.send({"type": "secondary_demod", "value": message})
def write_secondary_dsp_config(self, cfg): def write_secondary_dsp_config(self, cfg):
@ -411,24 +409,46 @@ class OpenWebRxReceiverClient(OpenWebRxClient, SdrSourceEventClient):
def write_metadata(self, metadata): def write_metadata(self, metadata):
self.send({"type": "metadata", "value": metadata}) self.send({"type": "metadata", "value": metadata})
def write_wsjt_message(self, message):
self.send({"type": "wsjt_message", "value": message})
def write_dial_frequencies(self, frequencies): def write_dial_frequencies(self, frequencies):
self.send({"type": "dial_frequencies", "value": frequencies}) self.send({"type": "dial_frequencies", "value": frequencies})
def write_bookmarks(self, bookmarks): def write_bookmarks(self, bookmarks):
self.send({"type": "bookmarks", "value": bookmarks}) self.send({"type": "bookmarks", "value": bookmarks})
def write_aprs_data(self, data):
self.send({"type": "aprs_data", "value": data})
def write_log_message(self, message): def write_log_message(self, message):
self.send({"type": "log_message", "value": message}) self.send({"type": "log_message", "value": message})
def write_sdr_error(self, message): def write_sdr_error(self, message):
self.send({"type": "sdr_error", "value": message}) self.send({"type": "sdr_error", "value": message})
def write_demodulator_error(self, message): def write_pocsag_data(self, data):
self.send({"type": "demodulator_error", "value": message}) self.send({"type": "pocsag_data", "value": data})
def write_backoff_message(self, reason): def write_backoff_message(self, reason):
self.send({"type": "backoff", "reason": 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,
},
}
)
def write_modes(self, modes): def write_modes(self, modes):
def to_json(m): def to_json(m):
res = { res = {
@ -456,7 +476,6 @@ class MapConnection(OpenWebRxClient):
"google_maps_api_key", "google_maps_api_key",
"receiver_gps", "receiver_gps",
"map_position_retention_time", "map_position_retention_time",
"callsign_service",
"receiver_name", "receiver_name",
) )
filtered_config.wire(self.write_config) filtered_config.wire(self.write_config)
@ -468,9 +487,9 @@ class MapConnection(OpenWebRxClient):
def handleTextMessage(self, conn, message): def handleTextMessage(self, conn, message):
pass pass
def close(self, error: bool = False): def close(self):
Map.getSharedInstance().removeClient(self) Map.getSharedInstance().removeClient(self)
super().close(error) super().close()
def write_config(self, cfg): def write_config(self, cfg):
self.send({"type": "config", "value": cfg}) self.send({"type": "config", "value": cfg})
@ -494,17 +513,17 @@ class HandshakeMessageHandler(Handler):
client = None client = None
if "type" in handshake: if "type" in handshake:
if handshake["type"] == "receiver": if handshake["type"] == "receiver":
client = OpenWebRxReceiverClient client = OpenWebRxReceiverClient(conn)
elif handshake["type"] == "map": elif handshake["type"] == "map":
client = MapConnection client = MapConnection(conn)
else: else:
logger.warning("invalid connection type: %s", handshake["type"]) logger.warning("invalid connection type: %s", handshake["type"])
if client is not None: if client is not None:
logger.debug("handshake complete, handing off to %s", client.__name__) logger.debug("handshake complete, handing off to %s", type(client).__name__)
# hand off all further communication to the correspondig connection # hand off all further communication to the correspondig connection
conn.send("CLIENT DE SERVER server=openwebrx version={version}".format(version=openwebrx_version)) conn.send("CLIENT DE SERVER server=openwebrx version={version}".format(version=openwebrx_version))
conn.setMessageHandler(client(conn)) conn.setMessageHandler(client)
else: else:
logger.warning('invalid handshake received') logger.warning('invalid handshake received')
else: else:

View File

@ -158,7 +158,6 @@ class CompiledAssetsController(GzipMixin, ModificationAwareController):
"lib/settings/OptionalSection.js", "lib/settings/OptionalSection.js",
"lib/settings/SchedulerInput.js", "lib/settings/SchedulerInput.js",
"lib/settings/ExponentialInput.js", "lib/settings/ExponentialInput.js",
"lib/settings/LogMessages.js",
"settings.js", "settings.js",
], ],
} }

View File

@ -1,8 +1,6 @@
from . import Controller from . import Controller
from owrx.metrics import CounterMetric, DirectMetric, Metrics from owrx.metrics import CounterMetric, DirectMetric, Metrics
import json import json
import re
class MetricsController(Controller): class MetricsController(Controller):
@ -23,7 +21,7 @@ class MetricsController(Controller):
else: else:
raise ValueError("Unexpected metric type for metric {}".format(repr(metric))) raise ValueError("Unexpected metric type for metric {}".format(repr(metric)))
return "{key} {value}".format(key=re.sub('[^a-zA-Z0-9:_]', '_', key), value=value) return "{key} {value}".format(key=key.replace(".", "_"), value=value)
data = ["# https://prometheus.io/docs/instrumenting/exposition_formats/"] + [ data = ["# https://prometheus.io/docs/instrumenting/exposition_formats/"] + [
prometheusFormat(k, v) for k, v in metrics.items() prometheusFormat(k, v) for k, v in metrics.items()

View File

@ -72,8 +72,8 @@ class SessionController(WebpageController):
self.set_response_cookies(cookie) self.set_response_cookies(cookie)
self.send_redirect(target) self.send_redirect(target)
return return
target = "{}login?{}".format(self.get_document_root(), urlencode({"ref": self.request.query["ref"][0]})) target = "?{}".format(urlencode({"ref": self.request.query["ref"][0]})) if "ref" in self.request.query else ""
self.send_redirect(target) self.send_redirect(self.request.path + target)
def logoutAction(self): def logoutAction(self):
self.send_redirect("logout happening here") self.send_redirect("logout happening here")

View File

@ -5,6 +5,7 @@ from owrx.form.input import (
TextInput, TextInput,
NumberInput, NumberInput,
FloatInput, FloatInput,
LocationInput,
TextAreaInput, TextAreaInput,
DropdownInput, DropdownInput,
Option, Option,
@ -13,7 +14,6 @@ from owrx.form.input.converter import WaterfallColorsConverter, IntConverter
from owrx.form.input.receiverid import ReceiverKeysConverter from owrx.form.input.receiverid import ReceiverKeysConverter
from owrx.form.input.gfx import AvatarInput, TopPhotoInput from owrx.form.input.gfx import AvatarInput, TopPhotoInput
from owrx.form.input.device import WaterfallLevelsInput, WaterfallAutoLevelsInput from owrx.form.input.device import WaterfallLevelsInput, WaterfallAutoLevelsInput
from owrx.form.input.location import LocationInput
from owrx.waterfall import WaterfallOptions from owrx.waterfall import WaterfallOptions
from owrx.breadcrumb import Breadcrumb, BreadcrumbItem from owrx.breadcrumb import Breadcrumb, BreadcrumbItem
from owrx.controllers.settings import SettingsBreadcrumb from owrx.controllers.settings import SettingsBreadcrumb
@ -48,7 +48,7 @@ class GeneralSettingsController(SettingsFormController):
TextInput("receiver_admin", "Receiver admin"), TextInput("receiver_admin", "Receiver admin"),
LocationInput("receiver_gps", "Receiver coordinates"), LocationInput("receiver_gps", "Receiver coordinates"),
TextInput("photo_title", "Photo title"), TextInput("photo_title", "Photo title"),
TextAreaInput("photo_desc", "Photo description", infotext="HTML supported "), TextAreaInput("photo_desc", "Photo description"),
), ),
Section( Section(
"Receiver images", "Receiver images",
@ -168,18 +168,6 @@ class GeneralSettingsController(SettingsFormController):
infotext="Specifies how log markers / grids will remain visible on the map", infotext="Specifies how log markers / grids will remain visible on the map",
append="s", append="s",
), ),
DropdownInput(
"callsign_service",
"Callsign database service",
infotext="Allows users to navigate to an external callsign database service by clicking on "
+ "callsigns",
options=[
Option(None, "disabled"),
Option("qrzcq", "qrzcq.com"),
Option("qrz", "qrz.com"),
Option("aprsfi", "aprs.fi"),
],
),
), ),
] ]

View File

@ -12,7 +12,6 @@ from owrx.form.input import TextInput, DropdownInput, Option
from owrx.form.input.validator import RequiredValidator from owrx.form.input.validator import RequiredValidator
from owrx.property import PropertyLayer from owrx.property import PropertyLayer
from owrx.breadcrumb import BreadcrumbMixin, Breadcrumb, BreadcrumbItem from owrx.breadcrumb import BreadcrumbMixin, Breadcrumb, BreadcrumbItem
from owrx.log import HistoryHandler
from abc import ABCMeta, abstractmethod from abc import ABCMeta, abstractmethod
from uuid import uuid4 from uuid import uuid4
@ -280,21 +279,6 @@ class SdrDeviceController(SdrFormControllerWithModal):
config.store() config.store()
return self.send_redirect("{}settings/sdr".format(self.get_document_root())) return self.send_redirect("{}settings/sdr".format(self.get_document_root()))
def render_sections(self):
handler = HistoryHandler.getHandler("owrx.source.{id}".format(id=self.device_id))
return """
{sections}
<div class="card mt-2">
<div class="card-header">Recent device log messages</div>
<div class="card-body">
<pre class="card-text device-log-messages">{messages}</pre>
</div>
</div>
""".format(
sections=super().render_sections(),
messages=handler.getFormattedHistory(),
)
class NewSdrDeviceController(SettingsFormController): class NewSdrDeviceController(SettingsFormController):
def __init__(self, handler, request, options): def __init__(self, handler, request, options):

View File

@ -22,7 +22,6 @@ class CpuUsageThread(threading.Thread):
self.last_worktime = 0 self.last_worktime = 0
self.last_idletime = 0 self.last_idletime = 0
self.endEvent = threading.Event() self.endEvent = threading.Event()
self.startLock = threading.Lock()
super().__init__() super().__init__()
def run(self): def run(self):
@ -60,7 +59,6 @@ class CpuUsageThread(threading.Thread):
def add_client(self, c): def add_client(self, c):
self.clients.append(c) self.clients.append(c)
with self.startLock:
if not self.is_alive(): if not self.is_alive():
self.start() self.start()

View File

@ -2,9 +2,6 @@ from owrx.config import Config
from owrx.locator import Locator from owrx.locator import Locator
from owrx.property import PropertyFilter from owrx.property import PropertyFilter
from owrx.property.filter import ByPropertyName from owrx.property.filter import ByPropertyName
import logging
logger = logging.getLogger(__name__)
class ReceiverDetails(PropertyFilter): class ReceiverDetails(PropertyFilter):
@ -23,8 +20,5 @@ class ReceiverDetails(PropertyFilter):
def __dict__(self): def __dict__(self):
receiver_info = super().__dict__() receiver_info = super().__dict__()
try:
receiver_info["locator"] = Locator.fromCoordinates(receiver_info["receiver_gps"]) receiver_info["locator"] = Locator.fromCoordinates(receiver_info["receiver_gps"])
except ValueError as e:
logger.error("invalid receiver location, check in settings: %s", str(e))
return receiver_info return receiver_info

View File

@ -1,381 +1,23 @@
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 SdrSourceEventClient, SdrSourceState, SdrClientClass from owrx.source import SdrSourceEventClient, SdrSourceState, SdrClientClass
from owrx.property import PropertyStack, PropertyLayer, PropertyValidator from owrx.property import PropertyStack, PropertyLayer, PropertyValidator
from owrx.property.validators import OrValidator, RegexValidator, BoolValidator from owrx.property.validators import OrValidator, RegexValidator, BoolValidator
from owrx.modes import Modes, DigitalMode from owrx.modes import Modes
from csdr.chain import Chain from owrx.config.core import CoreConfig
from csdr.chain.demodulator import BaseDemodulatorChain, FixedIfSampleRateChain, FixedAudioRateChain, HdAudio, SecondaryDemodulator, DialFrequencyReceiver, MetaProvider, SlotFilterChain, SecondarySelectorChain, DeemphasisTauChain, DemodulatorError from csdr.output import Output
from csdr.chain.selector import Selector, SecondarySelector from csdr import Dsp
from csdr.chain.clientaudio import ClientAudioChain
from csdr.chain.fft import FftChain
from csdr.chain.dummy import DummyDemodulator
from pycsdr.modules import Buffer, Writer
from pycsdr.types import Format
from typing import Union, Optional
from io import BytesIO
from abc import ABC, abstractmethod
import threading import threading
import re import re
import pickle
import logging import logging
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
# now that's a name. help, i've reached enterprise level OOP here
class ClientDemodulatorSecondaryDspEventClient(ABC):
@abstractmethod
def onSecondaryDspRateChange(self, rate):
pass
@abstractmethod
def onSecondaryDspBandwidthChange(self, bw):
pass
class ClientDemodulatorChain(Chain):
def __init__(self, demod: BaseDemodulatorChain, sampleRate: int, outputRate: int, hdOutputRate: int, audioCompression: str, secondaryDspEventReceiver: ClientDemodulatorSecondaryDspEventClient):
self.sampleRate = sampleRate
self.outputRate = outputRate
self.hdOutputRate = hdOutputRate
self.secondaryDspEventReceiver = secondaryDspEventReceiver
self.selector = Selector(sampleRate, outputRate)
self.selector.setBandpass(-4000, 4000)
self.selectorBuffer = Buffer(Format.COMPLEX_FLOAT)
self.audioBuffer = None
self.demodulator = demod
self.secondaryDemodulator = None
self.centerFrequency = None
self.frequencyOffset = None
self.wfmDeemphasisTau = 50e-6
inputRate = demod.getFixedAudioRate() if isinstance(demod, FixedAudioRateChain) else outputRate
oRate = hdOutputRate if isinstance(demod, HdAudio) else outputRate
self.clientAudioChain = ClientAudioChain(demod.getOutputFormat(), inputRate, oRate, audioCompression)
self.secondaryFftSize = 2048
self.secondaryFftOverlapFactor = 0.3
self.secondaryFftFps = 9
self.secondaryFftCompression = "adpcm"
self.secondaryFftChain = None
self.metaWriter = None
self.secondaryFftWriter = None
self.secondaryWriter = None
self.squelchLevel = -150
self.secondarySelector = None
self.secondaryFrequencyOffset = None
super().__init__([self.selector, self.demodulator, self.clientAudioChain])
def stop(self):
super().stop()
if self.secondaryFftChain is not None:
self.secondaryFftChain.stop()
self.secondaryFftChain = None
if self.secondaryDemodulator is not None:
self.secondaryDemodulator.stop()
self.secondaryDemodulator = None
def _connect(self, w1, w2, buffer: Optional[Buffer] = None) -> None:
if w1 is self.selector:
super()._connect(w1, w2, self.selectorBuffer)
elif w2 is self.clientAudioChain:
format = w1.getOutputFormat()
if self.audioBuffer is None or self.audioBuffer.getFormat() != format:
self.audioBuffer = Buffer(format)
if self.secondaryDemodulator is not None and self.secondaryDemodulator.getInputFormat() is not Format.COMPLEX_FLOAT:
self.secondaryDemodulator.setReader(self.audioBuffer.getReader())
super()._connect(w1, w2, self.audioBuffer)
else:
super()._connect(w1, w2)
def setDemodulator(self, demodulator: BaseDemodulatorChain):
if demodulator is self.demodulator:
return
try:
self.clientAudioChain.setFormat(demodulator.getOutputFormat())
except ValueError:
# this will happen if the new format does not match the current demodulator.
# it's expected and should be mended when swapping out the demodulator in the next step
pass
self.replace(1, demodulator)
if self.demodulator is not None:
self.demodulator.stop()
self.demodulator = demodulator
self.selector.setOutputRate(self._getSelectorOutputRate())
clientRate = self._getClientAudioInputRate()
self.clientAudioChain.setInputRate(clientRate)
self.demodulator.setSampleRate(clientRate)
if isinstance(self.demodulator, DeemphasisTauChain):
self.demodulator.setDeemphasisTau(self.wfmDeemphasisTau)
self._updateDialFrequency()
self._syncSquelch()
outputRate = self.hdOutputRate if isinstance(self.demodulator, HdAudio) else self.outputRate
self.clientAudioChain.setClientRate(outputRate)
if self.metaWriter is not None and isinstance(demodulator, MetaProvider):
demodulator.setMetaWriter(self.metaWriter)
def stopDemodulator(self):
if self.demodulator is None:
return
# we need to get the currrent demodulator out of the chain so that it can be deallocated properly
# so we just replace it with a dummy here
# in order to avoid any client audio chain hassle, the dummy simply imitates the output format of the current
# demodulator
self.replace(1, DummyDemodulator(self.demodulator.getOutputFormat()))
self.demodulator.stop()
self.demodulator = None
def _getSelectorOutputRate(self):
if isinstance(self.demodulator, FixedIfSampleRateChain):
return self.demodulator.getFixedIfSampleRate()
elif isinstance(self.secondaryDemodulator, FixedAudioRateChain):
if isinstance(self.demodulator, FixedAudioRateChain) and self.demodulator.getFixedAudioRate() != self.secondaryDemodulator.getFixedAudioRate():
raise ValueError("secondary and primary demodulator chain audio rates do not match!")
return self.secondaryDemodulator.getFixedAudioRate()
else:
return self.hdOutputRate if isinstance(self.demodulator, HdAudio) else self.outputRate
def _getClientAudioInputRate(self):
if isinstance(self.demodulator, FixedAudioRateChain):
return self.demodulator.getFixedAudioRate()
elif isinstance(self.secondaryDemodulator, FixedAudioRateChain):
return self.secondaryDemodulator.getFixedAudioRate()
else:
return self.hdOutputRate if isinstance(self.demodulator, HdAudio) else self.outputRate
def setSecondaryDemodulator(self, demod: Optional[SecondaryDemodulator]):
if demod is self.secondaryDemodulator:
return
if self.secondaryDemodulator is not None:
self.secondaryDemodulator.stop()
self.secondaryDemodulator = demod
rate = self._getSelectorOutputRate()
self.selector.setOutputRate(rate)
clientRate = self._getClientAudioInputRate()
self.clientAudioChain.setInputRate(clientRate)
self.demodulator.setSampleRate(clientRate)
self._updateDialFrequency()
self._syncSquelch()
if isinstance(self.secondaryDemodulator, SecondarySelectorChain):
bandwidth = self.secondaryDemodulator.getBandwidth()
self.secondarySelector = SecondarySelector(rate, bandwidth)
self.secondarySelector.setReader(self.selectorBuffer.getReader())
self.secondarySelector.setFrequencyOffset(self.secondaryFrequencyOffset)
self.secondaryDspEventReceiver.onSecondaryDspBandwidthChange(bandwidth)
else:
self.secondarySelector = None
if self.secondaryDemodulator is not None:
self.secondaryDemodulator.setSampleRate(rate)
if self.secondarySelector is not None:
buffer = Buffer(Format.COMPLEX_FLOAT)
self.secondarySelector.setWriter(buffer)
self.secondaryDemodulator.setReader(buffer.getReader())
elif self.secondaryDemodulator.getInputFormat() is Format.COMPLEX_FLOAT:
self.secondaryDemodulator.setReader(self.selectorBuffer.getReader())
else:
self.secondaryDemodulator.setReader(self.audioBuffer.getReader())
self.secondaryDemodulator.setWriter(self.secondaryWriter)
if self.secondaryDemodulator is None and self.secondaryFftChain is not None:
self.secondaryFftChain.stop()
self.secondaryFftChain = None
if self.secondaryDemodulator is not None and self.secondaryFftChain is None:
self._createSecondaryFftChain()
if self.secondaryFftChain is not None:
self.secondaryFftChain.setSampleRate(rate)
self.secondaryDspEventReceiver.onSecondaryDspRateChange(rate)
def _createSecondaryFftChain(self):
if self.secondaryFftChain is not None:
self.secondaryFftChain.stop()
self.secondaryFftChain = FftChain(self._getSelectorOutputRate(), self.secondaryFftSize, self.secondaryFftOverlapFactor, self.secondaryFftFps, self.secondaryFftCompression)
self.secondaryFftChain.setReader(self.selectorBuffer.getReader())
self.secondaryFftChain.setWriter(self.secondaryFftWriter)
def _syncSquelch(self):
if not self.demodulator.supportsSquelch() or (self.secondaryDemodulator is not None and not self.secondaryDemodulator.supportsSquelch()):
self.selector.setSquelchLevel(-150)
else:
self.selector.setSquelchLevel(self.squelchLevel)
def setLowCut(self, lowCut):
self.selector.setLowCut(lowCut)
def setHighCut(self, highCut):
self.selector.setHighCut(highCut)
def setBandpass(self, lowCut, highCut):
self.selector.setBandpass(lowCut, highCut)
def setFrequencyOffset(self, offset: int) -> None:
if offset == self.frequencyOffset:
return
self.frequencyOffset = offset
self.selector.setFrequencyOffset(offset)
self._updateDialFrequency()
def setCenterFrequency(self, frequency: int) -> None:
if frequency == self.centerFrequency:
return
self.centerFrequency = frequency
self._updateDialFrequency()
def _updateDialFrequency(self):
if self.centerFrequency is None or self.frequencyOffset is None:
return
dialFrequency = self.centerFrequency + self.frequencyOffset
if isinstance(self.demodulator, DialFrequencyReceiver):
self.demodulator.setDialFrequency(dialFrequency)
if isinstance(self.secondaryDemodulator, DialFrequencyReceiver):
self.secondaryDemodulator.setDialFrequency(dialFrequency)
def setAudioCompression(self, compression: str) -> None:
self.clientAudioChain.setAudioCompression(compression)
def setSquelchLevel(self, level: float) -> None:
if level == self.squelchLevel:
return
self.squelchLevel = level
self._syncSquelch()
def setOutputRate(self, outputRate) -> None:
if outputRate == self.outputRate:
return
self.outputRate = outputRate
if isinstance(self.demodulator, HdAudio):
return
self._updateDemodulatorOutputRate(outputRate)
def setHdOutputRate(self, outputRate) -> None:
if outputRate == self.hdOutputRate:
return
self.hdOutputRate = outputRate
if not isinstance(self.demodulator, HdAudio):
return
self._updateDemodulatorOutputRate(outputRate)
def _updateDemodulatorOutputRate(self, outputRate):
if not isinstance(self.demodulator, FixedIfSampleRateChain):
self.selector.setOutputRate(outputRate)
self.demodulator.setSampleRate(outputRate)
if self.secondaryDemodulator is not None:
self.secondaryDemodulator.setSampleRate(outputRate)
if not isinstance(self.demodulator, FixedAudioRateChain):
self.clientAudioChain.setClientRate(outputRate)
def setSampleRate(self, sampleRate: int) -> None:
if sampleRate == self.sampleRate:
return
self.sampleRate = sampleRate
self.selector.setInputRate(sampleRate)
def setPowerWriter(self, writer: Writer) -> None:
self.selector.setPowerWriter(writer)
def setMetaWriter(self, writer: Writer) -> None:
if writer is self.metaWriter:
return
self.metaWriter = writer
if isinstance(self.demodulator, MetaProvider):
self.demodulator.setMetaWriter(self.metaWriter)
def setSecondaryFftWriter(self, writer: Writer) -> None:
if writer is self.secondaryFftWriter:
return
self.secondaryFftWriter = writer
if self.secondaryFftChain is not None:
self.secondaryFftChain.setWriter(writer)
def setSecondaryWriter(self, writer: Writer) -> None:
if writer is self.secondaryWriter:
return
self.secondaryWriter = writer
if self.secondaryDemodulator is not None:
self.secondaryDemodulator.setWriter(writer)
def setSlotFilter(self, filter: int) -> None:
if not isinstance(self.demodulator, SlotFilterChain):
return
self.demodulator.setSlotFilter(filter)
def setSecondaryFftSize(self, size: int) -> None:
if size == self.secondaryFftSize:
return
self.secondaryFftSize = size
if not self.secondaryFftChain:
return
self._createSecondaryFftChain()
def setSecondaryFrequencyOffset(self, freq: int) -> None:
if self.secondaryFrequencyOffset == freq:
return
self.secondaryFrequencyOffset = freq
if self.secondarySelector is None:
return
self.secondarySelector.setFrequencyOffset(self.secondaryFrequencyOffset)
def setSecondaryFftCompression(self, compression: str) -> None:
if compression == self.secondaryFftCompression:
return
self.secondaryFftCompression = compression
if not self.secondaryFftChain:
return
self.secondaryFftChain.setCompression(self.secondaryFftCompression)
def setSecondaryFftOverlapFactor(self, overlap: float) -> None:
if overlap == self.secondaryFftOverlapFactor:
return
self.secondaryFftOverlapFactor = overlap
if not self.secondaryFftChain:
return
self.secondaryFftChain.setVOverlapFactor(self.secondaryFftOverlapFactor)
def setSecondaryFftFps(self, fps: int) -> None:
if fps == self.secondaryFftFps:
return
self.secondaryFftFps = fps
if not self.secondaryFftChain:
return
self.secondaryFftChain.setFps(self.secondaryFftFps)
def getSecondaryFftOutputFormat(self) -> Format:
if self.secondaryFftCompression == "adpcm":
return Format.CHAR
return Format.SHORT
def setWfmDeemphasisTau(self, tau: float) -> None:
if tau == self.wfmDeemphasisTau:
return
self.wfmDeemphasisTau = tau
if isinstance(self.demodulator, DeemphasisTauChain):
self.demodulator.setDeemphasisTau(self.wfmDeemphasisTau)
class ModulationValidator(OrValidator): class ModulationValidator(OrValidator):
""" """
This validator only allows alphanumeric characters and numbers, but no spaces or special characters This validator only allows alphanumeric characters and numbers, but no spaces or special characters
@ -385,16 +27,20 @@ class ModulationValidator(OrValidator):
super().__init__(BoolValidator(), RegexValidator(re.compile("^[a-z0-9]+$"))) super().__init__(BoolValidator(), RegexValidator(re.compile("^[a-z0-9]+$")))
class DspManager(SdrSourceEventClient, ClientDemodulatorSecondaryDspEventClient): class DspManager(Output, SdrSourceEventClient):
def __init__(self, handler, sdrSource): def __init__(self, handler, sdrSource):
self.handler = handler self.handler = handler
self.sdrSource = sdrSource self.sdrSource = sdrSource
self.parsers = {
"meta": MetaParser(self.handler),
"wsjt_demod": WsjtParser(self.handler),
"packet_demod": AprsParser(self.handler),
"pocsag_demod": PocsagParser(self.handler),
"js8_demod": Js8Parser(self.handler),
}
self.props = PropertyStack() self.props = PropertyStack()
# current audio mode. should be "audio" or "hd_audio" depending on what demodulatur is in use.
self.audioOutput = None
# local demodulator properties not forwarded to the sdr # local demodulator properties not forwarded to the sdr
# ensure strict validation since these can be set from the client # ensure strict validation since these can be set from the client
# and are used to build executable commands # and are used to build executable commands
@ -429,247 +75,118 @@ class DspManager(SdrSourceEventClient, ClientDemodulatorSecondaryDspEventClient)
), ),
) )
# defaults for values that may not be set self.dsp = Dsp(self)
self.props.addLayer( self.dsp.nc_port = self.sdrSource.getPort()
2,
PropertyLayer(
output_rate=12000,
hd_output_rate=48000,
digital_voice_codecserver="",
).readonly()
)
self.chain = ClientDemodulatorChain( def set_low_cut(cut):
self._getDemodulator("nfm"), bpf = self.dsp.get_bpf()
self.props["samp_rate"], bpf[0] = cut
self.props["output_rate"], self.dsp.set_bpf(*bpf)
self.props["hd_output_rate"],
self.props["audio_compression"],
self
)
self.readers = {} def set_high_cut(cut):
bpf = self.dsp.get_bpf()
bpf[1] = cut
self.dsp.set_bpf(*bpf)
def set_dial_freq(changes):
if (
"center_freq" not in self.props
or self.props["center_freq"] is None
or "offset_freq" not in self.props
or self.props["offset_freq"] is None
):
return
freq = self.props["center_freq"] + self.props["offset_freq"]
for parser in self.parsers.values():
parser.setDialFrequency(freq)
if "start_mod" in self.props: if "start_mod" in self.props:
self.dsp.set_demodulator(self.props["start_mod"])
mode = Modes.findByModulation(self.props["start_mod"]) mode = Modes.findByModulation(self.props["start_mod"])
if mode:
self.setDemodulator(mode.get_modulation()) if mode and mode.bandpass:
if isinstance(mode, DigitalMode): self.dsp.set_bpf(mode.bandpass.low_cut, mode.bandpass.high_cut)
self.setSecondaryDemodulator(mode.modulation)
if mode.bandpass:
bpf = [mode.bandpass.low_cut, mode.bandpass.high_cut]
self.chain.setBandpass(*bpf)
else: else:
# TODO modes should be mandatory self.dsp.set_bpf(-4000, 4000)
self.setDemodulator(self.props["start_mod"])
if "start_freq" in self.props and "center_freq" in self.props: if "start_freq" in self.props and "center_freq" in self.props:
self.chain.setFrequencyOffset(self.props["start_freq"] - self.props["center_freq"]) self.dsp.set_offset_freq(self.props["start_freq"] - self.props["center_freq"])
else: else:
self.chain.setFrequencyOffset(0) self.dsp.set_offset_freq(0)
self.subscriptions = [ self.subscriptions = [
self.props.wireProperty("audio_compression", self.setAudioCompression), self.props.wireProperty("audio_compression", self.dsp.set_audio_compression),
self.props.wireProperty("fft_compression", self.chain.setSecondaryFftCompression), self.props.wireProperty("fft_compression", self.dsp.set_fft_compression),
self.props.wireProperty("fft_voverlap_factor", self.chain.setSecondaryFftOverlapFactor), self.props.wireProperty("digimodes_fft_size", self.dsp.set_secondary_fft_size),
self.props.wireProperty("fft_fps", self.chain.setSecondaryFftFps), self.props.wireProperty("samp_rate", self.dsp.set_samp_rate),
self.props.wireProperty("digimodes_fft_size", self.setSecondaryFftSize), self.props.wireProperty("output_rate", self.dsp.set_output_rate),
self.props.wireProperty("samp_rate", self.chain.setSampleRate), self.props.wireProperty("hd_output_rate", self.dsp.set_hd_output_rate),
self.props.wireProperty("output_rate", self.chain.setOutputRate), self.props.wireProperty("offset_freq", self.dsp.set_offset_freq),
self.props.wireProperty("hd_output_rate", self.chain.setHdOutputRate), self.props.wireProperty("center_freq", self.dsp.set_center_freq),
self.props.wireProperty("offset_freq", self.chain.setFrequencyOffset), self.props.wireProperty("squelch_level", self.dsp.set_squelch_level),
self.props.wireProperty("center_freq", self.chain.setCenterFrequency), self.props.wireProperty("low_cut", set_low_cut),
self.props.wireProperty("squelch_level", self.chain.setSquelchLevel), self.props.wireProperty("high_cut", set_high_cut),
self.props.wireProperty("low_cut", self.chain.setLowCut), self.props.wireProperty("mod", self.dsp.set_demodulator),
self.props.wireProperty("high_cut", self.chain.setHighCut), self.props.wireProperty("dmr_filter", self.dsp.set_dmr_filter),
self.props.wireProperty("mod", self.setDemodulator), self.props.wireProperty("wfm_deemphasis_tau", self.dsp.set_wfm_deemphasis_tau),
self.props.wireProperty("dmr_filter", self.chain.setSlotFilter), self.props.wireProperty("digital_voice_codecserver", self.dsp.set_codecserver),
self.props.wireProperty("wfm_deemphasis_tau", self.chain.setWfmDeemphasisTau), self.props.filter("center_freq", "offset_freq").wire(set_dial_freq),
self.props.wireProperty("secondary_mod", self.setSecondaryDemodulator),
self.props.wireProperty("secondary_offset_freq", self.chain.setSecondaryFrequencyOffset),
] ]
# wire power level output self.dsp.set_temporary_directory(CoreConfig().get_temporary_directory())
buffer = Buffer(Format.FLOAT)
self.chain.setPowerWriter(buffer)
self.wireOutput("smeter", buffer)
# wire meta output def send_secondary_config(*args):
buffer = Buffer(Format.CHAR) self.handler.write_secondary_dsp_config(
self.chain.setMetaWriter(buffer) {
self.wireOutput("meta", buffer) "secondary_fft_size": self.props["digimodes_fft_size"],
"if_samp_rate": self.dsp.if_samp_rate(),
"secondary_bw": self.dsp.secondary_bw(),
}
)
# wire secondary FFT def set_secondary_mod(mod):
buffer = Buffer(self.chain.getSecondaryFftOutputFormat()) if mod == False:
self.chain.setSecondaryFftWriter(buffer) mod = None
self.wireOutput("secondary_fft", buffer) self.dsp.set_secondary_demodulator(mod)
if mod is not None:
send_secondary_config()
# wire secondary demodulator self.subscriptions += [
buffer = Buffer(Format.CHAR) self.props.wireProperty("secondary_mod", set_secondary_mod),
self.chain.setSecondaryWriter(buffer) self.props.wireProperty("digimodes_fft_size", send_secondary_config),
self.wireOutput("secondary_demod", buffer) self.props.wireProperty("secondary_offset_freq", self.dsp.set_secondary_offset_freq),
]
self.startOnAvailable = False self.startOnAvailable = False
self.sdrSource.addClient(self) self.sdrSource.addClient(self)
def setSecondaryFftSize(self, size): super().__init__()
self.chain.setSecondaryFftSize(size)
self.handler.write_secondary_dsp_config({"secondary_fft_size": size})
def _getDemodulator(self, demod: Union[str, BaseDemodulatorChain]) -> Optional[BaseDemodulatorChain]:
if isinstance(demod, BaseDemodulatorChain):
return demod
# TODO: move this to Modes
if demod == "nfm":
from csdr.chain.analog import NFm
return NFm(self.props["output_rate"])
elif demod == "wfm":
from csdr.chain.analog import WFm
return WFm(self.props["hd_output_rate"], self.props["wfm_deemphasis_tau"])
elif demod == "am":
from csdr.chain.analog import Am
return Am()
elif demod in ["usb", "lsb", "cw"]:
from csdr.chain.analog import Ssb
return Ssb()
elif demod == "dmr":
from csdr.chain.digiham import Dmr
return Dmr(self.props["digital_voice_codecserver"])
elif demod == "dstar":
from csdr.chain.digiham import Dstar
return Dstar(self.props["digital_voice_codecserver"])
elif demod == "ysf":
from csdr.chain.digiham import Ysf
return Ysf(self.props["digital_voice_codecserver"])
elif demod == "nxdn":
from csdr.chain.digiham import Nxdn
return Nxdn(self.props["digital_voice_codecserver"])
elif demod == "m17":
from csdr.chain.m17 import M17
return M17()
elif demod == "drm":
from csdr.chain.drm import Drm
return Drm()
elif demod == "freedv":
from csdr.chain.freedv import FreeDV
return FreeDV()
def setDemodulator(self, mod):
self.chain.stopDemodulator()
try:
demodulator = self._getDemodulator(mod)
if demodulator is None:
raise ValueError("unsupported demodulator: {}".format(mod))
self.chain.setDemodulator(demodulator)
output = "hd_audio" if isinstance(demodulator, HdAudio) else "audio"
if output != self.audioOutput:
self.audioOutput = output
# re-wire the audio to the correct client API
buffer = Buffer(self.chain.getOutputFormat())
self.chain.setWriter(buffer)
self.wireOutput(self.audioOutput, buffer)
except DemodulatorError as de:
self.handler.write_demodulator_error(str(de))
def _getSecondaryDemodulator(self, mod) -> Optional[SecondaryDemodulator]:
if isinstance(mod, SecondaryDemodulator):
return mod
if mod in ["ft8", "wspr", "jt65", "jt9", "ft4", "fst4", "fst4w", "q65"]:
from csdr.chain.digimodes import AudioChopperDemodulator
from owrx.wsjt import WsjtParser
return AudioChopperDemodulator(mod, WsjtParser())
elif mod == "msk144":
from csdr.chain.digimodes import Msk144Demodulator
return Msk144Demodulator()
elif mod == "js8":
from csdr.chain.digimodes import AudioChopperDemodulator
from owrx.js8 import Js8Parser
return AudioChopperDemodulator(mod, Js8Parser())
elif mod == "packet":
from csdr.chain.digimodes import PacketDemodulator
return PacketDemodulator()
elif mod == "pocsag":
from csdr.chain.digiham import PocsagDemodulator
return PocsagDemodulator()
elif mod == "bpsk31":
from csdr.chain.digimodes import PskDemodulator
return PskDemodulator(31.25)
elif mod == "bpsk63":
from csdr.chain.digimodes import PskDemodulator
return PskDemodulator(62.5)
def setSecondaryDemodulator(self, mod):
demodulator = self._getSecondaryDemodulator(mod)
if not demodulator:
self.chain.setSecondaryDemodulator(None)
else:
self.chain.setSecondaryDemodulator(demodulator)
def setAudioCompression(self, comp):
try:
self.chain.setAudioCompression(comp)
except ValueError:
# wrong output format... need to re-wire
buffer = Buffer(self.chain.getOutputFormat())
self.chain.setWriter(buffer)
self.wireOutput(self.audioOutput, buffer)
def start(self): def start(self):
if self.sdrSource.isAvailable(): if self.sdrSource.isAvailable():
self.chain.setReader(self.sdrSource.getBuffer().getReader()) self.dsp.start()
else: else:
self.startOnAvailable = True self.startOnAvailable = True
def unwireOutput(self, t: str): def receive_output(self, t, read_fn):
if t in self.readers: logger.debug("adding new output of type %s", t)
self.readers[t].stop()
del self.readers[t]
def wireOutput(self, t: str, buffer: Buffer):
logger.debug("wiring new output of type %s", t)
writers = { writers = {
"audio": self.handler.write_dsp_data, "audio": self.handler.write_dsp_data,
"hd_audio": self.handler.write_hd_audio, "hd_audio": self.handler.write_hd_audio,
"smeter": self.handler.write_s_meter_level, "smeter": self.handler.write_s_meter_level,
"secondary_fft": self.handler.write_secondary_fft, "secondary_fft": self.handler.write_secondary_fft,
"secondary_demod": self._unpickle(self.handler.write_secondary_demod), "secondary_demod": self.handler.write_secondary_demod,
"meta": self._unpickle(self.handler.write_metadata),
} }
for demod, parser in self.parsers.items():
writers[demod] = parser.parse
write = writers[t] write = writers[t]
self.unwireOutput(t) threading.Thread(target=self.pump(read_fn, write), name="dsp_pump_{}".format(t)).start()
reader = buffer.getReader()
self.readers[t] = reader
threading.Thread(target=self.chain.pump(reader.read, write), name="dsp_pump_{}".format(t)).start()
def _unpickle(self, callback):
def unpickler(data):
b = data.tobytes()
io = BytesIO(b)
try:
while True:
callback(pickle.load(io))
except EOFError:
pass
# TODO: this is not ideal. is there a way to know beforehand if the data will be pickled?
except pickle.UnpicklingError:
callback(b.decode("ascii"))
return unpickler
def stop(self): def stop(self):
if self.chain: self.dsp.stop()
self.chain.stop()
self.chain = None
for reader in self.readers.values():
reader.stop()
self.readers = {}
self.startOnAvailable = False self.startOnAvailable = False
self.sdrSource.removeClient(self) self.sdrSource.removeClient(self)
for sub in self.subscriptions: for sub in self.subscriptions:
@ -690,21 +207,15 @@ class DspManager(SdrSourceEventClient, ClientDemodulatorSecondaryDspEventClient)
if state is SdrSourceState.RUNNING: if state is SdrSourceState.RUNNING:
logger.debug("received STATE_RUNNING, attempting DspSource restart") logger.debug("received STATE_RUNNING, attempting DspSource restart")
if self.startOnAvailable: if self.startOnAvailable:
self.chain.setReader(self.sdrSource.getBuffer().getReader()) self.dsp.start()
self.startOnAvailable = False self.startOnAvailable = False
elif state is SdrSourceState.STOPPING: elif state is SdrSourceState.STOPPING:
logger.debug("received STATE_STOPPING, shutting down DspSource") logger.debug("received STATE_STOPPING, shutting down DspSource")
self.stop() self.dsp.stop()
def onFail(self): def onFail(self):
logger.debug("received onFail(), shutting down DspSource") logger.debug("received onFail(), shutting down DspSource")
self.stop() self.dsp.stop()
def onShutdown(self): def onShutdown(self):
self.stop() self.dsp.stop()
def onSecondaryDspBandwidthChange(self, bw):
self.handler.write_secondary_dsp_config({"secondary_bw": bw})
def onSecondaryDspRateChange(self, rate):
self.handler.write_secondary_dsp_config({"if_samp_rate": rate})

View File

@ -2,7 +2,7 @@ import subprocess
from functools import reduce from functools import reduce
from operator import and_ from operator import and_
import re import re
from distutils.version import LooseVersion, StrictVersion from distutils.version import LooseVersion
import inspect import inspect
from owrx.config.core import CoreConfig from owrx.config.core import CoreConfig
from owrx.config import Config from owrx.config import Config
@ -13,7 +13,6 @@ from datetime import datetime, timedelta
import logging import logging
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
logger.setLevel(logging.INFO)
class UnknownFeatureException(Exception): class UnknownFeatureException(Exception):
@ -52,39 +51,37 @@ class FeatureCache(object):
class FeatureDetector(object): class FeatureDetector(object):
features = { features = {
# core features; we won't start without these # core features; we won't start without these
"core": ["csdr"], "core": ["csdr", "nmux", "nc"],
# different types of sdrs and their requirements # different types of sdrs and their requirements
"rtl_sdr": ["rtl_connector"], "rtl_sdr": ["rtl_connector"],
"rtl_sdr_soapy": ["soapy_connector", "soapy_rtl_sdr"], "rtl_sdr_soapy": ["soapy_connector", "soapy_rtl_sdr"],
"rtl_tcp": ["rtl_tcp_connector"], "rtl_tcp": ["rtl_tcp_connector"],
"sdrplay": ["soapy_connector", "soapy_sdrplay"], "sdrplay": ["soapy_connector", "soapy_sdrplay"],
"hackrf": ["soapy_connector", "soapy_hackrf"], "hackrf": ["soapy_connector", "soapy_hackrf"],
"perseussdr": ["perseustest", "nmux"], "perseussdr": ["perseustest"],
"airspy": ["soapy_connector", "soapy_airspy"], "airspy": ["soapy_connector", "soapy_airspy"],
"airspyhf": ["soapy_connector", "soapy_airspyhf"], "airspyhf": ["soapy_connector", "soapy_airspyhf"],
"lime_sdr": ["soapy_connector", "soapy_lime_sdr"], "lime_sdr": ["soapy_connector", "soapy_lime_sdr"],
"fifi_sdr": ["alsa", "rockprog", "nmux"], "fifi_sdr": ["alsa", "rockprog"],
"pluto_sdr": ["soapy_connector", "soapy_pluto_sdr"], "pluto_sdr": ["soapy_connector", "soapy_pluto_sdr"],
"soapy_remote": ["soapy_connector", "soapy_remote"], "soapy_remote": ["soapy_connector", "soapy_remote"],
"uhd": ["soapy_connector", "soapy_uhd"], "uhd": ["soapy_connector", "soapy_uhd"],
"radioberry": ["soapy_connector", "soapy_radioberry"], "radioberry": ["soapy_connector", "soapy_radioberry"],
"fcdpp": ["soapy_connector", "soapy_fcdpp"], "fcdpp": ["soapy_connector", "soapy_fcdpp"],
"bladerf": ["soapy_connector", "soapy_bladerf"],
"sddc": ["sddc_connector"], "sddc": ["sddc_connector"],
"hpsdr": ["hpsdr_connector"], "hpsdr": ["hpsdr_connector"],
"runds": ["runds_connector"], "runds": ["runds_connector"],
# optional features and their requirements # optional features and their requirements
"digital_voice_digiham": ["digiham", "codecserver_ambe"], "digital_voice_digiham": ["digiham", "sox", "codecserver_ambe"],
"digital_voice_freedv": ["freedv_rx"], "digital_voice_freedv": ["freedv_rx", "sox"],
"digital_voice_m17": ["m17_demod", "digiham"], "digital_voice_m17": ["m17_demod", "sox", "digiham"],
"wsjt-x": ["wsjtx"], "wsjt-x": ["wsjtx", "sox"],
"wsjt-x-2-3": ["wsjtx_2_3"], "wsjt-x-2-3": ["wsjtx_2_3", "sox"],
"wsjt-x-2-4": ["wsjtx_2_4"], "wsjt-x-2-4": ["wsjtx_2_4", "sox"],
"msk144": ["msk144decoder"], "packet": ["direwolf", "sox"],
"packet": ["direwolf"], "pocsag": ["digiham", "sox"],
"pocsag": ["digiham"], "js8call": ["js8", "sox"],
"js8call": ["js8", "js8py"], "drm": ["dream", "sox"],
"drm": ["dream"],
} }
def feature_availability(self): def feature_availability(self):
@ -111,9 +108,6 @@ class FeatureDetector(object):
def is_available(self, feature): def is_available(self, feature):
return self.has_requirements(self.get_requirements(feature)) return self.has_requirements(self.get_requirements(feature))
def get_failed_requirements(self, feature):
return [req for req in self.get_requirements(feature) if not self.has_requirement(req)]
def get_requirements(self, feature): def get_requirements(self, feature):
try: try:
return FeatureDetector.features[feature] return FeatureDetector.features[feature]
@ -137,14 +131,12 @@ class FeatureDetector(object):
if cache.has(requirement): if cache.has(requirement):
return cache.get(requirement) return cache.get(requirement)
logger.debug("performing feature check for %s", requirement)
method = self._get_requirement_method(requirement) method = self._get_requirement_method(requirement)
result = False result = False
if method is not None: if method is not None:
result = method() result = method()
else: else:
logger.error("detection of requirement {0} not implement. please fix in code!".format(requirement)) logger.error("detection of requirement {0} not implement. please fix in code!".format(requirement))
logger.debug("feature check for %s complete. result: %s", requirement, result)
cache.set(requirement, result) cache.set(requirement, result)
return result return result
@ -179,21 +171,20 @@ class FeatureDetector(object):
""" """
OpenWebRX uses the demodulator and pipeline tools provided by the csdr project. Please check out [the project OpenWebRX uses the demodulator and pipeline tools provided by the csdr project. Please check out [the project
page on github](https://github.com/jketterl/csdr) for further details and installation instructions. page on github](https://github.com/jketterl/csdr) for further details and installation instructions.
In addition, the [pycsdr](https://github.com/jketterl/pycsdr) package must be installed to provide
python bindings for the csdr library.
""" """
required_version = LooseVersion("0.18.0") required_version = LooseVersion("0.17.0")
csdr_version_regex = re.compile("^csdr version (.*)$")
try: try:
from pycsdr.modules import csdr_version process = subprocess.Popen(["csdr", "version"], stderr=subprocess.PIPE)
from pycsdr.modules import version as pycsdr_version matches = csdr_version_regex.match(process.stderr.readline().decode())
if matches is None:
return ( return False
LooseVersion(csdr_version) >= required_version and version = LooseVersion(matches.group(1))
LooseVersion(pycsdr_version) >= required_version process.wait(1)
) return version >= required_version
except ImportError: except FileNotFoundError:
return False return False
def has_nmux(self): def has_nmux(self):
@ -203,6 +194,13 @@ class FeatureDetector(object):
""" """
return self.command_is_runnable("nmux --help") return self.command_is_runnable("nmux --help")
def has_nc(self):
"""
Nc is the client used to connect to the nmux multiplexer. It is provided by either the BSD netcat (recommended
for better performance) or GNU netcat packages. Please check your distribution package manager for options.
"""
return self.command_is_runnable("nc --help")
def has_perseustest(self): def has_perseustest(self):
""" """
To use a Microtelecom Perseus HF receiver, compile and To use a Microtelecom Perseus HF receiver, compile and
@ -227,25 +225,46 @@ class FeatureDetector(object):
To use digital voice modes, the digiham package is required. You can find the package and installation To use digital voice modes, the digiham package is required. You can find the package and installation
instructions [here](https://github.com/jketterl/digiham). instructions [here](https://github.com/jketterl/digiham).
In addition, the [pydigiham](https://github.com/jketterl/pydigiham) package must be installed to provide
python bindings for the digiham library.
Please note: there is close interaction between digiham and openwebrx, so older versions will probably not work. Please note: there is close interaction between digiham and openwebrx, so older versions will probably not work.
If you have an older verison of digiham installed, please update it along with openwebrx. If you have an older verison of digiham installed, please update it along with openwebrx.
As of now, we require version 0.6 of digiham. As of now, we require version 0.3 of digiham.
""" """
required_version = LooseVersion("0.6") required_version = LooseVersion("0.5")
digiham_version_regex = re.compile("^(.*) version (.*)$")
def check_digiham_version(command):
try: try:
from digiham.modules import digiham_version as digiham_version process = subprocess.Popen([command, "--version"], stdout=subprocess.PIPE)
from digiham.modules import version as pydigiham_version matches = digiham_version_regex.match(process.stdout.readline().decode())
if matches is None:
return (
LooseVersion(digiham_version) >= required_version
and LooseVersion(pydigiham_version) >= required_version
)
except ImportError:
return False return False
version = LooseVersion(matches.group(2))
process.wait(1)
return matches.group(1) in [command, "digiham"] and version >= required_version
except FileNotFoundError:
return False
return reduce(
and_,
map(
check_digiham_version,
[
"rrc_filter",
"ysf_decoder",
"dmr_decoder",
"mbe_synthesizer",
"gfsk_demodulator",
"digitalvoice_filter",
"fsk_demodulator",
"pocsag_decoder",
"dstar_decoder",
"nxdn_decoder",
"dc_block",
],
),
True,
)
def _check_connector(self, command, required_version): def _check_connector(self, command, required_version):
owrx_connector_version_regex = re.compile("^{} version (.*)$".format(re.escape(command))) owrx_connector_version_regex = re.compile("^{} version (.*)$".format(re.escape(command)))
@ -396,14 +415,6 @@ class FeatureDetector(object):
""" """
return self._has_soapy_driver("fcdpp") return self._has_soapy_driver("fcdpp")
def has_soapy_bladerf(self):
"""
The SoapyBladeRF module allows the use of Blade RF devices.
You can get it [here](https://github.com/pothosware/SoapyBladeRF).
"""
return self._has_soapy_driver("bladerf")
def has_m17_demod(self): def has_m17_demod(self):
""" """
The `m17-demod` tool is used to demodulate M17 digital voice signals. The `m17-demod` tool is used to demodulate M17 digital voice signals.
@ -412,6 +423,15 @@ class FeatureDetector(object):
""" """
return self.command_is_runnable("m17-demod") return self.command_is_runnable("m17-demod")
def has_sox(self):
"""
The sox audio library is used to convert between the typical 8 kHz audio sampling rate used by digital modes and
the audio sampling rate requested by the client.
It is available for most distributions through the respective package manager.
"""
return self.command_is_runnable("sox")
def has_direwolf(self): def has_direwolf(self):
""" """
OpenWebRX uses the [direwolf](https://github.com/wb2osz/direwolf) software modem to decode Packet Radio and OpenWebRX uses the [direwolf](https://github.com/wb2osz/direwolf) software modem to decode Packet Radio and
@ -429,7 +449,7 @@ class FeatureDetector(object):
def has_wsjtx(self): def has_wsjtx(self):
""" """
To decode FT8 and other digimodes, you need to install the WSJT-X software suite. Please check the To decode FT8 and other digimodes, you need to install the WSJT-X software suite. Please check the
[WSJT-X homepage](https://wsjt.sourceforge.io/) for ready-made packages or instructions [WSJT-X homepage](https://physics.princeton.edu/pulsar/k1jt/wsjtx.html) for ready-made packages or instructions
on how to build from source. on how to build from source.
""" """
return reduce(and_, map(self.command_is_runnable, ["jt9", "wsprd"]), True) return reduce(and_, map(self.command_is_runnable, ["jt9", "wsprd"]), True)
@ -460,13 +480,6 @@ class FeatureDetector(object):
""" """
return self.has_wsjtx() and self._has_wsjtx_version(LooseVersion("2.4")) return self.has_wsjtx() and self._has_wsjtx_version(LooseVersion("2.4"))
def has_msk144decoder(self):
"""
To decode the MSK144 digimode please install the "msk144decoder". See the
[project page](https://github.com/alexander-sholohov/msk144decoder) for more details.
"""
return self.command_is_runnable("msk144decoder")
def has_js8(self): def has_js8(self):
""" """
To decode JS8, you will need to install [JS8Call](http://js8call.com/) To decode JS8, you will need to install [JS8Call](http://js8call.com/)
@ -477,19 +490,6 @@ class FeatureDetector(object):
""" """
return self.command_is_runnable("js8") return self.command_is_runnable("js8")
def has_js8py(self):
"""
The js8py library is used to decode binary JS8 messages into readable text. More information is available on
[its github page](https://github.com/jketterl/js8py).
"""
required_version = StrictVersion("0.2")
try:
from js8py.version import strictversion
return strictversion >= required_version
except ImportError:
return False
def has_alsa(self): def has_alsa(self):
""" """
Some SDR receivers are identifying themselves as a soundcard. In order to read their data, OpenWebRX relies Some SDR receivers are identifying themselves as a soundcard. In order to read their data, OpenWebRX relies
@ -555,16 +555,19 @@ class FeatureDetector(object):
You can find more information [here](https://github.com/jketterl/codecserver). You can find more information [here](https://github.com/jketterl/codecserver).
""" """
tmp_dir = CoreConfig().get_temporary_directory()
cmd = ["mbe_synthesizer", "--test"]
config = Config.get() config = Config.get()
server = ""
if "digital_voice_codecserver" in config: if "digital_voice_codecserver" in config:
server = config["digital_voice_codecserver"] cmd += ["--server", config["digital_voice_codecserver"]]
try: try:
from digiham.modules import MbeSynthesizer process = subprocess.Popen(
cmd,
return MbeSynthesizer.hasAmbe(server) stdin=subprocess.DEVNULL,
except ImportError: stdout=subprocess.DEVNULL,
return False stderr=subprocess.DEVNULL,
except ConnectionError: cwd=tmp_dir,
)
return process.wait() == 0
except FileNotFoundError:
return False return False

View File

@ -1,16 +1,17 @@
from owrx.config.core import CoreConfig
from owrx.config import Config from owrx.config import Config
from csdr.chain.fft import FftChain import csdr
from csdr.output import Output
import threading
from owrx.source import SdrSourceEventClient, SdrSourceState, SdrClientClass from owrx.source import SdrSourceEventClient, SdrSourceState, SdrClientClass
from owrx.property import PropertyStack from owrx.property import PropertyStack
from pycsdr.modules import Buffer
import threading
import logging import logging
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
class SpectrumThread(SdrSourceEventClient): class SpectrumThread(Output, SdrSourceEventClient):
def __init__(self, sdrSource): def __init__(self, sdrSource):
self.sdrSource = sdrSource self.sdrSource = sdrSource
super().__init__() super().__init__()
@ -18,7 +19,7 @@ class SpectrumThread(SdrSourceEventClient):
stack = PropertyStack() stack = PropertyStack()
stack.addLayer(0, self.sdrSource.props) stack.addLayer(0, self.sdrSource.props)
stack.addLayer(1, Config.get()) stack.addLayer(1, Config.get())
self.props = stack.filter( self.props = props = stack.filter(
"samp_rate", "samp_rate",
"fft_size", "fft_size",
"fft_fps", "fft_fps",
@ -26,87 +27,64 @@ class SpectrumThread(SdrSourceEventClient):
"fft_compression", "fft_compression",
) )
self.dsp = None self.dsp = dsp = csdr.Dsp(self)
self.reader = None dsp.nc_port = self.sdrSource.getPort()
dsp.set_demodulator("fft")
self.subscriptions = [] def set_fft_averages(changes=None):
samp_rate = props["samp_rate"]
fft_size = props["fft_size"]
fft_fps = props["fft_fps"]
fft_voverlap_factor = props["fft_voverlap_factor"]
dsp.set_fft_averages(
int(round(1.0 * samp_rate / fft_size / fft_fps / (1.0 - fft_voverlap_factor)))
if fft_voverlap_factor > 0
else 0
)
self.subscriptions = [
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.filter("samp_rate", "fft_size", "fft_fps", "fft_voverlap_factor").wire(set_fft_averages),
]
set_fft_averages()
dsp.set_temporary_directory(CoreConfig().get_temporary_directory())
logger.debug("Spectrum thread initialized successfully.") logger.debug("Spectrum thread initialized successfully.")
def start(self): def start(self):
if self.dsp is not None:
return
self.dsp = FftChain(
self.props['samp_rate'],
self.props['fft_size'],
self.props['fft_voverlap_factor'],
self.props['fft_fps'],
self.props['fft_compression']
)
self.sdrSource.addClient(self) self.sdrSource.addClient(self)
self.subscriptions += [
self.props.filter("fft_size").wire(self.restart),
# these props can be set on the fly
self.props.wireProperty("samp_rate", self.dsp.setSampleRate),
self.props.wireProperty("fft_fps", self.dsp.setFps),
self.props.wireProperty("fft_voverlap_factor", self.dsp.setVOverlapFactor),
self.props.wireProperty("fft_compression", self._setCompression),
]
if self.sdrSource.isAvailable(): if self.sdrSource.isAvailable():
self.dsp.setReader(self.sdrSource.getBuffer().getReader()) self.dsp.start()
def _setCompression(self, compression): def supports_type(self, t):
if self.reader: return t == "audio"
self.reader.stop()
try:
self.dsp.setCompression(compression)
except ValueError:
# expected since the compressions have different formats
pass
buffer = Buffer(self.dsp.getOutputFormat()) def receive_output(self, type, read_fn):
self.dsp.setWriter(buffer) threading.Thread(target=self.pump(read_fn, self.sdrSource.writeSpectrumData)).start()
self.reader = buffer.getReader()
threading.Thread(target=self.dsp.pump(self.reader.read, self.sdrSource.writeSpectrumData)).start()
def stop(self): def stop(self):
if self.dsp is None:
return
self.dsp.stop() self.dsp.stop()
self.dsp = None
if self.reader:
self.reader.stop()
self.reader = None
self.sdrSource.removeClient(self) self.sdrSource.removeClient(self)
while self.subscriptions: for c in self.subscriptions:
self.subscriptions.pop().cancel() c.cancel()
self.subscriptions = []
def restart(self, *args, **kwargs):
self.stop()
self.start()
def getClientClass(self) -> SdrClientClass: def getClientClass(self) -> SdrClientClass:
return SdrClientClass.USER return SdrClientClass.USER
def onStateChange(self, state: SdrSourceState): def onStateChange(self, state: SdrSourceState):
if state is SdrSourceState.STOPPING: if state is SdrSourceState.STOPPING:
if self.dsp:
self.dsp.stop() self.dsp.stop()
elif state == SdrSourceState.RUNNING: elif state is SdrSourceState.RUNNING:
if self.dsp is None: self.dsp.start()
self.start()
else:
self.dsp.setReader(self.sdrSource.getBuffer().getReader())
def onFail(self): def onFail(self):
if self.dsp is None:
return
self.dsp.stop() self.dsp.stop()
def onShutdown(self): def onShutdown(self):
if self.dsp is None:
return
self.dsp.stop() self.dsp.stop()

View File

@ -1,7 +1,8 @@
from abc import ABC from abc import ABC
from owrx.modes import Modes from owrx.modes import Modes
from owrx.config import Config
from owrx.form.input.validator import Validator from owrx.form.input.validator import Validator
from owrx.form.input.converter import Converter, NullConverter, IntConverter, FloatConverter, EnumConverter, TextConverter from owrx.form.input.converter import Converter, NullConverter, IntConverter, FloatConverter, EnumConverter
from enum import Enum from enum import Enum
@ -106,9 +107,6 @@ class TextInput(Input):
props["type"] = "text" props["type"] = "text"
return props return props
def defaultConverter(self):
return TextConverter()
class NumberInput(Input): class NumberInput(Input):
def __init__(self, id, label, infotext=None, append="", converter: Converter = None, validator: Validator = None): def __init__(self, id, label, infotext=None, append="", converter: Converter = None, validator: Validator = None):
@ -160,6 +158,45 @@ class FloatInput(NumberInput):
return FloatConverter() return FloatConverter()
class LocationInput(Input):
def render_input_group(self, value, errors):
return """
<div class="row {rowclass}">
{inputs}
</div>
{errors}
<div class="row">
<div class="col map-input" data-key="{key}" for="{id}"></div>
</div>
""".format(
id=self.id,
rowclass="is-invalid" if errors else "",
inputs=self.render_input(value, errors),
errors=self.render_errors(errors),
key=Config.get()["google_maps_api_key"],
)
def render_input(self, value, errors):
return "".join(self.render_sub_input(value, id, errors) for id in ["lat", "lon"])
def render_sub_input(self, value, id, errors):
return """
<div class="col">
<input type="number" class="{classes}" id="{id}" name="{id}" placeholder="{label}" value="{value}"
step="any" {disabled}>
</div>
""".format(
id="{0}-{1}".format(self.id, id),
label=self.label,
classes=self.input_classes(errors),
value=value[id],
disabled="disabled" if self.disabled else "",
)
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): class TextAreaInput(Input):
def render_input(self, value, errors): def render_input(self, value, errors):
return """ return """

View File

@ -14,10 +14,6 @@ class Converter(ABC):
class NullConverter(Converter): class NullConverter(Converter):
"""
The default converter class
Does not change the value in any way, just passes them through
"""
def convert_to_form(self, value): def convert_to_form(self, value):
return value return value
@ -25,25 +21,10 @@ class NullConverter(Converter):
return value return value
class TextConverter(Converter):
"""
Converter class for text inputs
Does nothing more than to prevent the special python value "None" from appearing in the form
The string "None" should pass
"""
def convert_to_form(self, value):
if value is None:
return ""
return value
def convert_from_form(self, value):
return value
class OptionalConverter(Converter): class OptionalConverter(Converter):
""" """
Transforms a special form value to None Transforms a special form value to None
The default is to look for an empty string, but this can be used to adopt to other types. The default is look for an empty string, but this can be used to adopt to other types.
If the default is not found, the actual value is passed to the sub_converter for further transformation. If the default is not found, the actual value is passed to the sub_converter for further transformation.
useful for optional fields since None is not stored in the configuration useful for optional fields since None is not stored in the configuration
""" """
@ -80,14 +61,7 @@ class EnumConverter(Converter):
self.enumCls = enumCls self.enumCls = enumCls
def convert_to_form(self, value): def convert_to_form(self, value):
if value is None: return None if value is None else self.enumCls(value).name
return None
try:
return self.enumCls(value).name
# if the current value is not part of the enum, this will happen:
except ValueError:
# and this will restore the default
return None
def convert_from_form(self, value): def convert_from_form(self, value):
return self.enumCls[value].value return self.enumCls[value].value

View File

@ -1,64 +0,0 @@
from owrx.form.input import Input
from owrx.form.input.validator import Validator
from owrx.form.error import ValidationError
from owrx.config import Config
import logging
logger = logging.getLogger(__name__)
class LocationValidator(Validator):
def validate(self, key, value):
if "lat" in value and not -90 < value["lat"] < 90:
raise ValidationError(key, "Latitude out of range (-90 to 90)")
if "lon" in value and not -180 < value["lon"] < 180:
raise ValidationError(key, "Longitude out of range (-180 to 180)")
pass
class LocationInput(Input):
def __init__(self, id, label, validator: Validator = None):
if validator is None:
validator = LocationValidator()
super().__init__(id, label, validator=validator)
def render_input_group(self, value, errors):
return """
<div class="row {rowclass}">
{inputs}
</div>
{errors}
<div class="row">
<div class="col map-input" data-key="{key}" for="{id}"></div>
</div>
""".format(
id=self.id,
rowclass="is-invalid" if errors else "",
inputs=self.render_input(value, errors),
errors=self.render_errors(errors),
key=Config.get()["google_maps_api_key"],
)
def render_input(self, value, errors):
return "".join(self.render_sub_input(value, id, errors) for id in ["lat", "lon"])
def render_sub_input(self, value, id, errors):
return """
<div class="col">
<input type="number" class="{classes}" id="{id}" name="{id}" placeholder="{label}" value="{value}"
step="any" {disabled}>
</div>
""".format(
id="{0}-{1}".format(self.id, id),
label=self.label,
classes=self.input_classes(errors),
value=value[id],
disabled="disabled" if self.disabled else "",
)
def parse(self, data):
value = {k: float(data["{0}-{1}".format(self.id, k)][0]) for k in ["lat", "lon"]}
if self.validator is not None:
self.validator.validate(self.id, value)
return {self.id: value}

View File

@ -4,26 +4,23 @@ from owrx.form.error import ValidationError
class Validator(ABC): class Validator(ABC):
@abstractmethod @abstractmethod
def validate(self, key, value) -> None: def validate(self, key, value):
pass pass
class RequiredValidator(Validator): class RequiredValidator(Validator):
def validate(self, key, value) -> None: def validate(self, key, value):
if value is None or value == "": if value is None or value == "":
raise ValidationError(key, "Field is required") raise ValidationError(key, "Field is required")
class RangeValidator(Validator): class RangeValidator(Validator):
def __init__(self, minValue, maxValue): def __init__(self, minValue, maxValue):
self.minValue = minValue self.minValue = minValue
self.maxValue = maxValue self.maxValue = maxValue
def validate(self, key, value) -> None: def validate(self, key, value):
if value is None or value == "": if value is None or value == "":
return # Ignore empty values return # Ignore empty values
n = float(value) n = float(value)
if n < self.minValue or n > self.maxValue: if n < self.minValue or n > self.maxValue:
raise ValidationError( raise ValidationError(key, 'Value must be between %s and %s'%(self.minValue, self.maxValue))
key, "Value must be between {min} and {max}".format(min=self.minValue, max=self.maxValue)
)

View File

@ -1,5 +1,5 @@
from owrx.audio import AudioChopperProfile, ConfigWiredProfileSource from owrx.audio import AudioChopperProfile, ConfigWiredProfileSource
from owrx.audio.chopper import AudioChopperParser from owrx.parser import Parser
import re import re
from js8py import Js8 from js8py import Js8
from js8py.frames import Js8FrameHeartbeat, Js8FrameCompound from js8py.frames import Js8FrameHeartbeat, Js8FrameCompound
@ -8,7 +8,6 @@ from owrx.metrics import Metrics, CounterMetric
from owrx.config import Config from owrx.config import Config
from abc import ABCMeta, abstractmethod from abc import ABCMeta, abstractmethod
from owrx.reporting import ReportingEngine from owrx.reporting import ReportingEngine
from owrx.bands import Bandplan
from typing import List from typing import List
import logging import logging
@ -82,15 +81,13 @@ class Js8TurboProfile(Js8Profile):
return "C" return "C"
class Js8Parser(AudioChopperParser): class Js8Parser(Parser):
decoderRegex = re.compile(" ?<Decode(Started|Debug|Finished)>") decoderRegex = re.compile(" ?<Decode(Started|Debug|Finished)>")
def parse(self, profile: AudioChopperProfile, freq: int, raw_msg: bytes): def parse(self, raw):
try: try:
band = None profile, freq, raw_msg = raw
if freq is not None: self.setDialFrequency(freq)
band = Bandplan.getSharedInstance().findBand(freq)
msg = raw_msg.decode().rstrip() msg = raw_msg.decode().rstrip()
if Js8Parser.decoderRegex.match(msg): if Js8Parser.decoderRegex.match(msg):
return return
@ -98,48 +95,38 @@ class Js8Parser(AudioChopperParser):
return return
frame = Js8().parse_message(msg) frame = Js8().parse_message(msg)
self.handler.write_js8_message(frame, self.dial_freq)
self.pushDecode(band) self.pushDecode()
if (isinstance(frame, Js8FrameHeartbeat) or isinstance(frame, Js8FrameCompound)) and frame.grid: if (isinstance(frame, Js8FrameHeartbeat) or isinstance(frame, Js8FrameCompound)) and frame.grid:
Map.getSharedInstance().updateLocation( Map.getSharedInstance().updateLocation(
frame.source, LocatorLocation(frame.grid), "JS8", band frame.callsign, LocatorLocation(frame.grid), "JS8", self.band
) )
ReportingEngine.getSharedInstance().spot( ReportingEngine.getSharedInstance().spot(
{ {
"source": frame.source, "callsign": frame.callsign,
"mode": "JS8", "mode": "JS8",
"locator": frame.grid, "locator": frame.grid,
"freq": freq + frame.freq, "freq": self.dial_freq + frame.freq,
"db": frame.db, "db": frame.db,
"timestamp": frame.timestamp, "timestamp": frame.timestamp,
"msg": str(frame), "msg": str(frame),
} }
) )
out = {
"mode": "JS8",
"msg": str(frame),
"timestamp": frame.timestamp,
"db": frame.db,
"dt": frame.dt,
"freq": freq + frame.freq,
"thread_type": frame.thread_type,
"js8mode": frame.mode,
}
return out
except Exception: except Exception:
logger.exception("error while parsing js8 message") logger.exception("error while parsing js8 message")
def pushDecode(self, band): def pushDecode(self):
metrics = Metrics.getSharedInstance() metrics = Metrics.getSharedInstance()
bandName = "unknown" band = "unknown"
if band is not None: if self.band is not None:
bandName = band.getName() band = self.band.getName()
if band is None:
band = "unknown"
name = "js8call.decodes.{band}.JS8".format(band=bandName) name = "js8call.decodes.{band}.JS8".format(band=band)
metric = metrics.getMetric(name) metric = metrics.getMetric(name)
if metric is None: if metric is None:
metric = CounterMetric() metric = CounterMetric()

View File

@ -1,12 +1,17 @@
import socket
import time
import logging
import random import random
from owrx.config import Config from owrx.config import Config
from abc import ABC, abstractmethod from abc import ABC, abstractmethod
import socket
import logging
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
FEND = 0xC0
FESC = 0xDB
TFEND = 0xDC
TFESC = 0xDD
FEET_PER_METER = 3.28084 FEET_PER_METER = 3.28084
@ -16,7 +21,7 @@ class DirewolfConfigSubscriber(ABC):
pass pass
class DirewolfConfig: class DirewolfConfig(object):
config_keys = [ config_keys = [
"aprs_callsign", "aprs_callsign",
"aprs_igate_enabled", "aprs_igate_enabled",
@ -136,3 +141,51 @@ IGLOGIN {callsign} {password}
) )
return config return config
class KissClient(object):
def __init__(self, port):
delay = 0.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)
class KissDeframer(object):
def __init__(self):
self.escaped = False
self.buf = bytearray()
def parse(self, input):
frames = []
for b in input:
if b == FESC:
self.escaped = True
elif self.escaped:
if b == TFEND:
self.buf.append(FEND)
elif b == TFESC:
self.buf.append(FESC)
else:
logger.warning("invalid escape char: %s", str(input[0]))
self.escaped = False
elif input[0] == FEND:
# data frames start with 0x00
if len(self.buf) > 1 and self.buf[0] == 0x00:
frames += [self.buf[1:]]
self.buf = bytearray()
else:
self.buf.append(b)
return frames

View File

@ -5,11 +5,6 @@ class Locator(object):
lat = coordinates["lat"] lat = coordinates["lat"]
lon = coordinates["lon"] lon = coordinates["lon"]
if not -90 < lat < 90:
raise ValueError("invalid latitude: {}".format(lat))
if not -180 < lon < 180:
raise ValueError("invalid longitude: {}".format(lon))
lon = lon + 180 lon = lon + 180
lat = lat + 90 lat = lat + 90

View File

@ -1,52 +0,0 @@
import threading
import os
from logging import Logger, Handler, LogRecord, Formatter
class LogPipe(threading.Thread):
def __init__(self, level: int, logger: Logger, prefix: str = ""):
threading.Thread.__init__(self)
self.daemon = False
self.level = level
self.logger = logger
self.prefix = prefix
self.fdRead, self.fdWrite = os.pipe()
self.pipeReader = os.fdopen(self.fdRead)
self.start()
def fileno(self):
return self.fdWrite
def run(self):
for line in iter(self.pipeReader.readline, ''):
self.logger.log(self.level, "{}: {}".format(self.prefix, line.strip('\n')))
self.pipeReader.close()
def close(self):
os.close(self.fdWrite)
class HistoryHandler(Handler):
handlers = {}
@staticmethod
def getHandler(name: str):
if name not in HistoryHandler.handlers:
HistoryHandler.handlers[name] = HistoryHandler()
return HistoryHandler.handlers[name]
def __init__(self, maxRecords: int = 200):
super().__init__()
self.history = []
self.maxRecords = maxRecords
self.setFormatter(Formatter(fmt="%(asctime)s - %(name)s - %(levelname)s - %(message)s"))
def emit(self, record: LogRecord) -> None:
self.history.append(record)
# truncate
self.history = self.history[-self.maxRecords:]
def getFormattedHistory(self) -> str:
return "\n".join([self.format(r) for r in self.history])

View File

@ -61,13 +61,13 @@ class Map(object):
client.write_update( client.write_update(
[ [
{ {
"source": record["source"], "callsign": callsign,
"location": record["location"].__dict__(), "location": record["location"].__dict__(),
"lastseen": record["updated"].timestamp() * 1000, "lastseen": record["updated"].timestamp() * 1000,
"mode": record["mode"], "mode": record["mode"],
"band": record["band"].getName() if record["band"] is not None else None, "band": record["band"].getName() if record["band"] is not None else None,
} }
for record in self.positions.values() for (callsign, record) in self.positions.items()
] ]
) )
@ -77,20 +77,14 @@ class Map(object):
except ValueError: except ValueError:
pass pass
def _sourceToKey(self, source): def updateLocation(self, callsign, loc: Location, mode: str, band: Band = None):
if "ssid" in source:
return "{callsign}-{ssid}".format(**source)
return source["callsign"]
def updateLocation(self, source, loc: Location, mode: str, band: Band = None):
ts = datetime.now() ts = datetime.now()
key = self._sourceToKey(source)
with self.positionsLock: with self.positionsLock:
self.positions[key] = {"source": source, "location": loc, "updated": ts, "mode": mode, "band": band} self.positions[callsign] = {"location": loc, "updated": ts, "mode": mode, "band": band}
self.broadcast( self.broadcast(
[ [
{ {
"source": source, "callsign": callsign,
"location": loc.__dict__(), "location": loc.__dict__(),
"lastseen": ts.timestamp() * 1000, "lastseen": ts.timestamp() * 1000,
"mode": mode, "mode": mode,
@ -99,18 +93,17 @@ class Map(object):
] ]
) )
def touchLocation(self, source): def touchLocation(self, callsign):
# not implemented on the client side yet, so do not use! # not implemented on the client side yet, so do not use!
ts = datetime.now() ts = datetime.now()
key = self._sourceToKey(source)
with self.positionsLock: with self.positionsLock:
if key in self.positions: if callsign in self.positions:
self.positions[key]["updated"] = ts self.positions[callsign]["updated"] = ts
self.broadcast([{"source": source, "lastseen": ts.timestamp() * 1000}]) self.broadcast([{"callsign": callsign, "lastseen": ts.timestamp() * 1000}])
def removeLocation(self, key): def removeLocation(self, callsign):
with self.positionsLock: with self.positionsLock:
del self.positions[key] del self.positions[callsign]
# TODO broadcast removal to clients # TODO broadcast removal to clients
def removeOldPositions(self): def removeOldPositions(self):
@ -118,9 +111,9 @@ class Map(object):
retention = timedelta(seconds=pm["map_position_retention_time"]) retention = timedelta(seconds=pm["map_position_retention_time"])
cutoff = datetime.now() - retention cutoff = datetime.now() - retention
to_be_removed = [key for (key, pos) in self.positions.items() if pos["updated"] < cutoff] to_be_removed = [callsign for (callsign, pos) in self.positions.items() if pos["updated"] < cutoff]
for key in to_be_removed: for callsign in to_be_removed:
self.removeLocation(key) self.removeLocation(callsign)
def rebuildPositions(self): def rebuildPositions(self):
logger.debug("rebuilding map storage; size before: %i", sys.getsizeof(self.positions)) logger.debug("rebuilding map storage; size before: %i", sys.getsizeof(self.positions))

View File

@ -1,18 +1,13 @@
from owrx.config import Config
from urllib import request
import json import json
from datetime import datetime, timedelta
import logging import logging
import threading import threading
import pickle
import re
from abc import ABC, ABCMeta, abstractmethod
from datetime import datetime, timedelta
from urllib import request
from urllib.error import HTTPError
from csdr.module import PickleModule
from owrx.aprs import AprsParser, AprsLocation
from owrx.config import Config
from owrx.map import Map, LatLngLocation from owrx.map import Map, LatLngLocation
from owrx.bands import Bandplan from owrx.parser import Parser
from owrx.aprs import AprsParser, AprsLocation
from abc import ABC, abstractmethod
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -22,7 +17,7 @@ class Enricher(ABC):
self.parser = parser self.parser = parser
@abstractmethod @abstractmethod
def enrich(self, meta, callback): def enrich(self, meta):
pass pass
@ -63,15 +58,9 @@ class RadioIDEnricher(Enricher):
super().__init__(parser) super().__init__(parser)
self.mode = mode self.mode = mode
self.threads = {} self.threads = {}
self.callbacks = {}
def _fillCache(self, id): def _fillCache(self, id):
data = self._downloadRadioIdData(id) RadioIDCache.getSharedInstance().put(self.mode, id, self._downloadRadioIdData(id))
RadioIDCache.getSharedInstance().put(self.mode, id, data)
if id in self.callbacks:
while self.callbacks[id]:
self.callbacks[id].pop()(data)
del self.callbacks[id]
del self.threads[id] del self.threads[id]
def _downloadRadioIdData(self, id): def _downloadRadioIdData(self, id):
@ -88,12 +77,10 @@ class RadioIDEnricher(Enricher):
return item return item
except json.JSONDecodeError: except json.JSONDecodeError:
logger.warning("unable to parse radioid response JSON") logger.warning("unable to parse radioid response JSON")
except HTTPError as e:
logger.warning("radioid responded with error: %s", str(e))
return None return None
def enrich(self, meta, callback): def enrich(self, meta):
config_key = "digital_voice_{}_id_lookup".format(self.mode) config_key = "digital_voice_{}_id_lookup".format(self.mode)
if not Config.get()[config_key]: if not Config.get()[config_key]:
return meta return meta
@ -105,15 +92,6 @@ class RadioIDEnricher(Enricher):
if id not in self.threads: if id not in self.threads:
self.threads[id] = threading.Thread(target=self._fillCache, args=[id], daemon=True) self.threads[id] = threading.Thread(target=self._fillCache, args=[id], daemon=True)
self.threads[id].start() self.threads[id].start()
if id not in self.callbacks:
self.callbacks[id] = []
def onFinish(data):
if data is not None:
meta["additional"] = data
callback(meta)
self.callbacks[id].append(onFinish)
return meta return meta
data = cache.get(self.mode, id) data = cache.get(self.mode, id)
if data is not None: if data is not None:
@ -121,76 +99,34 @@ class RadioIDEnricher(Enricher):
return meta return meta
class DigihamEnricher(Enricher, metaclass=ABCMeta): class YsfMetaEnricher(Enricher):
def parseCoordinate(self, meta, mode): def enrich(self, meta):
for key in ["source", "up", "down", "target"]:
if key in meta:
meta[key] = meta[key].strip()
for key in ["lat", "lon"]: for key in ["lat", "lon"]:
if key in meta: if key in meta:
meta[key] = float(meta[key]) meta[key] = float(meta[key])
callsign = self.getCallsign(meta) if "source" in meta and "lat" in meta and "lon" in meta:
if callsign is not None and "lat" in meta and "lon" in meta:
loc = LatLngLocation(meta["lat"], meta["lon"]) loc = LatLngLocation(meta["lat"], meta["lon"])
Map.getSharedInstance().updateLocation({"callsign": callsign}, loc, mode, self.parser.getBand()) Map.getSharedInstance().updateLocation(meta["source"], loc, "YSF", self.parser.getBand())
return meta
@abstractmethod
def getCallsign(self, meta):
pass
class DmrEnricher(DigihamEnricher, RadioIDEnricher):
# callsign must be uppercase alphanumeric and at the beginning
# if there's anything after the callsign, it must be separated by a whitespace
talkerAliasRegex = re.compile("^([A-Z0-9]+)(\\s.*)?$")
def __init__(self, parser):
super().__init__("dmr", parser)
def getCallsign(self, meta):
# there's no explicit callsign data in dmr, so we can only rely on one of the following:
# a) a callsign provided by a radioid lookup
if "additional" in meta and "callsign" in meta["additional"]:
return meta["additional"]["callsign"]
# b) a callsign in the talker alias
if "talkeralias" in meta:
matches = DmrEnricher.talkerAliasRegex.match(meta["talkeralias"])
if matches:
return matches.group(1)
def enrich(self, meta, callback):
def asyncParse(meta):
self.parseCoordinate(meta, "DMR")
callback(meta)
meta = super().enrich(meta, asyncParse)
meta = self.parseCoordinate(meta, "DMR")
return meta return meta
class YsfMetaEnricher(DigihamEnricher): class DStarEnricher(Enricher):
def getCallsign(self, meta): def enrich(self, meta):
if "source" in meta: for key in ["lat", "lon"]:
return meta["source"] if key in meta:
meta[key] = float(meta[key])
def enrich(self, meta, callback): if "ourcall" in meta and "lat" in meta and "lon" in meta:
meta = self.parseCoordinate(meta, "YSF") loc = LatLngLocation(meta["lat"], meta["lon"])
return meta Map.getSharedInstance().updateLocation(meta["ourcall"], loc, "D-Star", self.parser.getBand())
class DStarEnricher(DigihamEnricher):
def getCallsign(self, meta):
if "ourcall" in meta:
return meta["ourcall"]
def enrich(self, meta, callback):
meta = self.parseCoordinate(meta, "D-Star")
meta = self.parseDprs(meta)
return meta
def parseDprs(self, meta):
if "dprs" in meta: if "dprs" in meta:
try: try:
# we can send the DPRS stuff through our APRS parser to extract the information # we can send the DPRS stuff through our APRS parser to extract the information
# TODO: only third-party parsing accepts this format right now # TODO: only third-party parsing accepts this format right now
parser = AprsParser() # TODO: we also need to pass a handler, which is not needed
parser = AprsParser(None)
dprsData = parser.parseThirdpartyAprsData(meta["dprs"]) dprsData = parser.parseThirdpartyAprsData(meta["dprs"])
if "data" in dprsData: if "data" in dprsData:
data = dprsData["data"] data = dprsData["data"]
@ -202,41 +138,29 @@ class DStarEnricher(DigihamEnricher):
if "ourcall" in meta: if "ourcall" in meta:
# send location info to map as well (it will show up with the correct symbol there!) # send location info to map as well (it will show up with the correct symbol there!)
loc = AprsLocation(data) loc = AprsLocation(data)
Map.getSharedInstance().updateLocation({"callsign": meta["ourcall"]}, loc, "DPRS", self.parser.getBand()) Map.getSharedInstance().updateLocation(meta["ourcall"], loc, "DPRS", self.parser.getBand())
except Exception: except Exception:
logger.exception("Error while parsing DPRS data") logger.exception("Error while parsing DPRS data")
return meta return meta
class MetaParser(PickleModule): class MetaParser(Parser):
def __init__(self): def __init__(self, handler):
super().__init__(handler)
self.enrichers = { self.enrichers = {
"DMR": DmrEnricher(self), "DMR": RadioIDEnricher("dmr", self),
"YSF": YsfMetaEnricher(self), "YSF": YsfMetaEnricher(self),
"DSTAR": DStarEnricher(self), "DSTAR": DStarEnricher(self),
"NXDN": RadioIDEnricher("nxdn", self), "NXDN": RadioIDEnricher("nxdn", self),
} }
self.currentMetaData = None
self.band = None
super().__init__()
def process(self, meta): def parse(self, meta):
self.currentMetaData = None fields = meta.split(";")
meta = {v[0]: ":".join(v[1:]) for v in map(lambda x: x.split(":"), fields) if v[0] != ""}
if "protocol" in meta: if "protocol" in meta:
protocol = meta["protocol"] protocol = meta["protocol"]
if protocol in self.enrichers: if protocol in self.enrichers:
self.currentMetaData = meta = self.enrichers[protocol].enrich(meta, self.receive) meta = self.enrichers[protocol].enrich(meta)
return meta self.handler.write_metadata(meta)
def receive(self, meta):
# we may have moved on in the meantime
if meta is not self.currentMetaData:
return
self.writer.write(pickle.dumps(meta))
def setDialFrequency(self, freq):
self.band = Bandplan.getSharedInstance().findBand(freq)
def getBand(self):
return self.band

View File

@ -10,8 +10,8 @@ class Bandpass(object):
self.high_cut = high_cut self.high_cut = high_cut
class Mode: class Mode(object):
def __init__(self, modulation: str, name: str, bandpass: Bandpass = None, requirements=None, service=False, squelch=True): def __init__(self, modulation, name, bandpass: Bandpass = None, requirements=None, service=False, squelch=True):
self.modulation = modulation self.modulation = modulation
self.name = name self.name = name
self.requirements = requirements if requirements is not None else [] self.requirements = requirements if requirements is not None else []
@ -44,16 +44,13 @@ class DigitalMode(Mode):
super().__init__(modulation, name, bandpass, requirements, service, squelch) super().__init__(modulation, name, bandpass, requirements, service, squelch)
self.underlying = underlying self.underlying = underlying
def get_underlying_mode(self):
return Modes.findByModulation(self.underlying[0])
def get_bandpass(self): def get_bandpass(self):
if self.bandpass is not None: if self.bandpass is not None:
return self.bandpass return self.bandpass
return self.get_underlying_mode().get_bandpass() return Modes.findByModulation(self.underlying[0]).get_bandpass()
def get_modulation(self): def get_modulation(self):
return self.get_underlying_mode().get_modulation() return Modes.findByModulation(self.underlying[0]).get_modulation()
class AudioChopperMode(DigitalMode, metaclass=ABCMeta): class AudioChopperMode(DigitalMode, metaclass=ABCMeta):
@ -120,7 +117,6 @@ class Modes(object):
WsjtMode("fst4", "FST4", requirements=["wsjt-x-2-3"]), WsjtMode("fst4", "FST4", requirements=["wsjt-x-2-3"]),
WsjtMode("fst4w", "FST4W", bandpass=Bandpass(1350, 1650), requirements=["wsjt-x-2-3"]), WsjtMode("fst4w", "FST4W", bandpass=Bandpass(1350, 1650), requirements=["wsjt-x-2-3"]),
WsjtMode("q65", "Q65", requirements=["wsjt-x-2-4"]), WsjtMode("q65", "Q65", requirements=["wsjt-x-2-4"]),
DigitalMode("msk144", "MSK144", requirements=["msk144"], underlying=["usb"], service=True),
Js8Mode("js8", "JS8Call"), Js8Mode("js8", "JS8Call"),
DigitalMode( DigitalMode(
"packet", "packet",

20
owrx/parser.py Normal file
View File

@ -0,0 +1,20 @@
from abc import ABC, abstractmethod
from owrx.bands import Bandplan
class Parser(ABC):
def __init__(self, handler):
self.handler = handler
self.dial_freq = None
self.band = None
@abstractmethod
def parse(self, raw):
pass
def setDialFrequency(self, freq):
self.dial_freq = freq
self.band = Bandplan.getSharedInstance().findBand(freq)
def getBand(self):
return self.band

View File

@ -1,37 +1,17 @@
from csdr.module import PickleModule from owrx.parser import Parser
from owrx.bands import Bandplan
from owrx.metrics import Metrics, CounterMetric
import logging import logging
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
class PocsagParser(PickleModule): class PocsagParser(Parser):
def __init__(self): def parse(self, raw):
self.band = None
super().__init__()
def process(self, meta):
try: try:
fields = raw.decode("ascii", "replace").rstrip("\n").split(";")
meta = {v[0]: "".join(v[1:]) for v in map(lambda x: x.split(":"), fields) if v[0] != ""}
if "address" in meta: if "address" in meta:
meta["address"] = int(meta["address"]) meta["address"] = int(meta["address"])
meta["mode"] = "Pocsag" self.handler.write_pocsag_data(meta)
self.pushDecode()
return meta
except Exception: except Exception:
logger.exception("Exception while parsing Pocsag message") logger.exception("Exception while parsing Pocsag message")
def setDialFrequency(self, freq: int) -> None:
self.band = Bandplan.getSharedInstance().findBand(freq)
def pushDecode(self):
band = "unknown"
if self.band is not None:
band = self.band.getName()
name = "digiham.decodes.{band}.pocsag".format(band=band)
metrics = Metrics.getSharedInstance()
metric = metrics.getMetric(name)
if metric is None:
metric = CounterMetric()
metrics.addMetric(name, metric)
metric.inc()

View File

@ -15,21 +15,10 @@ logger = logging.getLogger(__name__)
class PskReporter(Reporter): class PskReporter(Reporter):
"""
This class implements the reporting interface to send received signals to pskreporter.info.
It interfaces with pskreporter as documented here: https://pskreporter.info/pskdev.html
"""
interval = 300 interval = 300
def getSupportedModes(self): def getSupportedModes(self):
""" return ["FT8", "FT4", "JT9", "JT65", "FST4", "JS8", "Q65"]
Supports all valid MODE and SUBMODE values from the ADIF standard.
Current version at the time of the last change:
https://www.adif.org/314/ADIF_314.htm#Mode_Enumeration
"""
return ["FT8", "FT4", "JT9", "JT65", "FST4", "JS8", "Q65", "WSPR", "FST4W", "MSK144"]
def stop(self): def stop(self):
self.cancelTimer() self.cancelTimer()
@ -56,7 +45,7 @@ class PskReporter(Reporter):
self.timer.start() self.timer.start()
def spotEquals(self, s1, s2): def spotEquals(self, s1, s2):
keys = ["source", "timestamp", "locator", "mode", "msg"] keys = ["callsign", "timestamp", "locator", "mode", "msg"]
return reduce(and_, map(lambda key: s1[key] == s2[key], keys)) return reduce(and_, map(lambda key: s1[key] == s2[key], keys))
@ -105,34 +94,27 @@ class Uploader(object):
# filter out any erroneous encodes # filter out any erroneous encodes
encoded = [e for e in encoded if e is not None] encoded = [e for e in encoded if e is not None]
def chunks(block, max_size): def chunks(l, n):
size = 0 """Yield successive n-sized chunks from l."""
current = [] for i in range(0, len(l), n):
for r in block: yield l[i : i + n]
if size + len(r) > max_size:
yield current
current = []
size = 0
size += len(r)
current.append(r)
yield current
rHeader = self.getReceiverInformationHeader() rHeader = self.getReceiverInformationHeader()
rInfo = self.getReceiverInformation() rInfo = self.getReceiverInformation()
sHeader = self.getSenderInformationHeader() sHeader = self.getSenderInformationHeader()
packets = [] packets = []
# 1200 bytes of sender data should keep the packet size below MTU for most cases # 50 seems to be a safe bet
for chunk in chunks(encoded, 1200): for chunk in chunks(encoded, 50):
sInfo = self.getSenderInformation(chunk) sInfo = self.getSenderInformation(chunk)
length = 16 + len(rHeader) + len(sHeader) + len(rInfo) + len(sInfo) length = 16 + len(rHeader) + len(sHeader) + len(rInfo) + len(sInfo)
header = self.getHeader(length) header = self.getHeader(length)
packets.append(header + rHeader + sHeader + rInfo + sInfo) packets.append(header + rHeader + sHeader + rInfo + sInfo)
self.sequence = (self.sequence + len(chunk)) % (1 << 32)
return packets return packets
def getHeader(self, length): def getHeader(self, length):
self.sequence += 1
return bytes( return bytes(
# protocol version # protocol version
[0x00, 0x0A] [0x00, 0x0A]
@ -148,8 +130,8 @@ class Uploader(object):
def encodeSpot(self, spot): def encodeSpot(self, spot):
try: try:
return bytes( return bytes(
self.encodeString(spot["source"]["callsign"]) self.encodeString(spot["callsign"])
+ list(int(spot["freq"]).to_bytes(5, "big")) + list(int(spot["freq"]).to_bytes(4, "big"))
+ list(int(spot["db"]).to_bytes(1, "big", signed=True)) + list(int(spot["db"]).to_bytes(1, "big", signed=True))
+ self.encodeString(spot["mode"]) + self.encodeString(spot["mode"])
+ self.encodeString(spot["locator"]) + self.encodeString(spot["locator"])
@ -215,7 +197,7 @@ class Uploader(object):
# senderCallsign # senderCallsign
+ [0x80, 0x01, 0xFF, 0xFF, 0x00, 0x00, 0x76, 0x8F] + [0x80, 0x01, 0xFF, 0xFF, 0x00, 0x00, 0x76, 0x8F]
# frequency # frequency
+ [0x80, 0x05, 0x00, 0x05, 0x00, 0x00, 0x76, 0x8F] + [0x80, 0x05, 0x00, 0x04, 0x00, 0x00, 0x76, 0x8F]
# sNR # sNR
+ [0x80, 0x06, 0x00, 0x01, 0x00, 0x00, 0x76, 0x8F] + [0x80, 0x06, 0x00, 0x01, 0x00, 0x00, 0x76, 0x8F]
# mode # mode

View File

@ -56,7 +56,7 @@ class Worker(threading.Thread):
# FST4W does not have drift # FST4W does not have drift
"drift": spot["drift"] if "drift" in spot else 0, "drift": spot["drift"] if "drift" in spot else 0,
"tqrg": spot["freq"] / 1e6, "tqrg": spot["freq"] / 1e6,
"tcall": spot["source"]["callsign"], "tcall": spot["callsign"],
"tgrid": spot["locator"], "tgrid": spot["locator"],
"dbm": spot["dbm"], "dbm": spot["dbm"],
"version": openwebrx_version, "version": openwebrx_version,

View File

@ -259,8 +259,3 @@ class SdrService(object):
if SdrService.availableProfiles is None: if SdrService.availableProfiles is None:
SdrService.availableProfiles = AvailableProfiles(SdrService.getActiveSources()) SdrService.availableProfiles = AvailableProfiles(SdrService.getActiveSources())
return SdrService.availableProfiles return SdrService.availableProfiles
@staticmethod
def stopAllSources():
for source in SdrService.getAllSources().values():
source.stop()

View File

@ -2,21 +2,65 @@ import threading
from owrx.source import SdrSourceEventClient, SdrSourceState, SdrClientClass from owrx.source import SdrSourceEventClient, SdrSourceState, SdrClientClass
from owrx.sdr import SdrService from owrx.sdr import SdrService
from owrx.bands import Bandplan from owrx.bands import Bandplan
from csdr.output import Output
from csdr import Dsp
from owrx.wsjt import WsjtParser
from owrx.aprs import AprsParser
from owrx.js8 import Js8Parser
from owrx.config.core import CoreConfig
from owrx.config import Config from owrx.config import Config
from owrx.source.resampler import Resampler from owrx.source.resampler import Resampler
from owrx.property import PropertyLayer, PropertyDeleted from owrx.property import PropertyLayer, PropertyDeleted
from js8py import Js8Frame
from abc import ABCMeta, abstractmethod
from owrx.service.schedule import ServiceScheduler from owrx.service.schedule import ServiceScheduler
from owrx.service.chain import ServiceDemodulatorChain from owrx.modes import Modes
from owrx.modes import Modes, DigitalMode
from typing import Union, Optional
from csdr.chain.demodulator import BaseDemodulatorChain, ServiceDemodulator, DialFrequencyReceiver
from pycsdr.modules import Buffer
import logging import logging
logger = logging.getLogger(__name__) 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(SdrSourceEventClient): class ServiceHandler(SdrSourceEventClient):
def __init__(self, source): def __init__(self, source):
self.lock = threading.RLock() self.lock = threading.RLock()
@ -122,15 +166,6 @@ class ServiceHandler(SdrSourceEventClient):
self.startupTimer.start() self.startupTimer.start()
def updateServices(self): def updateServices(self):
def addService(dial, source):
mode = dial["mode"]
frequency = dial["frequency"]
try:
service = self.setupService(mode, frequency, source)
self.services.append(service)
except Exception:
logger.exception("Error setting up service %s on frequency %d", mode, frequency)
with self.lock: with self.lock:
logger.debug("re-scheduling services due to sdr changes") logger.debug("re-scheduling services due to sdr changes")
self.stopServices() self.stopServices()
@ -155,25 +190,28 @@ class ServiceHandler(SdrSourceEventClient):
groups = self.optimizeResampling(dials, sr) groups = self.optimizeResampling(dials, sr)
if groups is None: if groups is None:
for dial in dials: for dial in dials:
addService(dial, self.source) self.services.append(self.setupService(dial["mode"], dial["frequency"], self.source))
else: else:
for group in groups: for group in groups:
if len(group) > 1: if len(group) > 1:
cf = self.get_center_frequency(group) cf = self.get_center_frequency(group)
bw = self.get_bandwidth(group) bw = self.get_bandwidth(group)
logger.debug("setting up resampler on center frequency: {0}, bandwidth: {1}".format(cf, bw)) logger.debug("group center frequency: {0}, bandwidth: {1}".format(cf, bw))
resampler_props = PropertyLayer(center_freq=cf, samp_rate=bw) resampler_props = PropertyLayer()
resampler_props["center_freq"] = cf
resampler_props["samp_rate"] = bw
resampler = Resampler(resampler_props, self.source) resampler = Resampler(resampler_props, self.source)
resampler.start()
for dial in group: for dial in group:
addService(dial, resampler) 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 # resampler goes in after the services since it must not be shutdown as long as the services are
# still running # still running
self.services.append(resampler) self.services.append(resampler)
else: else:
dial = group[0] dial = group[0]
addService(dial, self.source) self.services.append(self.setupService(dial["mode"], dial["frequency"], self.source))
def get_min_max(self, group): def get_min_max(self, group):
frequencies = sorted(group, key=lambda f: f["frequency"]) frequencies = sorted(group, key=lambda f: f["frequency"])
@ -249,61 +287,43 @@ class ServiceHandler(SdrSourceEventClient):
def setupService(self, mode, frequency, source): def setupService(self, mode, frequency, source):
logger.debug("setting up service {0} on frequency {1}".format(mode, frequency)) logger.debug("setting up service {0} on frequency {1}".format(mode, frequency))
# TODO selecting outputs will need some more intelligence here
modeObject = Modes.findByModulation(mode) if mode == "packet":
if not isinstance(modeObject, DigitalMode): output = AprsServiceOutput(frequency)
logger.warning("mode is not a digimode: %s", mode) elif mode == "js8":
return None output = Js8ServiceOutput(frequency)
else:
demod = self._getDemodulator(modeObject.get_modulation()) output = WsjtServiceOutput(frequency)
secondaryDemod = self._getSecondaryDemodulator(modeObject.modulation) d = Dsp(output)
d.nc_port = source.getPort()
center_freq = source.getProps()["center_freq"] center_freq = source.getProps()["center_freq"]
sampleRate = source.getProps()["samp_rate"] d.set_offset_freq(frequency - center_freq)
bandpass = modeObject.get_bandpass() d.set_center_freq(center_freq)
if isinstance(secondaryDemod, DialFrequencyReceiver): modeObject = Modes.findByModulation(mode)
secondaryDemod.setDialFrequency(frequency) d.set_demodulator(modeObject.get_modulation())
d.set_bandpass(modeObject.get_bandpass())
d.set_secondary_demodulator(mode)
d.set_audio_compression("none")
d.set_samp_rate(source.getProps()["samp_rate"])
d.set_temporary_directory(CoreConfig().get_temporary_directory())
d.set_service()
d.start()
return d
chain = ServiceDemodulatorChain(demod, secondaryDemod, sampleRate, frequency - center_freq)
chain.setBandPass(bandpass.low_cut, bandpass.high_cut)
chain.setReader(source.getBuffer().getReader())
# dummy buffer, we don't use the output right now class WsjtHandler(object):
buffer = Buffer(chain.getOutputFormat()) def write_wsjt_message(self, msg):
chain.setWriter(buffer) pass
return chain
# TODO move this elsewhere
def _getDemodulator(self, demod: Union[str, BaseDemodulatorChain]):
if isinstance(demod, BaseDemodulatorChain):
return demod
# TODO: move this to Modes
if demod == "nfm":
from csdr.chain.analog import NFm
return NFm(48000)
elif demod in ["usb", "lsb", "cw"]:
from csdr.chain.analog import Ssb
return Ssb()
# TODO move this elsewhere class AprsHandler(object):
def _getSecondaryDemodulator(self, mod) -> Optional[ServiceDemodulator]: def write_aprs_data(self, data):
if isinstance(mod, ServiceDemodulatorChain): pass
return mod
if mod in ["ft8", "wspr", "jt65", "jt9", "ft4", "fst4", "fst4w", "q65"]:
from csdr.chain.digimodes import AudioChopperDemodulator
from owrx.wsjt import WsjtParser
return AudioChopperDemodulator(mod, WsjtParser())
elif mod == "msk144":
from csdr.chain.digimodes import Msk144Demodulator
return Msk144Demodulator()
elif mod == "js8":
from csdr.chain.digimodes import AudioChopperDemodulator
from owrx.js8 import Js8Parser
return AudioChopperDemodulator(mod, Js8Parser())
elif mod == "packet":
from csdr.chain.digimodes import PacketDemodulator
return PacketDemodulator(service=True)
raise ValueError("unsupported service modulation: {}".format(mod))
class Js8Handler(object):
def write_js8_message(self, frame: Js8Frame, freq: int):
pass
class Services(object): class Services(object):

View File

@ -1,23 +0,0 @@
from csdr.chain import Chain
from csdr.chain.selector import Selector
from csdr.chain.demodulator import BaseDemodulatorChain, ServiceDemodulator
from pycsdr.types import Format
class ServiceDemodulatorChain(Chain):
def __init__(self, demod: BaseDemodulatorChain, secondaryDemod: ServiceDemodulator, sampleRate: int, frequencyOffset: int):
self.selector = Selector(sampleRate, secondaryDemod.getFixedAudioRate(), withSquelch=False)
self.selector.setFrequencyOffset(frequencyOffset)
workers = [self.selector]
# primary demodulator is only necessary if the secondary does not accept IQ input
if secondaryDemod.getInputFormat() is not Format.COMPLEX_FLOAT:
workers += [demod]
workers += [secondaryDemod]
super().__init__(workers)
def setBandPass(self, lowCut, highCut):
self.selector.setBandpass(lowCut, highCut)

Some files were not shown because too many files have changed in this diff Show More