Merge branch 'develop' into active_arrays

This commit is contained in:
Jakob Ketterl 2024-01-14 19:32:27 +01:00
commit dcc0f404b7
85 changed files with 2953 additions and 507 deletions

View file

@ -1,9 +1,17 @@
**unreleased**
- SDR device log messages are now available in the web configuration to simplify troubleshooting
- Added support for the MSK144 digimode
- Added support for decoding ADS-B with dump1090
- Added support for decoding HFDL and VDL2 aircraft communications
- Added decoding of ISM band transmissions using rtl_433
- Added IPv6 support
- Added profile re-ordering using drag & drop
- Added the ability to disable profiles
- New devices supported:
- Afedri SDR-Net
**1.2.2**
- Fixed an over-the-air code injection vulnerability
**1.2.1**
- FifiSDR support fixed (pipeline formats now line up correctly)

View file

@ -367,5 +367,23 @@
"lower_bound": 446000000,
"upper_bound": 446200000,
"tags": ["public"]
},
{
"name": "Aeronautical Radionavigation",
"lower_bound": 960000000,
"upper_bound": 1215000000,
"tags": [],
"frequencies": {
"adsb": 1090000000
}
},
{
"name": "ISM-433",
"lower_bound": 433050000,
"upper_bound": 434790000,
"tags": [],
"frequencies": {
"ism": 433920000
}
}
]

View file

@ -94,19 +94,24 @@ class Chain(Module):
if self.writer is not None:
newWorker.setWriter(self.writer)
def insert(self, newWorker):
def insert(self, index, newWorker):
nextWorker = None
if self.workers:
nextWorker = self.workers[0]
previousWorker = None
if index < len(self.workers):
nextWorker = self.workers[index]
if index > 0:
previousWorker = self.workers[index - 1]
self.workers.insert(0, newWorker)
self.workers.insert(index, newWorker)
if nextWorker:
self._connect(newWorker, nextWorker)
elif self.writer is not None:
newWorker.setWriter(self.writer)
if self.reader is not None:
if previousWorker:
self._connect(previousWorker, newWorker)
elif self.reader is not None:
newWorker.setReader(self.reader)
def remove(self, index):

View file

@ -74,3 +74,14 @@ class Ssb(BaseDemodulatorChain):
Agc(Format.FLOAT),
]
super().__init__(workers)
class Empty(BaseDemodulatorChain):
def __init__(self):
super().__init__([])
def getOutputFormat(self) -> Format:
return Format.FLOAT
def setWriter(self, writer):
pass

View file

@ -42,7 +42,7 @@ class ClientAudioChain(Chain):
if index >= 0:
self.replace(index, converter)
else:
self.insert(converter)
self.insert(0, converter)
def setFormat(self, format: Format) -> None:
if format == self.format:

View file

@ -64,6 +64,9 @@ class SecondaryDemodulator(Chain):
def setSampleRate(self, sampleRate: int) -> None:
pass
def isSecondaryFftShown(self):
return True
class ServiceDemodulator(SecondaryDemodulator, FixedAudioRateChain, metaclass=ABCMeta):
pass

View file

@ -1,11 +1,15 @@
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, DcBlock, Lowpass
from pycsdr.types import Format
from digiham.modules import DstarDecoder, DcBlock, FskDemodulator, GfskDemodulator, DigitalVoiceFilter, MbeSynthesizer, NarrowRrcFilter, NxdnDecoder, DmrDecoder, WideRrcFilter, YsfDecoder, PocsagDecoder
from digiham.modules import DstarDecoder, FskDemodulator, GfskDemodulator, DigitalVoiceFilter, MbeSynthesizer, NarrowRrcFilter, NxdnDecoder, DmrDecoder, WideRrcFilter, YsfDecoder, PocsagDecoder
from digiham.ambe import Modes, ServerError
from owrx.meta import MetaParser
from owrx.pocsag import PocsagParser
import logging
logger = logging.getLogger(__name__)
class DigihamChain(BaseDemodulatorChain, FixedIfSampleRateChain, FixedAudioRateChain, DialFrequencyReceiver, MetaProvider):
def __init__(self, fskDemodulator, decoder, mbeMode, filter=None, codecserver: str = ""):
@ -24,6 +28,9 @@ class DigihamChain(BaseDemodulatorChain, FixedIfSampleRateChain, FixedAudioRateC
raise DemodulatorError("Connection to codecserver failed: {}".format(ce))
except ServerError as se:
raise DemodulatorError("Codecserver error: {}".format(se))
except RuntimeError as re:
logger.exception("Codecserver error while instantiating MbeSynthesizer:")
raise DemodulatorError("Fatal codecserver error. Please check receiver logs.")
workers += [
fskDemodulator,
decoder,
@ -72,6 +79,7 @@ class Dstar(DigihamChain):
fskDemodulator=FskDemodulator(samplesPerSymbol=10),
decoder=DstarDecoder(),
mbeMode=Modes.DStarMode,
filter=WideRrcFilter(),
codecserver=codecserver
)
@ -117,6 +125,8 @@ class PocsagDemodulator(ServiceDemodulator, DialFrequencyReceiver):
self.parser = PocsagParser()
workers = [
FmDemod(),
DcBlock(),
Lowpass(Format.FLOAT, 1200 / self.getFixedAudioRate()),
FskDemodulator(samplesPerSymbol=40, invert=True),
PocsagDecoder(),
self.parser,

View file

@ -3,9 +3,9 @@ from csdr.module.msk144 import Msk144Module, ParserAdapter
from owrx.audio.chopper import AudioChopper, AudioChopperParser
from owrx.aprs.kiss import KissDeframer
from owrx.aprs import Ax25Parser, AprsParser
from pycsdr.modules import Convert, FmDemod, Agc, TimingRecovery, DBPskDecoder, VaricodeDecoder
from pycsdr.modules import Convert, FmDemod, Agc, TimingRecovery, DBPskDecoder, VaricodeDecoder, RttyDecoder, BaudotDecoder, Lowpass
from pycsdr.types import Format
from owrx.aprs.module import DirewolfModule
from owrx.aprs.direwolf import DirewolfModule
class AudioChopperDemodulator(ServiceDemodulator, DialFrequencyReceiver):
@ -69,7 +69,7 @@ class PskDemodulator(SecondaryDemodulator, SecondarySelectorChain):
secondary_samples_per_bits = int(round(self.sampleRate / self.baudRate)) & ~3
workers = [
Agc(Format.COMPLEX_FLOAT),
TimingRecovery(secondary_samples_per_bits, 0.5, 2, useQ=True),
TimingRecovery(Format.COMPLEX_FLOAT, secondary_samples_per_bits, 0.5, 2),
DBPskDecoder(),
VaricodeDecoder(),
]
@ -83,4 +83,38 @@ class PskDemodulator(SecondaryDemodulator, SecondarySelectorChain):
return
self.sampleRate = sampleRate
secondary_samples_per_bits = int(round(self.sampleRate / self.baudRate)) & ~3
self.replace(1, TimingRecovery(secondary_samples_per_bits, 0.5, 2, useQ=True))
self.replace(1, TimingRecovery(Format.COMPLEX_FLOAT, secondary_samples_per_bits, 0.5, 2))
class RttyDemodulator(SecondaryDemodulator, SecondarySelectorChain):
def __init__(self, baudRate, bandWidth, invert=False):
self.baudRate = baudRate
self.bandWidth = bandWidth
self.invert = invert
# this is an assumption, we will adjust in setSampleRate
self.sampleRate = 12000
secondary_samples_per_bit = int(round(self.sampleRate / self.baudRate))
cutoff = self.baudRate / self.sampleRate
loop_gain = self.sampleRate / self.getBandwidth() / 5
workers = [
Agc(Format.COMPLEX_FLOAT),
FmDemod(),
Lowpass(Format.FLOAT, cutoff),
TimingRecovery(Format.FLOAT, secondary_samples_per_bit, loop_gain, 10),
RttyDecoder(invert),
BaudotDecoder(),
]
super().__init__(workers)
def getBandwidth(self) -> float:
return self.bandWidth
def setSampleRate(self, sampleRate: int) -> None:
if sampleRate == self.sampleRate:
return
self.sampleRate = sampleRate
secondary_samples_per_bit = int(round(self.sampleRate / self.baudRate))
cutoff = self.baudRate / self.sampleRate
loop_gain = self.sampleRate / self.getBandwidth() / 5
self.replace(2, Lowpass(Format.FLOAT, cutoff))
self.replace(3, TimingRecovery(Format.FLOAT, secondary_samples_per_bit, loop_gain, 10))

27
csdr/chain/dump1090.py Normal file
View file

@ -0,0 +1,27 @@
from pycsdr.modules import Convert
from pycsdr.types import Format
from csdr.chain.demodulator import ServiceDemodulator
from owrx.adsb.dump1090 import Dump1090Module, RawDeframer
from owrx.adsb.modes import ModeSParser
class Dump1090(ServiceDemodulator):
def __init__(self):
workers = [
Convert(Format.COMPLEX_FLOAT, Format.COMPLEX_SHORT),
Dump1090Module(),
RawDeframer(),
ModeSParser(),
]
super().__init__(workers)
pass
def getFixedAudioRate(self) -> int:
return 2400000
def isSecondaryFftShown(self):
return False
def supportsSquelch(self) -> bool:
return False

16
csdr/chain/dumphfdl.py Normal file
View file

@ -0,0 +1,16 @@
from csdr.chain.demodulator import ServiceDemodulator
from owrx.hfdl.dumphfdl import DumpHFDLModule, HFDLMessageParser
class DumpHFDL(ServiceDemodulator):
def __init__(self):
super().__init__([
DumpHFDLModule(),
HFDLMessageParser(),
])
def getFixedAudioRate(self) -> int:
return 12000
def supportsSquelch(self) -> bool:
return False

19
csdr/chain/dumpvdl2.py Normal file
View file

@ -0,0 +1,19 @@
from csdr.chain.demodulator import ServiceDemodulator
from owrx.vdl2.dumpvdl2 import DumpVDL2Module, VDL2MessageParser
from pycsdr.modules import Convert
from pycsdr.types import Format
class DumpVDL2(ServiceDemodulator):
def __init__(self):
super().__init__([
Convert(Format.COMPLEX_FLOAT, Format.COMPLEX_SHORT),
DumpVDL2Module(),
VDL2MessageParser(),
])
def getFixedAudioRate(self) -> int:
return 105000
def supportsSquelch(self) -> bool:
return False

View file

@ -1,8 +1,7 @@
from csdr.chain.demodulator import BaseDemodulatorChain, FixedIfSampleRateChain, FixedAudioRateChain, MetaProvider
from csdr.module.m17 import M17Module
from pycsdr.modules import FmDemod, Limit, Convert, Writer
from pycsdr.modules import FmDemod, Limit, Convert, Writer, DcBlock
from pycsdr.types import Format
from digiham.modules import DcBlock
class M17(BaseDemodulatorChain, FixedIfSampleRateChain, FixedAudioRateChain, MetaProvider):

19
csdr/chain/rtl433.py Normal file
View file

@ -0,0 +1,19 @@
from csdr.module import JsonParser
from owrx.ism.rtl433 import Rtl433Module
from csdr.chain.demodulator import ServiceDemodulator
class Rtl433(ServiceDemodulator):
def getFixedAudioRate(self) -> int:
return 250000
def __init__(self):
super().__init__(
[
Rtl433Module(),
JsonParser("ISM"),
]
)
def supportsSquelch(self) -> bool:
return False

View file

@ -1,6 +1,7 @@
from csdr.chain import Chain
from pycsdr.modules import Shift, FirDecimate, Bandpass, Squelch, FractionalDecimator, Writer
from pycsdr.types import Format
from typing import Union
import math
@ -28,6 +29,13 @@ class Decimator(Chain):
super().__init__(workers)
def _getDecimation(self, outputRate: int) -> (int, float):
if outputRate > self.inputRate:
raise SelectorError(
"cannot provide selected output rate {} since it is bigger than input rate {}".format(
outputRate,
self.inputRate
)
)
d = self.inputRate / outputRate
dInt = int(d)
dFloat = float(self.inputRate / dInt) / outputRate
@ -72,10 +80,9 @@ class Selector(Chain):
self.decimation = Decimator(inputRate, outputRate)
self.bandpass = self._buildBandpass()
self.bandpassCutoffs = None
self.setBandpass(-4000, 4000)
self.bandpassCutoffs = [None, None]
workers = [self.shift, self.decimation, self.bandpass]
workers = [self.shift, self.decimation]
if withSquelch:
self.readings_per_second = 4
@ -106,16 +113,30 @@ class Selector(Chain):
def setSquelchLevel(self, level: float) -> None:
self.squelch.setSquelchLevel(self._convertToLinear(level))
def _enableBandpass(self):
index = self.indexOf(lambda x: isinstance(x, Bandpass))
if index < 0:
self.insert(2, self.bandpass)
def _disableBandpass(self):
index = self.indexOf(lambda x: isinstance(x, Bandpass))
if index >= 0:
self.remove(index)
def setBandpass(self, lowCut: float, highCut: float) -> None:
self.bandpassCutoffs = [lowCut, highCut]
scaled = [x / self.outputRate for x in self.bandpassCutoffs]
self.bandpass.setBandpass(*scaled)
if None in self.bandpassCutoffs:
self._disableBandpass()
else:
self._enableBandpass()
scaled = [x / self.outputRate for x in self.bandpassCutoffs]
self.bandpass.setBandpass(*scaled)
def setLowCut(self, lowCut: float) -> None:
def setLowCut(self, lowCut: Union[float, None]) -> None:
self.bandpassCutoffs[0] = lowCut
self.setBandpass(*self.bandpassCutoffs)
def setHighCut(self, highCut: float) -> None:
def setHighCut(self, highCut: Union[float, None]) -> None:
self.bandpassCutoffs[1] = highCut
self.setBandpass(*self.bandpassCutoffs)
@ -129,9 +150,11 @@ class Selector(Chain):
self.decimation.setOutputRate(outputRate)
self.squelch.setReportInterval(int(outputRate / (self.readings_per_second * 1024)))
index = self.indexOf(lambda x: isinstance(x, Bandpass))
self.bandpass = self._buildBandpass()
self.setBandpass(*self.bandpassCutoffs)
self.replace(2, self.bandpass)
if index >= 0:
self.replace(index, self.bandpass)
def setInputRate(self, inputRate: int) -> None:
if inputRate == self.inputRate:
@ -158,3 +181,7 @@ class SecondarySelector(Chain):
if self.frequencyOffset is None:
return
self.shift.setRate(-offset / self.sampleRate)
class SelectorError(Exception):
pass

View file

@ -1,12 +1,16 @@
from pycsdr.modules import Module as BaseModule
from pycsdr.modules import Reader, Writer
from pycsdr.modules import Reader, Writer, Buffer
from pycsdr.types import Format
from abc import ABCMeta, abstractmethod
from threading import Thread
from io import BytesIO
from subprocess import Popen, PIPE
from subprocess import Popen, PIPE, TimeoutExpired
from functools import partial
import pickle
import logging
import json
logger = logging.getLogger(__name__)
class Module(BaseModule, metaclass=ABCMeta):
@ -41,7 +45,10 @@ class Module(BaseModule, metaclass=ABCMeta):
break
if data is None or isinstance(data, bytes) and len(data) == 0:
break
write(data)
try:
write(data)
except BrokenPipeError:
break
return copy
@ -109,6 +116,57 @@ class PickleModule(ThreadModule):
pass
class LineBasedModule(ThreadModule, metaclass=ABCMeta):
def __init__(self):
self.retained = bytes()
super().__init__()
def getInputFormat(self) -> Format:
return Format.CHAR
def getOutputFormat(self) -> Format:
return Format.CHAR
def run(self):
while self.doRun:
data = self.reader.read()
if data is None:
self.doRun = False
else:
self.retained += data
lines = self.retained.split(b"\n")
# keep the last line
# this should either be empty if the last char was \n
# or an incomplete line if the read returned early
self.retained = lines[-1]
# log all completed lines
for line in lines[0:-1]:
parsed = self.process(line)
if parsed is not None:
self.writer.write(pickle.dumps(parsed))
@abstractmethod
def process(self, line: bytes) -> any:
pass
class JsonParser(LineBasedModule):
def __init__(self, mode: str):
self.mode = mode
super().__init__()
def process(self, line):
try:
msg = json.loads(line)
msg["mode"] = self.mode
logger.debug(msg)
return msg
except json.JSONDecodeError:
logger.exception("error parsing rtl433 json")
class PopenModule(AutoStartModule, metaclass=ABCMeta):
def __init__(self):
self.process = None
@ -130,7 +188,41 @@ class PopenModule(AutoStartModule, metaclass=ABCMeta):
def stop(self):
if self.process is not None:
self.process.terminate()
self.process.wait()
# Try terminating normally, kill if failed to terminate
try:
self.process.terminate()
self.process.wait(3)
except TimeoutExpired:
self.process.kill()
self.process = None
self.reader.stop()
class LogReader(Thread):
def __init__(self, prefix: str, buffer: Buffer):
self.reader = buffer.getReader()
self.logger = logging.getLogger(prefix)
self.retained = bytes()
super().__init__()
self.start()
def run(self) -> None:
while True:
data = self.reader.read()
if data is None:
return
self.retained += data
lines = self.retained.split(b"\n")
# keep the last line
# this should either be empty if the last char was \n
# or an incomplete line if the read returned early
self.retained = lines[-1]
# log all completed lines
for line in lines[0:-1]:
self.logger.info("{}: {}".format("STDOUT", line.decode(errors="replace")))
def stop(self):
self.reader.stop()

View file

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

View file

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

View file

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

11
debian/changelog vendored
View file

@ -2,12 +2,23 @@ openwebrx (1.3.0) UNRELEASED; urgency=low
* SDR device log messages are now available in the web configuration to
simplify troubleshooting
* Added support for the MSK144 digimode
* Added support for decoding ADS-B with dump1090
* Added support for decoding HFDL and VDL2 aircraft communications
* Added decoding of ISM band transmissions using rtl_433
* Added IPv6 support
* Added profile re-ordering using drag & drop
* Added the ability to disable profiles
* New devices supported:
- Afedri SDR-Net
-- Jakob Ketterl <jakob.ketterl@gmx.de> Fri, 30 Sep 2022 16:47:00 +0000
openwebrx (1.2.2) bullseye jammy; urgency=high
* - Fixed an over-the-air code injection vulnerability
-- Jakob Ketterl <jakob.ketterl@gmx.de> Sun, 08 Oct 2023 21:29:00 +0000
openwebrx (1.2.1) bullseye jammy; urgency=low
* FifiSDR support fixed (pipeline formats now line up correctly)

36
debian/control vendored
View file

@ -2,15 +2,45 @@ Source: openwebrx
Maintainer: Jakob Ketterl <jakob.ketterl@gmx.de>
Section: hamradio
Priority: optional
Rules-Requires-Root: no
Standards-Version: 4.2.0
Build-Depends: debhelper (>= 11), dh-python, python3-all (>= 3.5), python3-setuptools
Build-Depends: debhelper (>= 11),
dh-python,
python3-all (>= 3.5),
python3-setuptools
Homepage: https://www.openwebrx.de/
Vcs-Browser: https://github.com/jketterl/openwebrx
Vcs-Git: https://github.com/jketterl/openwebrx.git
Package: openwebrx
Architecture: all
Depends: adduser, python3 (>= 3.5), python3-pkg-resources, owrx-connector (>= 0.7), 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.2), nmux (>= 0.18), codecserver (>= 0.1), msk144decoder
Depends: adduser,
python3 (>= 3.5),
python3-pkg-resources,
owrx-connector (>= 0.7),
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.2),
nmux (>= 0.18),
codecserver (>= 0.1),
msk144decoder,
dump1090-fa-minimal,
dumphfdl,
dumpvdl2,
rtl-433,
extra-sdr-drivers,
perseus-tools,
dream-headless,
codec2
Description: multi-user web sdr
Open source, multi-user SDR receiver with a web interface

View file

@ -3,6 +3,7 @@
db_get openwebrx/admin_user_configured
if [ "${1:-}" = "reconfigure" ] || [ "${RET}" != true ]; then
db_settitle openwebrx/title
db_input high openwebrx/admin_user_password || true
db_go
fi

View file

@ -14,25 +14,29 @@ case "$1" in
adduser --system --group --no-create-home --home /nonexistent --quiet "${OWRX_USER}"
usermod -aG plugdev "${OWRX_USER}"
# ensure group exists first (dependency is optional)
addgroup --system --quiet perseususb
usermod -aG perseususb "${OWRX_USER}"
# create OpenWebRX data directory and set the correct permissions
if [ ! -d "${OWRX_DATADIR}" ] && [ ! -L "${OWRX_DATADIR}" ]; then mkdir "${OWRX_DATADIR}"; fi
chown "${OWRX_USER}". ${OWRX_DATADIR}
chown "${OWRX_USER}": ${OWRX_DATADIR}
# create empty config files now to avoid permission problems later
if [ ! -e "${OWRX_USERS_FILE}" ]; then
echo "[]" > "${OWRX_USERS_FILE}"
chown "${OWRX_USER}". "${OWRX_USERS_FILE}"
chown "${OWRX_USER}": "${OWRX_USERS_FILE}"
chmod 0600 "${OWRX_USERS_FILE}"
fi
if [ ! -e "${OWRX_SETTINGS_FILE}" ]; then
echo "{}" > "${OWRX_SETTINGS_FILE}"
chown "${OWRX_USER}". "${OWRX_SETTINGS_FILE}"
chown "${OWRX_USER}": "${OWRX_SETTINGS_FILE}"
fi
if [ ! -e "${OWRX_BOOKMARKS_FILE}" ]; then
touch "${OWRX_BOOKMARKS_FILE}"
chown "${OWRX_USER}". "${OWRX_BOOKMARKS_FILE}"
chown "${OWRX_USER}": "${OWRX_BOOKMARKS_FILE}"
fi
db_get openwebrx/admin_user_password

