48 Commits

Author SHA1 Message Date
b31581dc80 implement active list transformation 2022-12-14 01:22:48 +01:00
f73c62c5df change the list notification interface 2022-12-14 01:07:20 +01:00
e7e5af9a53 add a test for listener removal 2022-12-12 17:42:16 +01:00
c7d2a5502c add first shot at active list implementation 2022-12-12 17:39:07 +01:00
59759fa79d move tests to match folder structure 2022-12-12 16:06:15 +01:00
d79a1396a6 change name for sdrplay device in default config 2022-12-11 23:54:35 +01:00
1e6e7528b5 add a pseudo-input to display the sdr device type 2022-12-11 23:48:56 +01:00
f6326f8631 use a more generic include instead of manually updating the list 2022-12-11 22:41:10 +01:00
c61986dbcc add log module to the list 2022-12-11 21:06:22 +01:00
bbc9d9e7a8 log names instead of ids for improved transparency 2022-12-11 20:41:35 +01:00
a309af40e9 update dependencies 2022-12-11 20:29:45 +01:00
f3a3b9243c update connectors 2022-12-11 00:58:58 +01:00
edc9009359 fix svg include path 2022-12-10 19:50:47 +01:00
13e323cdd2 show sdr device log messages in the web configuration 2022-12-10 19:50:26 +01:00
ab40a2934f remove old config (no longer used) 2022-12-10 19:44:35 +01:00
322b6a0d52 set loglevels sooner 2022-11-30 18:53:09 +01:00
bba900d8f8 fix config default 2022-11-30 18:51:01 +01:00
64f0510da0 use a dropdown for callsign database setting; add aprs.fi 2022-11-30 16:54:22 +01:00
4050bd7f96 update version in feature check 2022-11-30 01:16:12 +01:00
35abd711ca update dependencies 2022-11-30 01:15:28 +01:00
258e41669e structured callsign data 2022-11-30 01:07:16 +01:00
975f5ffdf0 make loglevel adjustable in config or on CLI 2022-11-29 20:23:39 +01:00
90ed47a115 move pocsag demodulator to digiham to fix import problems 2022-11-10 22:43:08 +01:00
271bd723bc Merge pull request #318 from luarvique/map_distance
Adding distance display to the info windows.
2022-10-04 16:32:09 +02:00
8a7e91be38 update all docker dependencies 2022-09-30 18:51:47 +02:00
9416db5f42 Merge branch 'master' into develop 2022-09-30 18:49:45 +02:00
0127e32ea1 Removing dash from APRS callsigns and showing distance to them. 2022-09-26 15:24:59 -04:00
e20d94e241 update dependencies for docker 2022-09-20 18:51:09 +02:00
6c01d48493 update version 2022-09-20 18:06:03 +02:00
94269e211e fix changelog timestamp 2022-09-20 18:01:43 +02:00
811d95c7bc fifisdr fixes 2022-09-20 18:01:08 +02:00
c07e33d19d update csdr and pycsdr dependencies in docker 2022-09-19 19:07:23 +02:00
c150eca75c fifisdr fixes 2022-09-19 18:46:11 +02:00
7a61f991ad Removed parentheses, added a space before "km". 2022-09-16 00:02:17 -04:00
4423c7f13a Adding distance display to the info windows. 2022-09-15 19:34:39 -04:00
5cd0847362 Merge pull request #306 from luarvique/filter_width
Added filter boundaries display.
2022-07-31 22:44:07 +02:00
1635cbfa42 Merge pull request #307 from luarvique/callsign_lookup
Added an option to add callsign database URL for lookups on a map.
2022-07-31 22:35:19 +02:00
e92b6d657b Addressing comments from jketterl. 2022-07-31 16:10:33 -04:00
d5f7ce9508 Merge pull request #309 from luarvique/waterfall_colors
Now calculating waterfall colors based on what is on the screen (with…
2022-07-31 22:08:01 +02:00
bfd4d5657c Merge pull request #308 from luarvique/zoom_reset
Now resetting zoom when changing to a different profile.
2022-07-31 21:59:15 +02:00
bb625a5f9f Added filter boundaries display. 2022-07-31 15:27:41 -04:00
2ccdc90cc5 Added an option to add callsign database URL for lookups on a map. 2022-07-31 15:25:52 -04:00
ff43555411 Now resetting zoom when changing to a different profile. 2022-07-31 15:16:52 -04:00
339864a572 Now calculating waterfall colors based on what is on the screen (with zoom). 2022-07-31 15:15:38 -04:00
6192978f2f Merge branch 'master' into develop 2022-07-10 03:18:54 +02:00
66d4d88156 update hpsdrconnector to 0.6.1 2022-07-09 18:34:28 +02:00
c87daaabbe fix scroll events on the frequency bar 2022-06-19 22:40:47 +02:00
6f0a209a38 fix deprecation warning 2022-06-18 20:42:11 +02:00
72 changed files with 727 additions and 661 deletions

View File

@ -1,4 +1,9 @@
**unreleased** **unreleased**
- SDR device log messages are now available in the web configuration to simplify troubleshooting
**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** **1.2.0**
- Major rewrite of all demodulation components to make use of the new csdr/pycsdr and digiham/pydigiham demodulator - Major rewrite of all demodulation components to make use of the new csdr/pycsdr and digiham/pydigiham demodulator

View File

@ -1,386 +0,0 @@
# -*- 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

@ -1,5 +1,5 @@
from csdr.chain import Chain from csdr.chain import Chain
from pycsdr.modules import AudioResampler, Convert, AdpcmEncoder, OpusEncoder, Limit from pycsdr.modules import AudioResampler, Convert, AdpcmEncoder, Limit
from pycsdr.types import Format from pycsdr.types import Format
@ -27,12 +27,10 @@ class ClientAudioChain(Chain):
workers += [converter] workers += [converter]
if compression == "adpcm": if compression == "adpcm":
workers += [AdpcmEncoder(sync=True)] workers += [AdpcmEncoder(sync=True)]
elif compression == "opus":
workers += [OpusEncoder()]
super().__init__(workers) super().__init__(workers)
def _buildConverter(self): def _buildConverter(self):
return Converter(self.format, self.inputRate, 12000) return Converter(self.format, self.inputRate, self.clientRate)
def _updateConverter(self): def _updateConverter(self):
converter = self._buildConverter() converter = self._buildConverter()
@ -65,18 +63,10 @@ class ClientAudioChain(Chain):
self._updateConverter() self._updateConverter()
def setAudioCompression(self, compression: str) -> None: def setAudioCompression(self, compression: str) -> None:
index = self.indexOf(lambda x: isinstance(x, AdpcmEncoder) or isinstance(x, OpusEncoder)) index = self.indexOf(lambda x: isinstance(x, AdpcmEncoder))
newEncoder = None
if compression == "adpcm": if compression == "adpcm":
newEncoder = AdpcmEncoder(sync=True)
elif compression == "opus":
newEncoder = OpusEncoder()
if newEncoder:
if index < 0: if index < 0:
self.append(newEncoder) self.append(AdpcmEncoder(sync=True))
else:
self.replace(index, newEncoder)
else: else:
if index >= 0: if index >= 0:
self.remove(index) self.remove(index)

View File

@ -1,9 +1,10 @@
from csdr.chain.demodulator import BaseDemodulatorChain, FixedAudioRateChain, FixedIfSampleRateChain, DialFrequencyReceiver, MetaProvider, SlotFilterChain, DemodulatorError from csdr.chain.demodulator import BaseDemodulatorChain, FixedAudioRateChain, FixedIfSampleRateChain, DialFrequencyReceiver, MetaProvider, SlotFilterChain, DemodulatorError, ServiceDemodulator
from pycsdr.modules import FmDemod, Agc, Writer, Buffer from pycsdr.modules import FmDemod, Agc, Writer, Buffer
from pycsdr.types import Format from pycsdr.types import Format
from digiham.modules import DstarDecoder, DcBlock, FskDemodulator, GfskDemodulator, DigitalVoiceFilter, MbeSynthesizer, NarrowRrcFilter, NxdnDecoder, DmrDecoder, WideRrcFilter, YsfDecoder from digiham.modules import DstarDecoder, DcBlock, FskDemodulator, GfskDemodulator, DigitalVoiceFilter, MbeSynthesizer, NarrowRrcFilter, NxdnDecoder, DmrDecoder, WideRrcFilter, YsfDecoder, PocsagDecoder
from digiham.ambe import Modes, ServerError from digiham.ambe import Modes, ServerError
from owrx.meta import MetaParser from owrx.meta import MetaParser
from owrx.pocsag import PocsagParser
class DigihamChain(BaseDemodulatorChain, FixedIfSampleRateChain, FixedAudioRateChain, DialFrequencyReceiver, MetaProvider): class DigihamChain(BaseDemodulatorChain, FixedIfSampleRateChain, FixedAudioRateChain, DialFrequencyReceiver, MetaProvider):
@ -109,3 +110,24 @@ class Ysf(DigihamChain):
filter=WideRrcFilter(), filter=WideRrcFilter(),
codecserver=codecserver 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

@ -5,8 +5,6 @@ from owrx.aprs import Ax25Parser, AprsParser
from pycsdr.modules import Convert, FmDemod, Agc, TimingRecovery, DBPskDecoder, VaricodeDecoder from pycsdr.modules import Convert, FmDemod, Agc, TimingRecovery, DBPskDecoder, VaricodeDecoder
from pycsdr.types import Format from pycsdr.types import Format
from owrx.aprs.module import DirewolfModule from owrx.aprs.module import DirewolfModule
from digiham.modules import FskDemodulator, PocsagDecoder
from owrx.pocsag import PocsagParser
class AudioChopperDemodulator(ServiceDemodulator, DialFrequencyReceiver): class AudioChopperDemodulator(ServiceDemodulator, DialFrequencyReceiver):
@ -45,27 +43,6 @@ class PacketDemodulator(ServiceDemodulator, DialFrequencyReceiver):
self.parser.setDialFrequency(frequency) self.parser.setDialFrequency(frequency)
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)
class PskDemodulator(SecondaryDemodulator, SecondarySelectorChain): class PskDemodulator(SecondaryDemodulator, SecondarySelectorChain):
def __init__(self, baudRate: float): def __init__(self, baudRate: float):
self.baudRate = baudRate self.baudRate = baudRate