View file

@ -20,4 +20,8 @@ Type: boolean
Default: false
Description: OpenWebRX "admin" user previously configured?
Marker used internally by the config scripts to remember if an admin user has
been created.
been created.
Template: openwebrx/title
Type: title
Description: Configuring OpenWebRX

View file

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

View file

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

View file

@ -1,4 +1,4 @@
FROM debian:bullseye-slim
FROM debian:bookworm-slim
COPY docker/files/js8call/js8call-hamlib.patch \
docker/files/wsjtx/wsjtx.patch \
@ -23,6 +23,6 @@ VOLUME /etc/openwebrx
VOLUME /var/lib/openwebrx
ENV S6_CMD_ARG0="/opt/openwebrx/docker/scripts/run.sh"
CMD []
CMD [""]
EXPOSE 8073

View file

@ -10,6 +10,7 @@ RUN /install-dependencies-rtlsdr.sh &&\
/install-dependencies-hackrf.sh &&\
/install-dependencies-sdrplay.sh &&\
/install-dependencies-airspy.sh &&\
/install-dependencies-afedri.sh &&\
/install-dependencies-rtlsdr-soapy.sh &&\
/install-dependencies-plutosdr.sh &&\
/install-dependencies-limesdr.sh &&\

View file

@ -1,23 +0,0 @@
diff -ur sdrplay-orig/install_lib.sh sdrplay/install_lib.sh
--- sdrplay-orig/install_lib.sh 2020-05-24 14:30:06.022483867 +0000
+++ sdrplay/install_lib.sh 2020-05-24 14:30:49.093435726 +0000
@@ -4,19 +4,6 @@
export MAJVERS="3"
echo "Installing SDRplay RSP API library ${VERS}..."
-read -p "Press RETURN to view the license agreement" ret
-
-more sdrplay_license.txt
-
-while true; do
- echo "Press y and RETURN to accept the license agreement and continue with"
- read -p "the installation, or press n and RETURN to exit the installer [y/n] " yn
- case $yn in
- [Yy]* ) break;;
- [Nn]* ) exit;;
- * ) echo "Please answer y or n";;
- esac
-done
export ARCH=`uname -m`

View file

@ -0,0 +1 @@
install-lib.x86_64.patch

View file

@ -1,14 +1,11 @@
diff -ur sdrplay-orig/install_lib.sh sdrplay/install_lib.sh
--- sdrplay-orig/install_lib.sh 2020-05-24 13:56:56.622000041 +0000
+++ sdrplay/install_lib.sh 2020-05-24 13:58:51.837801559 +0000
@@ -4,19 +4,6 @@
MAJVERS="3"
--- sdrplay-orig/install_lib.sh 2024-01-01 15:03:53.377291864 +0100
+++ sdrplay/install_lib.sh 2024-01-01 16:09:25.948363042 +0100
@@ -17,26 +17,7 @@
echo "the system files."
echo " "
echo "Installing SDRplay RSP API library ${VERS}..."
-read -p "Press RETURN to view the license agreement" ret
-
-more sdrplay_license.txt
-
-more -d sdrplay_license.txt
-while true; do
- echo "Press y and RETURN to accept the license agreement and continue with"
- read -p "the installation, or press n and RETURN to exit the installer [y/n] " yn
@ -18,22 +15,133 @@ diff -ur sdrplay-orig/install_lib.sh sdrplay/install_lib.sh
- * ) echo "Please answer y or n";;
- esac
-done
-
-echo " "
-echo "A copy of the license agreement can be found here: ${HOME}/sdrplay_license.txt"
-cp sdrplay_license.txt ${HOME}/.
-chmod 644 ${HOME}/sdrplay_license.txt
-echo " "
-
ARCH=$(uname -m|sed -e 's/x86_64/64/' -e 's/aarch64/64/' -e 's/arm64/64/' -e 's/i.86/32/')
-INIT=$(file -L /sbin/init|sed -e 's/^.* \(32\|64\)-bit.*$/\1/')
COMPILER=$(getconf LONG_BIT)
ARCHM=$(uname -m)
INSTALLARCH=$(uname -m)
@@ -47,12 +28,11 @@
ARCH=`uname -m`
OSDIST="Unknown"
@@ -157,15 +144,6 @@
echo " "
echo "SDRplay API ${VERS} Installation Finished"
echo "Architecture reported as being $ARCH bit"
-echo "System reports $INIT bit files found"
echo "System is also setup to produce $COMPILER bit files"
echo "Architecture reports machine as being $ARCHM compliant"
echo " "
-if [ "${ARCH}" != "64" ] || [ "${INIT}" != "64" ] || [ "${COMPILER}" != "64" ]; then
+if [ "${ARCH}" != "64" ] || [ "${COMPILER}" != "64" ]; then
echo "This installer only supports 64 bit architectures."
echo "One of the above indicates that something is not set for"
echo "64 bit operation. Please either fix the relevant OS issue or"
@@ -193,11 +173,6 @@
sudo chmod 644 /etc/udev/hwdb.d/20-sdrplay.hwdb
sudo systemd-hwdb update
sudo udevadm trigger
- if [ "${SRVTYPE}" != "initd" ]; then
- sudo systemctl restart udev
- else
- sudo service udev restart
- fi
echo "Done"
fi
fi
@@ -227,7 +202,7 @@
fi
echo " "
-locservice="/opt/sdrplay_api"
+locservice="/usr/local/bin"
locheader="/usr/local/include"
loclib="/usr/local/lib"
locscripts="/etc/systemd/system"
@@ -247,45 +222,6 @@
echo "Daemon start system : ${DAEMON_SYS}"
echo " "
-# 0--------1---------2---------3---------4---------5---------6---------7---------8
-while true; do
- echo "Would you like to add SDRplay USB IDs to the local database for easier"
- read -p "identification in applications such as lsusb? [y/n] " yn
- echo "To continue the installation with these defaults press y and RETURN"
- read -p "or press n and RETURN to change them [y/n] " yn
- case $yn in
- [Yy]* ) break;;
- [Nn]* ) exit;;
- [Yy]* ) change="n";break;;
- [Nn]* ) change="y";break;;
- * ) echo "Please answer y or n";;
- esac
-done
sudo cp scripts/sdrplay_usbids.sh ${INSTALLBINDIR}/.
sudo chmod 755 ${INSTALLBINDIR}/sdrplay_usbids.sh
sudo cp scripts/sdrplay_ids.txt ${INSTALLBINDIR}/.
-
-if [ "${change}" == "y" ]; then
- echo "Changing default locations..."
- read -p "API service location [${locservice}]: " newloc
- if [ "${newloc}" != "" ]; then
- locservice=${newloc}
- fi
- read -p "API header files location [${locheader}]: " newloc
- if [ "${newloc}" != "" ]; then
- locheader=${newloc}
- fi
- read -p "API shared library location [${loclib}]: " newloc
- if [ "${newloc}" != "" ]; then
- loclib=${newloc}
- fi
-
- echo "API service : ${locservice}"
- echo "API header files : ${locheader}"
- echo "API shared library : ${loclib}"
- while true; do
- read -p "Please confirm these are correct [y/n] " yn
- case $yn in
- [Yy]* ) break;;
- [Nn]* ) echo "paths not confirmed. Exiting...";exit 1;;
- * ) echo "Please answer y or n";;
- esac
- done
-fi
-
sudo mkdir -p -m 755 ${locservice} >> /dev/null 2>&1
sudo mkdir -p -m 755 ${locheader} >> /dev/null 2>&1
sudo mkdir -p -m 755 ${loclib} >> /dev/null 2>&1
@@ -317,10 +253,6 @@
echo -n "Installing Service scripts and starting daemon..."
if [ -d "/etc/systemd/system" ]; then
SRVTYPE="systemd"
- if [ -f "/etc/systemd/system/sdrplay.service" ]; then
- sudo systemctl stop sdrplay
- sudo systemctl disable sdrplay
- fi
sudo bash -c 'cat > /etc/systemd/system/sdrplay.service' << EOF
[Unit]
Description=SDRplay API Service
@@ -339,8 +271,6 @@
EOF
sudo chmod 644 /etc/systemd/system/sdrplay.service
- sudo systemctl enable sdrplay
- sudo systemctl start sdrplay
else
SRVTYPE="initd"
if [ -f "/etc/init.d/sdrplayService" ]; then
@@ -443,16 +373,6 @@
echo "finished, please reboot this device."
echo " "
-echo "To start and stop the API service, use the following commands..."
-echo " "
-if [ "${SRVTYPE}" != "systemd" ]; then
- echo "sudo service sdrplayService start"
- echo "sudo service sdrplayService stop"
-else
- echo "sudo systemctl start sdrplay"
- echo "sudo systemctl stop sdrplay"
-fi
-echo " "
echo "If supported on your system, lsusb will now show the RSP name"
echo " "
echo "SDRplay API ${VERS} Installation Finished"

View file

@ -1,2 +1,2 @@
#!/usr/bin/execlineb -P
#!/command/execlineb -P
/usr/local/bin/codecserver

View file

@ -1,2 +1,2 @@
#!/usr/bin/execlineb -P
#!/command/execlineb -P
/usr/local/bin/sdrplay_apiService

View file

@ -18,14 +18,15 @@ function cmakebuild() {
cd /tmp
STATIC_PACKAGES="libfftw3-single3"
BUILD_PACKAGES="git cmake make gcc g++ libsamplerate-dev libfftw3-dev"
apt-get update
apt-get -y install --no-install-recommends $BUILD_PACKAGES
apt-get -y install --no-install-recommends $STATIC_PACKAGES $BUILD_PACKAGES
git clone https://github.com/jketterl/owrx_connector.git
# latest develop as of 2023-03-18 (added --listdriver option)
cmakebuild owrx_connector beda260363c0e77617d4e17ef864e1f5c3fd86b2
# latest develop as of 2024-01-01 (fixed startup race condition)
cmakebuild owrx_connector 62219d40e180abb539ad61fcd9625b90c34f0e26
apt-get -y purge --autoremove $BUILD_PACKAGES
apt-get clean

View file

@ -0,0 +1,33 @@
#!/usr/bin/env bash
set -euo pipefail
export MAKEFLAGS="-j4"
function cmakebuild() {
cd $1
if [[ ! -z "${2:-}" ]]; then
git checkout $2
fi
mkdir build
cd build
cmake ..
make
make install
cd ../..
rm -rf $1
}
cd /tmp
STATIC_PACKAGES=""
BUILD_PACKAGES="git cmake make gcc g++"
apt-get update
apt-get -y install --no-install-recommends $STATIC_PACKAGES $BUILD_PACKAGES
git clone https://github.com/alexander-sholohov/SoapyAfedri.git
# latest from master as of 2023-11-16
cmakebuild SoapyAfedri a7d0d942fe966c2b69c8817dd6f097fc94122660
apt-get -y purge --autoremove $BUILD_PACKAGES
apt-get clean
rm -rf /var/lib/apt/lists/*

View file

@ -25,11 +25,11 @@ apt-get update
apt-get -y install --no-install-recommends $STATIC_PACKAGES $BUILD_PACKAGES
git clone https://github.com/Nuand/bladeRF.git
cmakebuild bladeRF 2021.10
cmakebuild bladeRF 2023.02
git clone https://github.com/pothosware/SoapyBladeRF.git
# latest from master as of 2022-01-12
cmakebuild SoapyBladeRF 70505a5cdf8c9deabc4af3eb3384aa82a7b6f021
# latest from master as of 2023-08-30
cmakebuild SoapyBladeRF 85f6dc554ed4c618304d99395b19c4e1523675b0
apt-get -y purge --autoremove $BUILD_PACKAGES
apt-get clean

View file

@ -18,7 +18,7 @@ function cmakebuild() {
cd /tmp
STATIC_PACKAGES="libusb-1.0-0 libfftw3-3 udev"
STATIC_PACKAGES="libusb-1.0-0 libfftw3-single3 udev"
BUILD_PACKAGES="git cmake make patch wget sudo gcc g++ libusb-1.0-0-dev libfftw3-dev pkg-config"
apt-get update

View file

@ -10,7 +10,7 @@ apt-get -y install --no-install-recommends $BUILD_PACKAGES
pushd /tmp
ARCH=$(uname -m)
GOVERSION=1.15.5
GOVERSION=1.20.10
case ${ARCH} in
x86_64)
@ -29,7 +29,7 @@ tar xfz $PACKAGE
git clone https://github.com/jancona/hpsdrconnector.git
pushd hpsdrconnector
git checkout v0.6.1
git checkout v0.6.4
/tmp/go/bin/go build
install -m 0755 hpsdrconnector /usr/local/bin

View file

@ -18,8 +18,8 @@ function cmakebuild() {
cd /tmp
STATIC_PACKAGES="libusb-1.0-0 libfftw3-3 udev"
BUILD_PACKAGES="git cmake make patch wget sudo gcc g++ libusb-1.0-0-dev libfftw3-dev pkg-config"
STATIC_PACKAGES=""
BUILD_PACKAGES="git cmake make gcc g++"
apt-get update
apt-get -y install --no-install-recommends $STATIC_PACKAGES $BUILD_PACKAGES

View file

@ -25,11 +25,12 @@ apt-get update
apt-get -y install --no-install-recommends $STATIC_PACKAGES $BUILD_PACKAGES
git clone https://github.com/osmocom/rtl-sdr.git
# latest from master as of 2020-09-04
cmakebuild rtl-sdr ed0317e6a58c098874ac58b769cf2e609c18d9a5
# latest from master as of 2023-09-13 (integration of rtlsdr blog v4 dongle)
cmakebuild rtl-sdr 1261fbb285297da08f4620b18871b6d6d9ec2a7b
git clone https://github.com/pothosware/SoapyRTLSDR.git
cmakebuild SoapyRTLSDR soapy-rtl-sdr-0.3.1
# latest from master as of 2023-09-13
cmakebuild SoapyRTLSDR 068aa77a4c938b239c9d80cd42c4ee7986458e8f
apt-get -y purge --autoremove $BUILD_PACKAGES
apt-get clean

View file

@ -25,8 +25,8 @@ apt-get update
apt-get -y install --no-install-recommends $STATIC_PACKAGES $BUILD_PACKAGES
git clone https://github.com/osmocom/rtl-sdr.git
# latest from master as of 2020-09-04
cmakebuild rtl-sdr ed0317e6a58c098874ac58b769cf2e609c18d9a5
# latest from master as of 2023-09-13 (integration of rtlsdr blog v4 dongle)
cmakebuild rtl-sdr 1261fbb285297da08f4620b18871b6d6d9ec2a7b
apt-get -y purge --autoremove $BUILD_PACKAGES
apt-get clean

View file

@ -18,15 +18,15 @@ function cmakebuild() {
cd /tmp
STATIC_PACKAGES=""
BUILD_PACKAGES="git cmake make gcc g++ pkg-config"
STATIC_PACKAGES="libfftw3-single3"
BUILD_PACKAGES="git cmake make gcc g++ pkg-config libfftw3-dev"
apt-get update
apt-get -y install --no-install-recommends $STATIC_PACKAGES $BUILD_PACKAGES
git clone https://github.com/jketterl/runds_connector.git
# latest develop as of 2022-12-11 (std::endl implicit flushing)
cmakebuild runds_connector 06ca993a3c81ddb0a2581b1474895da07752a9e1
# latest develop as of 2023-07-04 (cmake exports)
cmakebuild runds_connector 435364002d756735015707e7f59aa40e8d743585
apt-get -y purge --autoremove $BUILD_PACKAGES
apt-get clean

View file

@ -28,13 +28,13 @@ ARCH=$(uname -m)
case $ARCH in
x86_64)
BINARY=SDRplay_RSP_API-Linux-3.07.1.run
BINARY=SDRplay_RSP_API-Linux-3.12.1.run
;;
armv*)
BINARY=SDRplay_RSP_API-ARM32-3.07.2.run
;;
aarch64)
BINARY=SDRplay_RSP_API-ARM64-3.07.1.run
BINARY=SDRplay_RSP_API-Linux-3.12.1.run
;;
esac

View file

@ -21,8 +21,8 @@ function cmakebuild() {
cd /tmp
STATIC_PACKAGES="libfftw3-bin python3 python3-setuptools netcat-openbsd libsndfile1 liblapack3 libusb-1.0-0 libqt5core5a libreadline8 libgfortran5 libgomp1 libasound2 libudev1 ca-certificates libpulse0 libfaad2 libopus0 libboost-program-options1.74.0 libboost-log1.74.0 libcurl4"
BUILD_PACKAGES="wget git libsndfile1-dev libfftw3-dev cmake make gcc g++ liblapack-dev texinfo gfortran libusb-1.0-0-dev qtbase5-dev qtmultimedia5-dev qttools5-dev libqt5serialport5-dev qttools5-dev-tools asciidoctor asciidoc libasound2-dev libudev-dev libhamlib-dev patch xsltproc qt5-qmake libfaad-dev libopus-dev libboost-dev libboost-program-options-dev libboost-log-dev libboost-regex-dev libpulse-dev libcurl4-openssl-dev"
STATIC_PACKAGES="libfftw3-single3 libfftw3-double3 python3 python3-setuptools netcat-openbsd libsndfile1 liblapack3 libusb-1.0-0 libqt5core5a libreadline8 libgfortran5 libgomp1 libasound2 libudev1 ca-certificates libpulse0 libfaad2 libopus0 libboost-program-options1.74.0 libboost-log1.74.0 libcurl4 libncurses6 libliquid1 libconfig++9v5"
BUILD_PACKAGES="wget git libsndfile1-dev libfftw3-dev cmake make gcc g++ liblapack-dev texinfo gfortran libusb-1.0-0-dev qtbase5-dev qtmultimedia5-dev qttools5-dev libqt5serialport5-dev qttools5-dev-tools asciidoctor asciidoc libasound2-dev libudev-dev libhamlib-dev patch xsltproc qt5-qmake libfaad-dev libopus-dev libboost-dev libboost-program-options-dev libboost-log-dev libboost-regex-dev libpulse-dev libcurl4-openssl-dev libncurses-dev xz-utils libliquid-dev libconfig++-dev"
apt-get update
apt-get -y install auto-apt-proxy
apt-get -y install --no-install-recommends $STATIC_PACKAGES $BUILD_PACKAGES
@ -35,13 +35,16 @@ case `uname -m` in
PLATFORM=aarch64
;;
x86_64*)
PLATFORM=amd64
PLATFORM=x86_64
;;
esac
wget https://github.com/just-containers/s6-overlay/releases/download/v1.21.8.0/s6-overlay-${PLATFORM}.tar.gz
tar xzf s6-overlay-${PLATFORM}.tar.gz -C /
rm s6-overlay-${PLATFORM}.tar.gz
wget https://github.com/just-containers/s6-overlay/releases/download/v3.1.5.0/s6-overlay-noarch.tar.xz
tar -Jxpf /tmp/s6-overlay-noarch.tar.xz -C /
rm s6-overlay-noarch.tar.xz
wget https://github.com/just-containers/s6-overlay/releases/download/v3.1.5.0/s6-overlay-${PLATFORM}.tar.xz
tar -Jxpf /tmp/s6-overlay-${PLATFORM}.tar.xz -C /
rm s6-overlay-${PLATFORM}.tar.xz
JS8CALL_VERSION=2.2.0
JS8CALL_DIR=js8call
@ -86,8 +89,7 @@ rm -rf /usr/local/share/doc/direwolf/examples/
git clone https://github.com/drowe67/codec2.git
cd codec2
# latest commit from master as of 2020-10-04
git checkout 55d7bb8d1bddf881bdbfcb971a718b83e6344598
git checkout 1.2.0
mkdir build
cd build
cmake ..
@ -111,6 +113,26 @@ rm dream-2.1.1-svn808.tar.gz
git clone https://github.com/mobilinkd/m17-cxx-demod.git
cmakebuild m17-cxx-demod v2.3
git clone --depth 1 -b v9.0 https://github.com/flightaware/dump1090
cd dump1090
make
install -m 0755 dump1090 /usr/local/bin
cd ..
rm -rf dump1090
git clone https://github.com/merbanan/rtl_433.git
# latest from master as of 2023-09-06
CMAKE_ARGS="-DENABLE_RTLSDR=OFF" cmakebuild rtl_433 70d84d01e1be87b459f7a10825966f3262b7dd34
git clone https://github.com/szpajder/libacars.git
cmakebuild libacars v2.2.0
git clone https://github.com/szpajder/dumphfdl
cmakebuild dumphfdl v1.4.1
git clone https://github.com/szpajder/dumpvdl2.git
cmakebuild dumpvdl2 v2.3.0
git clone https://github.com/hessu/aprs-symbols /usr/share/aprs-symbols
pushd /usr/share/aprs-symbols
git checkout 5c2abe2658ee4d2563f3c73b90c6f59124839802

View file

@ -18,7 +18,7 @@ function cmakebuild() {
cd /tmp
STATIC_PACKAGES="libfftw3-bin libprotobuf23 libsamplerate0 libicu67 libudev1"
STATIC_PACKAGES="libfftw3-single3 libprotobuf32 libsamplerate0 libicu72 libudev1"
BUILD_PACKAGES="git autoconf automake libtool libfftw3-dev pkg-config cmake make gcc g++ libprotobuf-dev protobuf-compiler libsamplerate-dev libicu-dev libpython3-dev libudev-dev"
apt-get update
apt-get -y install --no-install-recommends $STATIC_PACKAGES $BUILD_PACKAGES
@ -32,11 +32,13 @@ popd
rm -rf js8py
git clone https://github.com/jketterl/csdr.git
cmakebuild csdr 0.18.1
# latest develop as of 2023-09-08 (execmodule improvements)
cmakebuild csdr 764767933ed2b242190285f8ff56b11d80c7d530
git clone https://github.com/jketterl/pycsdr.git
cd pycsdr
git checkout 0.18.1
# latest develop as of 2023-08-21 (death of CallbackWriter))
git checkout 77b3709c545f510b52c7cea2e300e2e1613038e2
./setup.py install install_headers
cd ..
rm -rf pycsdr
@ -44,14 +46,17 @@ rm -rf pycsdr
git clone https://github.com/jketterl/codecserver.git
mkdir -p /usr/local/etc/codecserver
cp codecserver/conf/codecserver.conf /usr/local/etc/codecserver
cmakebuild codecserver 0.2.0
# latest develop as of 2023-07-03 (error handling)
cmakebuild codecserver 0f3703ce285acd85fcd28f6620d7795dc173cb50
git clone https://github.com/jketterl/digiham.git
cmakebuild digiham 0.6.1
# latest develop as of 2023-07-02 (codecserver protocol version)
cmakebuild digiham 262e6dfd9a2c56778bd4b597240756ad0fb9861d
git clone https://github.com/jketterl/pydigiham.git
cd pydigiham
git checkout 0.6.1
# latest develop as of 2023-06-30 (csdr cleanup)
git checkout 894aa87ea9a3534d1e7109da86194c7cd5e0b7c7
./setup.py install
cd ..
rm -rf pydigiham

View file

@ -34,4 +34,3 @@ python3 openwebrx.py $@ &
child=$!
wait "$child"

View file

@ -127,7 +127,7 @@ h1 {
}
.removable-group.removable .removable-item, .add-group .add-group-select {
flex: 1 0 auto;
flex: 1 0 0;
margin-right: .25rem;
}

View file

@ -791,11 +791,10 @@ img.openwebrx-mirror-img
#openwebrx-digimode-canvas-container
{
/*margin: -10px -10px 10px -10px;*/
margin: -10px -10px 0px -10px;
border-radius: 15px;
height: 150px;
background-color: #333;
margin: -10px -10px -10px -10px;
border-radius: 15px;
height: 200px;
background-color: #333;
position: relative;
overflow: hidden;
}
@ -902,7 +901,7 @@ img.openwebrx-mirror-img
#openwebrx-digimode-content-container
{
overflow-y: hidden;
display: block;
display: none;
height: 50px;
position: relative;
}
@ -921,16 +920,16 @@ img.openwebrx-mirror-img
{
transition: all 500ms;
background-color: Yellow;
display: block;
display: none;
position: absolute;
pointer-events: none;
height: 100%;
width: 0px;
top: 0px;
left: 0px;
width: 0;
top: 0;
left: 0;
opacity: 0.7;
border-style: solid;
border-width: 0px;
border-width: 0;
border-color: Red;
}
@ -1095,28 +1094,44 @@ img.openwebrx-mirror-img
}
.openwebrx-message-panel {
height: 180px;
min-height: 180px;
position: relative;
}
.openwebrx-message-panel tbody {
display: block;
overflow: auto;
height: 150px;
width: 100%;
.openwebrx-message-panel#openwebrx-panel-adsb-message {
min-height: 380px;
}
.openwebrx-message-panel thead tr {
.openwebrx-message-panel table {
display: block;
overflow: auto;
height: 100%;
width: 100%;
}
.openwebrx-message-panel th,
.openwebrx-message-panel td {
width: 50px;
min-width: 50px;
text-align: left;
vertical-align: top;
padding: 1px 3px;
}
.openwebrx-message-panel th {
position: sticky;
top: 0;
background-color: #575757;
}
.openwebrx-message-panel h4 {
margin: 0 0 .25em;
}
.openwebrx-message-panel .acars-message {
white-space: pre;
font-family: roboto-mono, monospace;
}
#openwebrx-panel-wsjt-message .message {
width: 380px;
}
@ -1254,49 +1269,19 @@ img.openwebrx-mirror-img
padding: 5px 10px;
}
#openwebrx-panel-digimodes[data-mode="ft8"] #openwebrx-digimode-content-container,
#openwebrx-panel-digimodes[data-mode="wspr"] #openwebrx-digimode-content-container,
#openwebrx-panel-digimodes[data-mode="jt65"] #openwebrx-digimode-content-container,
#openwebrx-panel-digimodes[data-mode="jt9"] #openwebrx-digimode-content-container,
#openwebrx-panel-digimodes[data-mode="ft4"] #openwebrx-digimode-content-container,
#openwebrx-panel-digimodes[data-mode="packet"] #openwebrx-digimode-content-container,
#openwebrx-panel-digimodes[data-mode="pocsag"] #openwebrx-digimode-content-container,
#openwebrx-panel-digimodes[data-mode="js8"] #openwebrx-digimode-content-container,
#openwebrx-panel-digimodes[data-mode="fst4"] #openwebrx-digimode-content-container,
#openwebrx-panel-digimodes[data-mode="fst4w"] #openwebrx-digimode-content-container,
#openwebrx-panel-digimodes[data-mode="q65"] #openwebrx-digimode-content-container,
#openwebrx-panel-digimodes[data-mode="msk144"] #openwebrx-digimode-content-container,
#openwebrx-panel-digimodes[data-mode="ft8"] #openwebrx-digimode-select-channel,
#openwebrx-panel-digimodes[data-mode="wspr"] #openwebrx-digimode-select-channel,
#openwebrx-panel-digimodes[data-mode="jt65"] #openwebrx-digimode-select-channel,
#openwebrx-panel-digimodes[data-mode="jt9"] #openwebrx-digimode-select-channel,
#openwebrx-panel-digimodes[data-mode="ft4"] #openwebrx-digimode-select-channel,
#openwebrx-panel-digimodes[data-mode="packet"] #openwebrx-digimode-select-channel,
#openwebrx-panel-digimodes[data-mode="pocsag"] #openwebrx-digimode-select-channel,
#openwebrx-panel-digimodes[data-mode="js8"] #openwebrx-digimode-select-channel,
#openwebrx-panel-digimodes[data-mode="fst4"] #openwebrx-digimode-select-channel,
#openwebrx-panel-digimodes[data-mode="fst4w"] #openwebrx-digimode-select-channel,
#openwebrx-panel-digimodes[data-mode="q65"] #openwebrx-digimode-select-channel,
#openwebrx-panel-digimodes[data-mode="msk144"] #openwebrx-digimode-select-channel
#openwebrx-panel-digimodes[data-mode^="bpsk"] #openwebrx-digimode-content-container,
#openwebrx-panel-digimodes[data-mode^="rtty"] #openwebrx-digimode-content-container,
#openwebrx-panel-digimodes[data-mode^="bpsk"] #openwebrx-digimode-select-channel,
#openwebrx-panel-digimodes[data-mode^="rtty"] #openwebrx-digimode-select-channel
{
display: none;
display: block;
}
#openwebrx-panel-digimodes[data-mode="ft8"] #openwebrx-digimode-canvas-container,
#openwebrx-panel-digimodes[data-mode="wspr"] #openwebrx-digimode-canvas-container,
#openwebrx-panel-digimodes[data-mode="jt65"] #openwebrx-digimode-canvas-container,
#openwebrx-panel-digimodes[data-mode="jt9"] #openwebrx-digimode-canvas-container,
#openwebrx-panel-digimodes[data-mode="ft4"] #openwebrx-digimode-canvas-container,
#openwebrx-panel-digimodes[data-mode="packet"] #openwebrx-digimode-canvas-container,
#openwebrx-panel-digimodes[data-mode="pocsag"] #openwebrx-digimode-canvas-container,
#openwebrx-panel-digimodes[data-mode="js8"] #openwebrx-digimode-canvas-container,
#openwebrx-panel-digimodes[data-mode="fst4"] #openwebrx-digimode-canvas-container,
#openwebrx-panel-digimodes[data-mode="fst4w"] #openwebrx-digimode-canvas-container,
#openwebrx-panel-digimodes[data-mode="q65"] #openwebrx-digimode-canvas-container,
#openwebrx-panel-digimodes[data-mode="msk144"] #openwebrx-digimode-canvas-container
#openwebrx-panel-digimodes[data-mode^="bpsk"] #openwebrx-digimode-canvas-container,
#openwebrx-panel-digimodes[data-mode^="rtty"] #openwebrx-digimode-canvas-container
{
height: 200px;
margin: -10px;
height: 150px;
margin-bottom: 0;
}
.openwebrx-zoom-button svg {

View file

@ -1,5 +1,5 @@
$(function(){
var converter = new showdown.Converter();
var converter = new showdown.Converter({openLinksInNewWindow: true});
$.ajax('api/features').done(function(data){
var $table = $('table.features');
$.each(data, function(name, details) {

View file

@ -74,6 +74,10 @@
<div class="openwebrx-panel openwebrx-message-panel" id="openwebrx-panel-js8-message" style="display:none; width: 619px;" data-panel-name="js8-message"></div>
<div class="openwebrx-panel openwebrx-message-panel" id="openwebrx-panel-packet-message" style="display: none; width: 619px;" data-panel-name="aprs-message"></div>
<div class="openwebrx-panel openwebrx-message-panel" id="openwebrx-panel-pocsag-message" style="display: none; width: 619px;" data-panel-name="pocsag-message"></div>
<div class="openwebrx-panel openwebrx-message-panel" id="openwebrx-panel-adsb-message" style="display: none; width: 619px;" data-panel-name="adsb-message"></div>
<div class="openwebrx-panel openwebrx-message-panel" id="openwebrx-panel-ism-message" style="display: none; width: 619px;" data-panel-name="ism-message"></div>
<div class="openwebrx-panel openwebrx-message-panel" id="openwebrx-panel-hfdl-message" style="display: none; width: 619px;" data-panel-name="hfdl-message"></div>
<div class="openwebrx-panel openwebrx-message-panel" id="openwebrx-panel-vdl2-message" style="display: none; width: 619px;" data-panel-name="vdl2-message"></div>
<div class="openwebrx-panel openwebrx-meta-panel" id="openwebrx-panel-metadata-m17" style="display: none;" data-panel-name="metadata-m17">
<div class="openwebrx-meta-slot">
<div class="openwebrx-meta-user-image">

View file

@ -2,6 +2,18 @@ function AprsMarker() {}
AprsMarker.prototype = new google.maps.OverlayView();
AprsMarker.prototype.isFacingEast = function(symbol) {
var candidates = ''
if (symbol.table === '/') {
// primary table
candidates = '(*<=>CFPUXYZabefgjkpsuv[';
} else {
// alternate table
candidates = '(T`efhjktuvw';
}
return candidates.includes(symbol.symbol);
};
AprsMarker.prototype.draw = function() {
var div = this.div;
var overlay = this.overlay;
@ -16,9 +28,15 @@ AprsMarker.prototype.draw = function() {
}
if (this.course) {
if (this.course > 180) {
if (this.symbol && !this.isFacingEast(this.symbol)) {
// assume symbol points to the north
div.style.transform = 'rotate(' + this.course + ' deg)';
} else if (this.course > 180) {
// symbol is pointing east
// don't rotate more than 180 degrees, rather mirror
div.style.transform = 'scalex(-1) rotate(' + (270 - this.course) + 'deg)'
} else {
// symbol is pointing east
div.style.transform = 'rotate(' + (this.course - 90) + 'deg)';
}
} else {
@ -79,7 +97,7 @@ AprsMarker.prototype.onAdd = function() {
panes.overlayImage.appendChild(div);
};
AprsMarker.prototype.remove = function() {
AprsMarker.prototype.onRemove = function() {
if (this.div) {
this.div.parentNode.removeChild(this.div);
this.div = null;

View file

@ -59,7 +59,6 @@ Envelope.prototype.draw = function(visible_range){
from_px -= (env_att_w + env_bounding_line_w);
to_px += (env_att_w + env_bounding_line_w);
// do drawing:
scale_ctx.lineWidth = 3;
var color = this.color || '#ffff00'; // yellow
scale_ctx.strokeStyle = color;
scale_ctx.fillStyle = color;
@ -77,16 +76,22 @@ Envelope.prototype.draw = function(visible_range){
scale_ctx.lineTo(to_px - env_bounding_line_w - env_att_w, env_h2);
scale_ctx.lineTo(to_px - env_bounding_line_w, env_h1);
scale_ctx.lineTo(to_px, env_h1);
scale_ctx.lineWidth = 3;
scale_ctx.globalAlpha = 0.3;
scale_ctx.fill();
scale_ctx.globalAlpha = 1;
scale_ctx.stroke();
scale_ctx.lineWidth = 1;
scale_ctx.font = "bold 11px sans-serif";
scale_ctx.textBaseline = "top";
scale_ctx.textAlign = "left";
scale_ctx.fillText(this.demodulator.high_cut.toString(), to_px + env_att_w, env_h2);
if (typeof(this.demodulator.high_cut) === 'number') {
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(this.demodulator.low_cut) === 'number') {
scale_ctx.fillText(this.demodulator.low_cut.toString(), from_px - env_att_w, env_h2);
}
}
if (typeof line !== "undefined") // out of screen?
{
@ -96,6 +101,7 @@ Envelope.prototype.draw = function(visible_range){
drag_ranges.line_on_screen = true;
scale_ctx.moveTo(line_px, env_h1 + env_lineplus);
scale_ctx.lineTo(line_px, env_h2 - env_lineplus);
scale_ctx.lineWidth = 3;
scale_ctx.stroke();
}
}
@ -332,6 +338,13 @@ Demodulator.prototype.setBandpass = function(bandpass) {
this.set();
};
Demodulator.prototype.disableBandpass = function() {
delete this.bandpass;
this.low_cut = null;
this.high_cut = null;
this.set()
}
Demodulator.prototype.setLowCut = function(low_cut) {
this.low_cut = low_cut;
this.set();

View file

@ -135,8 +135,12 @@ DemodulatorPanel.prototype.setMode = function(requestedModulation, underlyingMod
if (mode.type === 'digimode') {
this.demodulator.set_secondary_demod(mode.modulation);
if (mode.bandpass) {
this.demodulator.setBandpass(mode.bandpass);
var uMode = Modes.findByModulation(underlyingModulation);
var bandpass = mode.bandpass || (uMode && uMode.bandpass);
if (bandpass) {
this.demodulator.setBandpass(bandpass);
} else {
this.demodulator.disableBandpass();
}
} else {
this.demodulator.set_secondary_demod(false);
@ -158,11 +162,14 @@ DemodulatorPanel.prototype.disableDigiMode = function() {
DemodulatorPanel.prototype.updatePanels = function() {
var modulation = this.getDemodulator().get_secondary_demod();
$('#openwebrx-panel-digimodes').attr('data-mode', modulation);
toggle_panel("openwebrx-panel-digimodes", !!modulation);
var mode = Modes.findByModulation(modulation);
toggle_panel("openwebrx-panel-digimodes", modulation && (!mode || mode.secondaryFft));
// WSJT-X modes share the same panel
toggle_panel("openwebrx-panel-wsjt-message", ['ft8', 'wspr', 'jt65', 'jt9', 'ft4', 'fst4', 'fst4w', "q65", "msk144"].indexOf(modulation) >= 0);
toggle_panel("openwebrx-panel-js8-message", modulation === "js8");
toggle_panel("openwebrx-panel-packet-message", modulation === "packet");
toggle_panel("openwebrx-panel-pocsag-message", modulation === "pocsag");
// these modes come with their own
['js8', 'packet', 'pocsag', 'adsb', 'ism', 'hfdl', 'vdl2'].forEach(function(m) {
toggle_panel('openwebrx-panel-' + m + '-message', modulation === m);
});
modulation = this.getDemodulator().get_modulation();
var showing = 'openwebrx-panel-metadata-' + modulation;
@ -371,6 +378,6 @@ DemodulatorPanel.prototype.setTuningPrecision = function(precision) {
$.fn.demodulatorPanel = function(){
if (!this.data('panel')) {
this.data('panel', new DemodulatorPanel(this));
};
}
return this.data('panel');
};

View file

@ -98,13 +98,7 @@ TuneableFrequencyDisplay.prototype.setupEvents = function() {
if (index < 0) return;
var delta = 10 ** (Math.floor(Math.max(me.exponent, Math.log10(me.frequency))) - index);
var newFrequency;
if ('deltaMode' in e.originalEvent && e.originalEvent.deltaMode === 0) {
newFrequency = me.frequency - delta * (e.originalEvent.deltaY / 50);
} else {
if (e.originalEvent.deltaY > 0) delta *= -1;
newFrequency = me.frequency + delta;
}
var newFrequency = me.frequency - delta * wheelDelta(e.originalEvent);
me.element.trigger('frequencychange', newFrequency);
});
@ -126,6 +120,7 @@ TuneableFrequencyDisplay.prototype.setupEvents = function() {
submit();
});
$inputs.on('blur', function(e){
if (!me.input.is(':visible')) return;
if ($inputs.toArray().indexOf(e.relatedTarget) >= 0) {
return;
}

View file

@ -47,6 +47,15 @@ MessagePanel.prototype.initClearButton = function() {
$(me.el).append(me.clearButton);
};
MessagePanel.prototype.htmlEscape = function(input) {
return $('<div/>').text(input).html()
};
MessagePanel.prototype.scrollToBottom = function() {
var $t = $(this.el).find('table');
$t.scrollTop($t[0].scrollHeight);
};
function WsjtMessagePanel(el) {
MessagePanel.call(this, el);
this.initClearTimer();
@ -55,7 +64,7 @@ function WsjtMessagePanel(el) {
this.modes = [].concat(this.qsoModes, this.beaconModes);
}
WsjtMessagePanel.prototype = new MessagePanel();
WsjtMessagePanel.prototype = Object.create(MessagePanel.prototype);
WsjtMessagePanel.prototype.supportsMessage = function(message) {
return this.modes.indexOf(message['mode']) >= 0;
@ -85,23 +94,19 @@ WsjtMessagePanel.prototype.pushMessage = function(msg) {
var linkedmsg = msg['msg'];
var matches;
var html_escape = function(input) {
return $('<div/>').text(input).html()
};
if (this.qsoModes.indexOf(msg['mode']) >= 0) {
matches = linkedmsg.match(/(.*\s[A-Z0-9]+\s)([A-R]{2}[0-9]{2})$/);
if (matches && matches[2] !== 'RR73') {
linkedmsg = html_escape(matches[1]) + '<a href="map?locator=' + matches[2] + '" target="openwebrx-map">' + matches[2] + '</a>';
linkedmsg = this.htmlEscape(matches[1]) + '<a href="map?locator=' + matches[2] + '" target="openwebrx-map">' + matches[2] + '</a>';
} else {
linkedmsg = html_escape(linkedmsg);
linkedmsg = this.htmlEscape(linkedmsg);
}
} else if (this.beaconModes.indexOf(msg['mode']) >= 0) {
matches = linkedmsg.match(/([A-Z0-9]*\s)([A-R]{2}[0-9]{2})(\s[0-9]+)/);
if (matches) {
linkedmsg = html_escape(matches[1]) + '<a href="map?locator=' + matches[2] + '" target="openwebrx-map">' + matches[2] + '</a>' + html_escape(matches[3]);
linkedmsg = this.htmlEscape(matches[1]) + '<a href="map?locator=' + matches[2] + '" target="openwebrx-map">' + matches[2] + '</a>' + this.htmlEscape(matches[3]);
} else {
linkedmsg = html_escape(linkedmsg);
linkedmsg = this.htmlEscape(linkedmsg);
}
}
$b.append($(
@ -113,7 +118,7 @@ WsjtMessagePanel.prototype.pushMessage = function(msg) {
'<td class="message">' + linkedmsg + '</td>' +
'</tr>'
));
$b.scrollTop($b[0].scrollHeight);
this.scrollToBottom();
}
$.fn.wsjtMessagePanel = function(){
@ -128,7 +133,7 @@ function PacketMessagePanel(el) {
this.initClearTimer();
}
PacketMessagePanel.prototype = new MessagePanel();
PacketMessagePanel.prototype = Object.create(MessagePanel.prototype);
PacketMessagePanel.prototype.supportsMessage = function(message) {
return message['mode'] === 'APRS';
@ -216,10 +221,10 @@ PacketMessagePanel.prototype.pushMessage = function(msg) {
'<td>' + timestamp + '</td>' +
'<td class="callsign">' + callsign + '</td>' +
'<td class="coord">' + link + '</td>' +
'<td class="message">' + (msg.comment || msg.message || '') + '</td>' +
'<td class="message">' + this.htmlEscape(msg.comment || msg.message || '') + '</td>' +
'</tr>'
));
$b.scrollTop($b[0].scrollHeight);
this.scrollToBottom();
};
$.fn.packetMessagePanel = function() {
@ -234,7 +239,7 @@ PocsagMessagePanel = function(el) {
this.initClearTimer();
}
PocsagMessagePanel.prototype = new MessagePanel();
PocsagMessagePanel.prototype = Object.create(MessagePanel.prototype);
PocsagMessagePanel.prototype.supportsMessage = function(message) {
return message['mode'] === 'Pocsag';
@ -257,10 +262,10 @@ PocsagMessagePanel.prototype.pushMessage = function(msg) {
$b.append($(
'<tr>' +
'<td class="address">' + msg.address + '</td>' +
'<td class="message">' + msg.message + '</td>' +
'<td class="message">' + this.htmlEscape(msg.message) + '</td>' +
'</tr>'
));
$b.scrollTop($b[0].scrollHeight);
this.scrollToBottom();
};
$.fn.pocsagMessagePanel = function() {
@ -268,4 +273,540 @@ $.fn.pocsagMessagePanel = function() {
this.data('panel', new PocsagMessagePanel(this));
}
return this.data('panel');
};
AdsbMessagePanel = function(el) {
MessagePanel.call(this, el);
this.aircraft = {}
this.aircraftTrackingService = false;
this.initClearTimer();
}
AdsbMessagePanel.prototype = Object.create(MessagePanel.prototype);
AdsbMessagePanel.prototype.supportsMessage = function(message) {
return message["mode"] === "ADSB";
};
AdsbMessagePanel.prototype.render = function() {
$(this.el).append($(
'<table>' +
'<thead><tr>' +
'<th class="address">ICAO</th>' +
'<th class="callsign">Flight</th>' +
'<th class="altitude">Altitude</th>' +
'<th class="speed">Speed</th>' +
'<th class="track">Track</th>' +
'<th class="verticalspeed">V/S</th>' +
'<th class="position">Position</th>' +
'<th class="messages">Messages</th>' +
'</tr></thead>' +
'<tbody></tbody>' +
'</table>'
));
};
AdsbMessagePanel.prototype.pushMessage = function(message) {
if (!('icao' in message)) return;
if (!(message.icao in this.aircraft)) {
var el = $("<tr>");
$(this.el).find('tbody').append(el);
this.aircraft[message.icao] = {
el: el,
messages: 0
}
}
var state = this.aircraft[message.icao];
Object.assign(state, message);
state.lastSeen = Date.now();
state.messages += 1;
var ifDefined = function(input, formatter) {
if (typeof(input) !== 'undefined') {
if (formatter) return formatter(input);
return input;
}
return "";
}
var coordRound = function(i) {
return Math.round(i * 1000) / 1000;
}
var getPosition = function(state) {
if (!('lat' in state) || !('lon') in state) return '';
return '<a href="map?icao=' + state.icao + '" target="openwebrx-map">' + coordRound(state.lat) + ', ' + coordRound(state.lon) + '</a>';
}
state.el.html(
'<td>' + this.linkify(state, state.icao) + '</td>' +
'<td>' + this.linkify(state, ifDefined(state.identification)) + '</td>' +
'<td>' + ifDefined(state.altitude) + '</td>' +
'<td>' + ifDefined(state.groundspeed || state.IAS || state.TAS, Math.round) + '</td>' +
'<td>' + ifDefined(state.groundtrack || state.heading, Math.round) + '</td>' +
'<td>' + ifDefined(state.verticalspeed) + '</td>' +
'<td>' + getPosition(state) + '</td>' +
'<td>' + state.messages + '</td>'
);
};
AdsbMessagePanel.prototype.clearMessages = function(toRemain) {
var now = Date.now();
var me = this;
Object.entries(this.aircraft).forEach(function(e) {
if (now - e[1].lastSeen > toRemain) {
delete me.aircraft[e[0]];
e[1].el.remove();
}
})
};
AdsbMessagePanel.prototype.initClearTimer = function() {
var me = this;
if (me.removalInterval) clearInterval(me.removalInterval);
me.removalInterval = setInterval(function () {
me.clearMessages(30000);
}, 15000);
};
AdsbMessagePanel.prototype.setAircraftTrackingService = function(service) {
this.aircraftTrackingService = service;
};
AdsbMessagePanel.prototype.linkify = function(state, text) {
var link = false;
switch (this.aircraftTrackingService) {
case 'flightaware':
link = 'https://flightaware.com/live/modes/' + state.icao;
if (state.identification) link += "/ident/" + state.identification
link += '/redirect';
break;
case 'planefinder':
if (state.identification) link = 'https://planefinder.net/flight/' + state.identification;
break;
}
if (link) {
return '<a target="_blank" href="' + link + '">' + text + '</a>';
}
return text;
};
$.fn.adsbMessagePanel = function () {
if (!this.data('panel')) {
this.data('panel', new AdsbMessagePanel(this));
}
return this.data('panel');
};
IsmMessagePanel = function(el) {
MessagePanel.call(this, el);
this.initClearTimer();
};
IsmMessagePanel.prototype = Object.create(MessagePanel.prototype);
IsmMessagePanel.prototype.supportsMessage = function(message) {
return message['mode'] === 'ISM';
};
IsmMessagePanel.prototype.render = function() {
$(this.el).append($(
'<table>' +
'<thead><tr>' +
'<th class="model">Model</th>' +
'<th class="id">ID</th>' +
'<th class="channel">Channel</th>' +
'<th class="data">Data</th>' +
'</tr></thead>' +
'<tbody></tbody>' +
'</table>'
));
};
IsmMessagePanel.prototype.pushMessage = function(message) {
var $b = $(this.el).find('tbody');
var ifDefined = function(input, formatter) {
if (typeof(input) !== 'undefined') {
if (formatter) return formatter(input);
return input;
}
return "";
}
var mergeRemainingMessage = function(input, exclude) {
return Object.entries(input).map(function(entry) {
if (exclude.includes(entry[0])) return '';
return entry[0] + ': ' + entry[1] + ';';
}).join(' ');
}
$b.append($(
'<tr>' +
'<td class="model">' + ifDefined(message.model) + '</td>' +
'<td class="id">' + ifDefined(message.id) + '</td>' +
'<td class="channel">' + ifDefined(message.channel) + '</td>' +
'<td class="data">' + this.htmlEscape(mergeRemainingMessage(message, ['model', 'id', 'channel', 'mode', 'time'])) + '</td>' +
'</tr>'
));
this.scrollToBottom();
};
$.fn.ismMessagePanel = function() {
if (!this.data('panel')) {
this.data('panel', new IsmMessagePanel(this));
}
return this.data('panel');
};
AircraftMessagePanel = function(el) {
MessagePanel.call(this, el);
}
AircraftMessagePanel.prototype = Object.create(MessagePanel.prototype);
AircraftMessagePanel.prototype.renderAcars = function(acars) {
if (acars['more']) {
return '<h4>Partial ACARS message</h4>';
}
var details = '<h4>ACARS message</h4>';
if ('flight' in acars) {
details += '<div>Flight: ' + this.handleFlight(acars['flight']) + '</div>';
}
details += '<div>Registration: ' + acars['reg'].replace(/^\.+/g, '') + '</div>';
if ('media-adv' in acars) {
details += '<div>Media advisory</div>';
var mediaadv = acars['media-adv'];
if ('current_link' in mediaadv) {
details += '<div>Current link: ' + mediaadv['current_link']['descr'];
}
if ('links_avail' in mediaadv) {
details += '<div>Available links: ' + mediaadv['links_avail'].map(function (l) {
return l['descr'];
}).join(', ') + '</div>';
}
} else if ('arinc622' in acars) {
var arinc622 = acars['arinc622'];
if ('adsc' in arinc622) {
var adsc = arinc622['adsc'];
if ('tags' in adsc) {
adsc['tags'].forEach(function(tag) {
if ('basic_report' in tag) {
var basic_report = tag['basic_report'];
details += '<div>Basic ADS-C report</div>';
details += '<div>Position: ' + basic_report['lat'] + ', ' + basic_report['lon'] + '</div>';
details += '<div>Altitude: ' + basic_report['alt'] + '</div>';
} else if ('earth_ref_data' in tag) {
var earth_ref_data = tag['earth_ref_data'];
details += '<div>Track: ' + earth_ref_data['true_trk_deg'] + '</div>';
details += '<div>Speed: ' + earth_ref_data['gnd_spd_kts'] + ' kt</div>';
details += '<div>Vertical speed: ' + earth_ref_data['vspd_ftmin'] + ' ft/min</div>';
} else if ('cancel_all_contracts' in tag) {
details += '<div>Cancel all ADS-C contracts</div>';
} else if ('cancel_contract' in tag) {
details += '<div>Cancel ADS-C contract</div>';
} else {
details += '<div>Unsupported tag</div>';
}
});
} else {
details += '<div>Other ADS-C data</div>';
}
}
} else {
// plain text
details += '<div>Label: ' + acars['label'] + '</div>';
details += '<div class="acars-message">' + acars['msg_text'] + '</div>';
}
return details;
};
AircraftMessagePanel.prototype.handleFlight = function(raw) {
return raw.replace(/^([0-9A-Z]{2})0*([0-9A-Z]+$)/, '$1$2');
};
HfdlMessagePanel = function(el) {
AircraftMessagePanel.call(this, el);
this.initClearTimer();
}
HfdlMessagePanel.prototype = Object.create(AircraftMessagePanel.prototype);
HfdlMessagePanel.prototype.render = function() {
$(this.el).append($(
'<table>' +
'<thead><tr>' +
'<th class="source">Source</th>' +
'<th class="destination">Destination</th>' +
'<th class="details">Details</th>' +
'</tr></thead>' +
'<tbody></tbody>' +
'</table>'
));
};
HfdlMessagePanel.prototype.supportsMessage = function(message) {
return message['mode'] === 'HFDL';
};
HfdlMessagePanel.prototype.renderPosition = function(hfnpdu) {
if ('pos' in hfnpdu) {
var pos = hfnpdu['pos'];
var lat = pos['lat'] || 180;
var lon = pos['lon'] || 180;
if (Math.abs(lat) <= 90 && Math.abs(lon) <= 180) {
return '<div>Position: ' + pos['lat'] + ', ' + pos['lon'] + '</div>';
}
}
return '';
};
HfdlMessagePanel.prototype.renderLogon = function(lpdu) {
var details = ''
if (lpdu['ac_info'] && lpdu['ac_info']['icao']) {
details += '<div>ICAO: ' + lpdu['ac_info']['icao'] + '</div>';
}
if (lpdu['hfnpdu']) {
var hfnpdu = lpdu['hfnpdu'];
if (hfnpdu['flight_id'] && hfnpdu['flight_id'] !== '') {
details += '<div>Flight: ' + this.handleFlight(lpdu['hfnpdu']['flight_id']) + '</div>'
}
details += this.renderPosition(hfnpdu);
}
return details;
};
HfdlMessagePanel.prototype.pushMessage = function(message) {
var $b = $(this.el).find('tbody');
var src = '';
var dst = '';
var details = JSON.stringify(message);
var renderAddress = function(a) {
return a['id'];
}
// TODO remove safety net once parsing is complete
try {
var payload = message['hfdl'];
if ('spdu' in payload) {
var spdu = payload['spdu'];
src = renderAddress(spdu['src']);
details = '<h4>HFDL Squitter message</h4>'
details += '<div>Systable version: ' + spdu['systable_version'] + '</div>';
if ('gs_status' in spdu) {
details += spdu['gs_status'].map(function(gs){
return '<div>Ground station ' + gs['gs']['id'] + ' is operating on frequency ids ' + gs['freqs'].map(function(f) {return f['id']; }).join(', ') + '</div>';
}).join('')
}
} else if ('lpdu' in payload) {
var lpdu = payload['lpdu'];
src = renderAddress(lpdu['src']);
dst = renderAddress(lpdu['dst']);
if (lpdu['type']['id'] === 13 || lpdu['type']['id'] === 29) {
// unnumbered data
var hfnpdu = lpdu['hfnpdu'];
if (hfnpdu['type']['id'] === 209) {
// performance data
details = '<h4>Performance data</h4>';
details += '<div>Flight: ' + this.handleFlight(hfnpdu['flight_id']) + '</div>';
details += this.renderPosition(hfnpdu);
} else if (hfnpdu['type']['id'] === 255) {
// enveloped data
if ('acars' in hfnpdu) {
details = this.renderAcars(hfnpdu['acars']);
}
}
} else if (lpdu['type']['id'] === 47) {
// logon denied
details = '<h4>Logon denied</h4>';
} else if (lpdu['type']['id'] === 63) {
details = '<h4>Logoff request</h4>';
if (lpdu['ac_info'] && lpdu['ac_info']['icao']) {
details += '<div>ICAO: ' + lpdu['ac_info']['icao'] + '</div>';
}
} else if (lpdu['type']['id'] === 79) {
details = '<h4>Logon resume</h4>';
details += this.renderLogon(lpdu);
} else if (lpdu['type']['id'] === 95) {
details = '<h4>Logon resume confirmation</h4>';
} else if (lpdu['type']['id'] === 143) {
details = '<h4>Logon request</h4>';
details += this.renderLogon(lpdu);
} else if (lpdu['type']['id'] === 159) {
details = '<h4>Logon confirmation</h4>';
if (lpdu['ac_info'] && lpdu['ac_info']['icao']) {
details += '<div>ICAO: ' + lpdu['ac_info']['icao'] + '</div>';
}
if (lpdu['assigned_ac_id']) {
details += '<div>Assigned aircraft ID: ' + lpdu['assigned_ac_id'] + '</div>';
}
} else if (lpdu['type']['id'] === 191) {
details = '<h4>Logon request (DLS)</h4>';
details += this.renderLogon(lpdu);
}
}
} catch (e) {
console.error(e, e.stack);
}
$b.append($(
'<tr>' +
'<td class="source">' + src + '</td>' +
'<td class="destination">' + dst + '</td>' +
'<td class="details">' + details + '</td>' +
'</tr>'
));
this.scrollToBottom();
};
$.fn.hfdlMessagePanel = function() {
if (!this.data('panel')) {
this.data('panel', new HfdlMessagePanel(this));
}
return this.data('panel');
};
Vdl2MessagePanel = function(el) {
AircraftMessagePanel.call(this, el);
this.initClearTimer();
}
Vdl2MessagePanel.prototype = Object.create(AircraftMessagePanel.prototype);
Vdl2MessagePanel.prototype.render = function() {
$(this.el).append($(
'<table>' +
'<thead><tr>' +
'<th class="source">Source</th>' +
'<th class="destination">Destination</th>' +
'<th class="details">Details</th>' +
'</tr></thead>' +
'<tbody></tbody>' +
'</table>'
));
};
Vdl2MessagePanel.prototype.supportsMessage = function(message) {
return message['mode'] === 'VDL2';
};
Vdl2MessagePanel.prototype.pushMessage = function(message) {
var $b = $(this.el).find('tbody');
var src = '';
var dst = '';
var details = JSON.stringify(message);
var renderAddress = function(a) {
return '<div>' + a['addr'] + '</div><div>' + a['type'] + ( 'status' in a ? ' (' + a['status'] + ')' : '' ) + '</div>'
}
// TODO remove safety net once parsing is complete
try {
var payload = message['vdl2'];
if ('avlc' in payload) {
var avlc = payload['avlc'];
src = renderAddress(avlc['src']);
dst = renderAddress(avlc['dst']);
if (avlc['frame_type'] === 'S') {
details = '<h4>Supervisory frame</h4>';
if (avlc['cmd'] === 'Receive Ready') {
details = '<h4>Receive Ready</h4>';
}
} else if (avlc['frame_type'] === 'I') {
details = '<h4>Information frame</h4>';
if ('acars' in avlc) {
details = this.renderAcars(avlc['acars']);
} else if ('x25' in avlc) {
var x25 = avlc['x25'];
if (!('reasm_status' in x25) || ['skipped', 'complete'].includes(x25['reasm_status'])) {
details = '<h4>X.25 frame</h4>';
if ('clnp' in x25) {
var clnp = x25['clnp']
if ('cotp' in clnp) {
var cotp = clnp['cotp'];
if ('cpdlc' in cotp) {
var cpdlc = cotp['cpdlc'];
details = '<h4>CPDLC</h4>';
if ('atc_downlink_message' in cpdlc) {
var atc_downlink_message = cpdlc['atc_downlink_message'];
if ('msg_data' in atc_downlink_message) {
var msg_data = atc_downlink_message['msg_data'];
if ('msg_elements' in msg_data) {
details += '<div>' + msg_data['msg_elements'].map(function(e) { return e['msg_element']['choice_label']; }).join(', ') + '</div>';
}
} else {
details += '<div>' + JSON.stringify(cpdlc) + '</div>';
}
}
}
if ('adsc_v2' in cotp) {
var adsc_v2 = cotp['adsc_v2']
details = '<h4>ADS-C v2 Frame</h4>';
if ('adsc_report' in adsc_v2) {
var adsc_report = adsc_v2['adsc_report'];
var data = adsc_report['data'];
if ('periodic_report' in data) {
details += '<div>Periodic report</div>';
details += this.processReport(data['periodic_report']['report_data']);
} else if ('event_report' in data) {
details += '<div>Event report</div>';
details += this.processReport(data['event_report']['report_data']);
}
}
}
}
}
} else {
details = '<h4>Partial X.25 frame</h4>';
}
}
} else if (avlc['frame_type'] === 'U') {
details = '<h4>Unnumbered frame</h4>';
if ('xid' in avlc) {
var xid = avlc['xid'];
details = '<h4>' + xid['type_descr'] + '</h4>';
}
}
}
} catch (e) {
console.error(e, e.stack);
}
$b.append($(
'<tr>' +
'<td class="source">' + src + '</td>' +
'<td class="destination">' + dst + '</td>' +
'<td class="details">' + details + '</td>' +
'</tr>'
));
this.scrollToBottom();
};
Vdl2MessagePanel.prototype.processReport = function(report) {
var details = '';
if ('position' in report) {
var position = report['position'];
var lat = position['lat']
var lon = position['lon']
details += '<div>Position: ' +
lat['deg'] + '° ' + lat['min'] + '\' ' + lat['sec'] + '" ' + lat['dir'] + ', ' +
lon['deg'] + '° ' + lon['min'] + '\' ' + lon['sec'] + '" ' + lon['dir'] +
'</div>';
details += '<div>Altitude: ' + position['alt']['val'] + ' ' + position['alt']['unit'] + '</div>';
}
return details;
}
$.fn.vdl2MessagePanel = function() {
if (!this.data('panel')) {
this.data('panel', new Vdl2MessagePanel(this));
}
return this.data('panel');
};

View file

@ -43,6 +43,7 @@ var Mode = function(json){
}
if (this.type === 'digimode') {
this.underlying = json.underlying;
this.secondaryFft = json.secondaryFft;
}
};

108
htdocs/lib/PlaneMarker.js Normal file
View file

@ -0,0 +1,108 @@
function PlaneMarker(){}
PlaneMarker.prototype = new google.maps.OverlayView();
PlaneMarker.prototype.draw = function() {
var svg = this.svg;
if (!svg) return;
var angle = this.groundtrack || this.heading;
if (angle) {
svg.style.transform = 'rotate(' + angle + 'deg)';
} else {
svg.style.transform = null;
}
if (this.opacity) {
svg.style.opacity = this.opacity;
} else {
svg.style.opacity = null;
}
var point = this.getProjection().fromLatLngToDivPixel(this.position);
if (point) {
svg.style.left = point.x - 15 + 'px';
svg.style.top = point.y - 15 + 'px';
}
svg.setAttribute('fill', this.getMarkerColor());
};
PlaneMarker.prototype.setOptions = function(options) {
google.maps.OverlayView.prototype.setOptions.apply(this, arguments);
this.draw();
};
PlaneMarker.prototype.onAdd = function() {
var svg = this.svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
svg.setAttribute('viewBox', '0 0 65 65');
svg.setAttribute('fill', this.getMarkerColor());
svg.setAttribute('stroke', 'black');
var path = document.createElementNS('http://www.w3.org/2000/svg', 'path')
path.setAttribute('d', 'M 0,0 M 1.9565564,41.694305 C 1.7174505,40.497708 1.6419973,38.448747 1.8096508,37.70494 1.8936398,37.332056 2.0796653,36.88191 2.222907,36.70461 2.4497603,36.423844 4.087816,35.47248 14.917931,29.331528 l 12.434577,-7.050718 -0.04295,-7.613412 c -0.03657,-6.4844888 -0.01164,-7.7625804 0.168134,-8.6194061 0.276129,-1.3160905 0.762276,-2.5869575 1.347875,-3.5235502 l 0.472298,-0.7553719 1.083746,-0.6085497 c 1.194146,-0.67053522 1.399524,-0.71738842 2.146113,-0.48960552 1.077005,0.3285939 2.06344,1.41299352 2.797602,3.07543322 0.462378,1.0469993 0.978731,2.7738408 1.047635,3.5036272 0.02421,0.2570284 0.06357,3.78334 0.08732,7.836246 0.02375,4.052905 0.0658,7.409251 0.09345,7.458546 0.02764,0.04929 5.600384,3.561772 12.38386,7.805502 l 12.333598,7.715871 0.537584,0.959688 c 0.626485,1.118378 0.651686,1.311286 0.459287,3.516442 -0.175469,2.011604 -0.608966,2.863924 -1.590344,3.127136 -0.748529,0.200763 -1.293144,0.03637 -10.184829,-3.07436 C 48.007733,41.72562 44.793806,40.60197 43.35084,40.098045 l -2.623567,-0.916227 -1.981212,-0.06614 c -1.089663,-0.03638 -1.985079,-0.05089 -1.989804,-0.03225 -0.0052,0.01863 -0.02396,2.421278 -0.04267,5.339183 -0.0395,6.147742 -0.143635,7.215456 -0.862956,8.845475 l -0.300457,0.680872 2.91906,1.361455 c 2.929379,1.366269 3.714195,1.835385 4.04589,2.41841 0.368292,0.647353 0.594634,2.901439 0.395779,3.941627 -0.0705,0.368571 -0.106308,0.404853 -0.765159,0.773916 L 41.4545,62.83158 39.259237,62.80426 c -6.030106,-0.07507 -16.19508,-0.495041 -16.870991,-0.697033 -0.359409,-0.107405 -0.523792,-0.227482 -0.741884,-0.541926 -0.250591,-0.361297 -0.28386,-0.522402 -0.315075,-1.52589 -0.06327,-2.03378 0.23288,-3.033615 1.077963,-3.639283 0.307525,-0.2204 4.818478,-2.133627 6.017853,-2.552345 0.247872,-0.08654 0.247455,-0.102501 -0.01855,-0.711959 -0.330395,-0.756986 -0.708622,-2.221756 -0.832676,-3.224748 -0.05031,-0.406952 -0.133825,-3.078805 -0.185533,-5.937448 -0.0517,-2.858644 -0.145909,-5.208974 -0.209316,-5.222958 -0.06341,-0.01399 -0.974464,-0.0493 -2.024551,-0.07845 L 23.247235,38.61921 18.831373,39.8906 C 4.9432155,43.88916 4.2929558,44.057819 3.4954426,43.86823 2.7487826,43.690732 2.2007966,42.916622 1.9565564,41.694305 z')
svg.appendChild(path);
svg.style.position = 'absolute';
svg.style.cursor = 'pointer';
svg.style.width = '30px';
svg.style.height = '30px';
var self = this;
svg.addEventListener("click", function(event) {
event.stopPropagation();
google.maps.event.trigger(self, "click", event);
});
var panes = this.getPanes();
panes.overlayImage.appendChild(svg);
};
PlaneMarker.prototype.onRemove = function() {
if (this.svg) {
this.svg.parentNode.removeChild(this.svg);
this.svg = null;
}
};
PlaneMarker.prototype.getMarkerColor = function() {
var toHsl = function(input) {
return 'hsl(' + input.h + ', ' + input.s + '%, ' + input.l + '%)'
};
if (!this.altitude) {
return toHsl({h: 0, s: 0, l: 40});
}
if (this.altitude === "ground") {
return toHsl({h: 120, s: 100, l: 30});
}
// find the pair of points the current altitude lies between,
// and interpolate the hue between those points
var hpoints = [
{ alt: 2000, val: 20 }, // orange
{ alt: 10000, val: 140 }, // light green
{ alt: 40000, val: 300 }
];
var h = hpoints[0].val;
for (var i = hpoints.length-1; i >= 0; --i) {
if (this.altitude > hpoints[i].alt) {
if (i === hpoints.length - 1) {
h = hpoints[i].val;
} else {
h = hpoints[i].val + (hpoints[i+1].val - hpoints[i].val) * (this.altitude - hpoints[i].alt) / (hpoints[i+1].alt - hpoints[i].alt)
}
break;
}
}
if (h < 0) {
h = (h % 360) + 360;
} else if (h >= 360) {
h = h % 360;
}
return toHsl({h: h, s: 85, l: 50})
}

21
htdocs/lib/wheelDelta.js Normal file
View file

@ -0,0 +1,21 @@
/*
* Normalize scroll wheel events.
*
* It seems like there's no consent as to how mouse wheel events are presented in the javascript API. The standard
* states that a MouseEvent has a deltaY property that contains the scroll distances, together with a deltaMode
* property that state the "unit" that deltaY has been measured in. The deltaMode can be either pixels, lines or
* pages. The latter is seldomly used in practise.
*
* The troublesome part is that there is no standard on how to correlate the two at this point.
*
* The basic idea is that one tick of a mouse wheel results in a total return value of +/- 1 from this method.
* It's important to keep in mind that one tick of a wheel may result in multiple events in the browser. The aim
* of this method is to scale the sum of deltaY over
*/
function wheelDelta(evt) {
if ('deltaMode' in evt && evt.deltaMode === WheelEvent.DOM_DELTA_PIXEL) {
// chrome and webkit-based browsers seem to correlate one tick of the wheel to 120 pixels.
return evt.deltaY / 120;
}
return evt.deltaY;
}

View file

@ -7,6 +7,8 @@ $(function(){
}
var expectedLocator;
if (query.has('locator')) expectedLocator = query.get('locator');
var expectedIcao;
if (query.has('icao')) expectedIcao = query.get('icao');
var protocol = window.location.protocol.match(/https/) ? 'wss' : 'ws';
@ -28,11 +30,10 @@ $(function(){
var receiverMarker;
var updateQueue = [];
// reasonable default; will be overriden by server
var retention_time = 2 * 60 * 60 * 1000;
var strokeOpacity = 0.8;
var fillOpacity = 0.35;
var callsign_service;
var aircraft_tracking_service;
var colorKeys = {};
var colorScale = chroma.scale(['red', 'blue', 'green']).mode('hsl');
@ -114,7 +115,10 @@ $(function(){
switch (update.location.type) {
case 'latlon':
var pos = new google.maps.LatLng(update.location.lat, update.location.lon);
var pos = false;
if (update.location.lat && update.location.lon) {
pos = new google.maps.LatLng(update.location.lat, update.location.lon);
}
var marker;
var markerClass = google.maps.Marker;
var aprsOptions = {}
@ -123,25 +127,38 @@ $(function(){
aprsOptions.symbol = update.location.symbol;
aprsOptions.course = update.location.course;
aprsOptions.speed = update.location.speed;
} else if (update.source.icao || update.source.flight) {
markerClass = PlaneMarker;
aprsOptions = update.location;
}
if (markers[key]) {
marker = markers[key];
if (!pos) {
delete markers[key];
marker.setMap();
return;
}
} else {
marker = new markerClass();
marker.addListener('click', function(){
showMarkerInfoWindow(update.source, pos);
});
markers[key] = marker;
if (pos) {
marker = new markerClass();
marker.addListener('click', function () {
showMarkerInfoWindow(update.source, pos);
});
marker.setMap(map);
markers[key] = marker;
}
}
if (!marker) return;
marker.setOptions($.extend({
position: pos,
map: map,
title: sourceToString(update.source)
}, aprsOptions, getMarkerOpacityOptions(update.lastseen) ));
}, aprsOptions, getMarkerOpacityOptions(update.lastseen, update.location.ttl) ));
marker.source = update.source;
marker.lastseen = update.lastseen;
marker.mode = update.mode;
marker.band = update.band;
marker.comment = update.location.comment;
marker.ttl = update.location.ttl;
if (expectedCallsign && shallowEquals(expectedCallsign, update.source)) {
map.panTo(pos);
@ -149,6 +166,12 @@ $(function(){
expectedCallsign = false;
}
if (expectedIcao && expectedIcao === update.source.icao) {
map.panTo(pos);
showMarkerInfoWindow(update.source, pos);
expectedIcao = false;
}
if (infowindow && infowindow.source && shallowEquals(infowindow.source, update.source)) {
showMarkerInfoWindow(infowindow.source, pos);
}
@ -176,6 +199,7 @@ $(function(){
rectangle.mode = update.mode;
rectangle.band = update.band;
rectangle.center = center;
rectangle.ttl = update.location.ttl;
rectangle.setOptions($.extend({
strokeColor: color,
@ -188,7 +212,7 @@ $(function(){
west: lon,
east: lon + 2
}
}, getRectangleOpacityOptions(update.lastseen) ));
}, getRectangleOpacityOptions(update.lastseen, update.location.ttl) ));
if (expectedLocator && expectedLocator === update.location.locator) {
map.panTo(center);
@ -252,7 +276,10 @@ $(function(){
nite.init(map);
setInterval(function() { nite.refresh() }, 10000); // every 10s
});
$.getScript('static/lib/AprsMarker.js').done(function(){
$.when(
$.getScript('static/lib/AprsMarker.js'),
$.getScript('static/lib/PlaneMarker.js')
).done(function(){
processUpdates(updateQueue);
updateQueue = [];
});
@ -286,12 +313,12 @@ $(function(){
title: config['receiver_name']
});
}
if ('map_position_retention_time' in config) {
retention_time = config.map_position_retention_time * 1000;
}
if ('callsign_service' in config) {
callsign_service = config['callsign_service'];
}
if ('aircraft_tracking_service' in config) {
aircraft_tracking_service = config['aircraft_tracking_service'];
}
break;
case "update":
processUpdates(json.value);
@ -351,6 +378,8 @@ $(function(){
// 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'];
if ('icao' in source) return source['icao'];
if ('flight' in source) return source['flight'];
var key = source.callsign;
if ('ssid' in source) key += '-' + source.ssid;
return key;
@ -359,7 +388,7 @@ $(function(){
// we can reuse the same logic for displaying and indexing
var sourceToString = sourceToKey;
var linkifySource = function(source) {
var linkifyCallsign = function(source) {
var callsignString = sourceToString(source);
switch (callsign_service) {
case "qrzcq":
@ -374,6 +403,30 @@ $(function(){
}
};
var linkifyAircraft = function(source, identification) {
var aircraftString = identification || source.flight || source.icao;
var link = false;
switch (aircraft_tracking_service) {
case 'flightaware':
if (source.icao) {
link = 'https://flightaware.com/live/modes/' + source.icao;
if (identification) link += "/ident/" + identification
link += '/redirect';
} else if (source.flight) {
link = 'https://flightaware.com/live/flight/' + source.flight;
}
break;
case 'planefinder':
if (identification) link = 'https://planefinder.net/flight/' + identification;
if (source.flight) link = 'https://planefinder.net/flight/' + source.flight;
break;
}
if (link) {
return '<a target="_blank" href="' + link + '">' + aircraftString + '</a>';
}
return aircraftString;
}
var distanceKm = function(p1, p2) {
// Earth radius in km
var R = 6371.0;
@ -408,7 +461,7 @@ $(function(){
'<ul>' +
inLocator.map(function(i){
var timestring = moment(i.lastseen).fromNow();
var message = linkifySource(i.source) + ' (' + timestring + ' using ' + i.mode;
var message = linkifyCallsign(i.source) + ' (' + timestring + ' using ' + i.mode;
if (i.band) message += ' on ' + i.band;
message += ')';
return '<li>' + message + '</li>'
@ -432,8 +485,29 @@ $(function(){
if (receiverMarker) {
distance = " at " + distanceKm(receiverMarker.position, marker.position) + " km";
}
var title;
if (marker.source.icao || marker.source.flight) {
title = linkifyAircraft(source, marker.identification);
if ('altitude' in marker) {
commentString += '<div>Altitude: ' + marker.altitude + ' ft</div>';
}
if ('groundspeed' in marker) {
commentString += '<div>Speed: ' + Math.round(marker.groundspeed) + ' kt</div>';
}
if ('verticalspeed' in marker) {
commentString += '<div>V/S: ' + marker.verticalspeed + ' ft/min</div>';
}
if ('IAS' in marker) {
commentString += '<div>IAS: ' + marker.IAS + ' kt</div>';
}
if ('TAS' in marker) {
commentString += '<div>TAS: ' + marker.TAS + ' kt</div>/';
}
} else {
title = linkifyCallsign(source);
}
infowindow.setContent(
'<h3>' + linkifySource(source) + distance + '</h3>' +
'<h3>' + title + distance + '</h3>' +
'<div>' + timestring + ' using ' + marker.mode + ( marker.band ? ' on ' + marker.band : '' ) + '</div>' +
commentString
);
@ -449,25 +523,25 @@ $(function(){
infowindow.open(map, marker);
};
var getScale = function(lastseen) {
var getScale = function(lastseen, ttl) {
var age = new Date().getTime() - lastseen;
var scale = 1;
if (age >= retention_time / 2) {
scale = (retention_time - age) / (retention_time / 2);
if (age >= ttl / 2) {
scale = (ttl - age) / (ttl / 2);
}
return Math.max(0, Math.min(1, scale));
};
var getRectangleOpacityOptions = function(lastseen) {
var scale = getScale(lastseen);
var getRectangleOpacityOptions = function(lastseen, ttl) {
var scale = getScale(lastseen, ttl);
return {
strokeOpacity: strokeOpacity * scale,
fillOpacity: fillOpacity * scale
};
};
var getMarkerOpacityOptions = function(lastseen) {
var scale = getScale(lastseen);
var getMarkerOpacityOptions = function(lastseen, ttl) {
var scale = getScale(lastseen, ttl);
return {
opacity: scale
};
@ -478,21 +552,21 @@ $(function(){
var now = new Date().getTime();
Object.values(rectangles).forEach(function(m){
var age = now - m.lastseen;
if (age > retention_time) {
if (age > m.ttl) {
delete rectangles[sourceToKey(m.source)];
m.setMap();
return;
}
m.setOptions(getRectangleOpacityOptions(m.lastseen));
m.setOptions(getRectangleOpacityOptions(m.lastseen, m.ttl));
});
Object.values(markers).forEach(function(m) {
var age = now - m.lastseen;
if (age > retention_time) {
if (age > m.ttl) {
delete markers[sourceToKey(m.source)];
m.setMap();
return;
}
m.setOptions(getMarkerOpacityOptions(m.lastseen));
m.setOptions(getMarkerOpacityOptions(m.lastseen, m.ttl));
});
}, 1000);

View file

@ -619,11 +619,7 @@ function get_relative_x(evt) {
function canvas_mousewheel(evt) {
if (!waterfall_setup_done) return;
var delta = -evt.deltaY;
// deltaMode 0 means pixels instead of lines
if ('deltaMode' in evt && evt.deltaMode === 0) {
delta /= 50;
}
var delta = -wheelDelta(evt);
var relativeX = get_relative_x(evt);
zoom_step(delta, relativeX, zoom_center_where_calc(evt.pageX));
evt.preventDefault();
@ -792,6 +788,9 @@ function on_ws_recv(evt) {
if ('tuning_precision' in config)
$('#openwebrx-panel-receiver').demodulatorPanel().setTuningPrecision(config['tuning_precision']);
if ('aircraft_tracking_service' in config)
$('#openwebrx-panel-adsb-message').adsbMessagePanel().setAircraftTrackingService(config['aircraft_tracking_service']);
break;
case "secondary_config":
var s = json['value'];
@ -860,12 +859,10 @@ function on_ws_recv(evt) {
break;
case 'secondary_demod':
var value = json['value'];
var panels = [
$("#openwebrx-panel-wsjt-message").wsjtMessagePanel(),
$('#openwebrx-panel-packet-message').packetMessagePanel(),
$('#openwebrx-panel-pocsag-message').pocsagMessagePanel(),
$("#openwebrx-panel-js8-message").js8()
];
var panels = ['wsjt', 'packet', 'pocsag', 'adsb', 'ism', 'hfdl', 'vdl2'].map(function(id) {
return $('#openwebrx-panel-' + id + '-message')[id + 'MessagePanel']();
});
panels.push($('#openwebrx-panel-js8-message').js8());
if (!panels.some(function(panel) {
if (!panel.supportsMessage(value)) return false;
panel.pushMessage(value);
@ -1258,11 +1255,14 @@ function initSliders() {
var $slider = $(this);
if (!$slider.attr('step')) return;
var val = Number($slider.val());
// restore previous high-resolution mouse wheel delta
var mouseDelta = Number($slider.data('mouseDelta'));
if (mouseDelta) val += mouseDelta;
var step = Number($slider.attr('step'));
if (ev.originalEvent.deltaY > 0) {
step *= -1;
}
$slider.val(val + step);
var newVal = val + step * -wheelDelta(ev.originalEvent);
$slider.val(newVal);
// the calculated value can have a higher resolution than the element can store, so we put the delta into the data attributes
$slider.data('mouseDelta', newVal - $slider.val());
$slider.trigger('change');
});
@ -1463,9 +1463,9 @@ function secondary_demod_init() {
.mousedown(secondary_demod_canvas_container_mousedown)
.mouseenter(secondary_demod_canvas_container_mousein)
.mouseleave(secondary_demod_canvas_container_mouseleave);
$('#openwebrx-panel-wsjt-message').wsjtMessagePanel();
$('#openwebrx-panel-packet-message').packetMessagePanel();
$('#openwebrx-panel-pocsag-message').pocsagMessagePanel();
['wsjt', 'packet', 'pocsag', 'adsb', 'ism', 'hfdl'].forEach(function(id){
$('#openwebrx-panel-' + id + '-message')[id + 'MessagePanel']();
})
$('#openwebrx-panel-js8-message').js8();
}

View file

@ -20,6 +20,7 @@ from owrx.reporting import ReportingEngine
from owrx.version import openwebrx_version
from owrx.audio.queue import DecoderQueue
from owrx.admin import add_admin_parser, run_admin_action
from pathlib import Path
import signal
import argparse
import socket
@ -44,6 +45,14 @@ def handleSignal(sig, frame):
def main():
parser = argparse.ArgumentParser(description="OpenWebRX - Open Source SDR Web App for Everyone!")
parser.add_argument(
"-c",
"--config",
action="store",
help="Read core configuration from specified file",
metavar="configfile",
type=Path,
)
parser.add_argument("-v", "--version", action="store_true", help="Show the software version")
parser.add_argument("--debug", action="store_true", help="Set loglevel to DEBUG")
@ -66,6 +75,8 @@ def main():
print("OpenWebRX version {version}".format(version=openwebrx_version))
return 0
CoreConfig.load(args.config)
if args.module == "admin":
return run_admin_action(adminparser, args)
@ -86,7 +97,8 @@ Author contact info: Jakob Ketterl, DD5JFK <dd5jfk@darc.de>
Documentation: https://github.com/jketterl/openwebrx/wiki
Support and info: https://groups.io/g/openwebrx
"""
""",
flush=True
)
logger.info("OpenWebRX version {0} starting up...".format(openwebrx_version))
@ -124,6 +136,7 @@ Support and info: https://groups.io/g/openwebrx
try:
server = ThreadedHttpServer(coreConfig.get_web_port(), RequestHandler, coreConfig.get_web_ipv6())
logger.info("Ready to serve requests.")
server.serve_forever()
except SignalException:
pass

31
owrx/adsb/dump1090.py Normal file
View file

@ -0,0 +1,31 @@
from pycsdr.modules import ExecModule
from pycsdr.types import Format
from csdr.module import LineBasedModule
import logging
logger = logging.getLogger(__name__)
class Dump1090Module(ExecModule):
def __init__(self):
super().__init__(
Format.COMPLEX_SHORT,
Format.CHAR,
["dump1090", "--ifile", "-", "--iformat", "SC16", "--raw"],
# send some data on decoder shutdown since the dump1090 internal reader locks up otherwise
# dump1090 reads chunks of 100ms, which equals to 240k samples at 2.4MS/s
# some extra should not hurt
flushSize=300000
)
class RawDeframer(LineBasedModule):
def process(self, line: bytes):
if line.startswith(b'*') and line.endswith(b';') and len(line) in [16, 30]:
return bytes.fromhex(line[1:-1].decode())
elif line == b"*0000;":
# heartbeat message. not a valid message, but known. do not log.
return
else:
logger.warning("invalid raw message: %s", line)

392
owrx/adsb/modes.py Normal file
View file

@ -0,0 +1,392 @@
from csdr.module import PickleModule
from math import sqrt, atan2, pi, floor, acos, cos
from owrx.map import IncrementalUpdate, Location, Map, Source
from owrx.metrics import Metrics, CounterMetric
from owrx.aeronautical import AirplaneLocation, IcaoSource
from datetime import datetime, timedelta
from enum import Enum
FEET_PER_METER = 3.28084
class AdsbLocation(IncrementalUpdate, AirplaneLocation):
mapKeys = [
"lat",
"lon",
"altitude",
"heading",
"groundtrack",
"groundspeed",
"verticalspeed",
"identification",
"TAS",
"IAS",
"heading",
]
def __init__(self, message):
self.history = []
self.timestamp = datetime.now()
super().__init__(message)
def update(self, previousLocation: Location):
if isinstance(previousLocation, AdsbLocation):
history = previousLocation.history
now = datetime.now()
history = [p for p in history if now - p["timestamp"] < self.getTTL()]
else:
history = []
history += [{
"timestamp": self.timestamp,
"props": self.props,
}]
self.history = history
merged = {}
for p in self.history:
merged.update(p["props"])
self.props = merged
if "lat" in merged:
self.lat = merged["lat"]
if "lon" in merged:
self.lon = merged["lon"]
def getTTL(self) -> timedelta:
# fixed ttl for adsb-locations for now
return timedelta(seconds=30)
class CprRecordType(Enum):
AIR = ("air", 360)
GROUND = ("ground", 90)
def __new__(cls, *args, **kwargs):
name, baseAngle = args
obj = object.__new__(cls)
obj._value_ = name
obj.baseAngle = baseAngle
return obj
class CprCache:
def __init__(self):
self.airRecords = {}
self.groundRecords = {}
def __getRecords(self, cprType: CprRecordType):
if cprType is CprRecordType.AIR:
return self.airRecords
elif cprType is CprRecordType.GROUND:
return self.groundRecords
def getRecentData(self, icao: str, cprType: CprRecordType):
records = self.__getRecords(cprType)
if icao not in records:
return []
now = datetime.now()
filtered = [r for r in records[icao] if now - r["timestamp"] < timedelta(seconds=10)]
records_sorted = sorted(filtered, key=lambda r: r["timestamp"])
records[icao] = records_sorted
return [r["data"] for r in records_sorted]
def addRecord(self, icao: str, data: any, cprType: CprRecordType):
records = self.__getRecords(cprType)
if icao not in records:
records[icao] = []
records[icao].append({"timestamp": datetime.now(), "data": data})
class ModeSParser(PickleModule):
def __init__(self):
self.cprCache = CprCache()
name = "dump1090.decodes.adsb"
self.metrics = Metrics.getSharedInstance().getMetric(name)
if self.metrics is None:
self.metrics = CounterMetric()
Metrics.getSharedInstance().addMetric(name, self.metrics)
super().__init__()
def process(self, input):
format = (input[0] & 0b11111000) >> 3
message = {
"mode": "ADSB",
"format": format
}
if format == 17:
message["capability"] = input[0] & 0b111
message["icao"] = icao = input[1:4].hex()
type = (input[4] & 0b11111000) >> 3
message["adsb_type"] = type
if type in [1, 2, 3, 4]:
# identification message
id = [
(input[5] & 0b11111100) >> 2,
((input[5] & 0b00000011) << 4) | ((input[6] & 0b11110000) >> 4),
((input[6] & 0b00001111) << 2) | ((input[7] & 0b11000000) >> 6),
input[7] & 0b00111111,
(input[8] & 0b11111100) >> 2,
((input[8] & 0b00000011) << 4) | ((input[9] & 0b11110000) >> 4),
((input[9] & 0b00001111) << 2) | ((input[10] & 0b11000000) >> 6),
input[10] & 0b00111111
]
message["identification"] = bytes(b + (0x40 if b < 27 else 0) for b in id).decode("ascii").strip()
elif type in [5, 6, 7, 8]:
# surface position
# there's no altitude data in this message type, but the type implies the aircraft is on ground
message["altitude"] = "ground"
movement = ((input[4] & 0b00000111) << 4) | ((input[5] & 0b11110000) >> 4)
if movement == 1:
message["groundspeed"] = 0
elif 2 <= movement < 9:
message["groundspeed"] = (movement - 1) * .0125
elif 9 <= movement < 13:
message["groundspeed"] = 1 + (movement - 8) * .25
elif 13 <= movement < 39:
message["groundspeed"] = 2 + (movement - 12) * .5
elif 39 <= movement < 94:
message["groundspeed"] = 15 + (movement - 38) # * 1
elif 94 <= movement < 109:
message["groundspeed"] = 70 + (movement - 108) * 2
elif 109 <= movement < 124:
message["groundspeeed"] = 100 + (movement - 123) * 5
if (input[5] & 0b00001000) >> 3:
track = ((input[5] & 0b00000111) << 3) | ((input[6] & 0b11110000) >> 4)
message["groundtrack"] = (360 * track) / 128
cpr = self.__getCprData(icao, input, CprRecordType.GROUND)
if cpr is not None:
lat, lon = cpr
message["lat"] = lat
message["lon"] = lon
elif type in [9, 10, 11, 12, 13, 14, 15, 16, 17, 18]:
# airborne position (w/ baro altitude)
cpr = self.__getCprData(icao, input, CprRecordType.AIR)
if cpr is not None:
lat, lon = cpr
message["lat"] = lat
message["lon"] = lon
q = (input[5] & 0b1)
altitude = ((input[5] & 0b11111110) << 3) | ((input[6] & 0b11110000) >> 4)
if q:
message["altitude"] = altitude * 25 - 1000
elif altitude > 0:
altitude = self._gillhamDecode(altitude)
if altitude is not None:
message["altitude"] = altitude
elif type == 19:
# airborne velocity
subtype = input[4] & 0b111
if subtype in [1, 2]:
# velocity is reported in an east/west and a north/south component
# vew = velocity east / west
vew = ((input[5] & 0b00000011) << 8) | input[6]
# vns = velocity north / south
vns = ((input[7] & 0b01111111) << 3) | ((input[8] & 0b1110000000) >> 5)
# 0 means no data
if vew != 0 and vns != 0:
# dew = direction east/west (0 = to east, 1 = to west)
dew = (input[5] & 0b00000100) >> 2
# dns = direction north/south (0 = to north, 1 = to south)
dns = (input[7] & 0b10000000) >> 7
vx = vew - 1
if dew:
vx *= -1
vy = vns - 1
if dns:
vy *= -1
# supersonic
if subtype == 2:
vx *= 4
vy *= 4
message["groundspeed"] = sqrt(vx ** 2 + vy ** 2)
message["groundtrack"] = (atan2(vx, vy) * 360 / (2 * pi)) % 360
# vertical rate
vr = ((input[8] & 0b00000111) << 6) | ((input[9] & 0b11111100) >> 2)
if vr != 0:
# vertical speed sign (1 = negative)
svr = ((input[8] & 0b00001000) >> 3)
# vertical speed
vs = 64 * (vr - 1)
if svr:
vs *= -1
message["verticalspeed"] = vs
elif subtype in [3, 4]:
sh = (input[5] & 0b00000100) >> 2
if sh:
hdg = ((input[5] & 0b00000011) << 8) | input[6]
message["heading"] = hdg * 360 / 1024
airspeed = ((input[7] & 0b01111111) << 3) | ((input[8] & 0b11100000) >> 5)
if airspeed != 0:
airspeed -= 1
# supersonic
if subtype == 4:
airspeed *= 4
airspeed_type = (input[7] & 0b10000000) >> 7
if airspeed_type:
message["TAS"] = airspeed
else:
message["IAS"] = airspeed
elif type in [20, 21, 22]:
# airborne position (w/GNSS height)
cpr = self.__getCprData(icao, input, CprRecordType.AIR)
if cpr is not None:
lat, lon = cpr
message["lat"] = lat
message["lon"] = lon
altitude = (input[5] << 4) | ((input[6] & 0b1111) >> 4)
message["altitude"] = altitude * FEET_PER_METER
elif type == 28:
# aircraft status
pass
elif type == 29:
# target state and status information
pass
elif type == 31:
# aircraft operation status
pass
elif format == 11:
# Mode-S All-call reply
message["icao"] = input[1:4].hex()
self.metrics.inc()
if "icao" in message and AdsbLocation.mapKeys & message.keys():
data = {k: message[k] for k in AdsbLocation.mapKeys if k in message}
loc = AdsbLocation(data)
Map.getSharedInstance().updateLocation(IcaoSource(message['icao']), loc, "ADS-B", None)
return message
def __getCprData(self, icao: str, input, cprType: CprRecordType):
self.cprCache.addRecord(icao, {
"cpr_format": (input[6] & 0b00000100) >> 2,
"lat_cpr": ((input[6] & 0b00000011) << 15) | (input[7] << 7) | ((input[8] & 0b11111110) >> 1),
"lon_cpr": ((input[8] & 0b00000001) << 16) | (input[9] << 8) | (input[10]),
}, cprType)
records = self.cprCache.getRecentData(icao, cprType)
try:
# records are sorted by timestamp, last should be newest
odd = next(r for r in reversed(records) if r["cpr_format"])
even = next(r for r in reversed(records) if not r["cpr_format"])
newest = next(reversed(records))
lat_cpr_even = even["lat_cpr"] / 2 ** 17
lat_cpr_odd = odd["lat_cpr"] / 2 ** 17
# latitude zone index
j = floor(59 * lat_cpr_even - 60 * lat_cpr_odd + .5)
nz = 15
d_lat_even = cprType.baseAngle / (4 * nz)
d_lat_odd = cprType.baseAngle / (4 * nz - 1)
lat_even = d_lat_even * ((j % 60) + lat_cpr_even)
lat_odd = d_lat_odd * ((j % 59) + lat_cpr_odd)
if lat_even >= 270:
lat_even -= 360
if lat_odd >= 270:
lat_odd -= 360
def nl(lat):
if lat == 0:
return 59
elif lat == 87:
return 2
elif lat == -87:
return 2
elif lat > 87:
return 1
elif lat < -87:
return 1
else:
return floor((2 * pi) / acos(1 - (1 - cos(pi / (2 * nz))) / (cos((pi / 180) * abs(lat)) ** 2)))
if nl(lat_even) != nl(lat_odd):
# latitude zone mismatch.
return
lat = lat_odd if newest["cpr_format"] else lat_even
lon_cpr_even = even["lon_cpr"] / 2 ** 17
lon_cpr_odd = odd["lon_cpr"] / 2 ** 17
# longitude zone index
nl_lat = nl(lat)
m = floor(lon_cpr_even * (nl_lat - 1) - lon_cpr_odd * nl_lat + .5)
n_even = max(nl_lat, 1)
n_odd = max(nl_lat - 1, 1)
d_lon_even = cprType.baseAngle / n_even
d_lon_odd = cprType.baseAngle / n_odd
lon_even = d_lon_even * (m % n_even + lon_cpr_even)
lon_odd = d_lon_odd * (m % n_odd + lon_cpr_odd)
lon = lon_odd if newest["cpr_format"] else lon_even
if lon >= 180:
lon -= 360
return lat, lon
except StopIteration:
# we don't have both CPR records. better luck next time.
pass
def _grayDecode(self, input: int):
l = input.bit_length()
previous_bit = 0
output = 0
for i in reversed(range(0, l)):
bit = (previous_bit ^ ((input >> i) & 1))
output |= bit << i
previous_bit = bit
return output
gianniTable = [None, -200, 0, -100, 200, None, 100, None]
def _gillhamDecode(self, input: int):
c = ((input & 0b10000000000) >> 8) | ((input & 0b00100000000) >> 7) | ((input & 0b00001000000) >> 6)
b = ((input & 0b00000010000) >> 2) | ((input & 0b00000001000) >> 2) | ((input & 0b00000000010) >> 1)
a = ((input & 0b01000000000) >> 7) | ((input & 0b00010000000) >> 6) | ((input & 0b00000100000) >> 5)
d = ((input & 0b00000000100) >> 1) | (input & 0b00000000001)
dab = (d << 6) | (a << 3) | b
parity = dab.bit_count() % 2
offset = self.gianniTable[c]
if offset is None:
# invalid decode...
return None
if parity:
offset *= -1
altitude = self._grayDecode(dab) * 500 + offset - 1000
return altitude

89
owrx/aeronautical.py Normal file
View file

@ -0,0 +1,89 @@
from owrx.map import Map, LatLngLocation, Source
from csdr.module import JsonParser
from abc import ABCMeta
import re
class AirplaneLocation(LatLngLocation):
def __init__(self, message):
self.props = message
if "lat" in message and "lon" in message:
super().__init__(message["lat"], message["lon"])
else:
self.lat = None
self.lon = None
def __dict__(self):
res = super().__dict__()
res.update(self.props)
return res
class IcaoSource(Source):
def __init__(self, icao: str, flight: str = None):
self.icao = icao.upper()
self.flight = flight
def getKey(self) -> str:
return "icao:{}".format(self.icao)
def __dict__(self):
d = {"icao": self.icao}
if self.flight is not None:
d["flight"] = self.flight
return d
class FlightSource(Source):
def __init__(self, flight):
self.flight = flight
def getKey(self) -> str:
return "flight:{}".format(self.flight)
def __dict__(self):
return {"flight": self.flight}
class AcarsProcessor(JsonParser, metaclass=ABCMeta):
flightRegex = re.compile("^([0-9A-Z]{2})0*([0-9A-Z]+$)")
def processAcars(self, acars: dict, icao: str = None):
if "flight" in acars:
flight_id = self.processFlight(acars["flight"])
elif "reg" in acars:
flight_id = acars['reg'].lstrip(".")
else:
return
if "arinc622" in acars:
arinc622 = acars["arinc622"]
if "adsc" in arinc622:
adsc = arinc622["adsc"]
if "tags" in adsc and adsc["tags"]:
msg = {}
for tag in adsc["tags"]:
if "basic_report" in tag:
basic_report = tag["basic_report"]
msg.update({
"lat": basic_report["lat"],
"lon": basic_report["lon"],
"altitude": basic_report["alt"],
})
if "earth_ref_data" in tag:
earth_ref_data = tag["earth_ref_data"]
msg.update({
"groundtrack": earth_ref_data["true_trk_deg"],
"groundspeed": earth_ref_data["gnd_spd_kts"],
"verticalspeed": earth_ref_data["vspd_ftmin"],
})
if icao is not None:
source = IcaoSource(icao, flight=flight_id)
else:
source = FlightSource(flight_id)
Map.getSharedInstance().updateLocation(
source, AirplaneLocation(msg), "ACARS over {}".format(self.mode)
)
def processFlight(self, raw):
return self.flightRegex.sub(r"\g<1>\g<2>", raw)

View file

@ -1,4 +1,4 @@
from owrx.map import Map, LatLngLocation
from owrx.map import Map, LatLngLocation, Source
from owrx.metrics import Metrics, CounterMetric
from owrx.bands import Bandplan
from datetime import datetime, timezone
@ -155,6 +155,20 @@ class AprsLocation(LatLngLocation):
return res
class AprsSource(Source):
def __init__(self, source):
self.source = source
def getKey(self) -> str:
callsign = self.source["callsign"]
if "ssid" in self.source:
callsign += "-{}".format(self.source["ssid"])
return "aprs:{}".format(callsign)
def __dict__(self):
return self.source
class AprsParser(PickleModule):
def __init__(self):
super().__init__()
@ -215,7 +229,7 @@ class AprsParser(PickleModule):
source["item"] = mapData["item"]
elif mapData["type"] == "object" and "object" in mapData:
source["object"] = mapData["object"]
Map.getSharedInstance().updateLocation(source, loc, "APRS", self.band)
Map.getSharedInstance().updateLocation(AprsSource(source), loc, "APRS", self.band)
def hasCompressedCoordinates(self, raw):
return raw[0] == "/" or raw[0] == "\\"

View file

@ -1,6 +1,12 @@
import random
from pycsdr.types import Format
from pycsdr.modules import Writer, TcpSource, ExecModule, Buffer
from csdr.module import LogReader
from owrx.config.core import CoreConfig
from owrx.config import Config
from abc import ABC, abstractmethod
import time
import os
import random
import socket
import logging
@ -136,3 +142,67 @@ IGLOGIN {callsign} {password}
)
return config
class DirewolfModule(ExecModule, DirewolfConfigSubscriber):
def __init__(self, service: bool = False):
self.tcpSource = None
self.writer = None
self.service = service
self.direwolfConfigPath = "{tmp_dir}/openwebrx_direwolf_{myid}.conf".format(
tmp_dir=CoreConfig().get_temporary_directory(), myid=id(self)
)
self.direwolfConfig = DirewolfConfig()
self.direwolfConfig.wire(self)
self.__writeConfig()
super().__init__(Format.SHORT, Format.CHAR, ["direwolf", "-c", self.direwolfConfigPath, "-r", "48000", "-t", "0", "-q", "d", "-q", "h"])
# direwolf supplies the data via a socket which we tap into in start()
# the output on its STDOUT is informative, but we still want to log it
buffer = Buffer(Format.CHAR)
self.logReader = LogReader(__name__, buffer)
super().setWriter(buffer)
self.start()
def __writeConfig(self):
file = open(self.direwolfConfigPath, "w")
file.write(self.direwolfConfig.getConfig(self.service))
file.close()
def setWriter(self, writer: Writer) -> None:
self.writer = writer
if self.tcpSource is not None:
self.tcpSource.setWriter(writer)
def start(self):
delay = 0.5
retries = 0
while True:
try:
self.tcpSource = TcpSource(self.direwolfConfig.getPort(), Format.CHAR)
if self.writer:
self.tcpSource.setWriter(self.writer)
break
except ConnectionError:
if retries > 20:
logger.error("maximum number of connection attempts reached. did direwolf start up correctly?")
raise
retries += 1
time.sleep(delay)
def restart(self):
self.__writeConfig()
super().restart()
self.start()
def onConfigChanged(self):
self.restart()
def stop(self) -> None:
super().stop()
self.logReader.stop()
self.logReader = None
os.unlink(self.direwolfConfigPath)
self.direwolfConfig.unwire(self)
self.direwolfConfig = None

View file

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

View file

@ -29,6 +29,10 @@ class WaveFile(object):
self.waveFile.setsampwidth(2)
self.waveFile.setframerate(12000)
def __del__(self):
if self.waveFile is not None:
logger.warning("WaveFile going out of scope but not unlinked!")
def close(self):
self.waveFile.close()
@ -77,14 +81,18 @@ class AudioWriter(object):
def _scheduleNextSwitch(self):
self.cancelTimer()
delta = self.getNextDecodingTime() - datetime.utcnow()
self.timer = threading.Timer(delta.total_seconds(), self.switchFiles)
self.timer = threading.Timer(delta.total_seconds(), self._switchFiles)
self.timer.start()
def switchFiles(self):
def _switchFiles(self):
with self.switchingLock:
file = self.wavefile
self.wavefile = self.getWaveFile()
if file is None:
logger.warning("switchfiles() with no wave file. sequencing problem?")
return
file.close()
tmp_dir = CoreConfig().get_temporary_directory()
@ -117,6 +125,8 @@ class AudioWriter(object):
self._scheduleNextSwitch()
def start(self):
if self.wavefile is not None:
logger.warning("wavefile is not none on startup, sequencing problem?")
self.wavefile = self.getWaveFile()
self._scheduleNextSwitch()

View file

@ -1,10 +1,12 @@
from owrx.config import ConfigError
from configparser import ConfigParser
from pathlib import Path
import os
from glob import glob
class CoreConfig(object):
defaultSearchLocations = ["./openwebrx.conf", "/etc/openwebrx/openwebrx.conf"]
defaults = {
"core": {
"data_directory": "/var/lib/openwebrx",
@ -20,18 +22,41 @@ class CoreConfig(object):
}
}
def __init__(self):
sharedConfig = None
@staticmethod
def load(file: Path = None):
def expand_base(base: Path):
# check if config exists
if not base.exists() or not base.is_file():
return []
# every location can additionally have a directory containing config overrides
# this directory must have the same name, with the ".d" suffix
override_dir = Path(str(base) + ".d")
# check if override dir exists
if not override_dir.exists() or not override_dir.is_dir():
return [base]
# load all .conf files from the override dir
overrides = override_dir.glob("*.conf")
return [base] + [o for o in overrides if o.is_file()]
if file is None:
bases = [Path(b) for b in CoreConfig.defaultSearchLocations]
else:
bases = [file]
configFiles = [o for b in bases for o in expand_base(b)]
config = ConfigParser()
# set up config defaults
config.read_dict(CoreConfig.defaults)
# check for overrides
overrides_dir = "/etc/openwebrx/openwebrx.conf.d"
if os.path.exists(overrides_dir) and os.path.isdir(overrides_dir):
overrides = glob(overrides_dir + "/*.conf")
else:
overrides = []
# sequence things together
config.read(["./openwebrx.conf", "/etc/openwebrx/openwebrx.conf"] + overrides)
# read the allocated files
config.read(configFiles)
CoreConfig.sharedConfig = config
def __init__(self):
config = CoreConfig.sharedConfig
self.data_directory = config.get("core", "data_directory")
CoreConfig.checkDirectory(self.data_directory, "data_directory")
self.temporary_directory = config.get("core", "temporary_directory")

View file

@ -156,6 +156,7 @@ class OpenWebRxReceiverClient(OpenWebRxClient, SdrSourceEventClient):
"fft_compression",
"max_clients",
"tuning_precision",
"aircraft_tracking_service",
]
def __init__(self, conn):
@ -461,6 +462,7 @@ class OpenWebRxReceiverClient(OpenWebRxClient, SdrSourceEventClient):
res["bandpass"] = {"low_cut": m.bandpass.low_cut, "high_cut": m.bandpass.high_cut}
if isinstance(m, DigitalMode):
res["underlying"] = m.underlying
res["secondaryFft"] = m.secondaryFft
return res
self.send({"type": "modes", "value": [to_json(m) for m in modes]})
@ -474,11 +476,11 @@ class MapConnection(OpenWebRxClient):
filtered_config = pm.filter(
"google_maps_api_key",
"receiver_gps",
"map_position_retention_time",
"callsign_service",
"aircraft_tracking_service",
"receiver_name",
)
filtered_config.wire(self.write_config)
self.configSub = filtered_config.wire(self.write_config)
self.write_config(filtered_config.__dict__())
@ -489,6 +491,7 @@ class MapConnection(OpenWebRxClient):
def close(self, error: bool = False):
Map.getSharedInstance().removeClient(self)
self.configSub.cancel()
super().close(error)
def write_config(self, cfg):

View file

@ -119,6 +119,7 @@ class CompiledAssetsController(GzipMixin, ModificationAwareController):
profiles = {
"receiver.js": [
"lib/chroma.min.js",
"lib/wheelDelta.js",
"openwebrx.js",
"lib/jquery-3.2.1.min.js",
"lib/jquery.nanoscroller.min.js",

View file

@ -183,6 +183,17 @@ class GeneralSettingsController(SettingsFormController):
Option("aprsfi", "aprs.fi"),
],
),
DropdownInput(
"aircraft_tracking_service",
"Aircraft tracking service",
infotext="Allows users to navigate to an external flight tracking service by clicking on flight "
+ "numbers",
options=[
Option(None, "disabled"),
Option("flightaware", "FlightAware"),
Option("planefinder", "planefinder"),
]
)
),
]

View file

@ -1,5 +1,5 @@
from owrx.source import SdrSourceEventClient, SdrSourceState, SdrClientClass
from owrx.property import PropertyStack, PropertyLayer, PropertyValidator
from owrx.property import PropertyStack, PropertyLayer, PropertyValidator, PropertyDeleted, PropertyDeletion
from owrx.property.validators import OrValidator, RegexValidator, BoolValidator
from owrx.modes import Modes, DigitalMode
from csdr.chain import Chain
@ -40,7 +40,6 @@ class ClientDemodulatorChain(Chain):
self.hdOutputRate = hdOutputRate
self.secondaryDspEventReceiver = secondaryDspEventReceiver
self.selector = Selector(sampleRate, outputRate)
self.selector.setBandpass(-4000, 4000)
self.selectorBuffer = Buffer(Format.COMPLEX_FLOAT)
self.audioBuffer = None
self.demodulator = demod
@ -97,8 +96,6 @@ class ClientDemodulatorChain(Chain):
# it's expected and should be mended when swapping out the demodulator in the next step
pass
self.replace(1, demodulator)
if self.demodulator is not None:
self.demodulator.stop()
@ -107,7 +104,6 @@ class ClientDemodulatorChain(Chain):
self.selector.setOutputRate(self._getSelectorOutputRate())
clientRate = self._getClientAudioInputRate()
self.clientAudioChain.setInputRate(clientRate)
self.demodulator.setSampleRate(clientRate)
if isinstance(self.demodulator, DeemphasisTauChain):
@ -116,12 +112,15 @@ class ClientDemodulatorChain(Chain):
self._updateDialFrequency()
self._syncSquelch()
outputRate = self.hdOutputRate if isinstance(self.demodulator, HdAudio) else self.outputRate
self.clientAudioChain.setClientRate(outputRate)
if self.metaWriter is not None and isinstance(demodulator, MetaProvider):
demodulator.setMetaWriter(self.metaWriter)
self.replace(1, demodulator)
self.clientAudioChain.setInputRate(clientRate)
outputRate = self.hdOutputRate if isinstance(self.demodulator, HdAudio) else self.outputRate
self.clientAudioChain.setClientRate(outputRate)
def stopDemodulator(self):
if self.demodulator is None:
return
@ -135,6 +134,8 @@ class ClientDemodulatorChain(Chain):
self.demodulator.stop()
self.demodulator = None
self.setSecondaryDemodulator(None)
def _getSelectorOutputRate(self):
if isinstance(self.demodulator, FixedIfSampleRateChain):
return self.demodulator.getFixedIfSampleRate()
@ -167,7 +168,8 @@ class ClientDemodulatorChain(Chain):
clientRate = self._getClientAudioInputRate()
self.clientAudioChain.setInputRate(clientRate)
self.demodulator.setSampleRate(clientRate)
if self.demodulator is not None:
self.demodulator.setSampleRate(clientRate)
self._updateDialFrequency()
self._syncSquelch()
@ -193,11 +195,11 @@ class ClientDemodulatorChain(Chain):
self.secondaryDemodulator.setReader(self.audioBuffer.getReader())
self.secondaryDemodulator.setWriter(self.secondaryWriter)
if self.secondaryDemodulator is None and self.secondaryFftChain is not None:
if (self.secondaryDemodulator is None or not self.secondaryDemodulator.isSecondaryFftShown()) and self.secondaryFftChain is not None:
self.secondaryFftChain.stop()
self.secondaryFftChain = None
if self.secondaryDemodulator is not None and self.secondaryFftChain is None:
if (self.secondaryDemodulator is not None and self.secondaryDemodulator.isSecondaryFftShown()) and self.secondaryFftChain is None:
self._createSecondaryFftChain()
if self.secondaryFftChain is not None:
@ -212,15 +214,15 @@ class ClientDemodulatorChain(Chain):
self.secondaryFftChain.setWriter(self.secondaryFftWriter)
def _syncSquelch(self):
if not self.demodulator.supportsSquelch() or (self.secondaryDemodulator is not None and not self.secondaryDemodulator.supportsSquelch()):
if self.demodulator is not None and not self.demodulator.supportsSquelch() or (self.secondaryDemodulator is not None and not self.secondaryDemodulator.supportsSquelch()):
self.selector.setSquelchLevel(-150)
else:
self.selector.setSquelchLevel(self.squelchLevel)
def setLowCut(self, lowCut):
def setLowCut(self, lowCut: Union[float, None]):
self.selector.setLowCut(lowCut)
def setHighCut(self, highCut):
def setHighCut(self, highCut: Union[float, None]):
self.selector.setHighCut(highCut)
def setBandpass(self, lowCut, highCut):
@ -366,7 +368,7 @@ class ClientDemodulatorChain(Chain):
def getSecondaryFftOutputFormat(self) -> Format:
if self.secondaryFftCompression == "adpcm":
return Format.CHAR
return Format.SHORT
return Format.FLOAT
def setWfmDeemphasisTau(self, tau: float) -> None:
if tau == self.wfmDeemphasisTau:
@ -459,6 +461,10 @@ class DspManager(SdrSourceEventClient, ClientDemodulatorSecondaryDspEventClient)
if mode.bandpass:
bpf = [mode.bandpass.low_cut, mode.bandpass.high_cut]
self.chain.setBandpass(*bpf)
self.props["low_cut"] = mode.bandpass.low_cut
self.props["high_cut"] = mode.bandpass.high_cut
else:
self.chain.setBandpass(None, None)
else:
# TODO modes should be mandatory
self.setDemodulator(self.props["start_mod"])
@ -470,7 +476,7 @@ class DspManager(SdrSourceEventClient, ClientDemodulatorSecondaryDspEventClient)
self.subscriptions = [
self.props.wireProperty("audio_compression", self.setAudioCompression),
self.props.wireProperty("fft_compression", self.chain.setSecondaryFftCompression),
self.props.wireProperty("fft_compression", self.setSecondaryFftCompression),
self.props.wireProperty("fft_voverlap_factor", self.chain.setSecondaryFftOverlapFactor),
self.props.wireProperty("fft_fps", self.chain.setSecondaryFftFps),
self.props.wireProperty("digimodes_fft_size", self.setSecondaryFftSize),
@ -480,8 +486,8 @@ class DspManager(SdrSourceEventClient, ClientDemodulatorSecondaryDspEventClient)
self.props.wireProperty("offset_freq", self.chain.setFrequencyOffset),
self.props.wireProperty("center_freq", self.chain.setCenterFrequency),
self.props.wireProperty("squelch_level", self.chain.setSquelchLevel),
self.props.wireProperty("low_cut", self.chain.setLowCut),
self.props.wireProperty("high_cut", self.chain.setHighCut),
self.props.wireProperty("low_cut", self.setLowCut),
self.props.wireProperty("high_cut", self.setHighCut),
self.props.wireProperty("mod", self.setDemodulator),
self.props.wireProperty("dmr_filter", self.chain.setSlotFilter),
self.props.wireProperty("wfm_deemphasis_tau", self.chain.setWfmDeemphasisTau),
@ -554,6 +560,9 @@ class DspManager(SdrSourceEventClient, ClientDemodulatorSecondaryDspEventClient)
elif demod == "freedv":
from csdr.chain.freedv import FreeDV
return FreeDV()
elif demod == "empty":
from csdr.chain.analog import Empty
return Empty()
def setDemodulator(self, mod):
self.chain.stopDemodulator()
@ -600,6 +609,27 @@ class DspManager(SdrSourceEventClient, ClientDemodulatorSecondaryDspEventClient)
elif mod == "bpsk63":
from csdr.chain.digimodes import PskDemodulator
return PskDemodulator(62.5)
elif mod == "rtty170":
from csdr.chain.digimodes import RttyDemodulator
return RttyDemodulator(45.45, 170)
elif mod == "rtty450":
from csdr.chain.digimodes import RttyDemodulator
return RttyDemodulator(50, 450, invert=True)
elif mod == "rtty85":
from csdr.chain.digimodes import RttyDemodulator
return RttyDemodulator(50, 85, invert=True)
elif mod == "adsb":
from csdr.chain.dump1090 import Dump1090
return Dump1090()
elif mod == "ism":
from csdr.chain.rtl433 import Rtl433
return Rtl433()
elif mod == "hfdl":
from csdr.chain.dumphfdl import DumpHFDL
return DumpHFDL()
elif mod == "vdl2":
from csdr.chain.dumpvdl2 import DumpVDL2
return DumpVDL2()
def setSecondaryDemodulator(self, mod):
demodulator = self._getSecondaryDemodulator(mod)
@ -617,6 +647,23 @@ class DspManager(SdrSourceEventClient, ClientDemodulatorSecondaryDspEventClient)
self.chain.setWriter(buffer)
self.wireOutput(self.audioOutput, buffer)
def setSecondaryFftCompression(self, compression):
try:
self.chain.setSecondaryFftCompression(compression)
except ValueError:
# wrong output format... need to re-wire
pass
buffer = Buffer(self.chain.getSecondaryFftOutputFormat())
self.chain.setSecondaryFftWriter(buffer)
self.wireOutput("secondary_fft", buffer)
def setLowCut(self, lowCut: Union[float, PropertyDeletion]):
self.chain.setLowCut(None if lowCut is PropertyDeleted else lowCut)
def setHighCut(self, highCut: Union[float, PropertyDeletion]):
self.chain.setHighCut(None if highCut is PropertyDeleted else highCut)
def start(self):
if self.sdrSource.isAvailable():
self.chain.setReader(self.sdrSource.getBuffer().getReader())
@ -652,7 +699,7 @@ class DspManager(SdrSourceEventClient, ClientDemodulatorSecondaryDspEventClient)
b = data.tobytes()
# If we know it's not pickled, let us not unpickle
if len(b) < 2 or b[0] != 0x80 or not 3 <= b[1] <= pickle.HIGHEST_PROTOCOL:
callback(b.decode("ascii"))
callback(b.decode("ascii", errors="replace"))
return
io = BytesIO(b)
@ -662,7 +709,7 @@ class DspManager(SdrSourceEventClient, ClientDemodulatorSecondaryDspEventClient)
except EOFError:
pass
except pickle.UnpicklingError:
callback(b.decode("ascii"))
callback(b.decode("ascii", errors="replace"))
return unpickler
@ -685,7 +732,11 @@ class DspManager(SdrSourceEventClient, ClientDemodulatorSecondaryDspEventClient)
self.setProperty(k, v)
def setProperty(self, prop, value):
self.localProps[prop] = value
if value is None:
if prop in self.localProps:
del self.localProps[prop]
else:
self.localProps[prop] = value
def getClientClass(self) -> SdrClientClass:
return SdrClientClass.USER

View file

@ -13,7 +13,6 @@ from datetime import datetime, timedelta
import logging
logger = logging.getLogger(__name__)
logger.setLevel(logging.INFO)
class UnknownFeatureException(Exception):
@ -62,6 +61,7 @@ class FeatureDetector(object):
"perseussdr": ["perseustest", "nmux"],
"airspy": ["soapy_connector", "soapy_airspy"],
"airspyhf": ["soapy_connector", "soapy_airspyhf"],
"afedri": ["soapy_connector", "soapy_afedri"],
"lime_sdr": ["soapy_connector", "soapy_lime_sdr"],
"fifi_sdr": ["alsa", "rockprog", "nmux"],
"pluto_sdr": ["soapy_connector", "soapy_pluto_sdr"],
@ -76,7 +76,7 @@ class FeatureDetector(object):
# optional features and their requirements
"digital_voice_digiham": ["digiham", "codecserver_ambe"],
"digital_voice_freedv": ["freedv_rx"],
"digital_voice_m17": ["m17_demod", "digiham"],
"digital_voice_m17": ["m17_demod"],
"wsjt-x": ["wsjtx"],
"wsjt-x-2-3": ["wsjtx_2_3"],
"wsjt-x-2-4": ["wsjtx_2_4"],
@ -85,6 +85,10 @@ class FeatureDetector(object):
"pocsag": ["digiham"],
"js8call": ["js8", "js8py"],
"drm": ["dream"],
"dump1090": ["dump1090"],
"ism": ["rtl_433"],
"dumphfdl": ["dumphfdl"],
"dumpvdl2": ["dumpvdl2"],
}
def feature_availability(self):
@ -137,14 +141,12 @@ class FeatureDetector(object):
if cache.has(requirement):
return cache.get(requirement)
logger.debug("performing feature check for %s", requirement)
method = self._get_requirement_method(requirement)
result = False
if method is not None:
result = method()
else:
logger.error("detection of requirement {0} not implement. please fix in code!".format(requirement))
logger.debug("feature check for %s complete. result: %s", requirement, result)
cache.set(requirement, result)
return result
@ -167,7 +169,14 @@ class FeatureDetector(object):
cwd=tmp_dir,
env=env,
)
rc = process.wait()
while True:
try:
rc = process.wait(10)
break
except subprocess.TimeoutExpired:
logger.warning("feature check command \"%s\" did not return after 10 seconds!", command)
process.kill()
if expected_result is None:
return rc != 32512
else:
@ -336,6 +345,14 @@ class FeatureDetector(object):
"""
return self._has_soapy_driver("airspyhf")
def has_soapy_afedri(self):
"""
The SoapyAfedri module allows using Afedri SDR-Net devices with SoapySDR.
You can get it [here](https://github.com/alexander-sholohov/SoapyAfedri).
"""
return self._has_soapy_driver("afedri")
def has_soapy_lime_sdr(self):
"""
The Lime Suite installs - amongst others - a Soapy driver for the LimeSDR device series.
@ -406,7 +423,7 @@ class FeatureDetector(object):
You can find more information [here](https://github.com/mobilinkd/m17-cxx-demod)
"""
return self.command_is_runnable("m17-demod")
return self.command_is_runnable("m17-demod", 0)
def has_direwolf(self):
"""
@ -550,6 +567,9 @@ class FeatureDetector(object):
Codecserver is used to decode audio data from digital voice modes using the AMBE codec.
You can find more information [here](https://github.com/jketterl/codecserver).
NOTE: this feature flag checks both the availability of codecserver as well as the availability of the AMBE
codec in the configured codecserer instance.
"""
config = Config.get()
@ -564,3 +584,55 @@ class FeatureDetector(object):
return False
except ConnectionError:
return False
except RuntimeError as e:
logger.exception("Codecserver error while checking for AMBE support:")
return False
def has_dump1090(self):
"""
To be able to decode Mode-S and ADS-B traffic originating from airplanes, you need to install the dump1090
decoder. There is a number of forks available, any version that supports the `--ifile` and `--iformat` arguments
should work.
Recommended fork: [dump1090 by Flightaware](https://github.com/flightaware/dump1090)
If you are using the OpenWebRX Debian or Ubuntu repository, you should be able to install the package
`dump1090-fa-minimal`.
If you are running a different fork, please make sure that the command `dump1090` (without suffixes) runs the
version you would like to use. You can use symbolic links or the
[Debian alternatives system](https://wiki.debian.org/DebianAlternatives) to achieve this.
"""
return self.command_is_runnable("dump1090 --version")
def has_rtl_433(self):
"""
OpenWebRX can make use of the `rtl_433` software to decode various signals in the ISM bands.
You can find more information [here](https://github.com/merbanan/rtl_433).
Debian and Ubuntu based systems should be able to install the package `rtl-433` from the package manager.
"""
return self.command_is_runnable("rtl_433 -h")
def has_dumphfdl(self):
"""
OpenWebRX supports decoding HFDL airplane communications using the `dumphfdl` decoder.
You can find more information [here](https://github.com/szpajder/dumphfdl)
If you are using the OpenWebRX Debian or Ubuntu repository, you should be able to install the package
`dumphfdl`.
"""
return self.command_is_runnable("dumphfdl --version")
def has_dumpvdl2(self):
"""
OpenWebRX supports decoding VDL Mode 2 airplane communications using the `dumpvdl2` decoder.
You can find more information [here](https://github.com/szpajder/dumpvdl2)
If you are using the OpenWebRX Debian or Ubuntu repository, you should be able to install the package
`dumpvdl2`.
"""
return self.command_is_runnable("dumpvdl2 --version")

101
owrx/hfdl/dumphfdl.py Normal file
View file

@ -0,0 +1,101 @@
from pycsdr.modules import ExecModule
from pycsdr.types import Format
from owrx.aeronautical import AirplaneLocation, AcarsProcessor, IcaoSource, FlightSource
from owrx.map import Map
from owrx.metrics import Metrics, CounterMetric
from datetime import datetime, timezone, timedelta
import logging
logger = logging.getLogger(__name__)
class DumpHFDLModule(ExecModule):
def __init__(self):
super().__init__(
Format.COMPLEX_FLOAT,
Format.CHAR,
[
"dumphfdl",
"--iq-file", "-",
"--sample-format", "CF32",
"--sample-rate", "12000",
"--output", "decoded:json:file:path=-",
"0",
],
flushSize=50000,
)
class HFDLMessageParser(AcarsProcessor):
def __init__(self):
name = "dumphfdl.decodes.hfdl"
self.metrics = Metrics.getSharedInstance().getMetric(name)
if self.metrics is None:
self.metrics = CounterMetric()
Metrics.getSharedInstance().addMetric(name, self.metrics)
super().__init__("HFDL")
def process(self, line):
msg = super().process(line)
if msg is not None:
try:
payload = msg["hfdl"]
if "lpdu" in payload:
lpdu = payload["lpdu"]
icao = lpdu["src"]["ac_info"]["icao"] if "ac_info" in lpdu["src"] else None
if lpdu["type"]["id"] in [13, 29]:
hfnpdu = lpdu["hfnpdu"]
if hfnpdu["type"]["id"] == 209:
# performance data
self.processPosition(hfnpdu, icao)
elif hfnpdu["type"]["id"] == 255:
# enveloped data
if "acars" in hfnpdu:
self.processAcars(hfnpdu["acars"], icao)
elif lpdu["type"]["id"] in [79, 143, 191]:
if "ac_info" in lpdu:
icao = lpdu["ac_info"]["icao"]
self.processPosition(lpdu["hfnpdu"], icao)
except Exception:
logger.exception("error processing HFDL data")
self.metrics.inc()
return msg
def processPosition(self, hfnpdu, icao=None):
if "pos" in hfnpdu:
pos = hfnpdu["pos"]
if abs(pos['lat']) <= 90 and abs(pos['lon']) <= 180:
flight = self.processFlight(hfnpdu["flight_id"])
if icao is not None:
source = IcaoSource(icao, flight=flight)
elif flight:
source = FlightSource(flight)
else:
source = None
if source:
msg = {
"lat": pos["lat"],
"lon": pos["lon"],
"flight": flight
}
if "utc_time" in hfnpdu:
ts = self.processTimestamp(**hfnpdu["utc_time"])
elif "time" in hfnpdu:
ts = self.processTimestamp(**hfnpdu["time"])
else:
ts = None
Map.getSharedInstance().updateLocation(source, AirplaneLocation(msg), "HFDL", timestamp=ts)
def processTimestamp(self, hour, min, sec) -> datetime:
now = datetime.now(timezone.utc)
t = now.replace(hour=hour, minute=min, second=sec, microsecond=0)
# if we have moved the time to the future, it's most likely that we're close to midnight and the time
# we received actually refers to yesterday
if t > now:
t -= timedelta(days=1)
return t

13
owrx/ism/rtl433.py Normal file
View file

@ -0,0 +1,13 @@
from pycsdr.modules import ExecModule
from pycsdr.types import Format
class Rtl433Module(ExecModule):
def __init__(self):
super().__init__(
Format.COMPLEX_FLOAT,
Format.CHAR,
["rtl_433", "-r", "cf32:-", "-F", "json", "-M", "time:unix", "-C", "si"]
)

View file

@ -3,7 +3,7 @@ from owrx.audio.chopper import AudioChopperParser
import re
from js8py import Js8
from js8py.frames import Js8FrameHeartbeat, Js8FrameCompound
from owrx.map import Map, LocatorLocation
from owrx.map import Map, LocatorLocation, CallsignSource
from owrx.metrics import Metrics, CounterMetric
from owrx.config import Config
from abc import ABCMeta, abstractmethod
@ -103,7 +103,7 @@ class Js8Parser(AudioChopperParser):
if (isinstance(frame, Js8FrameHeartbeat) or isinstance(frame, Js8FrameCompound)) and frame.grid:
Map.getSharedInstance().updateLocation(
frame.source, LocatorLocation(frame.grid), "JS8", band
CallsignSource(**frame.source), LocatorLocation(frame.grid), "JS8", band
)
ReportingEngine.getSharedInstance().spot(
{

View file

@ -1,6 +1,7 @@
from datetime import datetime, timedelta
from datetime import datetime, timedelta, timezone
from owrx.config import Config
from owrx.bands import Band
from abc import abstractmethod, ABC, ABCMeta
import threading
import time
import sys
@ -8,10 +9,24 @@ import sys
import logging
logger = logging.getLogger(__name__)
logger.setLevel(logging.INFO)
class Location(object):
def getTTL(self) -> timedelta:
pm = Config.get()
return timedelta(seconds=pm["map_position_retention_time"])
def __dict__(self):
return {
"ttl": self.getTTL().total_seconds() * 1000
}
class Source(ABC):
@abstractmethod
def getKey(self) -> str:
pass
def __dict__(self):
return {}
@ -61,7 +76,7 @@ class Map(object):
client.write_update(
[
{
"source": record["source"],
"source": record["source"].__dict__(),
"location": record["location"].__dict__(),
"lastseen": record["updated"].timestamp() * 1000,
"mode": record["mode"],
@ -77,36 +92,38 @@ class Map(object):
except ValueError:
pass
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()
key = self._sourceToKey(source)
def updateLocation(self, source: Source, loc: Location, mode: str, band: Band = None, timestamp: datetime = None):
if timestamp is None:
timestamp = datetime.now(timezone.utc)
else:
# if we get an external timestamp, make sure it's not already expired
if datetime.now(timezone.utc) - loc.getTTL() > timestamp:
return
key = source.getKey()
with self.positionsLock:
self.positions[key] = {"source": source, "location": loc, "updated": ts, "mode": mode, "band": band}
if isinstance(loc, IncrementalUpdate) and key in self.positions:
loc.update(self.positions[key]["location"])
self.positions[key] = {"source": source, "location": loc, "updated": timestamp, "mode": mode, "band": band}
self.broadcast(
[
{
"source": source,
"source": source.__dict__(),
"location": loc.__dict__(),
"lastseen": ts.timestamp() * 1000,
"lastseen": timestamp.timestamp() * 1000,
"mode": mode,
"band": band.getName() if band is not None else None,
}
]
)
def touchLocation(self, source):
def touchLocation(self, source: Source):
# not implemented on the client side yet, so do not use!
ts = datetime.now()
key = self._sourceToKey(source)
ts = datetime.now(timezone.utc)
key = source.getKey()
with self.positionsLock:
if key in self.positions:
self.positions[key]["updated"] = ts
self.broadcast([{"source": source, "lastseen": ts.timestamp() * 1000}])
self.broadcast([{"source": source.__dict__(), "lastseen": ts.timestamp() * 1000}])
def removeLocation(self, key):
with self.positionsLock:
@ -114,11 +131,12 @@ class Map(object):
# TODO broadcast removal to clients
def removeOldPositions(self):
pm = Config.get()
retention = timedelta(seconds=pm["map_position_retention_time"])
cutoff = datetime.now() - retention
now = datetime.now(timezone.utc)
to_be_removed = [key for (key, pos) in self.positions.items() if pos["updated"] < cutoff]
with self.positionsLock:
to_be_removed = [
key for (key, pos) in self.positions.items() if now - pos["location"].getTTL() > pos["updated"]
]
for key in to_be_removed:
self.removeLocation(key)
@ -136,7 +154,10 @@ class LatLngLocation(Location):
self.lon = lon
def __dict__(self):
res = {"type": "latlon", "lat": self.lat, "lon": self.lon}
res = super().__dict__()
res.update(
{"type": "latlon", "lat": self.lat, "lon": self.lon}
)
return res
@ -145,4 +166,25 @@ class LocatorLocation(Location):
self.locator = locator
def __dict__(self):
return {"type": "locator", "locator": self.locator}
res = super().__dict__()
res.update(
{"type": "locator", "locator": self.locator}
)
return res
class IncrementalUpdate(Location, metaclass=ABCMeta):
@abstractmethod
def update(self, previousLocation: Location):
pass
class CallsignSource(Source):
def __init__(self, callsign: str):
self.callsign = callsign
def getKey(self) -> str:
return "callsign:{}".format(self.callsign)
def __dict__(self):
return {"callsign": self.callsign}

View file

@ -11,7 +11,7 @@ from urllib.error import HTTPError
from csdr.module import PickleModule
from owrx.aprs import AprsParser, AprsLocation
from owrx.config import Config
from owrx.map import Map, LatLngLocation
from owrx.map import Map, LatLngLocation, CallsignSource
from owrx.bands import Bandplan
logger = logging.getLogger(__name__)
@ -129,7 +129,7 @@ class DigihamEnricher(Enricher, metaclass=ABCMeta):
callsign = self.getCallsign(meta)
if callsign is not None and "lat" in meta and "lon" in meta:
loc = LatLngLocation(meta["lat"], meta["lon"])
Map.getSharedInstance().updateLocation({"callsign": callsign}, loc, mode, self.parser.getBand())
Map.getSharedInstance().updateLocation(CallsignSource(callsign), loc, mode, self.parser.getBand())
return meta
@abstractmethod
@ -202,7 +202,7 @@ class DStarEnricher(DigihamEnricher):
if "ourcall" in meta:
# send location info to map as well (it will show up with the correct symbol there!)
loc = AprsLocation(data)
Map.getSharedInstance().updateLocation({"callsign": meta["ourcall"]}, loc, "DPRS", self.parser.getBand())
Map.getSharedInstance().updateLocation(CallsignSource(meta["ourcall"]), loc, "DPRS", self.parser.getBand())
except Exception:
logger.exception("Error while parsing DPRS data")

View file

@ -33,19 +33,34 @@ class Mode:
return self.modulation
EmptyMode = Mode("empty", "Empty")
class AnalogMode(Mode):
pass
class DigitalMode(Mode):
def __init__(
self, modulation, name, underlying, bandpass: Bandpass = None, requirements=None, service=False, squelch=True
self,
modulation,
name,
underlying,
bandpass: Bandpass = None,
requirements=None,
service=False,
squelch=True,
secondaryFft=True
):
super().__init__(modulation, name, bandpass, requirements, service, squelch)
self.underlying = underlying
self.secondaryFft = secondaryFft
def get_underlying_mode(self):
return Modes.findByModulation(self.underlying[0])
mode = Modes.findByModulation(self.underlying[0])
if mode is None:
mode = EmptyMode
return mode
def get_bandpass(self):
if self.bandpass is not None:
@ -106,19 +121,22 @@ class Modes(object):
AnalogMode("lsb", "LSB", bandpass=Bandpass(-3000, -300)),
AnalogMode("usb", "USB", bandpass=Bandpass(300, 3000)),
AnalogMode("cw", "CW", bandpass=Bandpass(700, 900)),
AnalogMode("dmr", "DMR", bandpass=Bandpass(-4000, 4000), requirements=["digital_voice_digiham"], squelch=False),
AnalogMode("dmr", "DMR", bandpass=Bandpass(-6250, 6250), requirements=["digital_voice_digiham"], squelch=False),
AnalogMode(
"dstar", "D-Star", bandpass=Bandpass(-3250, 3250), requirements=["digital_voice_digiham"], squelch=False
),
AnalogMode("nxdn", "NXDN", bandpass=Bandpass(-3250, 3250), requirements=["digital_voice_digiham"], squelch=False),
AnalogMode("ysf", "YSF", bandpass=Bandpass(-4000, 4000), requirements=["digital_voice_digiham"], squelch=False),
AnalogMode("m17", "M17", bandpass=Bandpass(-4000, 4000), requirements=["digital_voice_m17"], squelch=False),
AnalogMode("ysf", "YSF", bandpass=Bandpass(-6250, 6250), requirements=["digital_voice_digiham"], squelch=False),
AnalogMode("m17", "M17", bandpass=Bandpass(-6250, 6250), requirements=["digital_voice_m17"], squelch=False),
AnalogMode(
"freedv", "FreeDV", bandpass=Bandpass(300, 3000), requirements=["digital_voice_freedv"], squelch=False
),
AnalogMode("drm", "DRM", bandpass=Bandpass(-5000, 5000), requirements=["drm"], squelch=False),
DigitalMode("bpsk31", "BPSK31", underlying=["usb"]),
DigitalMode("bpsk63", "BPSK63", underlying=["usb"]),
DigitalMode("rtty170", "RTTY 45/170", underlying=["usb", "lsb"]),
DigitalMode("rtty450", "RTTY 50N/450", underlying=["lsb", "usb"]),
DigitalMode("rtty85", "RTTY 50N/85", underlying=["lsb", "usb"]),
WsjtMode("ft8", "FT8"),
WsjtMode("ft4", "FT4"),
WsjtMode("jt65", "JT65"),
@ -142,10 +160,46 @@ class Modes(object):
"pocsag",
"Pocsag",
underlying=["nfm"],
bandpass=Bandpass(-6000, 6000),
bandpass=Bandpass(-6250, 6250),
requirements=["pocsag"],
squelch=False,
),
DigitalMode(
"adsb",
"ADS-B",
underlying=["empty"],
bandpass=None,
requirements=["dump1090"],
service=True,
squelch=False,
secondaryFft=False,
),
DigitalMode(
"ism",
"ISM",
underlying=["empty"],
bandpass=None,
requirements=["ism"],
squelch=False,
),
DigitalMode(
"hfdl",
"HFDL",
underlying=["empty"],
bandpass=Bandpass(0, 3000),
requirements=["dumphfdl"],
service=True,
squelch=False,
),
DigitalMode(
"vdl2",
"VDL2",
underlying=["empty"],
bandpass=Bandpass(-12500, 12500),
requirements=["dumpvdl2"],
service=True,
squelch=False,
)
]
@staticmethod

View file

@ -267,12 +267,15 @@ class ServiceHandler(SdrSourceEventClient):
secondaryDemod = self._getSecondaryDemodulator(modeObject.modulation)
center_freq = source.getProps()["center_freq"]
sampleRate = source.getProps()["samp_rate"]
bandpass = modeObject.get_bandpass()
if isinstance(secondaryDemod, DialFrequencyReceiver):
secondaryDemod.setDialFrequency(dial["frequency"])
chain = ServiceDemodulatorChain(demod, secondaryDemod, sampleRate, dial["frequency"] - center_freq)
chain.setBandPass(bandpass.low_cut, bandpass.high_cut)
bandpass = modeObject.get_bandpass()
if bandpass:
chain.setBandPass(bandpass.low_cut, bandpass.high_cut)
else:
chain.setBandPass(None, None)
chain.setReader(source.getBuffer().getReader())
# dummy buffer, we don't use the output right now
@ -310,6 +313,15 @@ class ServiceHandler(SdrSourceEventClient):
elif mod == "packet":
from csdr.chain.digimodes import PacketDemodulator
return PacketDemodulator(service=True)
elif mod == "adsb":
from csdr.chain.dump1090 import Dump1090
return Dump1090()
elif mod == "hfdl":
from csdr.chain.dumphfdl import DumpHFDL
return DumpHFDL()
elif mod == "vdl2":
from csdr.chain.dumpvdl2 import DumpVDL2
return DumpVDL2()
raise ValueError("unsupported service modulation: {}".format(mod))

125
owrx/source/afedri.py Normal file
View file

@ -0,0 +1,125 @@
import re
from ipaddress import IPv4Address, AddressValueError
from owrx.source.soapy import SoapyConnectorSource, SoapyConnectorDeviceDescription
from owrx.form.input import Input, CheckboxInput, DropdownInput, Option
from owrx.form.input.device import TextInput
from owrx.form.input.validator import Validator, ValidationError
from typing import List
AFEDRI_DEVICE_KEYS = ["rx_mode"]
AFEDRI_PROFILE_KEYS = ["r820t_lna_agc", "r820t_mixer_agc"]
class IPv4AndPortValidator(Validator):
def validate(self, key, value) -> None:
parts = value.split(":")
if len(parts) != 2:
raise ValidationError(key, "Wrong format. Expected IPv4:port")
try:
IPv4Address(parts[0])
except AddressValueError as e:
raise ValidationError(key, "IP address error: {}".format(str(e)))
try:
port = int(parts[1])
except ValueError:
raise ValidationError(key, "Port number invalid")
if not 0 <= port <= 65535:
raise ValidationError(key, "Port number out of range")
class AfedriAddressPortInput(TextInput):
def __init__(self):
super().__init__(
"afedri_adress_port",
"Afedri IP and Port",
infotext="Afedri IP and port to connect to. Format = IPv4:Port",
validator=IPv4AndPortValidator(),
)
class AfedriSource(SoapyConnectorSource):
def getSoapySettingsMappings(self):
mappings = super().getSoapySettingsMappings()
mappings.update({x: x for x in AFEDRI_PROFILE_KEYS})
return mappings
def getEventNames(self):
return super().getEventNames() + ["afedri_adress_port"] + AFEDRI_DEVICE_KEYS
def getDriver(self):
return "afedri"
def buildSoapyDeviceParameters(self, parsed, values):
params = super().buildSoapyDeviceParameters(parsed, values)
address, port = values["afedri_adress_port"].split(":")
params += [{"address": address, "port": port}]
can_be_set_at_start = AFEDRI_DEVICE_KEYS
for elm in can_be_set_at_start:
if elm in values:
params += [{elm: values[elm]}]
return params
class AfedriDeviceDescription(SoapyConnectorDeviceDescription):
def getName(self):
return "Afedri device"
def supportsPpm(self):
# not currently mapped, and it's unclear how this should be sent to the device
return False
def hasAgc(self):
# not currently mapped
return False
def getInputs(self) -> List[Input]:
return super().getInputs() + [
AfedriAddressPortInput(),
CheckboxInput(
"r820t_lna_agc",
"Enable R820T LNA AGC",
),
CheckboxInput(
"r820t_mixer_agc",
"Enable R820T Mixer AGC",
),
DropdownInput(
"rx_mode",
"Switch the device to a specific RX mode at start",
options=[
Option("0", "Single"),
Option("1", "DualDiversity"),
Option("2", "Dual"),
Option("3", "DiversityInternal"),
Option("4", "QuadDiversity"),
Option("5", "Quad"),
],
),
]
def getDeviceMandatoryKeys(self):
return super().getDeviceMandatoryKeys() + ["afedri_adress_port"]
def getDeviceOptionalKeys(self):
return super().getDeviceOptionalKeys() + AFEDRI_DEVICE_KEYS
def getProfileOptionalKeys(self):
return super().getProfileOptionalKeys() + AFEDRI_PROFILE_KEYS
def getGainStages(self):
return [
"RF",
"FE",
"R820T_LNA_GAIN",
"R820T_MIXER_GAIN",
"R820T_VGA_GAIN",
]
def getNumberOfChannels(self) -> int:
return 4

View file

@ -1,11 +1,10 @@
from owrx.source.connector import ConnectorSource, ConnectorDeviceDescription
from owrx.command import Option
from owrx.command import Option, Flag
from owrx.form.error import ValidationError
from owrx.form.input import Input, NumberInput, TextInput
from owrx.form.input import Input, NumberInput, TextInput, CheckboxInput
from owrx.form.input.validator import RangeValidator
from typing import List
# In order to use an HPSDR radio, you must install hpsdrconnector from https://github.com/jancona/hpsdrconnector
# These are the command line options available:
# --frequency uint
# Tune to specified frequency in Hz (default 7100000)
@ -17,10 +16,17 @@ from typing import List
# Use the specified samplerate: one of 48000, 96000, 192000, 384000 (default 96000)
# --debug
# Emit debug log messages on stdout
# --serverPort uint
# Server port for this radio (default 7300)
#
# If you omit `remote` from config_webrx.py, hpsdrconnector will use the HPSDR discovery protocol
# to find radios on your local network and will connect to the first radio it discovered.
# If a remote IP address is not set, the connector will use the HPSDR discovery protocol
# to find radios on the local network and will connect to the first radio it discovers.
# If there is more than one HPSDR radio on the network, the IP address of the desired radio
# should always be specified.
# To use multiple HPSDR radios, each radio should have its IP address and a unique server port
# specfied. For example:
# Radio 1: (Remote IP: 192.168.1.11, Server port: 7300)
# Radio 2: (Remote IP: 192.168.1.22, Server port: 7301)
class HpsdrSource(ConnectorSource):
def getCommandMapper(self):
@ -34,6 +40,8 @@ class HpsdrSource(ConnectorSource):
"samp_rate": Option("--samplerate"),
"remote": Option("--radio"),
"rf_gain": Option("--gain"),
"server_port": Option("--serverPort"),
"debug": Flag("--debug"),
}
)
)
@ -41,7 +49,12 @@ class HpsdrSource(ConnectorSource):
class RemoteInput(TextInput):
def __init__(self):
super().__init__(
"remote", "Remote IP", infotext="Remote IP address to connect to."
"remote",
"Remote IP",
infotext=(
"HPSDR radio IP address. If it is not set, the connector will connect to the first radio it discovers. "
"If there is more than one HPSDR radio on the network, IP addresses of the desired radios should always be specified."
)
)
class HpsdrDeviceDescription(ConnectorDeviceDescription):
@ -51,11 +64,26 @@ class HpsdrDeviceDescription(ConnectorDeviceDescription):
def getInputs(self) -> List[Input]:
return super().getInputs() + [
RemoteInput(),
NumberInput("rf_gain", "LNA Gain", "LNA gain between 0 (-12dB) and 60 (48dB)", validator=RangeValidator(0, 60)),
]
NumberInput(
"rf_gain",
"LNA Gain",
"LNA gain between 0 (-12dB) and 60 (48dB) (default 20)",
validator=RangeValidator(0, 60)
),
CheckboxInput(
"debug",
"Show connector debugging messages in the log"
),
NumberInput(
"server_port",
"Server port",
("Radio server port (default 7300). When using multiple radios, each must be on a separate port, "
"e.g. 7300 for the first, 7301 for the second.")
),
]
def getDeviceOptionalKeys(self):
return list(filter(lambda x : x not in ["rtltcp_compat", "iqswap"], super().getDeviceOptionalKeys())) + ["remote"]
return list(filter(lambda x : x not in ["rtltcp_compat", "iqswap"], super().getDeviceOptionalKeys())) + ["remote","debug","server_port"]
def getProfileOptionalKeys(self):
return list(filter(lambda x : x != "iqswap", super().getProfileOptionalKeys()))

View file

@ -1,7 +1,7 @@
from owrx.source.connector import ConnectorSource, ConnectorDeviceDescription
from owrx.command import Flag, Option, Argument
from owrx.form.input import Input
from owrx.form.input.device import RemoteInput
from owrx.form.input.device import RemoteInput, DirectSamplingInput
from typing import List
@ -26,7 +26,13 @@ class RtlTcpDeviceDescription(ConnectorDeviceDescription):
return "RTL-SDR device (via rtl_tcp)"
def getInputs(self) -> List[Input]:
return super().getInputs() + [RemoteInput()]
return super().getInputs() + [RemoteInput(), DirectSamplingInput()]
def getDeviceMandatoryKeys(self):
return super().getDeviceMandatoryKeys() + ["remote"]
def getDeviceOptionalKeys(self):
return super().getDeviceOptionalKeys() + ["direct_sampling"]
def getProfileOptionalKeys(self):
return super().getProfileOptionalKeys() + ["direct_sampling"]

View file

@ -2,7 +2,8 @@ from abc import ABCMeta, abstractmethod
from owrx.command import Option
from owrx.source.connector import ConnectorSource, ConnectorDeviceDescription
from typing import List
from owrx.form.input import Input, TextInput
from owrx.form.input import Input, NumberInput, TextInput
from owrx.form.input.validator import RangeValidator
from owrx.form.input.device import GainInput
from owrx.soapy import SoapySettings
@ -17,6 +18,7 @@ class SoapyConnectorSource(ConnectorSource, metaclass=ABCMeta):
{
"antenna": Option("-a"),
"soapy_settings": Option("-t"),
"channel": Option("-n"),
}
)
)
@ -83,7 +85,7 @@ class SoapyConnectorSource(ConnectorSource, metaclass=ABCMeta):
class SoapyConnectorDeviceDescription(ConnectorDeviceDescription):
def getInputs(self) -> List[Input]:
return super().getInputs() + [
inputs = super().getInputs() + [
TextInput(
"device",
"Device identifier",
@ -97,9 +99,25 @@ class SoapyConnectorDeviceDescription(ConnectorDeviceDescription):
),
TextInput("antenna", "Antenna"),
]
if self.getNumberOfChannels() > 1:
inputs += [
NumberInput(
"channel",
"Select SoapySDR Channel",
validator=RangeValidator(0, self.getNumberOfChannels() - 1)
)
]
return inputs
def getNumberOfChannels(self) -> int:
"""
can be overridden for sdr devices that have multiple channels. will allow the user to select a channel from
the device selection screen if > 1
"""
return 1
def getDeviceOptionalKeys(self):
return super().getDeviceOptionalKeys() + ["device", "rf_gain", "antenna"]
return super().getDeviceOptionalKeys() + ["device", "rf_gain", "antenna", "channel"]
def getProfileOptionalKeys(self):
return super().getProfileOptionalKeys() + ["antenna"]

116
owrx/vdl2/dumpvdl2.py Normal file
View file

@ -0,0 +1,116 @@
from pycsdr.modules import ExecModule
from pycsdr.types import Format
from owrx.aeronautical import AcarsProcessor
from owrx.map import Map
from owrx.aeronautical import AirplaneLocation, IcaoSource
from owrx.metrics import Metrics, CounterMetric
from datetime import datetime, date, time, timezone
import logging
logger = logging.getLogger(__name__)
class DumpVDL2Module(ExecModule):
def __init__(self):
super().__init__(
Format.COMPLEX_SHORT,
Format.CHAR,
[
"dumpvdl2",
"--iq-file", "-",
"--oversample", "1",
"--sample-format", "S16_LE",
"--output", "decoded:json:file:path=-",
]
)
class VDL2MessageParser(AcarsProcessor):
def __init__(self):
name = "dumpvdl2.decodes.vdl2"
self.metrics = Metrics.getSharedInstance().getMetric(name)
if self.metrics is None:
self.metrics = CounterMetric()
Metrics.getSharedInstance().addMetric(name, self.metrics)
super().__init__("VDL2")
def process(self, line):
msg = super().process(line)
if msg is not None:
try:
payload = msg["vdl2"]
if "avlc" in payload:
avlc = payload["avlc"]
src = avlc["src"]["addr"]
if avlc["frame_type"] == "I":
if "acars" in avlc:
self.processAcars(avlc["acars"], icao=src)
elif "x25" in avlc:
x25 = avlc["x25"]
if "clnp" in x25:
clnp = x25["clnp"]
if "cotp" in clnp:
cotp = clnp["cotp"]
if "adsc_v2" in cotp:
adsc_v2 = cotp["adsc_v2"]
if "adsc_report" in adsc_v2:
adsc_report = adsc_v2["adsc_report"]
data = adsc_report["data"]
if "periodic_report" in data:
report_data = data["periodic_report"]["report_data"]
self.processReport(report_data, src)
elif "event_report" in data:
report_data = data["event_report"]["report_data"]
self.processReport(report_data, src)
except Exception:
logger.exception("error processing VDL2 data")
self.metrics.inc()
return msg
def processReport(self, report, icao):
if "position" not in report:
return
msg = {
"lat": self.convertLatitude(**report["position"]["lat"]),
"lon": self.convertLongitude(**report["position"]["lon"]),
"altitude": report["position"]["alt"]["val"],
}
if "ground_vector" in report:
msg.update({
"groundtrack": report["ground_vector"]["ground_track"]["val"],
"groundspeed": report["ground_vector"]["ground_speed"]["val"],
})
if "air_vector" in report:
msg.update({
"verticalspeed": report["air_vector"]["vertical_rate"]["val"],
})
if "timestamp" in report:
timestamp = self.convertTimestamp(**report["timestamp"])
else:
timestamp = None
Map.getSharedInstance().updateLocation(IcaoSource(icao), AirplaneLocation(msg), "VDL2", timestamp=timestamp)
def convertLatitude(self, dir, **args) -> float:
coord = self.convertCoordinate(**args)
if dir == "south":
coord *= -1
return coord
def convertLongitude(self, dir, **args) -> float:
coord = self.convertCoordinate(**args)
if dir == "west":
coord *= -1
return coord
def convertCoordinate(self, deg, min, sec) -> float:
return deg + float(min) / 60 + float(sec) / 3600
def convertTimestamp(self, date, time):
return datetime.combine(self.convertDate(**date), self.convertTime(**time), tzinfo=timezone.utc)
def convertDate(self, year, month, day):
return date(year=year, month=month, day=day)
def convertTime(self, hour, min, sec):
return time(hour=hour, minute=min, second=sec, microsecond=0, tzinfo=timezone.utc)

View file

@ -1,6 +1,6 @@
from datetime import datetime, timezone
from typing import List
from owrx.map import Map, LocatorLocation
from owrx.map import Map, LocatorLocation, CallsignSource
from owrx.metrics import Metrics, CounterMetric
from owrx.reporting import ReportingEngine
from owrx.audio import AudioChopperProfile, StaticProfileSource, ConfigWiredProfileSource
@ -289,7 +289,7 @@ class WsjtParser(AudioChopperParser):
self.pushDecode(mode, band)
if "source" in out and "locator" in out:
Map.getSharedInstance().updateLocation(
out["source"], LocatorLocation(out["locator"]), mode, band
CallsignSource(**out["source"]), LocatorLocation(out["locator"]), mode, band
)
ReportingEngine.getSharedInstance().spot(out)