11
debian/changelog vendored
View File

@ -1,6 +1,15 @@
openwebrx (1.3.0) UNRELEASED; urgency=low openwebrx (1.3.0) UNRELEASED; urgency=low
* SDR device log messages are now available in the web configuration to
simplify troubleshooting
-- Jakob Ketterl <jakob.ketterl@gmx.de> Thu, 16 Jun 2022 21:47:00 +0000 -- Jakob Ketterl <jakob.ketterl@gmx.de> Fri, 30 Sep 2022 16:47: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 openwebrx (1.2.0) bullseye jammy; urgency=low

2
debian/control vendored
View File

@ -11,6 +11,6 @@ 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, owrx-connector (>= 0.5), soapysdr-tools, python3-csdr (>= 0.18), ${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.1), nmux (>= 0.18), codecserver (>= 0.1) 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)
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

@ -24,7 +24,8 @@ 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
cmakebuild owrx_connector 0.6.0 # latest develop as of 2022-12-11 (std::endl implicit flushing)
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

@ -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.0 git checkout v0.6.1
/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,7 +25,8 @@ 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
cmakebuild runds_connector 0.2.1 # latest develop as of 2022-12-11 (std::endl implicit flushing)
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

@ -25,17 +25,18 @@ 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
git checkout 0.1.2 # latest develop as of 2022-11-30 (structured callsign data)
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.0 cmakebuild csdr 0.18.1
git clone https://github.com/jketterl/pycsdr.git git clone https://github.com/jketterl/pycsdr.git
cd pycsdr cd pycsdr
git checkout 0.18.0 git checkout 0.18.1
./setup.py install install_headers ./setup.py install install_headers
cd .. cd ..
rm -rf pycsdr rm -rf pycsdr
@ -46,11 +47,11 @@ cp codecserver/conf/codecserver.conf /usr/local/etc/codecserver
cmakebuild codecserver 0.2.0 cmakebuild codecserver 0.2.0
git clone https://github.com/jketterl/digiham.git git clone https://github.com/jketterl/digiham.git
cmakebuild digiham 0.6.0 cmakebuild digiham 0.6.1
git clone https://github.com/jketterl/pydigiham.git git clone https://github.com/jketterl/pydigiham.git
cd pydigiham cd pydigiham
git checkout 0.6.0 git checkout 0.6.1
./setup.py install ./setup.py install
cd .. cd ..
rm -rf pydigiham rm -rf pydigiham

View File

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

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="static/gfx/svg-defs.svg#rx-details-arrow-down"></use></svg> <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="up" viewBox="0 0 43 12"><use xlink:href="static/gfx/svg-defs.svg#rx-details-arrow-up"></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>
</a> </a>
</div> </div>

View File

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

View File

@ -21,15 +21,7 @@ function AudioEngine(maxBufferLength, audioReporter) {
me._start(); me._start();
} }
var onAudio = function(audioData) { this.audioCodec = new ImaAdpcmCodec();
me.playbackAudio(audioData);
}
this.audioCodecs = {
"adpcm": new ImaAdpcmCodec(onAudio),
"opus": new OpusCodec(onAudio)
};
this.compression = 'none'; this.compression = 'none';
this.setupResampling(); this.setupResampling();
@ -287,26 +279,24 @@ AudioEngine.prototype.getSampleRate = function() {
AudioEngine.prototype.processAudio = function(data, resampler) { AudioEngine.prototype.processAudio = function(data, resampler) {
if (!this.audioNode) return; if (!this.audioNode) return;
this.audioBytes.add(data.byteLength); this.audioBytes.add(data.byteLength);
var buffer;
if (this.compression !== "none") { if (this.compression === "adpcm") {
this.audioCodecs[this.compression].decodeAsync(new Uint8Array(data)); //resampling & ADPCM
buffer = this.audioCodec.decodeWithSync(new Uint8Array(data));
} else { } else {
this.playbackAudio(new Int16Array(data)); buffer = new Int16Array(data);
} }
} buffer = resampler.process(buffer);
AudioEngine.prototype.playbackAudio = function(audioData) {
//var buffer = this.resampler.process(audioData);
if (this.audioNode.port) { if (this.audioNode.port) {
// AudioWorklets supported // AudioWorklets supported
this.audioNode.port.postMessage(audioData); this.audioNode.port.postMessage(buffer);
} else { } else {
// silently drop excess samples // silently drop excess samples
if (this.getBuffersize() + buffer.length <= this.maxBufferSize) { if (this.getBuffersize() + buffer.length <= this.maxBufferSize) {
this.audioBuffers.push(buffer); this.audioBuffers.push(buffer);
} }
} }
}; }
AudioEngine.prototype.pushAudio = function(data) { AudioEngine.prototype.pushAudio = function(data) {
this.processAudio(data, this.resampler); this.processAudio(data, this.resampler);
@ -330,9 +320,8 @@ AudioEngine.prototype.getBuffersize = function() {
return this.audioBuffers.map(function(b){ return b.length; }).reduce(function(a, b){ return a + b; }, 0); return this.audioBuffers.map(function(b){ return b.length; }).reduce(function(a, b){ return a + b; }, 0);
}; };
function ImaAdpcmCodec(callback) { function ImaAdpcmCodec() {
this.reset(); this.reset();
this.callback = callback;
} }
ImaAdpcmCodec.prototype.reset = function() { ImaAdpcmCodec.prototype.reset = function() {
@ -368,7 +357,7 @@ ImaAdpcmCodec.prototype.decode = function(data) {
return output; return output;
}; };
ImaAdpcmCodec.prototype.decodeAsync = function(data, callback) { ImaAdpcmCodec.prototype.decodeWithSync = function(data) {
var output = new Int16Array(data.length * 2); var output = new Int16Array(data.length * 2);
var index = this.skip; var index = this.skip;
var oi = 0; var oi = 0;
@ -402,7 +391,7 @@ ImaAdpcmCodec.prototype.decodeAsync = function(data, callback) {
} }
} }
this.skip = index - data.length; this.skip = index - data.length;
this.callback(output.slice(0, oi)); return output.slice(0, oi);
}; };
ImaAdpcmCodec.prototype.decodeNibble = function(nibble) { ImaAdpcmCodec.prototype.decodeNibble = function(nibble) {
@ -423,39 +412,6 @@ ImaAdpcmCodec.prototype.decodeNibble = function(nibble) {
return this.predictor; return this.predictor;
}; };
function OpusCodec(callback) {
this.callback = callback;
this.resetDecoder();
}
OpusCodec.prototype.resetDecoder = function() {
var me = this;
me.decoder = new AudioDecoder({
output: function(audioData) {
var buffer = new Float32Array(audioData.numberOfFrames * audioData.numberOfChannels);
audioData.copyTo(buffer, {planeIndex: 0});
me.callback(buffer);
},
error: function(e) {
console.error(e);
me.resetDecoder();
}
});
me.decoder.configure({
codec: "opus",
sampleRate: 12000,
numberOfChannels: 1
});
}
OpusCodec.prototype.decodeAsync = function(data) {
this.decoder.decode(new EncodedAudioChunk({
type: "key",
data: data,
timestamp: 0
}));
};
function Interpolator(factor) { function Interpolator(factor) {
this.factor = factor; this.factor = factor;
this.lowpass = new Lowpass(factor) this.lowpass = new Lowpass(factor)

View File

@ -81,6 +81,12 @@ 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

@ -157,13 +157,17 @@ 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;
if (msg.type) { var callsign;
if (msg.type === 'item') { if ('object' in source) {
source = msg.item; callsign = source.object;
} } else if ('item' in source) {
if (msg.type === 'object') { callsign = source.item;
source = msg.object; } else {
callsign = source.callsign;
if ('ssid' in source) {
callsign += '-' + source.ssid;
} }
} }
@ -202,7 +206,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?callsign=' + encodeURIComponent(source) + '" target="openwebrx-map">' + overlay + '</a>'; link = '<a ' + attrs + ' href="map?' + new URLSearchParams(source).toString() + '" target="openwebrx-map">' + overlay + '</a>';
} else { } else {
link = '<div ' + attrs + '>' + overlay + '</div>' link = '<div ' + attrs + '>' + overlay + '</div>'
} }
@ -210,7 +214,7 @@ PacketMessagePanel.prototype.pushMessage = function(msg) {
$b.append($( $b.append($(
'<tr>' + '<tr>' +
'<td>' + timestamp + '</td>' + '<td>' + timestamp + '</td>' +
'<td class="callsign">' + source + '</td>' + '<td class="callsign">' + callsign + '</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>'

View File

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

View File

@ -1,17 +1,12 @@
$(function(){ $(function(){
var query = window.location.search.replace(/^\?/, '').split('&').map(function(v){ var query = new URLSearchParams(window.location.search);
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.callsign) expectedCallsign = decodeURIComponent(query.callsign); if (query.has('callsign')) {
expectedCallsign = Object.fromEntries(query.entries());
}
var expectedLocator; var expectedLocator;
if (query.locator) expectedLocator = query.locator; if (query.has('locator')) expectedLocator = query.get('locator');
var protocol = window.location.protocol.match(/https/) ? 'wss' : 'ws'; var protocol = window.location.protocol.match(/https/) ? 'wss' : 'ws';
@ -37,6 +32,7 @@ $(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');
@ -101,6 +97,11 @@ $(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) {
@ -109,6 +110,7 @@ $(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':
@ -122,33 +124,33 @@ $(function(){
aprsOptions.course = update.location.course; aprsOptions.course = update.location.course;
aprsOptions.speed = update.location.speed; aprsOptions.speed = update.location.speed;
} }
if (markers[update.callsign]) { if (markers[key]) {
marker = markers[update.callsign]; marker = markers[key];
} else { } else {
marker = new markerClass(); marker = new markerClass();
marker.addListener('click', function(){ marker.addListener('click', function(){
showMarkerInfoWindow(update.callsign, pos); showMarkerInfoWindow(update.source, pos);
}); });
markers[update.callsign] = marker; markers[key] = marker;
} }
marker.setOptions($.extend({ marker.setOptions($.extend({
position: pos, position: pos,
map: map, map: map,
title: update.callsign title: sourceToString(update.source)
}, 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 && expectedCallsign == update.callsign) { if (expectedCallsign && shallowEquals(expectedCallsign, update.source)) {
map.panTo(pos); map.panTo(pos);
showMarkerInfoWindow(update.callsign, pos); showMarkerInfoWindow(update.source, pos);
expectedCallsign = false; expectedCallsign = false;
} }
if (infowindow && infowindow.callsign && infowindow.callsign == update.callsign) { if (infowindow && infowindow.source && shallowEquals(infowindow.source, update.source)) {
showMarkerInfoWindow(infowindow.callsign, pos); showMarkerInfoWindow(infowindow.source, pos);
} }
break; break;
case 'locator': case 'locator':
@ -159,15 +161,16 @@ $(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[update.callsign]) { if (rectangles[key]) {
rectangle = rectangles[update.callsign]; rectangle = rectangles[key];
} 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[update.callsign] = rectangle; rectangles[key] = 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;
@ -187,13 +190,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;
@ -202,7 +205,7 @@ $(function(){
}; };
var clearMap = function(){ var clearMap = function(){
var reset = function(callsign, item) { item.setMap(); }; var reset = function(_, item) { item.setMap(); };
$.each(markers, reset); $.each(markers, reset);
$.each(rectangles, reset); $.each(rectangles, reset);
receiverMarker.setMap(); receiverMarker.setMap();
@ -286,6 +289,9 @@ $(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);
@ -332,32 +338,77 @@ $(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.callsign; delete infowindow.source;
}); });
} }
delete infowindow.locator; delete infowindow.locator;
delete infowindow.callsign; delete infowindow.source;
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 = $.map(rectangles, function(r, callsign) { var inLocator = Object.values(rectangles).filter(rectangleFilter).filter(function(d) {
return {callsign: callsign, locator: r.locator, lastseen: r.lastseen, mode: r.mode, band: r.band} return d.locator === locator;
}).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 + '</h3>' + '<h3>Locator: ' + locator + distance + '</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 = i.callsign + ' (' + timestring + ' using ' + i.mode; var message = linkifySource(i.source) + ' (' + 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>'
@ -368,22 +419,26 @@ $(function(){
infowindow.open(map); infowindow.open(map);
}; };
var showMarkerInfoWindow = function(callsign, pos) { var showMarkerInfoWindow = function(source, pos) {
var infowindow = getInfoWindow(); var infowindow = getInfoWindow();
infowindow.callsign = callsign; infowindow.source = source;
var marker = markers[callsign]; var marker = markers[sourceToKey(source)];
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>' + callsign + '</h3>' + '<h3>' + linkifySource(source) + distance + '</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()
@ -392,7 +447,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;
@ -421,19 +476,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();
$.each(rectangles, function(callsign, m) { Object.values(rectangles).forEach(function(m){
var age = now - m.lastseen; var age = now - m.lastseen;
if (age > retention_time) { if (age > retention_time) {
delete rectangles[callsign]; delete rectangles[sourceToKey(m.source)];
m.setMap(); m.setMap();
return; return;
} }
m.setOptions(getRectangleOpacityOptions(m.lastseen)); m.setOptions(getRectangleOpacityOptions(m.lastseen));
}); });
$.each(markers, function(callsign, m) { Object.values(markers).forEach(function(m) {
var age = now - m.lastseen; var age = now - m.lastseen;
if (age > retention_time) { if (age > retention_time) {
delete markers[callsign]; delete markers[sourceToKey(m.source)];
m.setMap(); m.setMap();
return; return;
} }

View File

@ -754,7 +754,7 @@ function on_ws_recv(evt) {
if ('audio_compression' in config) { if ('audio_compression' in config) {
var audio_compression = config['audio_compression']; var audio_compression = config['audio_compression'];
audioEngine.setCompression(audio_compression); audioEngine.setCompression(audio_compression);
divlog("Audio stream is " + ((audio_compression !== "none") ? "compressed" : "uncompressed") + "."); divlog("Audio stream is " + ((audio_compression === "adpcm") ? "compressed" : "uncompressed") + ".");
} }
if ('fft_compression' in config) { if ('fft_compression' in config) {
fft_compression = config['fft_compression']; fft_compression = config['fft_compression'];
@ -778,6 +778,7 @@ 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)
@ -942,9 +943,15 @@ 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
var ignored = .1 * what.length; range.start = Math.max(0.1, (range.start - start) / bandwidth);
var data = what.slice(ignored, -ignored); range.end = Math.min(0.9, (range.end - start) / bandwidth);
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)
@ -1116,8 +1123,9 @@ 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);
var frequency_container = $("#openwebrx-frequency-container"); $("#openwebrx-frequency-container").each(function(){
frequency_container.on("wheel", canvas_mousewheel, false); this.addEventListener("wheel", canvas_mousewheel, false);
});
} }
canvas_maxshift = 0; canvas_maxshift = 0;

View File

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

View File

@ -1,6 +1,7 @@
[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,8 @@
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
logging.basicConfig(level=logging.DEBUG, format="%(asctime)s - %(name)s - %(levelname)s - %(message)s") # loglevel will be adjusted later, INFO is just for the startup
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
@ -51,9 +52,8 @@ def main():
args = parser.parse_args() args = parser.parse_args()
# set loglevel to info for CLI commands if args.debug:
if args.module is not None and not args.debug: logging.getLogger().setLevel(logging.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))
@ -65,10 +65,10 @@ def main():
if args.module == "config": if args.module == "config":
return run_admin_action(configparser, args) return run_admin_action(configparser, args)
return start_receiver() return start_receiver(loglevel=logging.DEBUG if args.debug else None)
def start_receiver(): def start_receiver(loglevel=None):
print( print(
""" """
@ -87,9 +87,13 @@ 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") failed = featureDetector.get_failed_requirements("core")

View File

@ -0,0 +1,104 @@
from abc import ABC, abstractmethod
import logging
logger = logging.getLogger(__name__)
class ActiveListChange(ABC):
pass
class ActiveListIndexUpdated(ActiveListChange):
def __init__(self, index: int, oldValue, newValue):
self.index = index
self.oldValue = oldValue
self.newValue = newValue
class ActiveListIndexAppended(ActiveListChange):
def __init__(self, index: int, newValue):
self.index = index
self.newValue = newValue
class ActiveListIndexDeleted(ActiveListChange):
def __init__(self, index: int, oldValue):
self.index = index
self.oldValue = oldValue
class ActiveListListener(ABC):
@abstractmethod
def onListChange(self, changes: list[ActiveListChange]):
pass
class ActiveListTransformationListener(ActiveListListener):
def __init__(self, transformation: callable, target: "ActiveList"):
self.transformation = transformation
self.target = target
def onListChange(self, changes: list[ActiveListChange]):
for change in changes:
if isinstance(change, ActiveListIndexUpdated):
self.target[change.index] = self.transformation(change.newValue)
elif isinstance(change, ActiveListIndexAppended):
self.target.append(self.transformation(change.newValue))
elif isinstance(change, ActiveListIndexDeleted):
del self.target[change.index]
class ActiveList:
def __init__(self, elements: list = None):
self.delegate = elements.copy() if elements is not None else []
self.listeners = []
def addListener(self, listener: ActiveListListener):
if listener in self.listeners:
return
self.listeners.append(listener)
def removeListener(self, listener: ActiveListListener):
if listener not in self.listeners:
return
self.listeners.remove(listener)
def append(self, value):
self.delegate.append(value)
self.__fireChanges([ActiveListIndexAppended(len(self) - 1, value)])
def __fireChanges(self, changes: list[ActiveListChange]):
for listener in self.listeners:
try:
listener.onListChange(changes)
except Exception:
logger.exception("Exception during onListChange notification")
def remove(self, value):
self.__delitem__(self.delegate.index(value))
def map(self, transform: callable):
res = ActiveList([transform(v) for v in self])
self.addListener(ActiveListTransformationListener(transform, res))
return res
def __setitem__(self, key, value):
if self.delegate[key] == value:
return
oldValue = self.delegate[key]
self.delegate[key] = value
self.__fireChanges([ActiveListIndexUpdated(key, oldValue, value)])
def __delitem__(self, key):
oldValue = self.delegate[key]
del self.delegate[key]
self.__fireChanges([ActiveListIndexDeleted(key, oldValue)])
def __getitem__(self, key):
return self.delegate[key]
def __len__(self):
return len(self.delegate)
def __iter__(self):
return self.delegate.__iter__()

View File

@ -33,7 +33,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]-[0-9]$") widePattern = re.compile("^WIDE[0-9]$")
def decodeBase91(input): def decodeBase91(input):
@ -67,12 +67,13 @@ class Ax25Parser(PickleModule):
logger.exception("error parsing ax25 frame") logger.exception("error parsing ax25 frame")
def extractCallsign(self, input): def extractCallsign(self, input):
cs = bytes([b >> 1 for b in input[0:6]]).decode(encoding, "replace").strip() cs = {
"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:
return "{callsign}-{ssid}".format(callsign=cs, ssid=ssid) cs["ssid"] = ssid
else: return cs
return cs
class WeatherMapping(object): class WeatherMapping(object):
@ -178,7 +179,7 @@ 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) is None] hops = [host for host in aprsData["path"] if widePattern.match(host["callsign"]) 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"]:
@ -207,12 +208,13 @@ 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"] source = mapData["source"].copy()
# these are special packets, sent on behalf of other entities
if "type" in mapData: if "type" in mapData:
if mapData["type"] == "item": if mapData["type"] == "item" and "item" in mapData:
source = mapData["item"] source["item"] = mapData["item"]
elif mapData["type"] == "object": elif mapData["type"] == "object" and "object" in mapData:
source = mapData["object"] source["object"] = 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):
@ -345,15 +347,24 @@ 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": matches.group(1).upper(), "source": parseCallsign(matches.group(1).upper()),
"destination": destination, "destination": parseCallsign(destination),
"path": path, "path": [parseCallsign(c) for c in path],
"data": matches.group(6).encode(encoding), "data": matches.group(6).encode(encoding),
} }
) )
@ -531,7 +542,7 @@ class MicEParser(object):
def parse(self, data): def parse(self, data):
information = data["data"] information = data["data"]
destination = data["destination"] destination = data["destination"]["callsign"]
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

@ -9,6 +9,7 @@ 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,
@ -34,6 +35,7 @@ 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")
@ -57,3 +59,6 @@ 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=7, version=8,
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 RSP2", name="SDRPlay device",
type="sdrplay", type="sdrplay",
antenna="Antenna A", antenna="Antenna A",
profiles=PropertyLayer( profiles=PropertyLayer(

View File

@ -111,8 +111,21 @@ 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 = 7 currentVersion = 8
migrators = { migrators = {
1: ConfigMigratorVersion1(), 1: ConfigMigratorVersion1(),
2: ConfigMigratorVersion2(), 2: ConfigMigratorVersion2(),
@ -120,6 +133,7 @@ class Migrator(object):
4: ConfigMigratorVersion4(), 4: ConfigMigratorVersion4(),
5: ConfigMigratorVersion5(), 5: ConfigMigratorVersion5(),
6: ConfigMigratorVersion6(), 6: ConfigMigratorVersion6(),
7: ConfigMigratorVersion7(),
} }
@staticmethod @staticmethod

View File

@ -456,6 +456,7 @@ 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)

View File

@ -158,6 +158,7 @@ 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

@ -132,7 +132,6 @@ class GeneralSettingsController(SettingsFormController):
"Audio compression", "Audio compression",
options=[ options=[
Option("adpcm", "ADPCM"), Option("adpcm", "ADPCM"),
Option("opus", "OPUS"),
Option("none", "None"), Option("none", "None"),
], ],
), ),
@ -169,6 +168,18 @@ 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,6 +12,7 @@ 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
@ -279,6 +280,21 @@ 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

@ -589,7 +589,7 @@ class DspManager(SdrSourceEventClient, ClientDemodulatorSecondaryDspEventClient)
from csdr.chain.digimodes import PacketDemodulator from csdr.chain.digimodes import PacketDemodulator
return PacketDemodulator() return PacketDemodulator()
elif mod == "pocsag": elif mod == "pocsag":
from csdr.chain.digimodes import PocsagDemodulator from csdr.chain.digiham import PocsagDemodulator
return PocsagDemodulator() return PocsagDemodulator()
elif mod == "bpsk31": elif mod == "bpsk31":
from csdr.chain.digimodes import PskDemodulator from csdr.chain.digimodes import PskDemodulator

View File

@ -474,7 +474,7 @@ class FeatureDetector(object):
The js8py library is used to decode binary JS8 messages into readable text. More information is available on 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). [its github page](https://github.com/jketterl/js8py).
""" """
required_version = StrictVersion("0.1") required_version = StrictVersion("0.2")
try: try:
from js8py.version import strictversion from js8py.version import strictversion

View File

@ -103,11 +103,11 @@ class Js8Parser(AudioChopperParser):
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.callsign, LocatorLocation(frame.grid), "JS8", band frame.source, LocatorLocation(frame.grid), "JS8", band
) )
ReportingEngine.getSharedInstance().spot( ReportingEngine.getSharedInstance().spot(
{ {
"callsign": frame.callsign, "source": frame.source,
"mode": "JS8", "mode": "JS8",
"locator": frame.grid, "locator": frame.grid,
"freq": freq + frame.freq, "freq": freq + frame.freq,

52
owrx/log/__init__.py Normal file
View File

@ -0,0 +1,52 @@
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(
[ [
{ {
"callsign": callsign, "source": record["source"],
"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 (callsign, record) in self.positions.items() for record in self.positions.values()
] ]
) )
@ -77,14 +77,20 @@ class Map(object):
except ValueError: except ValueError:
pass pass
def updateLocation(self, callsign, loc: Location, mode: str, band: Band = None): def _sourceToKey(self, source):
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[callsign] = {"location": loc, "updated": ts, "mode": mode, "band": band} self.positions[key] = {"source": source, "location": loc, "updated": ts, "mode": mode, "band": band}
self.broadcast( self.broadcast(
[ [
{ {
"callsign": callsign, "source": source,
"location": loc.__dict__(), "location": loc.__dict__(),
"lastseen": ts.timestamp() * 1000, "lastseen": ts.timestamp() * 1000,
"mode": mode, "mode": mode,
@ -93,17 +99,18 @@ class Map(object):
] ]
) )
def touchLocation(self, callsign): def touchLocation(self, source):
# 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 callsign in self.positions: if key in self.positions:
self.positions[callsign]["updated"] = ts self.positions[key]["updated"] = ts
self.broadcast([{"callsign": callsign, "lastseen": ts.timestamp() * 1000}]) self.broadcast([{"source": source, "lastseen": ts.timestamp() * 1000}])
def removeLocation(self, callsign): def removeLocation(self, key):
with self.positionsLock: with self.positionsLock:
del self.positions[callsign] del self.positions[key]
# TODO broadcast removal to clients # TODO broadcast removal to clients
def removeOldPositions(self): def removeOldPositions(self):
@ -111,9 +118,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 = [callsign for (callsign, pos) in self.positions.items() if pos["updated"] < cutoff] to_be_removed = [key for (key, pos) in self.positions.items() if pos["updated"] < cutoff]
for callsign in to_be_removed: for key in to_be_removed:
self.removeLocation(callsign) self.removeLocation(key)
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

@ -129,7 +129,7 @@ class DigihamEnricher(Enricher, metaclass=ABCMeta):
callsign = self.getCallsign(meta) callsign = self.getCallsign(meta)
if callsign is not None 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, loc, mode, self.parser.getBand()) Map.getSharedInstance().updateLocation({"callsign": callsign}, loc, mode, self.parser.getBand())
return meta return meta
@abstractmethod @abstractmethod
@ -202,7 +202,7 @@ 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(meta["ourcall"], loc, "DPRS", self.parser.getBand()) Map.getSharedInstance().updateLocation({"callsign": 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")

View File

@ -56,7 +56,7 @@ class PskReporter(Reporter):
self.timer.start() self.timer.start()
def spotEquals(self, s1, s2): def spotEquals(self, s1, s2):
keys = ["callsign", "timestamp", "locator", "mode", "msg"] keys = ["source", "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))
@ -141,7 +141,7 @@ class Uploader(object):
def encodeSpot(self, spot): def encodeSpot(self, spot):
try: try:
return bytes( return bytes(
self.encodeString(spot["callsign"]) self.encodeString(spot["source"]["callsign"])
+ list(int(spot["freq"]).to_bytes(4, "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"])

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["callsign"], "tcall": spot["source"]["callsign"],
"tgrid": spot["locator"], "tgrid": spot["locator"],
"dbm": spot["dbm"], "dbm": spot["dbm"],
"version": openwebrx_version, "version": openwebrx_version,

View File

@ -16,8 +16,10 @@ from owrx.form.input import Input, TextInput, NumberInput, CheckboxInput, ModesI
from owrx.form.input.converter import OptionalConverter from owrx.form.input.converter import OptionalConverter
from owrx.form.input.device import GainInput, SchedulerInput, WaterfallLevelsInput from owrx.form.input.device import GainInput, SchedulerInput, WaterfallLevelsInput
from owrx.form.input.validator import RequiredValidator from owrx.form.input.validator import RequiredValidator
from owrx.form.input.converter import Converter
from owrx.form.section import OptionalSection from owrx.form.section import OptionalSection
from owrx.feature import FeatureDetector from owrx.feature import FeatureDetector
from owrx.log import LogPipe, HistoryHandler
from typing import List from typing import List
from enum import Enum from enum import Enum
@ -114,6 +116,10 @@ class SdrSource(ABC):
self.commandMapper = None self.commandMapper = None
self.tcpSource = None self.tcpSource = None
self.buffer = None self.buffer = None
self.logger = logger.getChild(id) if id is not None else logger
self.logger.addHandler(HistoryHandler.getHandler(self.logger.name))
self.stdoutPipe = None
self.stderrPipe = None
self.props = PropertyStack() self.props = PropertyStack()
@ -185,17 +191,17 @@ class SdrSource(ABC):
for id, p in self.props["profiles"].items(): for id, p in self.props["profiles"].items():
props.replaceLayer(0, p) props.replaceLayer(0, p)
if "center_freq" not in props: if "center_freq" not in props:
logger.warning('Profile "%s" does not specify a center_freq', id) self.logger.warning('Profile "%s" does not specify a center_freq', id)
continue continue
if "samp_rate" not in props: if "samp_rate" not in props:
logger.warning('Profile "%s" does not specify a samp_rate', id) self.logger.warning('Profile "%s" does not specify a samp_rate', id)
continue continue
if "start_freq" in props: if "start_freq" in props:
start_freq = props["start_freq"] start_freq = props["start_freq"]
srh = props["samp_rate"] / 2 srh = props["samp_rate"] / 2
center_freq = props["center_freq"] center_freq = props["center_freq"]
if start_freq < center_freq - srh or start_freq > center_freq + srh: if start_freq < center_freq - srh or start_freq > center_freq + srh:
logger.warning('start_freq for profile "%s" is out of range', id) self.logger.warning('start_freq for profile "%s" is out of range', id)
def isAlwaysOn(self): def isAlwaysOn(self):
return "always-on" in self.props and self.props["always-on"] return "always-on" in self.props and self.props["always-on"]
@ -225,11 +231,12 @@ class SdrSource(ABC):
return [self.getCommandMapper().map(self.getCommandValues())] return [self.getCommandMapper().map(self.getCommandValues())]
def activateProfile(self, profile_id): def activateProfile(self, profile_id):
logger.debug("activating profile {0} for {1}".format(profile_id, self.getId()))
try: try:
profile_name = self.getProfiles()[profile_id]["name"]
self.logger.debug("activating profile \"%s\" for \"%s\"", profile_name, self.getName())
self.profileCarousel.switch(profile_id) self.profileCarousel.switch(profile_id)
except KeyError: except KeyError:
logger.warning("invalid profile %s for sdr %s. ignoring", profile_id, self.getId()) self.logger.warning("invalid profile %s for sdr %s. ignoring", profile_id, self.getId())
def getId(self): def getId(self):
return self.id return self.id
@ -249,10 +256,13 @@ class SdrSource(ABC):
def getPort(self): def getPort(self):
return self.port return self.port
def _getTcpSourceFormat(self):
return Format.COMPLEX_FLOAT
def _getTcpSource(self): def _getTcpSource(self):
with self.modificationLock: with self.modificationLock:
if self.tcpSource is None: if self.tcpSource is None:
self.tcpSource = TcpSource(self.port, Format.COMPLEX_FLOAT) self.tcpSource = TcpSource(self.port, self._getTcpSourceFormat())
return self.tcpSource return self.tcpSource
def getBuffer(self): def getBuffer(self):
@ -280,23 +290,37 @@ class SdrSource(ABC):
try: try:
self.preStart() self.preStart()
except Exception: except Exception:
logger.exception("Exception during preStart()") self.logger.exception("Exception during preStart()")
cmd = self.getCommand() cmd = self.getCommand()
cmd = [c for c in cmd if c is not None] cmd = [c for c in cmd if c is not None]
self.stdoutPipe = LogPipe(logging.INFO, self.logger, "STDOUT")
self.stderrPipe = LogPipe(logging.WARNING, self.logger, "STDERR")
# don't use shell mode for commands without piping # don't use shell mode for commands without piping
if len(cmd) > 1: if len(cmd) > 1:
# multiple commands with pipes # multiple commands with pipes
cmd = "|".join(cmd) cmd = "|".join(cmd)
self.process = subprocess.Popen(cmd, shell=True, start_new_session=True) self.process = subprocess.Popen(
cmd,
shell=True,
start_new_session=True,
stdout=self.stdoutPipe,
stderr=self.stderrPipe
)
else: else:
# single command # single command
cmd = cmd[0] cmd = cmd[0]
# start_new_session can go as soon as there's no piped commands left # start_new_session can go as soon as there's no piped commands left
# the os.killpg call must be replaced with something more reasonable at the same time # the os.killpg call must be replaced with something more reasonable at the same time
self.process = subprocess.Popen(shlex.split(cmd), start_new_session=True) self.process = subprocess.Popen(
logger.info("Started sdr source: " + cmd) shlex.split(cmd),
start_new_session=True,
stdout=self.stdoutPipe,
stderr=self.stderrPipe
)
self.logger.info("Started sdr source: " + cmd)
available = False available = False
failed = False failed = False
@ -304,9 +328,13 @@ class SdrSource(ABC):
def wait_for_process_to_end(): def wait_for_process_to_end():
nonlocal failed nonlocal failed
rc = self.process.wait() rc = self.process.wait()
logger.debug("shut down with RC={0}".format(rc)) self.logger.debug("shut down with RC={0}".format(rc))
self.process = None self.process = None
self.monitor = None self.monitor = None
self.stdoutPipe.close()
self.stdoutPipe = None
self.stderrPipe.close()
self.stderrPipe = None
if self.getState() is SdrSourceState.RUNNING: if self.getState() is SdrSourceState.RUNNING:
self.fail() self.fail()
else: else:
@ -337,7 +365,7 @@ class SdrSource(ABC):
try: try:
self.postStart() self.postStart()
except Exception: except Exception:
logger.exception("Exception during postStart()") self.logger.exception("Exception during postStart()")
failed = True failed = True
if failed: if failed:
@ -371,7 +399,7 @@ class SdrSource(ABC):
self.monitor.join(10) self.monitor.join(10)
# if the monitor is still running, the process still hasn't ended, so kill it # if the monitor is still running, the process still hasn't ended, so kill it
if self.monitor: if self.monitor:
logger.warning("source has not shut down normally within 10 seconds, sending SIGKILL") self.logger.warning("source has not shut down normally within 10 seconds, sending SIGKILL")
os.killpg(os.getpgid(self.process.pid), signal.SIGKILL) os.killpg(os.getpgid(self.process.pid), signal.SIGKILL)
except ProcessLookupError: except ProcessLookupError:
# been killed by something else, ignore # been killed by something else, ignore
@ -479,6 +507,32 @@ class SdrDeviceDescriptionMissing(Exception):
pass pass
class SdrDeviceTypeConverter(Converter):
def convert_to_form(self, value):
# local import due to circular dependendies
types = SdrDeviceDescription.getTypes()
if value in types:
return types[value]
return value
def convert_from_form(self, value):
return None
class SdrDeviceTypeDisplay(Input):
"""
Not an input per se, just an element that can display the SDR device type in the web config
"""
def __init__(self, id, label):
super().__init__(id, label, disabled=True)
def defaultConverter(self):
return SdrDeviceTypeConverter()
def parse(self, data):
return {}
class SdrDeviceDescription(object): class SdrDeviceDescription(object):
@staticmethod @staticmethod
def getByType(sdr_type: str) -> "SdrDeviceDescription": def getByType(sdr_type: str) -> "SdrDeviceDescription":
@ -536,6 +590,7 @@ class SdrDeviceDescription(object):
def getInputs(self) -> List[Input]: def getInputs(self) -> List[Input]:
return [ return [
SdrDeviceTypeDisplay("type", "Device type"),
CheckboxInput("enabled", "Enable this device", converter=OptionalConverter(defaultFormValue=True)), CheckboxInput("enabled", "Enable this device", converter=OptionalConverter(defaultFormValue=True)),
GainInput("rf_gain", "Device gain", self.hasAgc()), GainInput("rf_gain", "Device gain", self.hasAgc()),
NumberInput( NumberInput(
@ -573,7 +628,7 @@ class SdrDeviceDescription(object):
return True return True
def getDeviceMandatoryKeys(self): def getDeviceMandatoryKeys(self):
return ["name", "enabled"] return ["name", "type", "enabled"]
def getDeviceOptionalKeys(self): def getDeviceOptionalKeys(self):
keys = [ keys = [

View File

@ -6,10 +6,6 @@ from owrx.command import Flag, Option
from typing import List from typing import List
from owrx.form.input import Input, NumberInput, CheckboxInput from owrx.form.input import Input, NumberInput, CheckboxInput
import logging
logger = logging.getLogger(__name__)
class ConnectorSource(SdrSource): class ConnectorSource(SdrSource):
def __init__(self, id, props): def __init__(self, id, props):
@ -40,7 +36,7 @@ class ConnectorSource(SdrSource):
for prop, value in changes.items(): for prop, value in changes.items():
if value is PropertyDeleted: if value is PropertyDeleted:
value = None value = None
logger.debug("sending property change over control socket: {0} changed to {1}".format(prop, value)) self.logger.debug("sending property change over control socket: {0} changed to {1}".format(prop, value))
self.controlSocket.sendall("{prop}:{value}\n".format(prop=prop, value=value).encode()) self.controlSocket.sendall("{prop}:{value}\n".format(prop=prop, value=value).encode())
def onPropertyChange(self, changes): def onPropertyChange(self, changes):
@ -56,7 +52,7 @@ class ConnectorSource(SdrSource):
self.sendControlMessage(changes) self.sendControlMessage(changes)
def postStart(self): def postStart(self):
logger.debug("opening control socket...") self.logger.debug("opening control socket...")
self.controlSocket = socket.socket(socket.AF_INET, socket.SOCK_STREAM) self.controlSocket = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
self.controlSocket.connect(("localhost", self.controlPort)) self.controlSocket.connect(("localhost", self.controlPort))

View File

@ -5,14 +5,14 @@ from typing import Optional
from pycsdr.modules import Buffer from pycsdr.modules import Buffer
from pycsdr.types import Format from pycsdr.types import Format
import logging
logger = logging.getLogger(__name__)
class DirectSource(SdrSource, metaclass=ABCMeta): class DirectSource(SdrSource, metaclass=ABCMeta):
def __init__(self, id, props):
self._conversion = None
super().__init__(id, props)
def onPropertyChange(self, changes): def onPropertyChange(self, changes):
logger.debug("restarting sdr source due to property changes: {0}".format(changes)) self.logger.debug("restarting sdr source due to property changes: {0}".format(changes))
self.stop() self.stop()
self.sleepOnRestart() self.sleepOnRestart()
self.start() self.start()
@ -48,6 +48,10 @@ class DirectSource(SdrSource, metaclass=ABCMeta):
def getFormatConversion(self) -> Optional[Chain]: def getFormatConversion(self) -> Optional[Chain]:
return None return None
def _getTcpSourceFormat(self):
conversion = self.getFormatConversion()
return Format.COMPLEX_FLOAT if conversion is None else conversion.getInputFormat()
# override this in subclasses, if necessary # override this in subclasses, if necessary
def sleepOnRestart(self): def sleepOnRestart(self):
pass pass
@ -57,12 +61,12 @@ class DirectSource(SdrSource, metaclass=ABCMeta):
source = self._getTcpSource() source = self._getTcpSource()
buffer = Buffer(source.getOutputFormat()) buffer = Buffer(source.getOutputFormat())
source.setWriter(buffer) source.setWriter(buffer)
conversion = self.getFormatConversion() self._conversion = self.getFormatConversion()
if conversion is not None: if self._conversion is not None:
conversion.setReader(buffer.getReader()) self._conversion.setReader(buffer.getReader())
# this one must be COMPLEX_FLOAT # this one must be COMPLEX_FLOAT
buffer = Buffer(Format.COMPLEX_FLOAT) buffer = Buffer(Format.COMPLEX_FLOAT)
conversion.setWriter(buffer) self._conversion.setWriter(buffer)
self.buffer = buffer self.buffer = buffer
return self.buffer return self.buffer

View File

@ -1,14 +1,14 @@
from owrx.command import Option from owrx.command import Option
from owrx.source.direct import DirectSource, DirectSourceDeviceDescription from owrx.source.direct import DirectSource, DirectSourceDeviceDescription
from owrx.log import LogPipe
from subprocess import Popen from subprocess import Popen
from csdr.chain import Chain from csdr.chain import Chain
from pycsdr.modules import Convert, Gain from pycsdr.modules import Convert, Gain
from pycsdr.types import Format from pycsdr.types import Format
from typing import List
from owrx.form.input import Input, TextInput
import logging import logging
logger = logging.getLogger(__name__)
class FifiSdrSource(DirectSource): class FifiSdrSource(DirectSource):
def getCommandMapper(self): def getCommandMapper(self):
@ -27,11 +27,19 @@ class FifiSdrSource(DirectSource):
return Chain([Convert(Format.COMPLEX_SHORT, Format.COMPLEX_FLOAT), Gain(Format.COMPLEX_FLOAT, 5.0)]) return Chain([Convert(Format.COMPLEX_SHORT, Format.COMPLEX_FLOAT), Gain(Format.COMPLEX_FLOAT, 5.0)])
def sendRockProgFrequency(self, frequency): def sendRockProgFrequency(self, frequency):
process = Popen(["rockprog", "--vco", "-w", "--freq={}".format(frequency / 1e6)]) stdoutPipe = LogPipe(logging.DEBUG, self.logger, "STDOUT")
stderrPipe = LogPipe(logging.DEBUG, self.logger, "STDERR")
process = Popen(
["rockprog", "--vco", "-w", "--freq={}".format(frequency / 1e6)],
stdout=stdoutPipe,
stderr=stderrPipe
)
process.communicate() process.communicate()
rc = process.wait() rc = process.wait()
if rc != 0: if rc != 0:
logger.warning("rockprog failed to set frequency; rc=%i", rc) self.logger.warning("rockprog failed to set frequency; rc=%i", rc)
stdoutPipe.close()
stderrPipe.close()
def preStart(self): def preStart(self):
values = self.getCommandValues() values = self.getCommandValues()
@ -49,3 +57,15 @@ class FifiSdrDeviceDescription(DirectSourceDeviceDescription):
def supportsPpm(self): def supportsPpm(self):
# not currently mapped, and it's unclear how this should be sent to the device # not currently mapped, and it's unclear how this should be sent to the device
return False return False
def getInputs(self) -> List[Input]:
return super().getInputs() + [
TextInput(
"device",
"Device identifier",
infotext="Alsa audio device identifier",
),
]
def getDeviceOptionalKeys(self):
return super().getDeviceOptionalKeys() + ["device"]

View File

@ -3,14 +3,10 @@ from pycsdr.modules import Buffer, FirDecimate, Shift
from pycsdr.types import Format from pycsdr.types import Format
from csdr.chain import Chain from csdr.chain import Chain
import logging
logger = logging.getLogger(__name__)
class Resampler(SdrSource): class Resampler(SdrSource):
def onPropertyChange(self, changes): def onPropertyChange(self, changes):
logger.warning("Resampler is unable to handle property changes: {0}".format(changes)) self.logger.warning("Resampler is unable to handle property changes: {0}".format(changes))
def __init__(self, props, sdr): def __init__(self, props, sdr):
sdrProps = sdr.getProps() sdrProps = sdr.getProps()
@ -41,7 +37,7 @@ class Resampler(SdrSource):
super().stop() super().stop()
def activateProfile(self, profile_id=None): def activateProfile(self, profile_id=None):
logger.warning("Resampler does not support setting profiles") self.logger.warning("Resampler does not support setting profiles")
pass pass
def validateProfiles(self): def validateProfiles(self):

View File

@ -276,9 +276,9 @@ class WsjtParser(AudioChopperParser):
out["interval"] = profile.getInterval() out["interval"] = profile.getInterval()
self.pushDecode(mode, band) self.pushDecode(mode, band)
if "callsign" in out and "locator" in out: if "source" in out and "locator" in out:
Map.getSharedInstance().updateLocation( Map.getSharedInstance().updateLocation(
out["callsign"], LocatorLocation(out["locator"]), mode, band out["source"], LocatorLocation(out["locator"]), mode, band
) )
ReportingEngine.getSharedInstance().spot(out) ReportingEngine.getSharedInstance().spot(out)
@ -342,8 +342,8 @@ class QsoMessageParser(MessageParser):
# this is a valid locator in theory, but it's somewhere in the arctic ocean, near the north pole, so it's very # this is a valid locator in theory, but it's somewhere in the arctic ocean, near the north pole, so it's very
# likely this just means roger roger goodbye. # likely this just means roger roger goodbye.
if m.group(3) == "RR73": if m.group(3) == "RR73":
return {"callsign": m.group(1)} return {"source": {"callsign": m.group(1)}}
return {"callsign": m.group(1), "locator": m.group(3)} return {"source": {"callsign": m.group(1)}, "locator": m.group(3)}
# Used in propagation reporting / beacon modes (WSPR / FST4W) # Used in propagation reporting / beacon modes (WSPR / FST4W)
@ -354,7 +354,7 @@ class BeaconMessageParser(MessageParser):
m = BeaconMessageParser.wspr_splitter_pattern.match(msg) m = BeaconMessageParser.wspr_splitter_pattern.match(msg)
if m is None: if m is None:
return {} return {}
return {"callsign": m.group(1), "locator": m.group(2), "dbm": m.group(3)} return {"source": {"callsign": m.group(1)}, "locator": m.group(2), "dbm": m.group(3)}
class Jt9Decoder(Decoder): class Jt9Decoder(Decoder):

View File

@ -14,22 +14,8 @@ setup(
version=str(looseversion), version=str(looseversion),
packages=find_namespace_packages( packages=find_namespace_packages(
include=[ include=[
"owrx", "owrx*",
"owrx.source", "csdr*",
"owrx.service",
"owrx.controllers",
"owrx.controllers.settings",
"owrx.property",
"owrx.form",
"owrx.form.input",
"owrx.config",
"owrx.reporting",
"owrx.audio",
"owrx.admin",
"owrx.aprs",
"csdr",
"csdr.chain",
"csdr.module",
"htdocs", "htdocs",
] ]
), ),

View File

@ -0,0 +1,123 @@
from owrx.active.list import ActiveList, ActiveListIndexUpdated, ActiveListIndexAppended, ActiveListIndexDeleted
from unittest import TestCase
from unittest.mock import Mock
class ActiveListTest(TestCase):
def testListIndexReadAccess(self):
list = ActiveList(["testvalue"])
self.assertEqual(list[0], "testvalue")
def testListIndexWriteAccess(self):
list = ActiveList(["initialvalue"])
list[0] = "testvalue"
self.assertEqual(list[0], "testvalue")
def testListLength(self):
list = ActiveList(["somevalue"])
self.assertEqual(len(list), 1)
def testListIndexChangeNotification(self):
list = ActiveList(["initialvalue"])
listenerMock = Mock()
list.addListener(listenerMock)
list[0] = "testvalue"
listenerMock.onListChange.assert_called_once()
changes, = listenerMock.onListChange.call_args.args
self.assertEqual(len(changes), 1)
self.assertIsInstance(changes[0], ActiveListIndexUpdated)
self.assertEqual(changes[0].index, 0)
self.assertEqual(changes[0].oldValue, "initialvalue")
self.assertEqual(changes[0].newValue, "testvalue")
def testListIndexChangeNotficationNotDisturbedByException(self):
list = ActiveList(["initialvalue"])
throwingMock = Mock()
throwingMock.onListChange.side_effect = RuntimeError("this is a drill")
list.addListener(throwingMock)
listenerMock = Mock()
list.addListener(listenerMock)
list[0] = "testvalue"
listenerMock.onListChange.assert_called_once()
def testListAppend(self):
list = ActiveList()
list.append("testvalue")
self.assertEqual(len(list), 1)
self.assertEqual(list[0], "testvalue")
def testListAppendNotification(self):
list = ActiveList()
listenerMock = Mock()
list.addListener(listenerMock)
list.append("testvalue")
listenerMock.onListChange.assert_called_once()
changes, = listenerMock.onListChange.call_args.args
self.assertEqual(len(changes), 1)
self.assertIsInstance(changes[0], ActiveListIndexAppended)
self.assertEqual(changes[0].index, 0)
self.assertEqual(changes[0].newValue, "testvalue")
def testListDelete(self):
list = ActiveList(["value1", "value2"])
del list[0]
self.assertEqual(len(list), 1)
self.assertEqual(list[0], "value2")
def testListDeleteNotification(self):
list = ActiveList(["value1", "value2"])
listenerMock = Mock()
list.addListener(listenerMock)
del list[0]
listenerMock.onListChange.assert_called_once()
changes, = listenerMock.onListChange.call_args.args
self.assertEqual(len(changes), 1)
self.assertIsInstance(changes[0], ActiveListIndexDeleted)
self.assertEqual(changes[0].index, 0)
self.assertEqual(changes[0].oldValue, 'value1')
def testListDeleteByValue(self):
list = ActiveList(["value1", "value2"])
list.remove("value1")
self.assertEqual(len(list), 1)
self.assertEqual(list[0], "value2")
def testListComprehension(self):
list = ActiveList(["initialvalue"])
x = [m for m in list]
self.assertEqual(len(x), 1)
self.assertEqual(x[0], "initialvalue")
def testListenerRemoval(self):
list = ActiveList(["initialvalue"])
listenerMock = Mock()
list.addListener(listenerMock)
list[0] = "testvalue"
listenerMock.onListChange.assert_called_once()
listenerMock.reset_mock()
list.removeListener(listenerMock)
list[0] = "someothervalue"
listenerMock.onListChange.assert_not_called()
def testListMapTransformation(self):
list = ActiveList(["somevalue"])
transformedList = list.map(lambda x: "prefix-{}".format(x))
self.assertEqual(transformedList[0], "prefix-somevalue")
def testActiveTransformationUpdate(self):
list = ActiveList(["initialvalue"])
transformedList = list.map(lambda x: "prefix-{}".format(x))
list[0] = "testvalue"
self.assertEqual(transformedList[0], "prefix-testvalue")
def testActiveTransformationAppend(self):
list = ActiveList(["initialvalue"])
transformedList = list.map(lambda x: "prefix-{}".format(x))
list.append("newvalue")
self.assertEqual(transformedList[1], "prefix-newvalue")
def testActiveTransformationDelete(self):
list = ActiveList(["value1", "value2"])
transformedList = list.map(lambda x: "prefix-{}".format(x))
del list[0]
self.assertEqual(transformedList[0], "prefix-value2")

View File

View File