Merge branch 'develop' into pycsdr

This commit is contained in:
Jakob Ketterl
2021-07-15 18:09:39 +02:00
273 changed files with 16169 additions and 3734 deletions

View File

@ -1,6 +1,14 @@
import logging
# the linter will complain about this, but the logging must be configured before importing all the other modules
logging.basicConfig(level=logging.DEBUG, format="%(asctime)s - %(name)s - %(levelname)s - %(message)s")
logger = logging.getLogger(__name__)
from http.server import HTTPServer
from owrx.http import RequestHandler
from owrx.config.core import CoreConfig
from owrx.config import Config
from owrx.config.commands import MigrateCommand
from owrx.feature import FeatureDetector
from owrx.sdr import SdrService
from socketserver import ThreadingMixIn
@ -8,13 +16,10 @@ from owrx.service import Services
from owrx.websocket import WebSocketConnection
from owrx.reporting import ReportingEngine
from owrx.version import openwebrx_version
from owrx.audio import DecoderQueue
from owrx.audio.queue import DecoderQueue
from owrx.admin import add_admin_parser, run_admin_action
import signal
import logging
logging.basicConfig(level=logging.DEBUG, format="%(asctime)s - %(name)s - %(levelname)s - %(message)s")
logger = logging.getLogger(__name__)
import argparse
class ThreadedHttpServer(ThreadingMixIn, HTTPServer):
@ -30,6 +35,37 @@ def handleSignal(sig, frame):
def main():
parser = argparse.ArgumentParser(description="OpenWebRX - Open Source SDR Web App for Everyone!")
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")
moduleparser = parser.add_subparsers(title="Modules", dest="module")
adminparser = moduleparser.add_parser("admin", help="Administration actions")
add_admin_parser(adminparser)
configparser = moduleparser.add_parser("config", help="Configuration actions")
configcommandparser = configparser.add_subparsers(title="Commands", dest="command")
migrateparser = configcommandparser.add_parser("migrate", help="Migrate configuration files")
migrateparser.set_defaults(cls=MigrateCommand)
args = parser.parse_args()
# set loglevel to info for CLI commands
if args.module is not None and not args.debug:
logging.getLogger().setLevel(logging.INFO)
if args.version:
print("OpenWebRX version {version}".format(version=openwebrx_version))
elif args.module == "admin":
run_admin_action(adminparser, args)
elif args.module == "config":
run_admin_action(configparser, args)
else:
start_receiver()
def start_receiver():
print(
"""
@ -48,14 +84,9 @@ Support and info: https://groups.io/g/openwebrx
for sig in [signal.SIGINT, signal.SIGTERM]:
signal.signal(sig, handleSignal)
pm = Config.get()
configErrors = Config.validateConfig()
if configErrors:
logger.error("your configuration contains errors. please address the following errors:")
for e in configErrors:
logger.error(e)
return
# config warmup
Config.validateConfig()
coreConfig = CoreConfig()
featureDetector = FeatureDetector()
if not featureDetector.is_available("core"):
@ -68,15 +99,17 @@ Support and info: https://groups.io/g/openwebrx
# Get error messages about unknown / unavailable features as soon as possible
# start up "always-on" sources right away
SdrService.getSources()
SdrService.getAllSources()
Services.start()
try:
server = ThreadedHttpServer(("0.0.0.0", pm["web_port"]), RequestHandler)
server = ThreadedHttpServer(("0.0.0.0", coreConfig.get_web_port()), RequestHandler)
server.serve_forever()
except SignalException:
WebSocketConnection.closeAll()
Services.stop()
ReportingEngine.stopAll()
DecoderQueue.stopAll()
pass
WebSocketConnection.closeAll()
Services.stop()
ReportingEngine.stopAll()
DecoderQueue.stopAll()

60
owrx/admin/__init__.py Normal file
View File

@ -0,0 +1,60 @@
from owrx.admin.commands import NewUser, DeleteUser, ResetPassword, ListUsers, DisableUser, EnableUser, HasUser
import sys
import traceback
def add_admin_parser(moduleparser):
subparsers = moduleparser.add_subparsers(title="Commands", dest="command")
adduser_parser = subparsers.add_parser("adduser", help="Add a new user")
adduser_parser.add_argument("user", help="Username to be added")
adduser_parser.set_defaults(cls=NewUser)
removeuser_parser = subparsers.add_parser("removeuser", help="Remove an existing user")
removeuser_parser.add_argument("user", help="Username to be remvoed")
removeuser_parser.set_defaults(cls=DeleteUser)
resetpassword_parser = subparsers.add_parser("resetpassword", help="Reset a user's password")
resetpassword_parser.add_argument("user", help="Username to be remvoed")
resetpassword_parser.set_defaults(cls=ResetPassword)
listusers_parser = subparsers.add_parser("listusers", help="List enabled users")
listusers_parser.add_argument("-a", "--all", action="store_true", help="Show all users (including disabled ones)")
listusers_parser.set_defaults(cls=ListUsers)
disableuser_parser = subparsers.add_parser("disableuser", help="Disable a user")
disableuser_parser.add_argument("user", help="Username to be disabled")
disableuser_parser.set_defaults(cls=DisableUser)
enableuser_parser = subparsers.add_parser("enableuser", help="Enable a user")
enableuser_parser.add_argument("user", help="Username to be enabled")
enableuser_parser.set_defaults(cls=EnableUser)
hasuser_parser = subparsers.add_parser("hasuser", help="Test if a user exists")
hasuser_parser.add_argument("user", help="Username to be checked")
hasuser_parser.set_defaults(cls=HasUser)
moduleparser.add_argument(
"--noninteractive", action="store_true", help="Don't ask for any user input (useful for automation)"
)
moduleparser.add_argument("--silent", action="store_true", help="Ignore errors (useful for automation)")
def run_admin_action(parser, args):
if hasattr(args, "cls"):
command = args.cls()
else:
if not hasattr(args, "silent") or not args.silent:
parser.print_help()
sys.exit(1)
sys.exit(0)
try:
command.run(args)
except Exception:
if not hasattr(args, "silent") or not args.silent:
print("Error running command:")
traceback.print_exc()
sys.exit(1)
sys.exit(0)

115
owrx/admin/commands.py Normal file
View File

@ -0,0 +1,115 @@
from abc import ABC, ABCMeta, abstractmethod
from getpass import getpass
from owrx.users import UserList, User, DefaultPasswordClass
import sys
import random
import string
import os
class Command(ABC):
@abstractmethod
def run(self, args):
pass
class UserCommand(Command, metaclass=ABCMeta):
def getPassword(self, args, username):
if args.noninteractive:
if "OWRX_PASSWORD" in os.environ:
password = os.environ["OWRX_PASSWORD"]
generated = False
else:
print("Generating password for user {username}...".format(username=username))
password = self.getRandomPassword()
generated = True
print('Password for {username} is "{password}".'.format(username=username, password=password))
print('This password is suitable for initial setup only, you will be asked to reset it on initial use.')
print('This password cannot be recovered from the system, please copy it now.')
else:
password = getpass("Please enter the new password for {username}: ".format(username=username))
confirm = getpass("Please confirm the new password: ")
if password != confirm:
print("ERROR: Password mismatch.")
sys.exit(1)
generated = False
return password, generated
def getRandomPassword(self, length=10):
printable = list(string.ascii_letters) + list(string.digits)
return ''.join(random.choices(printable, k=length))
class NewUser(UserCommand):
def run(self, args):
username = args.user
userList = UserList()
# early test to bypass the password stuff if the user already exists
if username in userList:
raise KeyError("User {username} already exists".format(username=username))
password, generated = self.getPassword(args, username)
print("Creating user {username}...".format(username=username))
user = User(name=username, enabled=True, password=DefaultPasswordClass(password), must_change_password=generated)
userList.addUser(user)
class DeleteUser(UserCommand):
def run(self, args):
username = args.user
print("Deleting user {username}...".format(username=username))
userList = UserList()
userList.deleteUser(username)
class ResetPassword(UserCommand):
def run(self, args):
username = args.user
password, generated = self.getPassword(args, username)
userList = UserList()
userList[username].setPassword(DefaultPasswordClass(password), must_change_password=generated)
# this is a change to an object in the list, not the list itself
# in this case, store() is explicit
userList.store()
class DisableUser(UserCommand):
def run(self, args):
username = args.user
userList = UserList()
userList[username].disable()
userList.store()
class EnableUser(UserCommand):
def run(self, args):
username = args.user
userList = UserList()
userList[username].enable()
userList.store()
class ListUsers(Command):
def run(self, args):
userList = UserList()
print("List of enabled users:")
for u in userList.values():
if args.all or u.enabled:
print(" {name}".format(name=u.name))
class HasUser(Command):
"""
internal command used by the debian config scripts to test if the admin user has already been created
"""
def run(self, args):
userList = UserList()
if args.user in userList:
if not args.silent:
print('User "{name}" exists.'.format(name=args.user))
else:
if not args.silent:
print('User "{name}" does not exist.'.format(name=args.user))
# in bash, a return code > 0 is interpreted as "false"
sys.exit(1)

View File

@ -1,301 +0,0 @@
from abc import ABC, ABCMeta, abstractmethod
from owrx.config import Config
from owrx.metrics import Metrics, CounterMetric, DirectMetric
import threading
import wave
import subprocess
import os
from multiprocessing.connection import Pipe, wait
from datetime import datetime, timedelta
from queue import Queue, Full, Empty
import logging
logger = logging.getLogger(__name__)
logger.setLevel(logging.INFO)
class QueueJob(object):
def __init__(self, decoder, file, freq):
self.decoder = decoder
self.file = file
self.freq = freq
def run(self):
self.decoder.decode(self)
def unlink(self):
try:
os.unlink(self.file)
except FileNotFoundError:
pass
PoisonPill = object()
class QueueWorker(threading.Thread):
def __init__(self, queue):
self.queue = queue
self.doRun = True
super().__init__()
def run(self) -> None:
while self.doRun:
job = self.queue.get()
if job is PoisonPill:
self.doRun = False
else:
try:
job.run()
except Exception:
logger.exception("failed to decode job")
self.queue.onError()
finally:
job.unlink()
self.queue.task_done()
class DecoderQueue(Queue):
sharedInstance = None
creationLock = threading.Lock()
@staticmethod
def getSharedInstance():
with DecoderQueue.creationLock:
if DecoderQueue.sharedInstance is None:
pm = Config.get()
DecoderQueue.sharedInstance = DecoderQueue(
maxsize=pm["decoding_queue_length"], workers=pm["decoding_queue_workers"]
)
return DecoderQueue.sharedInstance
@staticmethod
def stopAll():
with DecoderQueue.creationLock:
if DecoderQueue.sharedInstance is not None:
DecoderQueue.sharedInstance.stop()
DecoderQueue.sharedInstance = None
def __init__(self, maxsize, workers):
super().__init__(maxsize)
metrics = Metrics.getSharedInstance()
metrics.addMetric("decoding.queue.length", DirectMetric(self.qsize))
self.inCounter = CounterMetric()
metrics.addMetric("decoding.queue.in", self.inCounter)
self.outCounter = CounterMetric()
metrics.addMetric("decoding.queue.out", self.outCounter)
self.overflowCounter = CounterMetric()
metrics.addMetric("decoding.queue.overflow", self.overflowCounter)
self.errorCounter = CounterMetric()
metrics.addMetric("decoding.queue.error", self.errorCounter)
self.workers = [self.newWorker() for _ in range(0, workers)]
def stop(self):
logger.debug("shutting down the queue")
try:
# purge all remaining jobs
while not self.empty():
job = self.get()
job.unlink()
self.task_done()
except Empty:
pass
# put() a PoisonPill for all active workers to shut them down
for w in self.workers:
if w.is_alive():
self.put(PoisonPill)
self.join()
def put(self, item, **kwars):
self.inCounter.inc()
try:
super(DecoderQueue, self).put(item, block=False)
except Full:
self.overflowCounter.inc()
raise
def get(self, **kwargs):
# super.get() is blocking, so it would mess up the stats to inc() first
out = super(DecoderQueue, self).get(**kwargs)
self.outCounter.inc()
return out
def newWorker(self):
worker = QueueWorker(self)
worker.start()
return worker
def onError(self):
self.errorCounter.inc()
class AudioChopperProfile(ABC):
@abstractmethod
def getInterval(self):
pass
@abstractmethod
def getFileTimestampFormat(self):
pass
@abstractmethod
def decoder_commandline(self, file):
pass
class AudioWriter(object):
def __init__(self, dsp, source, profile: AudioChopperProfile):
self.dsp = dsp
self.source = source
self.profile = profile
self.tmp_dir = Config.get()["temporary_directory"]
self.wavefile = None
self.wavefilename = None
self.switchingLock = threading.Lock()
self.timer = None
(self.outputReader, self.outputWriter) = Pipe()
def getWaveFile(self):
filename = "{tmp_dir}/openwebrx-audiochopper-{id}-{timestamp}.wav".format(
tmp_dir=self.tmp_dir,
id=id(self),
timestamp=datetime.utcnow().strftime(self.profile.getFileTimestampFormat()),
)
wavefile = wave.open(filename, "wb")
wavefile.setnchannels(1)
wavefile.setsampwidth(2)
wavefile.setframerate(12000)
return filename, wavefile
def getNextDecodingTime(self):
t = datetime.utcnow()
zeroed = t.replace(minute=0, second=0, microsecond=0)
delta = t - zeroed
interval = self.profile.getInterval()
seconds = (int(delta.total_seconds() / interval) + 1) * interval
t = zeroed + timedelta(seconds=seconds)
logger.debug("scheduling: {0}".format(t))
return t
def cancelTimer(self):
if self.timer:
self.timer.cancel()
self.timer = None
def _scheduleNextSwitch(self):
self.cancelTimer()
delta = self.getNextDecodingTime() - datetime.utcnow()
self.timer = threading.Timer(delta.total_seconds(), self.switchFiles)
self.timer.start()
def switchFiles(self):
with self.switchingLock:
file = self.wavefile
filename = self.wavefilename
(self.wavefilename, self.wavefile) = self.getWaveFile()
file.close()
job = QueueJob(self, filename, self.dsp.get_operating_freq())
try:
DecoderQueue.getSharedInstance().put(job)
except Full:
logger.warning("decoding queue overflow; dropping one file")
job.unlink()
self._scheduleNextSwitch()
def decode(self, job: QueueJob):
logger.debug("processing file %s", job.file)
decoder = subprocess.Popen(
["nice", "-n", "10"] + self.profile.decoder_commandline(job.file),
stdout=subprocess.PIPE,
cwd=self.tmp_dir,
close_fds=True,
)
try:
for line in decoder.stdout:
self.outputWriter.send((self.profile, job.freq, line))
except (OSError, AttributeError):
decoder.stdout.flush()
# TODO uncouple parsing from the output so that decodes can still go to the map and the spotters
logger.debug("output has gone away while decoding job.")
try:
rc = decoder.wait(timeout=10)
if rc != 0:
raise RuntimeError("decoder return code: {0}".format(rc))
except subprocess.TimeoutExpired:
logger.warning("subprocess (pid=%i}) did not terminate correctly; sending kill signal.", decoder.pid)
decoder.kill()
raise
def start(self):
(self.wavefilename, self.wavefile) = self.getWaveFile()
self._scheduleNextSwitch()
def write(self, data):
with self.switchingLock:
self.wavefile.writeframes(data)
def stop(self):
self.outputWriter.close()
self.outputWriter = None
# drain messages left in the queue so that the queue can be successfully closed
# this is necessary since python keeps the file descriptors open otherwise
try:
while True:
self.outputReader.recv()
except EOFError:
pass
self.outputReader.close()
self.outputReader = None
self.cancelTimer()
try:
self.wavefile.close()
except Exception:
logger.exception("error closing wave file")
try:
with self.switchingLock:
os.unlink(self.wavefilename)
except Exception:
logger.exception("error removing undecoded file")
self.wavefile = None
self.wavefilename = None
class AudioChopper(threading.Thread, metaclass=ABCMeta):
def __init__(self, dsp, source, *profiles: AudioChopperProfile):
self.source = source
self.writers = [AudioWriter(dsp, source, p) for p in profiles]
self.doRun = True
super().__init__()
def run(self) -> None:
logger.debug("Audio chopper starting up")
for w in self.writers:
w.start()
while self.doRun:
data = None
try:
data = self.source.read(256)
except ValueError:
pass
if data is None or (isinstance(data, bytes) and len(data) == 0):
self.doRun = False
else:
for w in self.writers:
w.write(data)
logger.debug("Audio chopper shutting down")
for w in self.writers:
w.stop()
def read(self):
try:
readers = wait([w.outputReader for w in self.writers])
return [r.recv() for r in readers]
except (EOFError, OSError):
return None

86
owrx/audio/__init__.py Normal file
View File

@ -0,0 +1,86 @@
from owrx.config import Config
from abc import ABC, ABCMeta, abstractmethod
from typing import List
import logging
logger = logging.getLogger(__name__)
class AudioChopperProfile(ABC):
@abstractmethod
def getInterval(self):
pass
@abstractmethod
def getFileTimestampFormat(self):
pass
@abstractmethod
def decoder_commandline(self, file):
pass
class ProfileSourceSubscriber(ABC):
@abstractmethod
def onProfilesChanged(self):
pass
class ProfileSource(ABC):
def __init__(self):
self.subscribers = []
@abstractmethod
def getProfiles(self) -> List[AudioChopperProfile]:
pass
def subscribe(self, subscriber: ProfileSourceSubscriber):
if subscriber in self.subscribers:
return
self.subscribers.append(subscriber)
def unsubscribe(self, subscriber: ProfileSourceSubscriber):
if subscriber not in self.subscribers:
return
self.subscribers.remove(subscriber)
def fireProfilesChanged(self):
for sub in self.subscribers.copy():
try:
sub.onProfilesChanged()
except Exception:
logger.exception("Error while notifying profile subscriptions")
class ConfigWiredProfileSource(ProfileSource, metaclass=ABCMeta):
def __init__(self):
super().__init__()
self.configSub = None
@abstractmethod
def getPropertiesToWire(self) -> List[str]:
pass
def subscribe(self, subscriber: ProfileSourceSubscriber):
super().subscribe(subscriber)
if self.subscribers and self.configSub is None:
self.configSub = Config.get().filter(*self.getPropertiesToWire()).wire(self.fireProfilesChanged)
def unsubscribe(self, subscriber: ProfileSourceSubscriber):
super().unsubscribe(subscriber)
if not self.subscribers and self.configSub is not None:
self.configSub.cancel()
self.configSub = None
def fireProfilesChanged(self, *args):
super().fireProfilesChanged()
class StaticProfileSource(ProfileSource):
def __init__(self, profiles: List[AudioChopperProfile]):
super().__init__()
self.profiles = profiles
def getProfiles(self) -> List[AudioChopperProfile]:
return self.profiles

90
owrx/audio/chopper.py Normal file
View File

@ -0,0 +1,90 @@
from owrx.modes import Modes, AudioChopperMode
from csdr.output import Output
from itertools import groupby
import threading
from owrx.audio import ProfileSourceSubscriber
from owrx.audio.wav import AudioWriter
from multiprocessing.connection import Pipe
import logging
logger = logging.getLogger(__name__)
logger.setLevel(logging.INFO)
class AudioChopper(threading.Thread, Output, ProfileSourceSubscriber):
def __init__(self, active_dsp, mode_str: str):
self.read_fn = None
self.doRun = True
self.dsp = active_dsp
self.writers = []
mode = Modes.findByModulation(mode_str)
if mode is None or not isinstance(mode, AudioChopperMode):
raise ValueError("Mode {} is not an audio chopper mode".format(mode_str))
self.profile_source = mode.get_profile_source()
(self.outputReader, self.outputWriter) = Pipe()
super().__init__()
def stop_writers(self):
while self.writers:
self.writers.pop().stop()
def setup_writers(self):
self.stop_writers()
sorted_profiles = sorted(self.profile_source.getProfiles(), key=lambda p: p.getInterval())
groups = {interval: list(group) for interval, group in groupby(sorted_profiles, key=lambda p: p.getInterval())}
writers = [
AudioWriter(self.dsp, self.outputWriter, interval, profiles) for interval, profiles in groups.items()
]
for w in writers:
w.start()
self.writers = writers
def supports_type(self, t):
return t == "audio"
def receive_output(self, t, read_fn):
self.read_fn = read_fn
self.start()
def run(self) -> None:
logger.debug("Audio chopper starting up")
self.setup_writers()
self.profile_source.subscribe(self)
while self.doRun:
data = None
try:
data = self.read_fn(256)
except ValueError:
pass
if data is None or (isinstance(data, bytes) and len(data) == 0):
self.doRun = False
else:
for w in self.writers:
w.write(data)
logger.debug("Audio chopper shutting down")
self.profile_source.unsubscribe(self)
self.stop_writers()
self.outputWriter.close()
self.outputWriter = None
# drain messages left in the queue so that the queue can be successfully closed
# this is necessary since python keeps the file descriptors open otherwise
try:
while True:
self.outputReader.recv()
except EOFError:
pass
self.outputReader.close()
self.outputReader = None
def onProfilesChanged(self):
logger.debug("profile change received, resetting writers...")
self.setup_writers()
def read(self):
try:
return self.outputReader.recv()
except (EOFError, OSError):
return None

172
owrx/audio/queue.py Normal file
View File

@ -0,0 +1,172 @@
from owrx.config import Config
from owrx.config.core import CoreConfig
from owrx.metrics import Metrics, CounterMetric, DirectMetric
from queue import Queue, Full, Empty
import subprocess
import os
import threading
import logging
logger = logging.getLogger(__name__)
logger.setLevel(logging.INFO)
class QueueJob(object):
def __init__(self, profile, writer, file, freq):
self.profile = profile
self.writer = writer
self.file = file
self.freq = freq
def run(self):
logger.debug("processing file %s", self.file)
tmp_dir = CoreConfig().get_temporary_directory()
decoder = subprocess.Popen(
["nice", "-n", "10"] + self.profile.decoder_commandline(self.file),
stdout=subprocess.PIPE,
cwd=tmp_dir,
close_fds=True,
)
try:
for line in decoder.stdout:
self.writer.send((self.profile, self.freq, line))
except (OSError, AttributeError):
decoder.stdout.flush()
# TODO uncouple parsing from the output so that decodes can still go to the map and the spotters
logger.debug("output has gone away while decoding job.")
try:
rc = decoder.wait(timeout=10)
if rc != 0:
raise RuntimeError("decoder return code: {0}".format(rc))
except subprocess.TimeoutExpired:
logger.warning("subprocess (pid=%i}) did not terminate correctly; sending kill signal.", decoder.pid)
decoder.kill()
raise
def unlink(self):
try:
os.unlink(self.file)
except FileNotFoundError:
pass
PoisonPill = object()
class QueueWorker(threading.Thread):
def __init__(self, queue):
self.queue = queue
self.doRun = True
super().__init__()
def run(self) -> None:
while self.doRun:
job = self.queue.get()
if job is PoisonPill:
self.stop()
else:
try:
job.run()
except Exception:
logger.exception("failed to decode job")
self.queue.onError()
finally:
job.unlink()
self.queue.task_done()
def stop(self):
self.doRun = False
class DecoderQueue(Queue):
sharedInstance = None
creationLock = threading.Lock()
@staticmethod
def getSharedInstance():
with DecoderQueue.creationLock:
if DecoderQueue.sharedInstance is None:
DecoderQueue.sharedInstance = DecoderQueue()
return DecoderQueue.sharedInstance
@staticmethod
def stopAll():
with DecoderQueue.creationLock:
if DecoderQueue.sharedInstance is not None:
DecoderQueue.sharedInstance.stop()
DecoderQueue.sharedInstance = None
def __init__(self):
pm = Config.get()
super().__init__(pm["decoding_queue_length"])
self.workers = []
self._setWorkers(pm["decoding_queue_workers"])
self.subscriptions = [
pm.wireProperty("decoding_queue_length", self._setMaxSize),
pm.wireProperty("decoding_queue_workers", self._setWorkers),
]
metrics = Metrics.getSharedInstance()
metrics.addMetric("decoding.queue.length", DirectMetric(self.qsize))
self.inCounter = CounterMetric()
metrics.addMetric("decoding.queue.in", self.inCounter)
self.outCounter = CounterMetric()
metrics.addMetric("decoding.queue.out", self.outCounter)
self.overflowCounter = CounterMetric()
metrics.addMetric("decoding.queue.overflow", self.overflowCounter)
self.errorCounter = CounterMetric()
metrics.addMetric("decoding.queue.error", self.errorCounter)
def _setMaxSize(self, size):
if self.maxsize == size:
return
self.maxsize = size
def _setWorkers(self, workers):
while len(self.workers) > workers:
logger.debug("stopping one worker")
self.workers.pop().stop()
while len(self.workers) < workers:
logger.debug("starting one worker")
self.workers.append(self.newWorker())
def stop(self):
logger.debug("shutting down the queue")
while self.subscriptions:
self.subscriptions.pop().cancel()
try:
# purge all remaining jobs
while not self.empty():
job = self.get()
job.unlink()
self.task_done()
except Empty:
pass
# put() a PoisonPill for all active workers to shut them down
for w in self.workers:
if w.is_alive():
self.put(PoisonPill)
self.join()
def put(self, item, **kwargs):
self.inCounter.inc()
try:
super(DecoderQueue, self).put(item, block=False)
except Full:
self.overflowCounter.inc()
raise
def get(self, **kwargs):
# super.get() is blocking, so it would mess up the stats to inc() first
out = super(DecoderQueue, self).get(**kwargs)
self.outCounter.inc()
return out
def newWorker(self):
worker = QueueWorker(self)
worker.start()
return worker
def onError(self):
self.errorCounter.inc()

139
owrx/audio/wav.py Normal file
View File

@ -0,0 +1,139 @@
from owrx.config.core import CoreConfig
from owrx.audio import AudioChopperProfile
from owrx.audio.queue import QueueJob, DecoderQueue
import threading
import wave
import os
from datetime import datetime, timedelta
from queue import Full
from typing import List
import logging
logger = logging.getLogger(__name__)
logger.setLevel(logging.INFO)
class WaveFile(object):
def __init__(self, writer_id):
self.timestamp = datetime.utcnow()
self.writer_id = writer_id
tmp_dir = CoreConfig().get_temporary_directory()
self.filename = "{tmp_dir}/openwebrx-audiochopper-master-{id}-{timestamp}.wav".format(
tmp_dir=tmp_dir,
id=self.writer_id,
timestamp=self.timestamp.strftime("%y%m%d_%H%M%S"),
)
self.waveFile = wave.open(self.filename, "wb")
self.waveFile.setnchannels(1)
self.waveFile.setsampwidth(2)
self.waveFile.setframerate(12000)
def close(self):
self.waveFile.close()
def getFileName(self):
return self.filename
def getTimestamp(self):
return self.timestamp
def writeframes(self, data):
return self.waveFile.writeframes(data)
def unlink(self):
os.unlink(self.filename)
self.waveFile = None
class AudioWriter(object):
def __init__(self, active_dsp, outputWriter, interval, profiles: List[AudioChopperProfile]):
self.dsp = active_dsp
self.outputWriter = outputWriter
self.interval = interval
self.profiles = profiles
self.wavefile = None
self.switchingLock = threading.Lock()
self.timer = None
def getWaveFile(self):
return WaveFile(id(self))
def getNextDecodingTime(self):
# add one second to have the intervals tick over one second earlier
# this avoids filename collisions, but also avoids decoding wave files with less than one second of audio
t = datetime.utcnow() + timedelta(seconds=1)
zeroed = t.replace(minute=0, second=0, microsecond=0)
delta = t - zeroed
seconds = (int(delta.total_seconds() / self.interval) + 1) * self.interval
t = zeroed + timedelta(seconds=seconds)
logger.debug("scheduling: {0}".format(t))
return t
def cancelTimer(self):
if self.timer:
self.timer.cancel()
self.timer = None
def _scheduleNextSwitch(self):
self.cancelTimer()
delta = self.getNextDecodingTime() - datetime.utcnow()
self.timer = threading.Timer(delta.total_seconds(), self.switchFiles)
self.timer.start()
def switchFiles(self):
with self.switchingLock:
file = self.wavefile
self.wavefile = self.getWaveFile()
file.close()
tmp_dir = CoreConfig().get_temporary_directory()
for profile in self.profiles:
# create hardlinks for the individual profiles
filename = "{tmp_dir}/openwebrx-audiochopper-{pid}-{timestamp}.wav".format(
tmp_dir=tmp_dir,
pid=id(profile),
timestamp=file.getTimestamp().strftime(profile.getFileTimestampFormat()),
)
try:
os.link(file.getFileName(), filename)
except OSError:
logger.exception("Error while linking job files")
continue
job = QueueJob(profile, self.outputWriter, filename, self.dsp.get_operating_freq())
try:
DecoderQueue.getSharedInstance().put(job)
except Full:
logger.warning("decoding queue overflow; dropping one file")
job.unlink()
try:
# our master can be deleted now, the profiles will delete their hardlinked copies after processing
file.unlink()
except OSError:
logger.exception("Error while unlinking job files")
self._scheduleNextSwitch()
def start(self):
self.wavefile = self.getWaveFile()
self._scheduleNextSwitch()
def write(self, data):
with self.switchingLock:
self.wavefile.writeframes(data)
def stop(self):
self.cancelTimer()
try:
self.wavefile.close()
except Exception:
logger.exception("error closing wave file")
try:
with self.switchingLock:
self.wavefile.unlink()
except Exception:
logger.exception("error removing undecoded file")
self.wavefile = None

View File

@ -1,5 +1,7 @@
from owrx.modes import Modes
from datetime import datetime, timezone
import json
import os
import logging
@ -55,10 +57,29 @@ class Bandplan(object):
return Bandplan.sharedInstance
def __init__(self):
self.bands = self.loadBands()
self.bands = []
self.file_modified = None
self.fileList = ["/etc/openwebrx/bands.json", "bands.json"]
def loadBands(self):
for file in ["/etc/openwebrx/bands.json", "bands.json"]:
def _refresh(self):
modified = self._getFileModifiedTimestamp()
if self.file_modified is None or modified > self.file_modified:
logger.debug("reloading bands from disk due to file modification")
self.bands = self._loadBands()
self.file_modified = modified
def _getFileModifiedTimestamp(self):
timestamp = 0
for file in self.fileList:
try:
timestamp = os.path.getmtime(file)
break
except FileNotFoundError:
pass
return datetime.fromtimestamp(timestamp, timezone.utc)
def _loadBands(self):
for file in self.fileList:
try:
f = open(file, "r")
bands_json = json.load(f)
@ -75,6 +96,7 @@ class Bandplan(object):
return []
def findBands(self, freq):
self._refresh()
return [band for band in self.bands if band.inBand(freq)]
def findBand(self, freq):
@ -85,4 +107,5 @@ class Bandplan(object):
return None
def collectDialFrequencies(self, range):
self._refresh()
return [e for b in self.bands for e in b.getDialFrequencies(range)]

View File

@ -1,4 +1,7 @@
from datetime import datetime, timezone
from owrx.config.core import CoreConfig
import json
import os
import logging
@ -28,6 +31,23 @@ class Bookmark(object):
}
class BookmakrSubscription(object):
def __init__(self, subscriptee, range, subscriber: callable):
self.subscriptee = subscriptee
self.range = range
self.subscriber = subscriber
def inRange(self, bookmark: Bookmark):
low, high = self.range
return low <= bookmark.getFrequency() <= high
def call(self, *args, **kwargs):
self.subscriber(*args, **kwargs)
def cancel(self):
self.subscriptee.unsubscribe(self)
class Bookmarks(object):
sharedInstance = None
@ -38,15 +58,36 @@ class Bookmarks(object):
return Bookmarks.sharedInstance
def __init__(self):
self.bookmarks = self.loadBookmarks()
self.file_modified = None
self.bookmarks = []
self.subscriptions = []
self.fileList = [Bookmarks._getBookmarksFile(), "/etc/openwebrx/bookmarks.json", "bookmarks.json"]
def loadBookmarks(self):
for file in ["/etc/openwebrx/bookmarks.json", "bookmarks.json"]:
def _refresh(self):
modified = self._getFileModifiedTimestamp()
if self.file_modified is None or modified > self.file_modified:
logger.debug("reloading bookmarks from disk due to file modification")
self.bookmarks = self._loadBookmarks()
self.file_modified = modified
def _getFileModifiedTimestamp(self):
timestamp = 0
for file in self.fileList:
try:
f = open(file, "r")
bookmarks_json = json.load(f)
f.close()
return [Bookmark(d) for d in bookmarks_json]
timestamp = os.path.getmtime(file)
break
except FileNotFoundError:
pass
return datetime.fromtimestamp(timestamp, timezone.utc)
def _loadBookmarks(self):
for file in self.fileList:
try:
with open(file, "r") as f:
content = f.read()
if content:
bookmarks_json = json.loads(content)
return [Bookmark(d) for d in bookmarks_json]
except FileNotFoundError:
pass
except json.JSONDecodeError:
@ -57,6 +98,48 @@ class Bookmarks(object):
return []
return []
def getBookmarks(self, range):
(lo, hi) = range
return [b for b in self.bookmarks if lo <= b.getFrequency() <= hi]
def getBookmarks(self, range=None):
self._refresh()
if range is None:
return self.bookmarks
else:
(lo, hi) = range
return [b for b in self.bookmarks if lo <= b.getFrequency() <= hi]
@staticmethod
def _getBookmarksFile():
coreConfig = CoreConfig()
return "{data_directory}/bookmarks.json".format(data_directory=coreConfig.get_data_directory())
def store(self):
# don't write directly to file to avoid corruption on exceptions
jsonContent = json.dumps([b.__dict__() for b in self.bookmarks], indent=4)
with open(Bookmarks._getBookmarksFile(), "w") as file:
file.write(jsonContent)
self.file_modified = self._getFileModifiedTimestamp()
def addBookmark(self, bookmark: Bookmark):
self.bookmarks.append(bookmark)
self.notifySubscriptions(bookmark)
def removeBookmark(self, bookmark: Bookmark):
if bookmark not in self.bookmarks:
return
self.bookmarks.remove(bookmark)
self.notifySubscriptions(bookmark)
def notifySubscriptions(self, bookmark: Bookmark):
for sub in self.subscriptions:
if sub.inRange(bookmark):
try:
sub.call()
except Exception:
logger.exception("Error while calling bookmark subscriptions")
def subscribe(self, range, callback):
self.subscriptions.append(BookmakrSubscription(self, range, callback))
def unsubscribe(self, subscriptions: BookmakrSubscription):
if subscriptions not in self.subscriptions:
return
self.subscriptions.remove(subscriptions)

44
owrx/breadcrumb.py Normal file
View File

@ -0,0 +1,44 @@
from typing import List
from abc import ABC, abstractmethod
class BreadcrumbItem(object):
def __init__(self, title, href):
self.title = title
self.href = href
def render(self, documentRoot, active=False):
return '<li class="breadcrumb-item {active}"><a href="{documentRoot}{href}">{title}</a></li>'.format(
documentRoot=documentRoot, href=self.href, title=self.title, active="active" if active else ""
)
class Breadcrumb(object):
def __init__(self, breadcrumbs: List[BreadcrumbItem]):
self.items = breadcrumbs
def render(self, documentRoot):
return """
<ol class="breadcrumb">
{crumbs}
{last_crumb}
</ol>
""".format(
crumbs="".join(item.render(documentRoot) for item in self.items[:-1]),
last_crumb="".join(item.render(documentRoot, True) for item in self.items[-1:]),
)
def append(self, crumb: BreadcrumbItem):
self.items.append(crumb)
return self
class BreadcrumbMixin(ABC):
def template_variables(self):
variables = super().template_variables()
variables["breadcrumb"] = self.get_breadcrumb().render(self.get_document_root())
return variables
@abstractmethod
def get_breadcrumb(self) -> Breadcrumb:
pass

View File

@ -23,6 +23,7 @@ class ClientRegistry(object):
def __init__(self):
self.clients = []
Config.get().wireProperty("max_clients", self._checkClientCount)
super().__init__()
def broadcast(self):
@ -46,3 +47,8 @@ class ClientRegistry(object):
except ValueError:
pass
self.broadcast()
def _checkClientCount(self, new_count):
for client in self.clients[new_count:]:
logger.debug("closing one connection...")
client.close()

View File

@ -1,142 +0,0 @@
from owrx.property import PropertyManager, PropertyLayer
import importlib.util
import os
import logging
import json
from abc import ABC, abstractmethod
logger = logging.getLogger(__name__)
class ConfigNotFoundException(Exception):
pass
class ConfigError(object):
def __init__(self, key, message):
self.key = key
self.message = message
def __str__(self):
return "Configuration Error (key: {0}): {1}".format(self.key, self.message)
class ConfigMigrator(ABC):
@abstractmethod
def migrate(self, config):
pass
def renameKey(self, config, old, new):
if old in config and not new in config:
config[new] = config[old]
del config[old]
class ConfigMigratorVersion1(ConfigMigrator):
def migrate(self, config):
if "receiver_gps" in config:
gps = config["receiver_gps"]
config["receiver_gps"] = {"lat": gps[0], "lon": gps[1]}
if "waterfall_auto_level_margin" in config:
levels = config["waterfall_auto_level_margin"]
config["waterfall_auto_level_margin"] = {"min": levels[0], "max": levels[1]}
self.renameKey(config, "wsjt_queue_workers", "decoding_queue_workers")
self.renameKey(config, "wsjt_queue_length", "decoding_queue_length")
config["version"] = 2
return config
class ConfigMigratorVersion2(ConfigMigrator):
def migrate(self, config):
if "waterfall_colors" in config and any(v > 0xFFFFFF for v in config["waterfall_colors"]):
config["waterfall_colors"] = [v >> 8 for v in config["waterfall_colors"]]
return config
class Config:
sharedConfig = None
currentVersion = 3
migrators = {
1: ConfigMigratorVersion1(),
2: ConfigMigratorVersion2(),
}
@staticmethod
def _loadPythonFile(file):
spec = importlib.util.spec_from_file_location("config_webrx", file)
cfg = importlib.util.module_from_spec(spec)
spec.loader.exec_module(cfg)
pm = PropertyLayer()
for name, value in cfg.__dict__.items():
if name.startswith("__"):
continue
pm[name] = value
return pm
@staticmethod
def _loadJsonFile(file):
with open(file, "r") as f:
pm = PropertyLayer()
for k, v in json.load(f).items():
pm[k] = v
return pm
@staticmethod
def _loadConfig():
for file in ["./settings.json", "/etc/openwebrx/config_webrx.py", "./config_webrx.py"]:
try:
if file.endswith(".py"):
return Config._loadPythonFile(file)
elif file.endswith(".json"):
return Config._loadJsonFile(file)
else:
logger.warning("unsupported file type: %s", file)
except FileNotFoundError:
pass
raise ConfigNotFoundException("no usable config found! please make sure you have a valid configuration file!")
@staticmethod
def get():
if Config.sharedConfig is None:
Config.sharedConfig = Config._migrate(Config._loadConfig())
return Config.sharedConfig
@staticmethod
def store():
with open("settings.json", "w") as file:
json.dump(Config.get().__dict__(), file, indent=4)
@staticmethod
def validateConfig():
pm = Config.get()
errors = [Config.checkTempDirectory(pm)]
return [e for e in errors if e is not None]
@staticmethod
def checkTempDirectory(pm: PropertyManager):
key = "temporary_directory"
if key not in pm or pm[key] is None:
return ConfigError(key, "temporary directory is not set")
if not os.path.exists(pm[key]):
return ConfigError(key, "temporary directory doesn't exist")
if not os.path.isdir(pm[key]):
return ConfigError(key, "temporary directory path is not a directory")
if not os.access(pm[key], os.W_OK):
return ConfigError(key, "temporary directory is not writable")
return None
@staticmethod
def _migrate(config):
version = config["version"] if "version" in config else 1
if version == Config.currentVersion:
return config
logger.debug("migrating config from version %i", version)
migrators = [Config.migrators[i] for i in range(version, Config.currentVersion)]
for migrator in migrators:
config = migrator.migrate(config)
return config

43
owrx/config/__init__.py Normal file
View File

@ -0,0 +1,43 @@
from owrx.property import PropertyStack
from owrx.config.error import ConfigError
from owrx.config.defaults import defaultConfig
from owrx.config.dynamic import DynamicConfig
from owrx.config.classic import ClassicConfig
class Config(PropertyStack):
sharedConfig = None
def __init__(self):
super().__init__()
self.storableConfig = DynamicConfig()
layers = [
self.storableConfig,
ClassicConfig(),
defaultConfig,
]
for i, l in enumerate(layers):
self.addLayer(i, l)
@staticmethod
def get():
if Config.sharedConfig is None:
Config.sharedConfig = Config()
return Config.sharedConfig
def store(self):
self.storableConfig.store()
@staticmethod
def validateConfig():
# no config checks atm
# just basic loading verification
Config.get()
def __setitem__(self, key, value):
# in the config, all writes go to the json layer
return self.storableConfig.__setitem__(key, value)
def __delitem__(self, key):
# all deletes go to the json layer, too
return self.storableConfig.__delitem__(key)

36
owrx/config/classic.py Normal file
View File

@ -0,0 +1,36 @@
from owrx.property import PropertyReadOnly, PropertyLayer
from owrx.config.migration import Migrator
import importlib.util
class ClassicConfig(PropertyReadOnly):
def __init__(self):
pm = ClassicConfig._loadConfig()
Migrator.migrate(pm)
super().__init__(pm)
@staticmethod
def _loadConfig():
for file in ["/etc/openwebrx/config_webrx.py", "./config_webrx.py"]:
try:
return ClassicConfig._loadPythonFile(file)
except FileNotFoundError:
pass
return PropertyLayer()
@staticmethod
def _toLayer(dictionary: dict):
layer = PropertyLayer()
for k, v in dictionary.items():
if isinstance(v, dict):
layer[k] = ClassicConfig._toLayer(v)
else:
layer[k] = v
return layer
@staticmethod
def _loadPythonFile(file):
spec = importlib.util.spec_from_file_location("config_webrx", file)
cfg = importlib.util.module_from_spec(spec)
spec.loader.exec_module(cfg)
return ClassicConfig._toLayer({k: v for k, v in cfg.__dict__.items() if not k.startswith("__")})

30
owrx/config/commands.py Normal file
View File

@ -0,0 +1,30 @@
from owrx.admin.commands import Command
from owrx.config import Config
from owrx.bookmarks import Bookmarks
class MigrateCommand(Command):
# these keys have been moved to openwebrx.conf
blacklisted_keys = [
"temporary_directory",
"web_port",
"aprs_symbols_path",
]
def run(self, args):
print("Migrating configuration...")
config = Config.get()
# a key that is set will end up in the DynamicConfig, so this will transfer everything there
for key, value in config.items():
if key not in MigrateCommand.blacklisted_keys:
config[key] = value
config.store()
print("Migrating bookmarks...")
# bookmarks just need to be saved
b = Bookmarks.getSharedInstance()
b.getBookmarks()
b.store()
print("Migration complete!")

59
owrx/config/core.py Normal file
View File

@ -0,0 +1,59 @@
from owrx.config import ConfigError
from configparser import ConfigParser
import os
from glob import glob
class CoreConfig(object):
defaults = {
"core": {
"data_directory": "/var/lib/openwebrx",
"temporary_directory": "/tmp",
},
"web": {
"port": 8073,
},
"aprs": {
"symbols_path": "/usr/share/aprs-symbols/png"
}
}
def __init__(self):
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)
self.data_directory = config.get("core", "data_directory")
CoreConfig.checkDirectory(self.data_directory, "data_directory")
self.temporary_directory = config.get("core", "temporary_directory")
CoreConfig.checkDirectory(self.temporary_directory, "temporary_directory")
self.web_port = config.getint("web", "port")
self.aprs_symbols_path = config.get("aprs", "symbols_path")
@staticmethod
def checkDirectory(dir, key):
if not os.path.exists(dir):
raise ConfigError(key, "{dir} doesn't exist".format(dir=dir))
if not os.path.isdir(dir):
raise ConfigError(key, "{dir} is not a directory".format(dir=dir))
if not os.access(dir, os.W_OK):
raise ConfigError(key, "{dir} is not writable".format(dir=dir))
def get_web_port(self):
return self.web_port
def get_data_directory(self):
return self.data_directory
def get_temporary_directory(self):
return self.temporary_directory
def get_aprs_symbols_path(self):
return self.aprs_symbols_path

177
owrx/config/defaults.py Normal file
View File

@ -0,0 +1,177 @@
from owrx.property import PropertyLayer
defaultConfig = PropertyLayer(
version=7,
max_clients=20,
receiver_name="[Callsign]",
receiver_location="Budapest, Hungary",
receiver_asl=200,
receiver_admin="example@example.com",
receiver_gps=PropertyLayer(lat=47.0, lon=19.0),
photo_title="Panorama of Budapest from Schönherz Zoltán Dormitory",
photo_desc="",
fft_fps=9,
fft_size=4096,
fft_voverlap_factor=0.3,
audio_compression="adpcm",
fft_compression="adpcm",
wfm_deemphasis_tau=50e-6,
digimodes_fft_size=2048,
digital_voice_unvoiced_quality=1,
digital_voice_dmr_id_lookup=True,
digital_voice_nxdn_id_lookup=True,
sdrs=PropertyLayer(
rtlsdr=PropertyLayer(
name="RTL-SDR USB Stick",
type="rtl_sdr",
profiles=PropertyLayer(
**{
"70cm": PropertyLayer(
name="70cm Repeaters",
center_freq=438800000,
rf_gain=29,
samp_rate=2400000,
start_freq=439275000,
start_mod="nfm",
),
"2m": PropertyLayer(
name="2m",
center_freq=145000000,
rf_gain=29,
samp_rate=2048000,
start_freq=145725000,
start_mod="nfm",
),
}
),
),
airspy=PropertyLayer(
name="Airspy HF+",
type="airspyhf",
rf_gain="auto",
profiles=PropertyLayer(
**{
"20m": PropertyLayer(
name="20m",
center_freq=14150000,
samp_rate=384000,
start_freq=14070000,
start_mod="usb",
),
"30m": PropertyLayer(
name="30m",
center_freq=10125000,
samp_rate=192000,
start_freq=10142000,
start_mod="usb",
),
"40m": PropertyLayer(
name="40m",
center_freq=7100000,
samp_rate=256000,
start_freq=7070000,
start_mod="lsb",
),
"80m": PropertyLayer(
name="80m",
center_freq=3650000,
samp_rate=384000,
start_freq=3570000,
start_mod="lsb",
),
"49m": PropertyLayer(
name="49m Broadcast",
center_freq=6050000,
samp_rate=384000,
start_freq=6070000,
start_mod="am",
),
}
),
),
sdrplay=PropertyLayer(
name="SDRPlay RSP2",
type="sdrplay",
antenna="Antenna A",
profiles=PropertyLayer(
**{
"20m": PropertyLayer(
name="20m",
center_freq=14150000,
rf_gain=0,
samp_rate=500000,
start_freq=14070000,
start_mod="usb",
),
"30m": PropertyLayer(
name="30m",
center_freq=10125000,
rf_gain=0,
samp_rate=250000,
start_freq=10142000,
start_mod="usb",
),
"40m": PropertyLayer(
name="40m",
center_freq=7100000,
rf_gain=0,
samp_rate=500000,
start_freq=7070000,
start_mod="lsb",
),
"80m": PropertyLayer(
name="80m",
center_freq=3650000,
rf_gain=0,
samp_rate=500000,
start_freq=3570000,
start_mod="lsb",
),
"49m": PropertyLayer(
name="49m Broadcast",
center_freq=6000000,
rf_gain=0,
samp_rate=500000,
start_freq=6070000,
start_mod="am",
),
}
),
),
),
waterfall_scheme="GoogleTurboWaterfall",
waterfall_levels=PropertyLayer(min=-88, max=-20),
waterfall_auto_levels=PropertyLayer(min=3, max=10),
waterfall_auto_min_range=50,
tuning_precision=2,
squelch_auto_margin=10,
google_maps_api_key="",
map_position_retention_time=2 * 60 * 60,
decoding_queue_workers=2,
decoding_queue_length=10,
wsjt_decoding_depth=3,
wsjt_decoding_depths=PropertyLayer(jt65=1),
fst4_enabled_intervals=[15, 30],
fst4w_enabled_intervals=[120, 300],
q65_enabled_combinations=["A30", "E120", "C60"],
js8_enabled_profiles=["normal", "slow"],
js8_decoding_depth=3,
services_enabled=False,
services_decoders=["ft8", "ft4", "wspr", "packet"],
aprs_callsign="N0CALL",
aprs_igate_enabled=False,
aprs_igate_server="euro.aprs2.net",
aprs_igate_password="",
aprs_igate_beacon=False,
aprs_igate_symbol="R&",
aprs_igate_comment="OpenWebRX APRS gateway",
# aprs_igate_height=None,
# aprs_igate_gain=None,
# aprs_igate_dir=None,
pskreporter_enabled=False,
pskreporter_callsign="N0CALL",
# pskreporter_antenna_information=None,
wsprnet_enabled=False,
wsprnet_callsign="N0CALL",
).readonly()

62
owrx/config/dynamic.py Normal file
View File

@ -0,0 +1,62 @@
from owrx.config.core import CoreConfig
from owrx.config.migration import Migrator
from owrx.property import PropertyLayer, PropertyDeleted
from owrx.jsons import Encoder
import json
class DynamicConfig(PropertyLayer):
def __init__(self):
super().__init__()
try:
with open(DynamicConfig._getSettingsFile(), "r") as f:
for k, v in json.load(f).items():
if isinstance(v, dict):
self[k] = DynamicConfig._toLayer(v)
else:
self[k] = v
except FileNotFoundError:
pass
Migrator.migrate(self)
@staticmethod
def _toLayer(dictionary: dict):
layer = PropertyLayer()
for k, v in dictionary.items():
if isinstance(v, dict):
layer[k] = DynamicConfig._toLayer(v)
else:
layer[k] = v
return layer
@staticmethod
def _getSettingsFile():
coreConfig = CoreConfig()
return "{data_directory}/settings.json".format(data_directory=coreConfig.get_data_directory())
def store(self):
# don't write directly to file to avoid corruption on exceptions
jsonContent = json.dumps(self.__dict__(), indent=4, cls=Encoder)
with open(DynamicConfig._getSettingsFile(), "w") as file:
file.write(jsonContent)
def __delitem__(self, key):
self.__setitem__(key, PropertyDeleted)
def __contains__(self, item):
if not super().__contains__(item):
return False
if super().__getitem__(item) is PropertyDeleted:
return False
return True
def __getitem__(self, item):
if self.__contains__(item):
return super().__getitem__(item)
raise KeyError('Key "{key}" does not exist'.format(key=item))
def __dict__(self):
return {k: v for k, v in super().__dict__().items() if v is not PropertyDeleted}
def keys(self):
return [k for k in super().keys() if self.__contains__(k)]

3
owrx/config/error.py Normal file
View File

@ -0,0 +1,3 @@
class ConfigError(Exception):
def __init__(self, key, message):
super().__init__("Configuration Error (key: {0}): {1}".format(key, message))

134
owrx/config/migration.py Normal file
View File

@ -0,0 +1,134 @@
from abc import ABC, abstractmethod
from owrx.property import PropertyLayer
import logging
logger = logging.getLogger(__name__)
class ConfigMigrator(ABC):
@abstractmethod
def migrate(self, config):
pass
def renameKey(self, config, old, new):
if old in config and new not in config:
config[new] = config[old]
del config[old]
class ConfigMigratorVersion1(ConfigMigrator):
def migrate(self, config):
if "receiver_gps" in config:
gps = config["receiver_gps"]
config["receiver_gps"] = {"lat": gps[0], "lon": gps[1]}
if "waterfall_auto_level_margin" in config:
levels = config["waterfall_auto_level_margin"]
config["waterfall_auto_level_margin"] = {"min": levels[0], "max": levels[1]}
self.renameKey(config, "wsjt_queue_workers", "decoding_queue_workers")
self.renameKey(config, "wsjt_queue_length", "decoding_queue_length")
config["version"] = 2
class ConfigMigratorVersion2(ConfigMigrator):
def migrate(self, config):
if "waterfall_colors" in config and any(v > 0xFFFFFF for v in config["waterfall_colors"]):
config["waterfall_colors"] = [v >> 8 for v in config["waterfall_colors"]]
config["version"] = 3
class ConfigMigratorVersion3(ConfigMigrator):
def migrate(self, config):
# inline import due to circular dependencies
from owrx.waterfall import WaterfallOptions
if "waterfall_scheme" in config:
scheme = WaterfallOptions(config["waterfall_scheme"])
if scheme is not WaterfallOptions.CUSTOM and "waterfall_colors" in config:
del config["waterfall_colors"]
elif "waterfall_colors" in config:
scheme = WaterfallOptions.findByColors(config["waterfall_colors"])
if scheme is not WaterfallOptions.CUSTOM:
logger.debug("detected waterfall option: %s", scheme.value)
if "waterfall_colors" in config:
del config["waterfall_colors"]
config["waterfall_scheme"] = scheme.value
config["version"] = 4
class ConfigMigratorVersion4(ConfigMigrator):
def _replaceWaterfallLevels(self, instance):
if (
"waterfall_min_level" in instance
and "waterfall_max_level" in instance
and not "waterfall_levels" in instance
):
instance["waterfall_levels"] = {
"min": instance["waterfall_min_level"],
"max": instance["waterfall_max_level"],
}
del instance["waterfall_min_level"]
del instance["waterfall_max_level"]
def migrate(self, config):
# migrate root level
self._replaceWaterfallLevels(config)
if "sdrs" in config:
for device in config["sdrs"].__dict__().values():
# migrate device level
self._replaceWaterfallLevels(device)
if "profiles" in device:
for profile in device["profiles"].__dict__().values():
# migrate profile level
self._replaceWaterfallLevels(profile)
config["version"] = 5
class ConfigMigratorVersion5(ConfigMigrator):
def migrate(self, config):
if "frequency_display_precision" in config:
# old config was always in relation to the display in MHz (1e6 Hz, hence the 6)
config["tuning_precision"] = 6 - config["frequency_display_precision"]
del config["frequency_display_precision"]
config["version"] = 6
class ConfigMigratorVersion6(ConfigMigrator):
def migrate(self, config):
if "waterfall_auto_level_margin" in config:
walm_config = config["waterfall_auto_level_margin"]
if "min_range" in walm_config:
config["waterfall_auto_min_range"] = walm_config["min_range"]
wal = {k: v for k, v in walm_config.items() if k in ["min", "max"]}
config["waterfall_auto_levels"] = PropertyLayer(**wal)
del config["waterfall_auto_level_margin"]
config["version"] = 7
class Migrator(object):
currentVersion = 7
migrators = {
1: ConfigMigratorVersion1(),
2: ConfigMigratorVersion2(),
3: ConfigMigratorVersion3(),
4: ConfigMigratorVersion4(),
5: ConfigMigratorVersion5(),
6: ConfigMigratorVersion6(),
}
@staticmethod
def migrate(config):
version = config["version"] if "version" in config else 1
if version == Migrator.currentVersion:
return config
logger.debug("migrating config from version %i", version)
migrators = [Migrator.migrators[i] for i in range(version, Migrator.currentVersion)]
for migrator in migrators:
migrator.migrate(config)

View File

@ -2,19 +2,21 @@ from owrx.details import ReceiverDetails
from owrx.dsp import DspManager
from owrx.cpu import CpuUsageThread
from owrx.sdr import SdrService
from owrx.source import SdrSource, SdrSourceEventClient
from owrx.source import SdrSourceState, SdrClientClass, SdrSourceEventClient
from owrx.client import ClientRegistry, TooManyClientsException
from owrx.feature import FeatureDetector
from owrx.version import openwebrx_version
from owrx.bands import Bandplan
from owrx.bookmarks import Bookmarks
from owrx.map import Map
from owrx.property import PropertyStack
from owrx.property import PropertyStack, PropertyDeleted
from owrx.modes import Modes, DigitalMode
from owrx.config import Config
from owrx.waterfall import WaterfallOptions
from owrx.websocket import Handler
from queue import Queue, Full, Empty
from js8py import Js8Frame
from abc import ABC, ABCMeta, abstractmethod
from abc import ABCMeta, abstractmethod
import json
import threading
@ -25,7 +27,7 @@ logger = logging.getLogger(__name__)
PoisonPill = object()
class Client(ABC):
class Client(Handler, metaclass=ABCMeta):
def __init__(self, conn):
self.conn = conn
self.multithreadingQueue = Queue(100)
@ -99,43 +101,50 @@ class OpenWebRxClient(Client, metaclass=ABCMeta):
receiver_info = receiver_details.__dict__()
self.write_receiver_details(receiver_info)
# TODO unsubscribe
receiver_details.wire(send_receiver_info)
self._detailsSubscription = receiver_details.wire(send_receiver_info)
send_receiver_info()
def write_receiver_details(self, details):
self.send({"type": "receiver_details", "value": details})
def close(self):
self._detailsSubscription.cancel()
super().close()
class OpenWebRxReceiverClient(OpenWebRxClient, SdrSourceEventClient):
sdr_config_keys = [
"waterfall_min_level",
"waterfall_max_level",
"waterfall_levels",
"samp_rate",
"start_mod",
"start_freq",
"center_freq",
"initial_squelch_level",
"sdr_id",
"profile_id",
"squelch_auto_margin",
]
global_config_keys = [
"waterfall_scheme",
"waterfall_colors",
"waterfall_auto_level_margin",
"waterfall_auto_levels",
"waterfall_auto_min_range",
"fft_size",
"audio_compression",
"fft_compression",
"max_clients",
"frequency_display_precision",
"tuning_precision",
]
def __init__(self, conn):
super().__init__(conn)
self.dsp = None
self.dspLock = threading.Lock()
self.sdr = None
self.configSubs = []
self.bookmarkSub = None
self.connectionProperties = {}
try:
@ -156,15 +165,12 @@ class OpenWebRxReceiverClient(OpenWebRxClient, SdrSourceEventClient):
modes = Modes.getModes()
self.write_modes(modes)
self.__sendProfiles()
self.configSubs.append(SdrService.getActiveSources().wire(self._onSdrDeviceChanges))
self.configSubs.append(SdrService.getAvailableProfiles().wire(self._sendProfiles))
self._sendProfiles()
CpuUsageThread.getSharedInstance().add_client(self)
def __del__(self):
if hasattr(self, "configSubs"):
while self.configSubs:
self.configSubs.pop().cancel()
def setupStack(self):
stack = PropertyStack()
# stack layer 0 reserved for sdr properties
@ -176,7 +182,8 @@ class OpenWebRxReceiverClient(OpenWebRxClient, SdrSourceEventClient):
if changes is None:
config = configProps.__dict__()
else:
config = changes
# transform deletions into Nones
config = {k: v if v is not PropertyDeleted else None for k, v in changes.items()}
if (
(changes is None or "start_freq" in changes or "center_freq" in changes)
and "start_freq" in configProps
@ -187,49 +194,77 @@ class OpenWebRxReceiverClient(OpenWebRxClient, SdrSourceEventClient):
config["sdr_id"] = self.sdr.getId()
self.write_config(config)
def sendBookmarks(changes=None):
def sendBookmarks(*args):
cf = configProps["center_freq"]
srh = configProps["samp_rate"] / 2
frequencyRange = (cf - srh, cf + srh)
self.write_dial_frequencies(Bandplan.getSharedInstance().collectDialFrequencies(frequencyRange))
bookmarks = [b.__dict__() for b in Bookmarks.getSharedInstance().getBookmarks(frequencyRange)]
dial_frequencies = []
bookmarks = []
if "center_freq" in configProps and "samp_rate" in configProps:
frequencyRange = (cf - srh, cf + srh)
dial_frequencies = Bandplan.getSharedInstance().collectDialFrequencies(frequencyRange)
bookmarks = [b.__dict__() for b in Bookmarks.getSharedInstance().getBookmarks(frequencyRange)]
self.write_dial_frequencies(dial_frequencies)
self.write_bookmarks(bookmarks)
def updateBookmarkSubscription(*args):
if self.bookmarkSub is not None:
self.bookmarkSub.cancel()
if "center_freq" in configProps and "samp_rate" in configProps:
cf = configProps["center_freq"]
srh = configProps["samp_rate"] / 2
frequencyRange = (cf - srh, cf + srh)
self.bookmarkSub = Bookmarks.getSharedInstance().subscribe(frequencyRange, sendBookmarks)
sendBookmarks()
self.configSubs.append(configProps.wire(sendConfig))
self.configSubs.append(stack.filter("center_freq", "samp_rate").wire(sendBookmarks))
self.configSubs.append(stack.filter("center_freq", "samp_rate").wire(updateBookmarkSubscription))
# send initial config
sendConfig()
return stack
def setupGlobalConfig(self):
def writeConfig(changes):
# TODO it would be nicer to have all options available and switchable in the client
# this restores the existing functionality for now, but there is lots of potential
if "waterfall_scheme" in changes or "waterfall_colors" in changes:
scheme = WaterfallOptions(globalConfig["waterfall_scheme"]).instantiate()
changes["waterfall_colors"] = scheme.getColors()
self.write_config(changes)
globalConfig = Config.get().filter(*OpenWebRxReceiverClient.global_config_keys)
self.configSubs.append(globalConfig.wire(self.write_config))
self.write_config(globalConfig.__dict__())
self.configSubs.append(globalConfig.wire(writeConfig))
writeConfig(globalConfig.__dict__())
def onStateChange(self, state):
if state == SdrSource.STATE_RUNNING:
def onStateChange(self, state: SdrSourceState):
if state is SdrSourceState.RUNNING:
self.handleSdrAvailable()
elif state == SdrSource.STATE_FAILED:
self.handleSdrFailed()
def handleSdrFailed(self):
def onFail(self):
logger.warning('SDR device "%s" has failed, selecting new device', self.sdr.getName())
self.write_log_message('SDR device "{0}" has failed, selecting new device'.format(self.sdr.getName()))
self.setSdr()
def onBusyStateChange(self, state):
pass
def onDisable(self):
logger.warning('SDR device "%s" was disabled, selecting new device', self.sdr.getName())
self.write_log_message('SDR device "{0}" was disabled, selecting new device'.format(self.sdr.getName()))
self.setSdr()
def getClientClass(self):
return SdrSource.CLIENT_USER
def onShutdown(self):
logger.warning('SDR device "%s" is shutting down, selecting new device', self.sdr.getName())
self.write_log_message('SDR device "{0}" is shutting down, selecting new device'.format(self.sdr.getName()))
self.setSdr()
def __sendProfiles(self):
profiles = [
{"name": s.getName() + " " + p["name"], "id": sid + "|" + pid}
for (sid, s) in SdrService.getSources().items()
for (pid, p) in s.getProfiles().items()
]
def getClientClass(self) -> SdrClientClass:
return SdrClientClass.USER
def _onSdrDeviceChanges(self, changes):
# restart the client if an sdr has become available
if self.sdr is None and any(s is not PropertyDeleted for s in changes.values()):
self.setSdr()
def _sendProfiles(self, *args):
profiles = [{"id": pid, "name": name} for pid, name in SdrService.getAvailableProfiles().items()]
self.write_profiles(profiles)
def handleTextMessage(self, conn, message):
@ -237,20 +272,17 @@ class OpenWebRxReceiverClient(OpenWebRxClient, SdrSourceEventClient):
message = json.loads(message)
if "type" in message:
if message["type"] == "dspcontrol":
if "action" in message and message["action"] == "start":
self.startDsp()
dsp = self.getDsp()
if dsp is None:
logger.warning("DSP not available; discarding client dspcontrol message")
else:
if "action" in message and message["action"] == "start":
dsp.start()
if "params" in message:
dsp = self.getDsp()
if dsp is None:
logger.warning("DSP not available; discarding client data")
else:
if "params" in message:
params = message["params"]
dsp.setProperties(params)
elif message["type"] == "config":
if "params" in message:
self.setParams(message["params"])
elif message["type"] == "setsdr":
if "params" in message:
self.setSdr(message["params"]["sdr"])
@ -283,66 +315,55 @@ class OpenWebRxReceiverClient(OpenWebRxClient, SdrSourceEventClient):
return
self.stopDsp()
self.stack.removeLayerByPriority(0)
if self.sdr is not None:
self.sdr.removeClient(self)
self.sdr = next
if next is None:
# exit condition: no sdrs available
logger.warning("no more SDR devices available")
self.handleNoSdrsAvailable()
return
self.sdr = next
self.sdr.addClient(self)
def handleSdrAvailable(self):
self.getDsp().setProperties(self.connectionProperties)
self.stack.replaceLayer(0, self.sdr.getProps())
self.__sendProfiles()
self.sdr.addSpectrumClient(self)
def handleNoSdrsAvailable(self):
self.write_sdr_error("No SDR Devices available")
def startDsp(self):
self.getDsp().start()
def close(self):
if self.sdr is not None:
self.sdr.removeClient(self)
self.stopDsp()
CpuUsageThread.getSharedInstance().remove_client(self)
ClientRegistry.getSharedInstance().removeClient(self)
while self.configSubs:
self.configSubs.pop().cancel()
if self.bookmarkSub is not None:
self.bookmarkSub.cancel()
self.bookmarkSub = None
super().close()
def stopDsp(self):
if self.dsp is not None:
self.dsp.stop()
self.dsp = None
with self.dspLock:
if self.dsp is not None:
self.dsp.stop()
self.dsp = None
if self.sdr is not None:
self.sdr.removeSpectrumClient(self)
def setParams(self, params):
config = Config.get()
# allow direct configuration only if enabled in the config
if "configurable_keys" not in config:
return
keys = config["configurable_keys"]
if not keys:
return
protected = self.stack.filter(*keys)
for key, value in params.items():
try:
protected[key] = value
except KeyError:
pass
def getDsp(self):
if self.dsp is None and self.sdr is not None:
self.dsp = DspManager(self, self.sdr)
with self.dspLock:
if self.dsp is None and self.sdr is not None:
self.dsp = DspManager(self, self.sdr)
return self.dsp
def write_spectrum_data(self, data):
@ -355,7 +376,10 @@ class OpenWebRxReceiverClient(OpenWebRxClient, SdrSourceEventClient):
self.send(bytes([0x04]) + data)
def write_s_meter_level(self, level):
self.send({"type": "smeter", "value": level})
try:
self.send({"type": "smeter", "value": level})
except ValueError:
logger.warning("unable to send smeter value: %s", str(level))
def write_cpu_usage(self, usage):
self.mp_send({"type": "cpuusage", "value": usage})
@ -448,14 +472,15 @@ class MapConnection(OpenWebRxClient):
super().__init__(conn)
pm = Config.get()
self.write_config(
pm.filter(
"google_maps_api_key",
"receiver_gps",
"map_position_retention_time",
"receiver_name",
).__dict__()
filtered_config = pm.filter(
"google_maps_api_key",
"receiver_gps",
"map_position_retention_time",
"receiver_name",
)
filtered_config.wire(self.write_config)
self.write_config(filtered_config.__dict__())
Map.getSharedInstance().addClient(self)
@ -473,35 +498,36 @@ class MapConnection(OpenWebRxClient):
self.mp_send({"type": "update", "value": update})
class WebSocketMessageHandler(object):
def __init__(self):
self.handshake = None
class HandshakeMessageHandler(Handler):
"""
This handler receives text messages, but will only respond to the second handshake string.
As soon as a valid handshake is received, the handler replaces itself with the corresponding handler type.
"""
def handleTextMessage(self, conn, message):
if message[:16] == "SERVER DE CLIENT":
meta = message[17:].split(" ")
self.handshake = {v[0]: "=".join(v[1:]) for v in map(lambda x: x.split("="), meta)}
handshake = {v[0]: "=".join(v[1:]) for v in map(lambda x: x.split("="), meta)}
conn.send("CLIENT DE SERVER server=openwebrx version={version}".format(version=openwebrx_version))
logger.debug("client connection initialized")
if "type" in self.handshake:
if self.handshake["type"] == "receiver":
client = None
if "type" in handshake:
if handshake["type"] == "receiver":
client = OpenWebRxReceiverClient(conn)
if self.handshake["type"] == "map":
elif handshake["type"] == "map":
client = MapConnection(conn)
# backwards compatibility
else:
logger.warning("invalid connection type: %s", handshake["type"])
if client is not None:
logger.debug("handshake complete, handing off to %s", type(client).__name__)
# hand off all further communication to the correspondig connection
conn.send("CLIENT DE SERVER server=openwebrx version={version}".format(version=openwebrx_version))
conn.setMessageHandler(client)
else:
client = OpenWebRxReceiverClient(conn)
# hand off all further communication to the correspondig connection
conn.setMessageHandler(client)
return
if not self.handshake:
logger.warning('invalid handshake received')
else:
logger.warning("not answering client request since handshake is not complete")
return
def handleBinaryMessage(self, conn, data):
pass

View File

@ -1,11 +1,16 @@
from datetime import datetime, timezone
class BodySizeError(Exception):
pass
class Controller(object):
def __init__(self, handler, request, options):
self.handler = handler
self.request = request
self.options = options
self.responseCookies = None
def send_response(
self, content, code=200, content_type="text/html", last_modified: datetime = None, max_age=None, headers=None
@ -21,22 +26,29 @@ class Controller(object):
headers["Cache-Control"] = "max-age={0}".format(max_age)
for key, value in headers.items():
self.handler.send_header(key, value)
if self.responseCookies is not None:
self.handler.send_header("Set-Cookie", self.responseCookies.output(header=""))
self.handler.end_headers()
if type(content) == str:
content = content.encode()
self.handler.wfile.write(content)
def send_redirect(self, location, code=303, cookies=None):
def send_redirect(self, location, code=303):
self.handler.send_response(code)
if cookies is not None:
self.handler.send_header("Set-Cookie", cookies.output(header=""))
if self.responseCookies is not None:
self.handler.send_header("Set-Cookie", self.responseCookies.output(header=""))
self.handler.send_header("Location", location)
self.handler.end_headers()
def get_body(self):
def set_response_cookies(self, cookies):
self.responseCookies = cookies
def get_body(self, max_size=None):
if "Content-Length" not in self.handler.headers:
return None
length = int(self.handler.headers["Content-Length"])
if max_size is not None and length > max_size:
raise BodySizeError("HTTP body exceeds maximum allowed size")
return self.handler.rfile.read(length)
def handle_request(self):

View File

@ -1,7 +1,7 @@
from .template import WebpageController
from .session import SessionStorage
from owrx.config import Config
from owrx.controllers.session import SessionStorage
from owrx.users import UserList
from urllib import parse
from http.cookies import SimpleCookie
import logging
@ -9,25 +9,48 @@ logger = logging.getLogger(__name__)
class Authentication(object):
def isAuthenticated(self, request):
if "owrx-session" in request.cookies:
session = SessionStorage.getSharedInstance().getSession(request.cookies["owrx-session"].value)
return session is not None
return False
def getUser(self, request):
if "owrx-session" not in request.cookies:
return None
session_id = request.cookies["owrx-session"].value
storage = SessionStorage.getSharedInstance()
session = storage.getSession(session_id)
if session is None:
return None
if "user" not in session:
return None
userList = UserList.getSharedInstance()
user = None
try:
user = userList[session["user"]]
storage.prolongSession(session_id)
except KeyError:
pass
return user
class AdminController(WebpageController):
class AuthorizationMixin(object):
def __init__(self, handler, request, options):
self.authentication = Authentication()
self.user = self.authentication.getUser(request)
super().__init__(handler, request, options)
def isAuthorized(self):
return self.user is not None and self.user.is_enabled() and not self.user.must_change_password
def handle_request(self):
config = Config.get()
if "webadmin_enabled" not in config or not config["webadmin_enabled"]:
self.send_response("Web Admin is disabled", code=403)
return
if self.authentication.isAuthenticated(self.request):
if self.isAuthorized():
super().handle_request()
else:
target = "/login?{0}".format(parse.urlencode({"ref": self.request.path}))
self.send_redirect(target)
cookie = SimpleCookie()
cookie["owrx-session"] = ""
cookie["owrx-session"]["expires"] = "Thu, 01 Jan 1970 00:00:00 GMT"
self.set_response_cookies(cookie)
if (
"x-requested-with" in self.request.headers
and self.request.headers["x-requested-with"] == "XMLHttpRequest"
):
self.send_response("{}", code=403)
else:
target = "{}login?{}".format(self.get_document_root(), parse.urlencode({"ref": self.request.path[1:]}))
self.send_redirect(target)

View File

@ -1,6 +1,5 @@
from . import Controller
from owrx.feature import FeatureDetector
from owrx.details import ReceiverDetails
import json
@ -8,8 +7,3 @@ class ApiController(Controller):
def indexAction(self):
data = json.dumps(FeatureDetector().feature_report())
self.send_response(data, content_type="application/json")
def receiverDetails(self):
receiver_details = ReceiverDetails()
data = json.dumps(receiver_details.__dict__())
self.send_response(data, content_type="application/json")

View File

@ -1,5 +1,5 @@
from . import Controller
from owrx.config import Config
from owrx.config.core import CoreConfig
from datetime import datetime, timezone
import mimetypes
import os
@ -13,7 +13,7 @@ logger = logging.getLogger(__name__)
class GzipMixin(object):
def send_response(self, content, headers=None, content_type="text/html", *args, **kwargs):
def send_response(self, content, code=200, headers=None, content_type="text/html", *args, **kwargs):
if self.zipable(content_type) and "accept-encoding" in self.request.headers:
accepted = [s.strip().lower() for s in self.request.headers["accept-encoding"].split(",")]
if "gzip" in accepted:
@ -23,10 +23,10 @@ class GzipMixin(object):
if headers is None:
headers = {}
headers["Content-Encoding"] = "gzip"
super().send_response(content, headers=headers, content_type=content_type, *args, **kwargs)
super().send_response(content, code, headers=headers, content_type=content_type, *args, **kwargs)
def zipable(self, content_type):
types = ["application/javascript", "text/css", "text/html"]
types = ["application/javascript", "text/css", "text/html", "image/svg+xml"]
return content_type in types
def gzip(self, content):
@ -78,7 +78,7 @@ class AssetsController(GzipMixin, ModificationAwareController, metaclass=ABCMeta
f.close()
if content_type is None:
(content_type, encoding) = mimetypes.MimeTypes().guess_type(file)
(content_type, encoding) = mimetypes.guess_type(self.getFilePath(file))
self.send_response(data, content_type=content_type, last_modified=modified, max_age=3600)
except FileNotFoundError:
self.send_response("file not found", code=404)
@ -90,13 +90,22 @@ class AssetsController(GzipMixin, ModificationAwareController, metaclass=ABCMeta
class OwrxAssetsController(AssetsController):
def getFilePath(self, file):
mappedFiles = {
"gfx/openwebrx-avatar.png": "receiver_avatar",
"gfx/openwebrx-top-photo.jpg": "receiver_top_photo",
}
if file in mappedFiles and ("mapped" not in self.request.query or self.request.query["mapped"][0] != "false"):
config = CoreConfig()
for ext in ["png", "jpg", "webp"]:
user_file = "{}/{}.{}".format(config.get_data_directory(), mappedFiles[file], ext)
if os.path.exists(user_file) and os.path.isfile(user_file):
return user_file
return pkg_resources.resource_filename("htdocs", file)
class AprsSymbolsController(AssetsController):
def __init__(self, handler, request, options):
pm = Config.get()
path = pm["aprs_symbols_path"]
path = CoreConfig().get_aprs_symbols_path()
if not path.endswith("/"):
path += "/"
self.path = path
@ -116,6 +125,7 @@ class CompiledAssetsController(GzipMixin, ModificationAwareController):
"lib/Header.js",
"lib/Demodulator.js",
"lib/DemodulatorPanel.js",
"lib/BookmarkLocalStorage.js",
"lib/BookmarkBar.js",
"lib/BookmarkDialog.js",
"lib/AudioEngine.js",
@ -135,9 +145,19 @@ class CompiledAssetsController(GzipMixin, ModificationAwareController):
],
"settings.js": [
"lib/jquery-3.2.1.min.js",
"lib/bootstrap.bundle.min.js",
"lib/location-picker.min.js",
"lib/Header.js",
"lib/settings/Input.js",
"lib/settings/SdrDevice.js",
"lib/settings/MapInput.js",
"lib/settings/ImageUpload.js",
"lib/BookmarkLocalStorage.js",
"lib/settings/BookmarkTable.js",
"lib/settings/WsjtDecodingDepthsInput.js",
"lib/settings/WaterfallDropdown.js",
"lib/settings/GainInput.js",
"lib/settings/OptionalSection.js",
"lib/settings/SchedulerInput.js",
"lib/settings/ExponentialInput.js",
"settings.js",
],
}
@ -159,7 +179,7 @@ class CompiledAssetsController(GzipMixin, ModificationAwareController):
contents = [self.getContents(f) for f in files]
(content_type, encoding) = mimetypes.MimeTypes().guess_type(profileName)
(content_type, encoding) = mimetypes.guess_type(profileName)
self.send_response("\n".join(contents), content_type=content_type, last_modified=modified, max_age=3600)
def getContents(self, file):

View File

@ -0,0 +1,11 @@
from owrx.controllers.template import WebpageController
from owrx.breadcrumb import Breadcrumb, BreadcrumbItem, BreadcrumbMixin
from owrx.controllers.settings import SettingsBreadcrumb
class FeatureController(BreadcrumbMixin, WebpageController):
def get_breadcrumb(self) -> Breadcrumb:
return SettingsBreadcrumb().append(BreadcrumbItem("Feature report", "features"))
def indexAction(self):
self.serve_template("features.html", **self.template_variables())

View File

@ -0,0 +1,79 @@
from owrx.controllers import BodySizeError
from owrx.controllers.assets import AssetsController
from owrx.controllers.admin import AuthorizationMixin
from owrx.config.core import CoreConfig
from owrx.form.input.gfx import AvatarInput, TopPhotoInput
import uuid
import json
class ImageUploadController(AuthorizationMixin, AssetsController):
# max upload filesizes
max_sizes = {
# not the best idea to instantiate inputs, but i didn't want to duplicate the sizes here
"receiver_avatar": AvatarInput("id", "label").getMaxSize(),
"receiver_top_photo": TopPhotoInput("id", "label").getMaxSize(),
}
def __init__(self, handler, request, options):
super().__init__(handler, request, options)
self.file = request.query["file"][0] if "file" in request.query else None
def getFilePath(self, file=None):
if self.file is None:
raise FileNotFoundError("missing filename")
return "{tmp}/{file}".format(
tmp=CoreConfig().get_temporary_directory(),
file=self.file
)
def indexAction(self):
self.serve_file(None)
def _is_png(self, contents):
return contents[0:8] == bytes([0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A])
def _is_jpg(self, contents):
return contents[0:3] == bytes([0xFF, 0xD8, 0xFF])
def _is_webp(self, contents):
return contents[0:4] == bytes([0x52, 0x49, 0x46, 0x46]) and contents[8:12] == bytes([0x57, 0x45, 0x42, 0x50])
def processImage(self):
if "id" not in self.request.query:
self.send_json_response({"error": "missing id"}, code=400)
return
file_id = self.request.query["id"][0]
if file_id not in ImageUploadController.max_sizes:
self.send_json_response({"error": "unexpected image id"}, code=400)
return
try:
contents = self.get_body(ImageUploadController.max_sizes[file_id])
except BodySizeError:
self.send_json_response({"error": "file size too large"}, code=400)
return
filetype = None
if self._is_png(contents):
filetype = "png"
elif self._is_jpg(contents):
filetype = "jpg"
elif self._is_webp(contents):
filetype = "webp"
if filetype is None:
self.send_json_response({"error": "unsupported file type"}, code=400)
return
self.file = "{id}-{uuid}.{ext}".format(
id=file_id,
uuid=uuid.uuid4().hex,
ext=filetype,
)
with open(self.getFilePath(), "wb") as f:
f.write(contents)
self.send_json_response({"file": self.file}, code=200)
def send_json_response(self, obj, code):
self.send_response(json.dumps(obj), code=code, content_type="application/json")

View File

@ -1,9 +1,30 @@
from . import Controller
from owrx.metrics import Metrics
from owrx.metrics import CounterMetric, DirectMetric, Metrics
import json
class MetricsController(Controller):
def indexAction(self):
data = json.dumps(Metrics.getSharedInstance().getMetrics())
data = json.dumps(Metrics.getSharedInstance().getHierarchicalMetrics())
self.send_response(data, content_type="application/json")
def prometheusAction(self):
metrics = Metrics.getSharedInstance().getFlatMetrics()
def prometheusFormat(key, metric):
value = metric.getValue()
if isinstance(metric, CounterMetric):
key += "_total"
value = value["count"]
elif isinstance(metric, DirectMetric):
pass
else:
raise ValueError("Unexpected metric type for metric {}".format(repr(metric)))
return "{key} {value}".format(key=key.replace(".", "_"), value=value)
data = ["# https://prometheus.io/docs/instrumenting/exposition_formats/"] + [
prometheusFormat(k, v) for k, v in metrics.items()
]
self.send_response("\n".join(data), content_type="text/plain; version=0.0.4")

View File

@ -0,0 +1,24 @@
from owrx.controllers.template import WebpageController
from owrx.controllers.admin import AuthorizationMixin
from owrx.users import UserList, DefaultPasswordClass
from urllib.parse import parse_qs
class ProfileController(AuthorizationMixin, WebpageController):
def isAuthorized(self):
return self.user is not None and self.user.is_enabled() and self.user.must_change_password
def indexAction(self):
self.serve_template("pwchange.html", **self.template_variables())
def processPwChange(self):
data = parse_qs(self.get_body().decode("utf-8"))
data = {k: v[0] for k, v in data.items()}
userlist = UserList.getSharedInstance()
if "password" in data and "confirm" in data and data["password"] == data["confirm"]:
self.user.setPassword(DefaultPasswordClass(data["password"]), must_change_password=False)
userlist.store()
target = self.request.query["ref"][0] if "ref" in self.request.query else "/settings"
else:
target = "/pwchange"
self.send_redirect(target)

View File

@ -0,0 +1,16 @@
from owrx.controllers import Controller
class RobotsController(Controller):
def indexAction(self):
# search engines should not be crawling internal / API routes
self.send_response(
"""User-agent: *
Disallow: /login
Disallow: /logout
Disallow: /pwchange
Disallow: /settings
Disallow: /imageupload
""",
content_type="text/plain",
)

View File

@ -1,12 +1,18 @@
from .template import WebpageController
from urllib.parse import parse_qs
from owrx.controllers.template import WebpageController
from urllib.parse import parse_qs, urlencode
from uuid import uuid4
from http.cookies import SimpleCookie
from owrx.users import UserList
from datetime import datetime, timedelta
import logging
logger = logging.getLogger(__name__)
class SessionStorage(object):
sharedInstance = None
sessionLifetime = timedelta(hours=6)
@staticmethod
def getSharedInstance():
@ -28,10 +34,21 @@ class SessionStorage(object):
def getSession(self, key):
if key not in self.sessions:
return None
return self.sessions[key]
expires, data = self.sessions[key]
if expires < datetime.utcnow():
del self.sessions[key]
return None
return data
def updateSession(self, key, data):
self.sessions[key] = data
expires = datetime.utcnow() + SessionStorage.sessionLifetime
self.sessions[key] = expires, data
def prolongSession(self, key):
data = self.getSession(key)
if data is None:
raise KeyError("Invalid session key")
self.updateSession(key, data)
class SessionController(WebpageController):
@ -45,15 +62,18 @@ class SessionController(WebpageController):
if "user" in data and "password" in data:
if data["user"] in userlist:
user = userlist[data["user"]]
if user.password.is_valid(data["password"]):
# TODO evaluate password force_change and redirect to password change
if user.is_enabled() and user.password.is_valid(data["password"]):
key = SessionStorage.getSharedInstance().startSession({"user": user.name})
cookie = SimpleCookie()
cookie["owrx-session"] = key
target = self.request.query["ref"][0] if "ref" in self.request.query else "/settings"
self.send_redirect(target, cookies=cookie)
if user.must_change_password:
target = "/pwchange?{0}".format(urlencode({"ref": target}))
self.set_response_cookies(cookie)
self.send_redirect(target)
return
self.send_redirect("/login")
target = "?{}".format(urlencode({"ref": self.request.query["ref"][0]})) if "ref" in self.request.query else ""
self.send_redirect(self.request.path + target)
def logoutAction(self):
self.send_redirect("logout happening here")

View File

@ -1,284 +0,0 @@
from .admin import AdminController
from owrx.config import Config
from urllib.parse import parse_qs
from owrx.form import (
TextInput,
NumberInput,
FloatInput,
LocationInput,
TextAreaInput,
CheckboxInput,
DropdownInput,
Option,
ServicesCheckboxInput,
Js8ProfileCheckboxInput,
)
from urllib.parse import quote
import json
import logging
logger = logging.getLogger(__name__)
class Section(object):
def __init__(self, title, *inputs):
self.title = title
self.inputs = inputs
def render_inputs(self):
config = Config.get()
return "".join([i.render(config) for i in self.inputs])
def render(self):
return """
<div class="col-12 settings-category">
<h3 class="settings-header">
{title}
</h3>
{inputs}
</div>
""".format(
title=self.title, inputs=self.render_inputs()
)
def parse(self, data):
return {k: v for i in self.inputs for k, v in i.parse(data).items()}
class SettingsController(AdminController):
def indexAction(self):
self.serve_template("settings.html", **self.template_variables())
class SdrSettingsController(AdminController):
def template_variables(self):
variables = super().template_variables()
variables["devices"] = self.render_devices()
return variables
def render_devices(self):
return "".join(self.render_device(key, value) for key, value in Config.get()["sdrs"].items())
def render_device(self, device_id, config):
return """
<div class="card device bg-dark text-white">
<div class="card-header">
{device_name}
</div>
<div class="card-body">
{form}
</div>
</div>
""".format(
device_name=config["name"], form=self.render_form(device_id, config)
)
def render_form(self, device_id, config):
return """
<form class="sdrdevice" data-config="{formdata}"></form>
""".format(
device_id=device_id, formdata=quote(json.dumps(config))
)
def indexAction(self):
self.serve_template("sdrsettings.html", **self.template_variables())
class GeneralSettingsController(AdminController):
sections = [
Section(
"General settings",
TextInput("receiver_name", "Receiver name"),
TextInput("receiver_location", "Receiver location"),
NumberInput(
"receiver_asl",
"Receiver elevation",
infotext="Elevation in meters above mean see level",
),
TextInput("receiver_admin", "Receiver admin"),
LocationInput("receiver_gps", "Receiver coordinates"),
TextInput("photo_title", "Photo title"),
TextAreaInput("photo_desc", "Photo description"),
),
Section(
"Waterfall settings",
NumberInput(
"fft_fps",
"FFT frames per second",
infotext="This setting specifies how many lines are being added to the waterfall per second. "
+ "Higher values will give you a faster waterfall, but will also use more CPU.",
),
NumberInput("fft_size", "FFT size"),
FloatInput(
"fft_voverlap_factor",
"FFT vertical overlap factor",
infotext="If fft_voverlap_factor is above 0, multiple FFTs will be used for creating a line on the "
+ "diagram.",
),
NumberInput("waterfall_min_level", "Lowest waterfall level"),
NumberInput("waterfall_max_level", "Highest waterfall level"),
),
Section(
"Compression",
DropdownInput(
"audio_compression",
"Audio compression",
options=[
Option("adpcm", "ADPCM"),
Option("none", "None"),
],
),
DropdownInput(
"fft_compression",
"Waterfall compression",
options=[
Option("adpcm", "ADPCM"),
Option("none", "None"),
],
),
),
Section(
"Digimodes",
CheckboxInput("digimodes_enable", "", checkboxText="Enable Digimodes"),
NumberInput("digimodes_fft_size", "Digimodes FFT size"),
),
Section(
"Digital voice",
NumberInput(
"digital_voice_unvoiced_quality",
"Quality of unvoiced sounds in synthesized voice",
infotext="Determines the quality, and thus the cpu usage, for the ambe codec used by digital voice"
+ "modes.<br />If you're running on a Raspi (up to 3B+) you should leave this set at 1",
),
CheckboxInput(
"digital_voice_dmr_id_lookup",
"DMR id lookup",
checkboxText="Enable lookup of DMR ids in the radioid database to show callsigns and names",
),
),
Section(
"Experimental pipe settings",
CheckboxInput(
"csdr_dynamic_bufsize",
"",
checkboxText="Enable dynamic buffer sizes",
infotext="This allows you to change the buffering mode of csdr.",
),
CheckboxInput(
"csdr_print_bufsizes",
"",
checkboxText="Print buffer sizez",
infotext="This prints the buffer sizes used for csdr processes.",
),
CheckboxInput(
"csdr_through",
"",
checkboxText="Print throughput",
infotext="Enabling this will print out how much data is going into the DSP chains.",
),
),
Section(
"Map settings",
TextInput(
"google_maps_api_key",
"Google Maps API key",
infotext="Google Maps requires an API key, check out "
+ '<a href="https://developers.google.com/maps/documentation/embed/get-api-key" target="_blank">'
+ "their documentation</a> on how to obtain one.",
),
NumberInput(
"map_position_retention_time",
"Map retention time",
infotext="Unit is seconds<br/>Specifies how log markers / grids will remain visible on the map",
),
),
Section(
"Decoding settings",
NumberInput("decoding_queue_workers", "Number of decoding workers"),
NumberInput("decoding_queue_length", "Maximum length of decoding job queue"),
NumberInput(
"wsjt_decoding_depth",
"Default WSJT decoding depth",
infotext="A higher decoding depth will allow more results, but will also consume more cpu",
),
NumberInput(
"js8_decoding_depth",
"Js8Call decoding depth",
infotext="A higher decoding depth will allow more results, but will also consume more cpu",
),
Js8ProfileCheckboxInput("js8_enabled_profiles", "Js8Call enabled modes"),
),
Section(
"Background decoding",
CheckboxInput(
"services_enabled",
"Service",
checkboxText="Enable background decoding services",
),
ServicesCheckboxInput("services_decoders", "Enabled services"),
),
Section(
"APRS settings",
TextInput(
"aprs_callsign",
"APRS callsign",
infotext="This callsign will be used to send data to the APRS-IS network",
),
CheckboxInput(
"aprs_igate_enabled",
"APRS I-Gate",
checkboxText="Enable APRS receive-only I-Gate",
),
TextInput("aprs_igate_server", "APRS-IS server"),
TextInput("aprs_igate_password", "APRS-IS network password"),
CheckboxInput(
"aprs_igate_beacon",
"APRS beacon",
checkboxText="Send the receiver position to the APRS-IS network",
infotext="Please check that your receiver location is setup correctly",
),
),
Section(
"pskreporter settings",
CheckboxInput(
"pskreporter_enabled",
"Reporting",
checkboxText="Enable sending spots to pskreporter.info",
),
TextInput(
"pskreporter_callsign",
"pskreporter callsign",
infotext="This callsign will be used to send spots to pskreporter.info",
),
),
]
def render_sections(self):
sections = "".join(section.render() for section in GeneralSettingsController.sections)
return """
<form class="settings-body" method="POST">
{sections}
<div class="buttons">
<button type="submit" class="btn btn-primary">Apply</button>
</div>
</form>
""".format(
sections=sections
)
def indexAction(self):
self.serve_template("generalsettings.html", **self.template_variables())
def template_variables(self):
variables = super().template_variables()
variables["sections"] = self.render_sections()
return variables
def processFormData(self):
data = parse_qs(self.get_body().decode("utf-8"))
data = {k: v for i in GeneralSettingsController.sections for k, v in i.parse(data).items()}
config = Config.get()
for k, v in data.items():
config[k] = v
Config.store()
self.send_redirect("/admin")

View File

@ -0,0 +1,147 @@
from owrx.config import Config
from owrx.controllers.admin import AuthorizationMixin
from owrx.controllers.template import WebpageController
from owrx.breadcrumb import Breadcrumb, BreadcrumbItem, BreadcrumbMixin
from abc import ABCMeta, abstractmethod
from urllib.parse import parse_qs
import logging
logger = logging.getLogger(__name__)
class SettingsController(AuthorizationMixin, WebpageController):
def indexAction(self):
self.serve_template("settings.html", **self.template_variables())
class SettingsFormController(AuthorizationMixin, BreadcrumbMixin, WebpageController, metaclass=ABCMeta):
def __init__(self, handler, request, options):
super().__init__(handler, request, options)
self.errors = {}
self.globalError = None
@abstractmethod
def getSections(self):
pass
@abstractmethod
def getTitle(self):
pass
def getData(self):
return Config.get()
def getErrors(self):
return self.errors
def render_sections(self):
sections = "".join(section.render(self.getData(), self.getErrors()) for section in self.getSections())
buttons = self.render_buttons()
return """
<form class="settings-body" method="POST">
{sections}
<div class="buttons container">
{buttons}
</div>
</form>
""".format(
sections=sections,
buttons=buttons,
)
def render_buttons(self):
return """
<button type="submit" class="btn btn-primary">Apply and save</button>
"""
def indexAction(self):
self.serve_template("settings/general.html", **self.template_variables())
def template_variables(self):
variables = super().template_variables()
variables["content"] = self.render_sections()
variables["title"] = self.getTitle()
variables["modal"] = self.buildModal()
variables["error"] = self.renderGlobalError()
return variables
def parseFormData(self):
data = parse_qs(self.get_body().decode("utf-8"), keep_blank_values=True)
result = {}
errors = []
for section in self.getSections():
section_data, section_errors = section.parse(data)
result.update(section_data)
errors += section_errors
return result, errors
def getSuccessfulRedirect(self):
return self.get_document_root() + self.request.path[1:]
def _mergeErrors(self, errors):
result = {}
for e in errors:
if e.getKey() not in result:
result[e.getKey()] = []
result[e.getKey()].append(e.getMessage())
return result
def processFormData(self):
data = None
errors = None
try:
data, errors = self.parseFormData()
except Exception as e:
logger.exception("Error while parsing form data")
self.globalError = str(e)
return self.indexAction()
if errors:
self.errors = self._mergeErrors(errors)
return self.indexAction()
try:
self.processData(data)
self.store()
self.send_redirect(self.getSuccessfulRedirect())
except Exception as e:
logger.exception("Error while processing form data")
self.globalError = str(e)
return self.indexAction()
def processData(self, data):
config = self.getData()
for k, v in data.items():
if v is None:
if k in config:
del config[k]
else:
config[k] = v
def store(self):
Config.get().store()
def buildModal(self):
return ""
def renderGlobalError(self):
if self.globalError is None:
return ""
return """
<div class="card text-white bg-danger">
<div class="card-header">Error</div>
<div class="card-body">
<div>Your settings could not be saved due to an error:</div>
<div>{error}</div>
</div>
</div>
""".format(
error=self.globalError
)
class SettingsBreadcrumb(Breadcrumb):
def __init__(self):
super().__init__([])
self.append(BreadcrumbItem("Settings", "settings"))

View File

@ -0,0 +1,25 @@
from owrx.controllers.settings import SettingsFormController
from owrx.form.section import Section
from owrx.form.input import CheckboxInput, ServicesCheckboxInput
from owrx.breadcrumb import Breadcrumb, BreadcrumbItem
from owrx.controllers.settings import SettingsBreadcrumb
class BackgroundDecodingController(SettingsFormController):
def getTitle(self):
return "Background decoding"
def get_breadcrumb(self) -> Breadcrumb:
return SettingsBreadcrumb().append(BreadcrumbItem("Background decoding", "settings/backgrounddecoding"))
def getSections(self):
return [
Section(
"Background decoding",
CheckboxInput(
"services_enabled",
"Enable background decoding services",
),
ServicesCheckboxInput("services_decoders", "Enabled services"),
),
]

View File

@ -0,0 +1,148 @@
from owrx.controllers.template import WebpageController
from owrx.controllers.admin import AuthorizationMixin
from owrx.controllers.settings import SettingsBreadcrumb
from owrx.bookmarks import Bookmark, Bookmarks
from owrx.modes import Modes
from owrx.breadcrumb import Breadcrumb, BreadcrumbItem, BreadcrumbMixin
import json
import math
import logging
logger = logging.getLogger(__name__)
class BookmarksController(AuthorizationMixin, BreadcrumbMixin, WebpageController):
def get_breadcrumb(self) -> Breadcrumb:
return SettingsBreadcrumb().append(BreadcrumbItem("Bookmark editor", "settings/bookmarks"))
def template_variables(self):
variables = super().template_variables()
variables["bookmarks"] = self.render_table()
return variables
def render_table(self):
bookmarks = Bookmarks.getSharedInstance().getBookmarks()
emptyText = """
<tr class="emptytext"><td colspan="4">
No bookmarks in storage. You can add new bookmarks using the buttons below.
</td></tr>
"""
return """
<table class="table" data-modes='{modes}'>
<tr>
<th>Name</th>
<th class="frequency">Frequency</th>
<th>Modulation</th>
<th>Actions</th>
</tr>
{bookmarks}
</table>
""".format(
bookmarks="".join(self.render_bookmark(b) for b in bookmarks) if bookmarks else emptyText,
modes=json.dumps({m.modulation: m.name for m in Modes.getAvailableModes()}),
)
def render_bookmark(self, bookmark: Bookmark):
def render_frequency(freq):
suffixes = {
0: "",
3: "k",
6: "M",
9: "G",
12: "T",
}
exp = 0
if freq > 0:
exp = int(math.log10(freq) / 3) * 3
num = freq
suffix = ""
if exp in suffixes:
num = freq / 10 ** exp
suffix = suffixes[exp]
return "{num:g} {suffix}Hz".format(num=num, suffix=suffix)
mode = Modes.findByModulation(bookmark.getModulation())
return """
<tr data-id="{id}">
<td data-editor="name" data-value="{name}">{name}</td>
<td data-editor="frequency" data-value="{frequency}" class="frequency">{rendered_frequency}</td>
<td data-editor="modulation" data-value="{modulation}">{modulation_name}</td>
<td>
<button type="button" class="btn btn-sm btn-danger bookmark-delete">delete</button>
</td>
</tr>
""".format(
id=id(bookmark),
name=bookmark.getName(),
# TODO render frequency in si units
frequency=bookmark.getFrequency(),
rendered_frequency=render_frequency(bookmark.getFrequency()),
modulation=bookmark.getModulation() if mode is None else mode.modulation,
modulation_name=bookmark.getModulation() if mode is None else mode.name,
)
def _findBookmark(self, bookmark_id):
bookmarks = Bookmarks.getSharedInstance()
try:
return next(b for b in bookmarks.getBookmarks() if id(b) == bookmark_id)
except StopIteration:
return None
def update(self):
bookmark_id = int(self.request.matches.group(1))
bookmark = self._findBookmark(bookmark_id)
if bookmark is None:
self.send_response("{}", content_type="application/json", code=404)
return
try:
data = json.loads(self.get_body().decode("utf-8"))
for key in ["name", "frequency", "modulation"]:
if key in data:
value = data[key]
if key == "frequency":
value = int(value)
setattr(bookmark, key, value)
Bookmarks.getSharedInstance().store()
# TODO this should not be called explicitly... bookmarks don't have any event capability right now, though
Bookmarks.getSharedInstance().notifySubscriptions(bookmark)
self.send_response("{}", content_type="application/json", code=200)
except json.JSONDecodeError:
self.send_response("{}", content_type="application/json", code=400)
def new(self):
bookmarks = Bookmarks.getSharedInstance()
def create(bookmark_data):
# sanitize
data = {
"name": bookmark_data["name"],
"frequency": int(bookmark_data["frequency"]),
"modulation": bookmark_data["modulation"],
}
bookmark = Bookmark(data)
bookmarks.addBookmark(bookmark)
return {"bookmark_id": id(bookmark)}
try:
data = json.loads(self.get_body().decode("utf-8"))
result = [create(b) for b in data]
bookmarks.store()
self.send_response(json.dumps(result), content_type="application/json", code=200)
except json.JSONDecodeError:
self.send_response("{}", content_type="application/json", code=400)
def delete(self):
bookmark_id = int(self.request.matches.group(1))
bookmark = self._findBookmark(bookmark_id)
if bookmark is None:
self.send_response("{}", content_type="application/json", code=404)
return
bookmarks = Bookmarks.getSharedInstance()
bookmarks.removeBookmark(bookmark)
bookmarks.store()
self.send_response("{}", content_type="application/json", code=200)
def indexAction(self):
self.serve_template("settings/bookmarks.html", **self.template_variables())

View File

@ -0,0 +1,97 @@
from owrx.controllers.settings import SettingsFormController, SettingsBreadcrumb
from owrx.form.section import Section
from owrx.form.input import CheckboxInput, NumberInput, DropdownInput, Js8ProfileCheckboxInput, MultiCheckboxInput, Option, TextInput
from owrx.form.input.wfm import WfmTauValues
from owrx.form.input.wsjt import Q65ModeMatrix, WsjtDecodingDepthsInput
from owrx.form.input.converter import OptionalConverter
from owrx.wsjt import Fst4Profile, Fst4wProfile
from owrx.breadcrumb import Breadcrumb, BreadcrumbItem
class DecodingSettingsController(SettingsFormController):
def getTitle(self):
return "Demodulation and decoding"
def get_breadcrumb(self) -> Breadcrumb:
return SettingsBreadcrumb().append(BreadcrumbItem("Demodulation and decoding", "settings/decoding"))
def getSections(self):
return [
Section(
"Demodulator settings",
NumberInput(
"squelch_auto_margin",
"Auto-Squelch threshold",
infotext="Offset to be added to the current signal level when using the auto-squelch",
append="dB",
),
DropdownInput(
"wfm_deemphasis_tau",
"Tau setting for WFM (broadcast FM) deemphasis",
WfmTauValues,
infotext='See <a href="https://en.wikipedia.org/wiki/FM_broadcasting#Pre-emphasis_and_de-emphasis"'
+ ' target="_blank">this Wikipedia article</a> for more information',
),
),
Section(
"Digital voice",
NumberInput(
"digital_voice_unvoiced_quality",
"Quality of unvoiced sounds in synthesized voice",
infotext="Determines the quality, and thus the cpu usage, for the ambe codec used by digital voice"
+ " modes.<br />If you're running on a Raspberry Pi (up to 3B+) you should leave this set at 1",
),
TextInput(
"digital_voice_codecserver",
"Codecserver address",
infotext="Address of a remote codecserver instance (name[:port]). Leave empty to use local"
+ " codecserver",
converter=OptionalConverter(),
),
CheckboxInput(
"digital_voice_dmr_id_lookup",
'Enable lookup of DMR ids in the <a href="https://www.radioid.net/" target="_blank">'
+ "radioid</a> database to show callsigns and names",
),
CheckboxInput(
"digital_voice_nxdn_id_lookup",
'Enable lookup of NXDN ids in the <a href="https://www.radioid.net/" target="_blank">'
+ "radioid</a> database to show callsigns and names",
),
),
Section(
"Digimodes",
NumberInput("digimodes_fft_size", "Digimodes FFT size", append="bins"),
),
Section(
"Decoding settings",
NumberInput("decoding_queue_workers", "Number of decoding workers"),
NumberInput("decoding_queue_length", "Maximum length of decoding job queue"),
NumberInput(
"wsjt_decoding_depth",
"Default WSJT decoding depth",
infotext="A higher decoding depth will allow more results, but will also consume more cpu",
),
WsjtDecodingDepthsInput(
"wsjt_decoding_depths",
"Individual decoding depths",
),
NumberInput(
"js8_decoding_depth",
"Js8Call decoding depth",
infotext="A higher decoding depth will allow more results, but will also consume more cpu",
),
Js8ProfileCheckboxInput("js8_enabled_profiles", "Js8Call enabled modes"),
MultiCheckboxInput(
"fst4_enabled_intervals",
"Enabled FST4 intervals",
[Option(v, "{}s".format(v)) for v in Fst4Profile.availableIntervals],
),
MultiCheckboxInput(
"fst4w_enabled_intervals",
"Enabled FST4W intervals",
[Option(v, "{}s".format(v)) for v in Fst4wProfile.availableIntervals],
),
Q65ModeMatrix("q65_enabled_combinations", "Enabled Q65 Mode combinations"),
),
]

View File

@ -0,0 +1,218 @@
from owrx.controllers.settings import SettingsFormController
from owrx.form.section import Section
from owrx.config.core import CoreConfig
from owrx.form.input import (
TextInput,
NumberInput,
FloatInput,
LocationInput,
TextAreaInput,
DropdownInput,
Option,
)
from owrx.form.input.converter import WaterfallColorsConverter, IntConverter
from owrx.form.input.receiverid import ReceiverKeysConverter
from owrx.form.input.gfx import AvatarInput, TopPhotoInput
from owrx.form.input.device import WaterfallLevelsInput, WaterfallAutoLevelsInput
from owrx.waterfall import WaterfallOptions
from owrx.breadcrumb import Breadcrumb, BreadcrumbItem
from owrx.controllers.settings import SettingsBreadcrumb
import shutil
import os
import re
from glob import glob
import logging
logger = logging.getLogger(__name__)
class GeneralSettingsController(SettingsFormController):
def getTitle(self):
return "General Settings"
def get_breadcrumb(self) -> Breadcrumb:
return SettingsBreadcrumb().append(BreadcrumbItem("General Settings", "settings/general"))
def getSections(self):
return [
Section(
"Receiver information",
TextInput("receiver_name", "Receiver name"),
TextInput("receiver_location", "Receiver location"),
NumberInput(
"receiver_asl",
"Receiver elevation",
append="meters above mean sea level",
),
TextInput("receiver_admin", "Receiver admin"),
LocationInput("receiver_gps", "Receiver coordinates"),
TextInput("photo_title", "Photo title"),
TextAreaInput("photo_desc", "Photo description"),
),
Section(
"Receiver images",
AvatarInput(
"receiver_avatar",
"Receiver Avatar",
infotext="For performance reasons, images are cached. "
+ "It can take a few hours until they appear on the site.",
),
TopPhotoInput(
"receiver_top_photo",
"Receiver Panorama",
infotext="For performance reasons, images are cached. "
+ "It can take a few hours until they appear on the site.",
),
),
Section(
"Receiver limits",
NumberInput(
"max_clients",
"Maximum number of clients",
),
),
Section(
"Receiver listings",
TextAreaInput(
"receiver_keys",
"Receiver keys",
converter=ReceiverKeysConverter(),
infotext="Put the keys you receive on listing sites (e.g. "
+ '<a href="https://www.receiverbook.de" target="_blank">Receiverbook</a>) here, one per line',
),
),
Section(
"Waterfall settings",
DropdownInput(
"waterfall_scheme",
"Waterfall color scheme",
options=WaterfallOptions,
),
TextAreaInput(
"waterfall_colors",
"Custom waterfall colors",
infotext="Please provide 6-digit hexadecimal RGB colors in HTML notation (#RRGGBB)"
+ " or HEX notation (0xRRGGBB), one per line",
converter=WaterfallColorsConverter(),
),
NumberInput(
"fft_fps",
"FFT speed",
infotext="This setting specifies how many lines are being added to the waterfall per second. "
+ "Higher values will give you a faster waterfall, but will also use more CPU.",
append="frames per second",
),
NumberInput("fft_size", "FFT size", append="bins"),
FloatInput(
"fft_voverlap_factor",
"FFT vertical overlap factor",
infotext="If fft_voverlap_factor is above 0, multiple FFTs will be used for creating a line on the "
+ "diagram.",
),
WaterfallLevelsInput("waterfall_levels", "Waterfall levels"),
WaterfallAutoLevelsInput(
"waterfall_auto_levels",
"Automatic adjustment margins",
infotext="Specifies the upper and lower dynamic headroom that should be added when automatically "
+ "adjusting waterfall colors",
),
NumberInput(
"waterfall_auto_min_range",
"Automatic adjustment minimum range",
append="dB",
infotext="Minimum dynamic range the waterfall should cover after automatically adjusting "
+ "waterfall colors",
),
),
Section(
"Compression",
DropdownInput(
"audio_compression",
"Audio compression",
options=[
Option("adpcm", "ADPCM"),
Option("none", "None"),
],
),
DropdownInput(
"fft_compression",
"Waterfall compression",
options=[
Option("adpcm", "ADPCM"),
Option("none", "None"),
],
),
),
Section(
"Display settings",
DropdownInput(
"tuning_precision",
"Tuning precision",
options=[Option(str(i), "{} Hz".format(10 ** i)) for i in range(0, 6)],
converter=IntConverter(),
),
),
Section(
"Map settings",
TextInput(
"google_maps_api_key",
"Google Maps API key",
infotext="Google Maps requires an API key, check out "
+ '<a href="https://developers.google.com/maps/documentation/embed/get-api-key" target="_blank">'
+ "their documentation</a> on how to obtain one.",
),
NumberInput(
"map_position_retention_time",
"Map retention time",
infotext="Specifies how log markers / grids will remain visible on the map",
append="s",
),
),
]
def remove_existing_image(self, image_id):
config = CoreConfig()
# remove all possible file extensions
for ext in ["png", "jpg", "webp"]:
try:
os.unlink("{}/{}.{}".format(config.get_data_directory(), image_id, ext))
except FileNotFoundError:
pass
def handle_image(self, data, image_id):
if image_id in data:
config = CoreConfig()
if data[image_id] == "restore":
self.remove_existing_image(image_id)
elif data[image_id]:
if not data[image_id].startswith(image_id):
logger.warning("invalid file name: %s", data[image_id])
else:
# get file extension (at least 3 characters)
# should be all lowercase since they are set by the upload script
pattern = re.compile(".*\\.([a-z]{3,})$")
matches = pattern.match(data[image_id])
if matches is None:
logger.warning("could not determine file extension for %s", image_id)
else:
self.remove_existing_image(image_id)
ext = matches.group(1)
data_file = "{}/{}.{}".format(config.get_data_directory(), image_id, ext)
temporary_file = "{}/{}".format(config.get_temporary_directory(), data[image_id])
shutil.copy(temporary_file, data_file)
del data[image_id]
# remove any accumulated temporary files on save
for file in glob("{}/{}*".format(config.get_temporary_directory(), image_id)):
os.unlink(file)
def processData(self, data):
# Image handling
for img in ["receiver_avatar", "receiver_top_photo"]:
self.handle_image(data, img)
# special handling for waterfall colors: custom colors only stay in config if custom color scheme is selected
if "waterfall_scheme" in data:
scheme = WaterfallOptions(data["waterfall_scheme"])
if scheme is not WaterfallOptions.CUSTOM and "waterfall_colors" in data:
data["waterfall_colors"] = None
super().processData(data)

View File

@ -0,0 +1,93 @@
from owrx.controllers.settings import SettingsFormController, SettingsBreadcrumb
from owrx.form.section import Section
from owrx.form.input.converter import OptionalConverter
from owrx.form.input.aprs import AprsBeaconSymbols, AprsAntennaDirections
from owrx.form.input import TextInput, CheckboxInput, DropdownInput, NumberInput
from owrx.breadcrumb import Breadcrumb, BreadcrumbItem
class ReportingController(SettingsFormController):
def getTitle(self):
return "Spotting and reporting"
def get_breadcrumb(self) -> Breadcrumb:
return SettingsBreadcrumb().append(BreadcrumbItem("Spotting and reporting", "settings/reporting"))
def getSections(self):
return [
Section(
"APRS-IS reporting",
CheckboxInput(
"aprs_igate_enabled",
"Send received APRS data to APRS-IS",
infotext="Due to limits of the APRS-IS network, reporting will only work for background decoders"
),
TextInput(
"aprs_callsign",
"APRS callsign",
infotext="This callsign will be used to send data to the APRS-IS network",
),
TextInput("aprs_igate_server", "APRS-IS server"),
TextInput("aprs_igate_password", "APRS-IS network password"),
CheckboxInput(
"aprs_igate_beacon",
"Send the receiver position to the APRS-IS network",
infotext="Please check that your receiver location is setup correctly before enabling the beacon",
),
DropdownInput(
"aprs_igate_symbol",
"APRS beacon symbol",
AprsBeaconSymbols,
),
TextInput(
"aprs_igate_comment",
"APRS beacon text",
infotext="This text will be sent as APRS comment along with your beacon",
converter=OptionalConverter(),
),
NumberInput(
"aprs_igate_height",
"Antenna height",
infotext="Antenna height above average terrain (HAAT)",
append="m",
converter=OptionalConverter(),
),
NumberInput(
"aprs_igate_gain",
"Antenna gain",
append="dBi",
converter=OptionalConverter(),
),
DropdownInput("aprs_igate_dir", "Antenna direction", AprsAntennaDirections),
),
Section(
"pskreporter settings",
CheckboxInput(
"pskreporter_enabled",
"Enable sending spots to pskreporter.info",
),
TextInput(
"pskreporter_callsign",
"pskreporter callsign",
infotext="This callsign will be used to send spots to pskreporter.info",
),
TextInput(
"pskreporter_antenna_information",
"Antenna information",
infotext="Antenna description to be sent along with spots to pskreporter",
converter=OptionalConverter(),
),
),
Section(
"WSPRnet settings",
CheckboxInput(
"wsprnet_enabled",
"Enable sending spots to wsprnet.org",
),
TextInput(
"wsprnet_callsign",
"wsprnet callsign",
infotext="This callsign will be used to send spots to wsprnet.org",
),
),
]

View File

@ -0,0 +1,433 @@
from owrx.controllers.admin import AuthorizationMixin
from owrx.controllers.template import WebpageController
from owrx.controllers.settings import SettingsFormController
from owrx.source import SdrDeviceDescription, SdrDeviceDescriptionMissing, SdrClientClass
from owrx.config import Config
from owrx.connection import OpenWebRxReceiverClient
from owrx.controllers.settings import SettingsBreadcrumb
from owrx.form.section import Section
from urllib.parse import quote, unquote
from owrx.sdr import SdrService
from owrx.form.input import TextInput, DropdownInput, Option
from owrx.form.input.validator import RequiredValidator
from owrx.property import PropertyLayer
from owrx.breadcrumb import BreadcrumbMixin, Breadcrumb, BreadcrumbItem
from abc import ABCMeta, abstractmethod
from uuid import uuid4
class SdrDeviceBreadcrumb(SettingsBreadcrumb):
def __init__(self):
super().__init__()
self.append(BreadcrumbItem("SDR device settings", "settings/sdr"))
class SdrDeviceListController(AuthorizationMixin, BreadcrumbMixin, WebpageController):
def template_variables(self):
variables = super().template_variables()
variables["content"] = self.render_devices()
variables["title"] = "SDR device settings"
variables["modal"] = ""
variables["error"] = ""
return variables
def get_breadcrumb(self):
return SdrDeviceBreadcrumb()
def render_devices(self):
def render_device(device_id, config):
sources = SdrService.getAllSources()
source = sources[device_id] if device_id in sources else None
additional_info = ""
state_info = "Unknown"
if source is not None:
profiles = source.getProfiles()
currentProfile = profiles[source.getProfileId()]
clients = {c: len(source.getClients(c)) for c in SdrClientClass}
clients = {c: v for c, v in clients.items() if v}
connections = len([c for c in source.getClients() if isinstance(c, OpenWebRxReceiverClient)])
additional_info = """
<div>{num_profiles} profile(s)</div>
<div>Current profile: {current_profile}</div>
<div>Clients: {clients}</div>
<div>Connections: {connections}</div>
""".format(
num_profiles=len(config["profiles"]),
current_profile=currentProfile["name"],
clients=", ".join("{cls}: {count}".format(cls=c.name, count=v) for c, v in clients.items()),
connections=connections,
)
state_info = ", ".join(
s
for s in [
str(source.getState()),
None if source.isEnabled() else "Disabled",
"Failed" if source.isFailed() else None,
]
if s is not None
)
return """
<li class="list-group-item">
<div class="row">
<div class="col-6">
<a href="{device_link}">
<h3>{device_name}</h3>
</a>
<div>State: {state}</div>
</div>
<div class="col-6">
{additional_info}
</div>
</div>
</li>
""".format(
device_name=config["name"] if config["name"] else "[Unnamed device]",
device_link="{}settings/sdr/{}".format(self.get_document_root(), quote(device_id)),
state=state_info,
additional_info=additional_info,
)
return """
<ul class="list-group list-group-flush sdr-device-list">
{devices}
</ul>
<div class="buttons container">
<a class="btn btn-success" href="newsdr">Add new device...</a>
</div>
""".format(
devices="".join(render_device(key, value) for key, value in Config.get()["sdrs"].items())
)
def indexAction(self):
self.serve_template("settings/general.html", **self.template_variables())
class SdrFormController(SettingsFormController, metaclass=ABCMeta):
def __init__(self, handler, request, options):
super().__init__(handler, request, options)
self.device_id, self.device = self._get_device()
def getTitle(self):
return self.device["name"]
def render_sections(self):
return """
{tabs}
<div class="tab-body">
{sections}
</div>
""".format(
tabs=self.render_tabs(),
sections=super().render_sections(),
)
def render_tabs(self):
return """
<ul class="nav nav-tabs">
<li class="nav-item">
<a class="nav-link {device_active}" href="{device_link}">{device_name}</a>
</li>
{profile_tabs}
<li class="nav-item">
<a href="{new_profile_link}" class="nav-link {new_profile_active}">New profile</a>
</li>
</ul>
""".format(
device_link="{}settings/sdr/{}".format(self.get_document_root(), quote(self.device_id)),
device_name=self.device["name"] if self.device["name"] else "[Unnamed device]",
device_active="active" if self.isDeviceActive() else "",
new_profile_active="active" if self.isNewProfileActive() else "",
new_profile_link="{}settings/sdr/{}/newprofile".format(self.get_document_root(), quote(self.device_id)),
profile_tabs="".join(
"""
<li class="nav-item">
<a class="nav-link {profile_active}" href="{profile_link}">{profile_name}</a>
</li>
""".format(
profile_link="{}settings/sdr/{}/profile/{}".format(
self.get_document_root(), quote(self.device_id), quote(profile_id)
),
profile_name=profile["name"] if profile["name"] else "[Unnamed profile]",
profile_active="active" if self.isProfileActive(profile_id) else "",
)
for profile_id, profile in self.device["profiles"].items()
),
)
def isDeviceActive(self) -> bool:
return False
def isProfileActive(self, profile_id) -> bool:
return False
def isNewProfileActive(self) -> bool:
return False
def store(self):
# need to overwrite the existing key in the config since the layering won't capture the changes otherwise
config = Config.get()
sdrs = config["sdrs"]
sdrs[self.device_id] = self.device
config["sdrs"] = sdrs
super().store()
def _get_device(self):
config = Config.get()
device_id = unquote(self.request.matches.group(1))
if device_id not in config["sdrs"]:
return None, None
return device_id, config["sdrs"][device_id]
class SdrFormControllerWithModal(SdrFormController, metaclass=ABCMeta):
def render_remove_button(self):
return ""
def render_buttons(self):
return self.render_remove_button() + super().render_buttons()
def buildModal(self):
return """
<div class="modal" id="deleteModal" tabindex="-1" role="dialog">
<div class="modal-dialog" role="document">
<div class="modal-content">
<div class="modal-header">
<h5>Please confirm</h5>
<button type="button" class="close" data-dismiss="modal" aria-label="Close">
<span aria-hidden="true">&times;</span>
</button>
</div>
<div class="modal-body">
<p>Do you really want to delete this {object_type}?</p>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-dismiss="modal">Cancel</button>
<a type="button" class="btn btn-danger" href="{confirm_url}">Delete</a>
</div>
</div>
</div>
</div>
""".format(
object_type=self.getModalObjectType(),
confirm_url=self.getModalConfirmUrl(),
)
@abstractmethod
def getModalObjectType(self):
pass
@abstractmethod
def getModalConfirmUrl(self):
pass
class SdrDeviceController(SdrFormControllerWithModal):
def get_breadcrumb(self) -> Breadcrumb:
return SdrDeviceBreadcrumb().append(
BreadcrumbItem(self.device["name"], "settings/sdr/{}".format(self.device_id))
)
def getData(self):
return self.device
def getSections(self):
try:
description = SdrDeviceDescription.getByType(self.device["type"])
return [description.getDeviceSection()]
except SdrDeviceDescriptionMissing:
# TODO provide a generic interface that allows to switch the type
return []
def render_remove_button(self):
return """
<button type="button" class="btn btn-danger" data-toggle="modal" data-target="#deleteModal">Remove device...</button>
"""
def isDeviceActive(self) -> bool:
return True
def indexAction(self):
if self.device is None:
self.send_response("device not found", code=404)
return
super().indexAction()
def processFormData(self):
if self.device is None:
self.send_response("device not found", code=404)
return
return super().processFormData()
def getModalObjectType(self):
return "SDR device"
def getModalConfirmUrl(self):
return "{}settings/deletesdr/{}".format(self.get_document_root(), quote(self.device_id))
def deleteDevice(self):
if self.device_id is None:
return self.send_response("device not found", code=404)
config = Config.get()
sdrs = config["sdrs"]
del sdrs[self.device_id]
# need to overwrite the existing key in the config since the layering won't capture the changes otherwise
config["sdrs"] = sdrs
config.store()
return self.send_redirect("{}settings/sdr".format(self.get_document_root()))
class NewSdrDeviceController(SettingsFormController):
def __init__(self, handler, request, options):
super().__init__(handler, request, options)
self.data_layer = PropertyLayer(name="", type="", profiles=PropertyLayer())
self.device_id = str(uuid4())
def get_breadcrumb(self) -> Breadcrumb:
return SdrDeviceBreadcrumb().append(BreadcrumbItem("New device", "settings/sdr/newsdr"))
def getSections(self):
return [
Section(
"New device settings",
TextInput("name", "Device name", validator=RequiredValidator()),
DropdownInput(
"type",
"Device type",
[Option(sdr_type, name) for sdr_type, name in SdrDeviceDescription.getTypes().items()],
infotext="Note: Switching the type will not be possible after creation since the set of available "
+ "options is different for each type.<br />Note: This dropdown only shows device types that have "
+ "their requirements met. If a type is missing from the list, please check the feature report.",
),
)
]
def getTitle(self):
return "New device"
def getData(self):
return self.data_layer
def store(self):
# need to overwrite the existing key in the config since the layering won't capture the changes otherwise
config = Config.get()
sdrs = config["sdrs"]
# a uuid should be unique, so i'm not sure if there's a point in this check
if self.device_id in sdrs:
raise ValueError("device {} already exists!".format(self.device_id))
sdrs[self.device_id] = self.data_layer
config["sdrs"] = sdrs
super().store()
def getSuccessfulRedirect(self):
return "{}settings/sdr/{}".format(self.get_document_root(), quote(self.device_id))
class SdrProfileController(SdrFormControllerWithModal):
def __init__(self, handler, request, options):
super().__init__(handler, request, options)
self.profile_id, self.profile = self._get_profile()
def get_breadcrumb(self) -> Breadcrumb:
return (
SdrDeviceBreadcrumb()
.append(BreadcrumbItem(self.device["name"], "settings/sdr/{}".format(self.device_id)))
.append(
BreadcrumbItem(
self.profile["name"], "settings/sdr/{}/profile/{}".format(self.device_id, self.profile_id)
)
)
)
def getData(self):
return self.profile
def _get_profile(self):
if self.device is None:
return None
profile_id = unquote(self.request.matches.group(2))
if profile_id not in self.device["profiles"]:
return None
return profile_id, self.device["profiles"][profile_id]
def isProfileActive(self, profile_id) -> bool:
return profile_id == self.profile_id
def getSections(self):
try:
description = SdrDeviceDescription.getByType(self.device["type"])
return [description.getProfileSection()]
except SdrDeviceDescriptionMissing:
# TODO provide a generic interface that allows to switch the type
return []
def indexAction(self):
if self.profile is None:
self.send_response("profile not found", code=404)
return
super().indexAction()
def processFormData(self):
if self.profile is None:
self.send_response("profile not found", code=404)
return
return super().processFormData()
def render_remove_button(self):
return """
<button type="button" class="btn btn-danger" data-toggle="modal" data-target="#deleteModal">Remove profile...</button>
"""
def getModalObjectType(self):
return "profile"
def getModalConfirmUrl(self):
return "{}settings/sdr/{}/deleteprofile/{}".format(
self.get_document_root(), quote(self.device_id), quote(self.profile_id)
)
def deleteProfile(self):
if self.profile_id is None:
return self.send_response("profile not found", code=404)
config = Config.get()
del self.device["profiles"][self.profile_id]
config.store()
return self.send_redirect("{}settings/sdr/{}".format(self.get_document_root(), quote(self.device_id)))
class NewProfileController(SdrProfileController):
def __init__(self, handler, request, options):
self.data_layer = PropertyLayer(name="")
super().__init__(handler, request, options)
def get_breadcrumb(self) -> Breadcrumb:
return (
SdrDeviceBreadcrumb()
.append(BreadcrumbItem(self.device["name"], "settings/sdr/{}".format(self.device_id)))
.append(BreadcrumbItem("New profile", "settings/sdr/{}/newprofile".format(self.device_id)))
)
def _get_profile(self):
return str(uuid4()), self.data_layer
def isNewProfileActive(self) -> bool:
return True
def store(self):
# a uuid should be unique, so i'm not sure if there's a point in this check
if self.profile_id in self.device["profiles"]:
raise ValueError("Profile {} already exists!".format(self.profile_id))
self.device["profiles"][self.profile_id] = self.data_layer
super().store()
def getSuccessfulRedirect(self):
return "{}settings/sdr/{}/profile/{}".format(
self.get_document_root(), quote(self.device_id), quote(self.profile_id)
)
def render_remove_button(self):
# new profile doesn't have a remove button
return ""

View File

@ -2,6 +2,7 @@ from .receiverid import ReceiverIdController
from owrx.version import openwebrx_version
from owrx.sdr import SdrService
from owrx.config import Config
from owrx.jsons import Encoder
import json
import logging
@ -38,6 +39,6 @@ class StatusController(ReceiverIdController):
},
"max_clients": pm["max_clients"],
"version": openwebrx_version,
"sdrs": [self.getReceiverStats(r) for r in SdrService.getSources().values()],
"sdrs": [self.getReceiverStats(r) for r in SdrService.getActiveSources().values()],
}
self.send_response(json.dumps(status), content_type="application/json")
self.send_response(json.dumps(status, cls=Encoder), content_type="application/json")

View File

@ -1,7 +1,7 @@
from . import Controller
import pkg_resources
from owrx.controllers import Controller
from owrx.details import ReceiverDetails
from string import Template
from owrx.config import Config
import pkg_resources
class TemplateController(Controller):
@ -19,13 +19,19 @@ class TemplateController(Controller):
class WebpageController(TemplateController):
def get_document_root(self):
path_parts = [part for part in self.request.path[1:].split("/")]
levels = max(0, len(path_parts) - 1)
return "../" * levels
def header_variables(self):
variables = {"document_root": self.get_document_root()}
variables.update(ReceiverDetails().__dict__())
return variables
def template_variables(self):
settingslink = ""
pm = Config.get()
if "webadmin_enabled" in pm and pm["webadmin_enabled"]:
settingslink = """<a class="button" href="settings" target="openwebrx-settings"><span class="sprite sprite-panel-settings"></span><br/>Settings</a>"""
header = self.render_template("include/header.include.html", settingslink=settingslink)
return {"header": header}
header = self.render_template("include/header.include.html", **self.header_variables())
return {"header": header, "document_root": self.get_document_root()}
class IndexController(WebpageController):
@ -37,8 +43,3 @@ class MapController(WebpageController):
def indexAction(self):
# TODO check if we have a google maps api key first?
self.serve_template("map.html", **self.template_variables())
class FeatureController(WebpageController):
def indexAction(self):
self.serve_template("features.html", **self.template_variables())

View File

@ -1,10 +1,10 @@
from . import Controller
from owrx.websocket import WebSocketConnection
from owrx.connection import WebSocketMessageHandler
from owrx.connection import HandshakeMessageHandler
class WebSocketController(Controller):
def indexAction(self):
conn = WebSocketConnection(self.handler, WebSocketMessageHandler())
conn = WebSocketConnection(self.handler, HandshakeMessageHandler())
# enter read loop
conn.handle()

View File

@ -1,18 +1,21 @@
from owrx.config import Config
from owrx.locator import Locator
from owrx.property import PropertyFilter
from owrx.property.filter import ByPropertyName
class ReceiverDetails(PropertyFilter):
def __init__(self):
super().__init__(
Config.get(),
"receiver_name",
"receiver_location",
"receiver_asl",
"receiver_gps",
"photo_title",
"photo_desc",
ByPropertyName(
"receiver_name",
"receiver_location",
"receiver_asl",
"receiver_gps",
"photo_title",
"photo_desc",
)
)
def __dict__(self):

View File

@ -3,18 +3,31 @@ from owrx.wsjt import WsjtParser
from owrx.js8 import Js8Parser
from owrx.aprs import AprsParser
from owrx.pocsag import PocsagParser
from owrx.source import SdrSource, SdrSourceEventClient
from owrx.property import PropertyStack, PropertyLayer
from owrx.source import SdrSourceEventClient, SdrSourceState, SdrClientClass
from owrx.property import PropertyStack, PropertyLayer, PropertyValidator
from owrx.property.validators import OrValidator, RegexValidator, BoolValidator
from owrx.modes import Modes
from csdr import csdr
from owrx.config.core import CoreConfig
from csdr.output import Output
from csdr import Dsp
import threading
import re
import logging
logger = logging.getLogger(__name__)
class DspManager(csdr.output, SdrSourceEventClient):
class ModulationValidator(OrValidator):
"""
This validator only allows alphanumeric characters and numbers, but no spaces or special characters
"""
def __init__(self):
super().__init__(BoolValidator(), RegexValidator(re.compile("^[a-z0-9]+$")))
class DspManager(Output, SdrSourceEventClient):
def __init__(self, handler, sdrSource):
self.handler = handler
self.sdrSource = sdrSource
@ -27,22 +40,25 @@ class DspManager(csdr.output, SdrSourceEventClient):
}
self.props = PropertyStack()
# local demodulator properties not forwarded to the sdr
self.props.addLayer(
0,
PropertyLayer().filter(
"output_rate",
"hd_output_rate",
"squelch_level",
"secondary_mod",
"low_cut",
"high_cut",
"offset_freq",
"mod",
"secondary_offset_freq",
"dmr_filter",
),
)
# ensure strict validation since these can be set from the client
# and are used to build executable commands
validators = {
"output_rate": "int",
"hd_output_rate": "int",
"squelch_level": "num",
"secondary_mod": ModulationValidator(),
"low_cut": "num",
"high_cut": "num",
"offset_freq": "int",
"mod": ModulationValidator(),
"secondary_offset_freq": "int",
"dmr_filter": "int",
}
self.localProps = PropertyValidator(PropertyLayer().filter(*validators.keys()), validators)
self.props.addLayer(0, self.localProps)
# properties that we inherit from the sdr
self.props.addLayer(
1,
@ -50,21 +66,17 @@ class DspManager(csdr.output, SdrSourceEventClient):
"audio_compression",
"fft_compression",
"digimodes_fft_size",
"csdr_dynamic_bufsize",
"csdr_print_bufsizes",
"csdr_through",
"digimodes_enable",
"samp_rate",
"digital_voice_unvoiced_quality",
"temporary_directory",
"center_freq",
"start_mod",
"start_freq",
"wfm_deemphasis_tau",
"digital_voice_codecserver",
),
)
self.dsp = csdr.dsp(self)
self.dsp = Dsp(self)
self.dsp.nc_port = self.sdrSource.getPort()
def set_low_cut(cut):
@ -118,34 +130,34 @@ class DspManager(csdr.output, SdrSourceEventClient):
self.props.wireProperty("mod", self.dsp.set_demodulator),
self.props.wireProperty("digital_voice_unvoiced_quality", self.dsp.set_unvoiced_quality),
self.props.wireProperty("dmr_filter", self.dsp.set_dmr_filter),
self.props.wireProperty("temporary_directory", self.dsp.set_temporary_directory),
self.props.wireProperty("wfm_deemphasis_tau", self.dsp.set_wfm_deemphasis_tau),
self.props.wireProperty("digital_voice_codecserver", self.dsp.set_codecserver),
self.props.filter("center_freq", "offset_freq").wire(set_dial_freq),
]
self.dsp.csdr_dynamic_bufsize = self.props["csdr_dynamic_bufsize"]
self.dsp.csdr_print_bufsizes = self.props["csdr_print_bufsizes"]
self.dsp.csdr_through = self.props["csdr_through"]
self.dsp.set_temporary_directory(CoreConfig().get_temporary_directory())
if self.props["digimodes_enable"]:
def send_secondary_config(*args):
self.handler.write_secondary_dsp_config(
{
"secondary_fft_size": self.props["digimodes_fft_size"],
"if_samp_rate": self.dsp.if_samp_rate(),
"secondary_bw": self.dsp.secondary_bw(),
}
)
def set_secondary_mod(mod):
if mod == False:
mod = None
self.dsp.set_secondary_demodulator(mod)
if mod is not None:
self.handler.write_secondary_dsp_config(
{
"secondary_fft_size": self.props["digimodes_fft_size"],
"if_samp_rate": self.dsp.if_samp_rate(),
"secondary_bw": self.dsp.secondary_bw(),
}
)
def set_secondary_mod(mod):
if mod == False:
mod = None
self.dsp.set_secondary_demodulator(mod)
if mod is not None:
send_secondary_config()
self.subscriptions += [
self.props.wireProperty("secondary_mod", set_secondary_mod),
self.props.wireProperty("secondary_offset_freq", self.dsp.set_secondary_offset_freq),
]
self.subscriptions += [
self.props.wireProperty("secondary_mod", set_secondary_mod),
self.props.wireProperty("digimodes_fft_size", send_secondary_config),
self.props.wireProperty("secondary_offset_freq", self.dsp.set_secondary_offset_freq),
]
self.startOnAvailable = False
@ -188,23 +200,24 @@ class DspManager(csdr.output, SdrSourceEventClient):
self.setProperty(k, v)
def setProperty(self, prop, value):
self.props[prop] = value
self.localProps[prop] = value
def getClientClass(self):
return SdrSource.CLIENT_USER
def getClientClass(self) -> SdrClientClass:
return SdrClientClass.USER
def onStateChange(self, state):
if state == SdrSource.STATE_RUNNING:
def onStateChange(self, state: SdrSourceState):
if state is SdrSourceState.RUNNING:
logger.debug("received STATE_RUNNING, attempting DspSource restart")
if self.startOnAvailable:
self.dsp.start()
self.startOnAvailable = False
elif state == SdrSource.STATE_STOPPING:
elif state is SdrSourceState.STOPPING:
logger.debug("received STATE_STOPPING, shutting down DspSource")
self.dsp.stop()
elif state == SdrSource.STATE_FAILED:
logger.debug("received STATE_FAILED, shutting down DspSource")
self.dsp.stop()
def onBusyStateChange(self, state):
pass
def onFail(self):
logger.debug("received onFail(), shutting down DspSource")
self.dsp.stop()
def onShutdown(self):
self.dsp.stop()

View File

@ -4,6 +4,7 @@ from operator import and_
import re
from distutils.version import LooseVersion
import inspect
from owrx.config.core import CoreConfig
from owrx.config import Config
import shlex
import os
@ -65,19 +66,18 @@ class FeatureDetector(object):
"pluto_sdr": ["soapy_connector", "soapy_pluto_sdr"],
"soapy_remote": ["soapy_connector", "soapy_remote"],
"uhd": ["soapy_connector", "soapy_uhd"],
"red_pitaya": ["soapy_connector", "soapy_red_pitaya"],
"radioberry": ["soapy_connector", "soapy_radioberry"],
"fcdpp": ["soapy_connector", "soapy_fcdpp"],
"sddc": ["sddc_connector"],
"hpsdr": ["hpsdr_connector"],
"eb200": ["eb200_connector"],
"runds": ["runds_connector"],
# optional features and their requirements
"digital_voice_digiham": ["digiham", "sox"],
"digital_voice_dsd": ["dsd", "sox", "digiham"],
"digital_voice_digiham": ["digiham", "sox", "codecserver_ambe"],
"digital_voice_freedv": ["freedv_rx", "sox"],
"digital_voice_m17": ["m17_demod", "sox"],
"digital_voice_m17": ["m17_demod", "sox", "digiham"],
"wsjt-x": ["wsjtx", "sox"],
"wsjt-x-2-3": ["wsjtx_2_3", "sox"],
"wsjt-x-2-4": ["wsjtx_2_4", "sox"],
"packet": ["direwolf", "sox"],
"pocsag": ["digiham", "sox"],
"js8call": ["js8", "sox"],
@ -99,7 +99,6 @@ class FeatureDetector(object):
def feature_details(name):
return {
"description": "",
"available": self.is_available(name),
"requirements": {name: requirement_details(name) for name in self.get_requirements(name)},
}
@ -146,7 +145,7 @@ class FeatureDetector(object):
return inspect.getdoc(self._get_requirement_method(requirement))
def command_is_runnable(self, command, expected_result=None):
tmp_dir = Config.get()["temporary_directory"]
tmp_dir = CoreConfig().get_temporary_directory()
cmd = shlex.split(command)
env = os.environ.copy()
# prevent X11 programs from opening windows if called from a GUI shell
@ -230,9 +229,9 @@ class FeatureDetector(object):
If you have an older verison of digiham installed, please update it along with openwebrx.
As of now, we require version 0.3 of digiham.
"""
required_version = LooseVersion("0.3")
required_version = LooseVersion("0.5")
digiham_version_regex = re.compile("^digiham version (.*)$")
digiham_version_regex = re.compile("^(.*) version (.*)$")
def check_digiham_version(command):
try:
@ -240,9 +239,9 @@ class FeatureDetector(object):
matches = digiham_version_regex.match(process.stdout.readline().decode())
if matches is None:
return False
version = LooseVersion(matches.group(1))
version = LooseVersion(matches.group(2))
process.wait(1)
return version >= required_version
return matches.group(1) in [command, "digiham"] and version >= required_version
except FileNotFoundError:
return False
@ -259,15 +258,14 @@ class FeatureDetector(object):
"digitalvoice_filter",
"fsk_demodulator",
"pocsag_decoder",
"dstar_decoder",
],
),
True,
)
def _check_connector(self, command):
required_version = LooseVersion("0.4")
owrx_connector_version_regex = re.compile("^owrx-connector version (.*)$")
def _check_connector(self, command, required_version):
owrx_connector_version_regex = re.compile("^{} version (.*)$".format(re.escape(command)))
try:
process = subprocess.Popen([command, "--version"], stdout=subprocess.PIPE)
@ -280,6 +278,9 @@ class FeatureDetector(object):
except FileNotFoundError:
return False
def _check_owrx_connector(self, command):
return self._check_connector(command, LooseVersion("0.4"))
def has_rtl_connector(self):
"""
The owrx_connector package offers direct interfacing between your hardware and openwebrx. It allows quicker
@ -287,7 +288,7 @@ class FeatureDetector(object):
You can get it [here](https://github.com/jketterl/owrx_connector).
"""
return self._check_connector("rtl_connector")
return self._check_owrx_connector("rtl_connector")
def has_rtl_tcp_connector(self):
"""
@ -296,7 +297,7 @@ class FeatureDetector(object):
You can get it [here](https://github.com/jketterl/owrx_connector).
"""
return self._check_connector("rtl_tcp_connector")
return self._check_owrx_connector("rtl_tcp_connector")
def has_soapy_connector(self):
"""
@ -305,7 +306,7 @@ class FeatureDetector(object):
You can get it [here](https://github.com/jketterl/owrx_connector).
"""
return self._check_connector("soapy_connector")
return self._check_owrx_connector("soapy_connector")
def _has_soapy_driver(self, driver):
try:
@ -368,7 +369,7 @@ class FeatureDetector(object):
"""
The SoapySDR module for PlutoSDR devices is required for interfacing with PlutoSDR devices.
You can get it [here](https://github.com/photosware/SoapyPlutoSDR).
You can get it [here](https://github.com/pothosware/SoapyPlutoSDR).
"""
return self._has_soapy_driver("plutosdr")
@ -388,14 +389,6 @@ class FeatureDetector(object):
"""
return self._has_soapy_driver("uhd")
def has_soapy_red_pitaya(self):
"""
The SoapyRedPitaya allows Red Pitaya deviced to be used with SoapySDR.
You can get it [here](https://github.com/pothosware/SoapyRedPitaya/wiki).
"""
return self._has_soapy_driver("redpitaya")
def has_soapy_radioberry(self):
"""
The Radioberry is a SDR hat for the Raspberry Pi.
@ -420,13 +413,6 @@ class FeatureDetector(object):
"""
return self._has_soapy_driver("fcdpp")
def has_dsd(self):
"""
The digital voice modes NXDN and D-Star can be decoded by the dsd project. Please note that you need the version
modified by F4EXB that provides stdin/stdout support. You can find it [here](https://github.com/f4exb/dsd).
"""
return self.command_is_runnable("dsd")
def has_m17_demod(self):
"""
The `m17-demod` tool is used to demodulate M17 digital voice signals.
@ -486,6 +472,12 @@ class FeatureDetector(object):
"""
return self.has_wsjtx() and self._has_wsjtx_version(LooseVersion("2.3"))
def has_wsjtx_2_4(self):
"""
WSJT-X version 2.4 introduced the Q65 mode.
"""
return self.has_wsjtx() and self._has_wsjtx_version(LooseVersion("2.4"))
def has_js8(self):
"""
To decode JS8, you will need to install [JS8Call](http://js8call.com/)
@ -538,7 +530,7 @@ class FeatureDetector(object):
You can find more information [here](https://github.com/jketterl/sddc_connector).
"""
return self._check_connector("sddc_connector")
return self._check_connector("sddc_connector", LooseVersion("0.1"))
def has_hpsdr_connector(self):
"""
@ -547,10 +539,28 @@ class FeatureDetector(object):
"""
return self.command_is_runnable("hpsdrconnector -h")
def has_eb200_connector(self):
def has_runds_connector(self):
"""
To use radios supporting the EB200 radios, you need to install the eb200_connector.
To use radios supporting R&S radios via EB200 or Ammos, you need to install the runds_connector.
You can find more information [here](https://github.com/jketterl/eb200_connector).
You can find more information [here](https://github.com/jketterl/runds_connector).
"""
return self._check_connector("eb200_connector")
return self._check_connector("runds_connector", LooseVersion("0.2"))
def has_codecserver_ambe(self):
tmp_dir = CoreConfig().get_temporary_directory()
cmd = ["mbe_synthesizer", "--test"]
config = Config.get()
if "digital_voice_codecserver" in config:
cmd += ["--server", config["digital_voice_codecserver"]]
try:
process = subprocess.Popen(
cmd,
stdin=subprocess.DEVNULL,
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
cwd=tmp_dir,
)
return process.wait() == 0
except FileNotFoundError:
return False

View File

@ -1,7 +1,8 @@
from owrx.config.core import CoreConfig
from owrx.config import Config
from csdr.chain.fft import FftChain
import threading
from owrx.source import SdrSource, SdrSourceEventClient
from owrx.source import SdrSourceEventClient, SdrSourceState, SdrClientClass
from owrx.property import PropertyStack
import logging
@ -23,9 +24,6 @@ class SpectrumThread(SdrSourceEventClient):
"fft_fps",
"fft_voverlap_factor",
"fft_compression",
"csdr_dynamic_bufsize",
"csdr_print_bufsizes",
"csdr_through",
)
self.dsp = None
@ -77,17 +75,20 @@ class SpectrumThread(SdrSourceEventClient):
self.stop()
self.start()
def getClientClass(self):
return SdrSource.CLIENT_USER
def getClientClass(self) -> SdrClientClass:
return SdrClientClass.USER
def onStateChange(self, state):
if state in [SdrSource.STATE_STOPPING, SdrSource.STATE_FAILED]:
def onStateChange(self, state: SdrSourceState):
if state is SdrSourceState.STOPPING:
self.dsp.stop()
elif state == SdrSource.STATE_RUNNING:
elif state == SdrSourceState.RUNNING:
if self.dsp is None:
self.start()
else:
self.dsp.setInput(self.sdrSource.getBuffer())
def onBusyStateChange(self, state):
pass
def onFail(self):
self.dsp.stop()
def onShutdown(self):
self.dsp.stop()

View File

@ -1,232 +0,0 @@
from abc import ABC, abstractmethod
from owrx.modes import Modes
from owrx.config import Config
class Input(ABC):
def __init__(self, id, label, infotext=None):
self.id = id
self.label = label
self.infotext = infotext
def bootstrap_decorate(self, input):
infotext = "<small>{text}</small>".format(text=self.infotext) if self.infotext else ""
return """
<div class="form-group row">
<label class="col-form-label col-form-label-sm col-3" for="{id}">{label}</label>
<div class="col-9 p-0">
{input}
{infotext}
</div>
</div>
""".format(
id=self.id, label=self.label, input=input, infotext=infotext
)
def input_classes(self):
return " ".join(["form-control", "form-control-sm"])
@abstractmethod
def render_input(self, value):
pass
def render(self, config):
return self.bootstrap_decorate(self.render_input(config[self.id]))
def parse(self, data):
return {self.id: data[self.id][0]} if self.id in data else {}
class TextInput(Input):
def render_input(self, value):
return """
<input type="text" class="{classes}" id="{id}" name="{id}" placeholder="{label}" value="{value}">
""".format(
id=self.id, label=self.label, classes=self.input_classes(), value=value
)
class NumberInput(Input):
def __init__(self, id, label, infotext=None):
super().__init__(id, label, infotext)
self.step = None
def render_input(self, value):
return """
<input type="number" class="{classes}" id="{id}" name="{id}" placeholder="{label}" value="{value}" {step}>
""".format(
id=self.id,
label=self.label,
classes=self.input_classes(),
value=value,
step='step="{0}"'.format(self.step) if self.step else "",
)
def convert_value(self, v):
return int(v)
def parse(self, data):
return {k: self.convert_value(v) for k, v in super().parse(data).items()}
class FloatInput(NumberInput):
def __init__(self, id, label, infotext=None):
super().__init__(id, label, infotext)
self.step = "any"
def convert_value(self, v):
return float(v)
class LocationInput(Input):
def render_input(self, value):
return """
<div class="row">
{inputs}
</div>
<div class="row">
<div class="col map-input" data-key="{key}" for="{id}"></div>
</div>
""".format(
id=self.id,
inputs="".join(self.render_sub_input(value, id) for id in ["lat", "lon"]),
key=Config.get()["google_maps_api_key"],
)
def render_sub_input(self, value, id):
return """
<div class="col">
<input type="number" class="{classes}" id="{id}" name="{id}" placeholder="{label}" value="{value}" step="any">
</div>
""".format(
id="{0}-{1}".format(self.id, id),
label=self.label,
classes=self.input_classes(),
value=value[id],
)
def parse(self, data):
return {self.id: {k: float(data["{0}-{1}".format(self.id, k)][0]) for k in ["lat", "lon"]}}
class TextAreaInput(Input):
def render_input(self, value):
return """
<textarea class="{classes}" id="{id}" name="{id}" style="height:200px;">{value}</textarea>
""".format(
id=self.id, classes=self.input_classes(), value=value
)
class CheckboxInput(Input):
def __init__(self, id, label, checkboxText, infotext=None):
super().__init__(id, label, infotext=infotext)
self.checkboxText = checkboxText
def render_input(self, value):
return """
<div class="{classes}">
<input class="form-check-input" type="checkbox" id="{id}" name="{id}" {checked}>
<label class="form-check-label" for="{id}">
{checkboxText}
</label>
</div>
""".format(
id=self.id,
classes=self.input_classes(),
checked="checked" if value else "",
checkboxText=self.checkboxText,
)
def input_classes(self):
return " ".join(["form-check", "form-control-sm"])
def parse(self, data):
return {self.id: self.id in data and data[self.id][0] == "on"}
class Option(object):
# used for both MultiCheckboxInput and DropdownInput
def __init__(self, value, text):
self.value = value
self.text = text
class MultiCheckboxInput(Input):
def __init__(self, id, label, options, infotext=None):
super().__init__(id, label, infotext=infotext)
self.options = options
def render_input(self, value):
return "".join(self.render_checkbox(o, value) for o in self.options)
def checkbox_id(self, option):
return "{0}-{1}".format(self.id, option.value)
def render_checkbox(self, option, value):
return """
<div class="{classes}">
<input class="form-check-input" type="checkbox" id="{id}" name="{id}" {checked}>
<label class="form-check-label" for="{id}">
{checkboxText}
</label>
</div>
""".format(
id=self.checkbox_id(option),
classes=self.input_classes(),
checked="checked" if option.value in value else "",
checkboxText=option.text,
)
def parse(self, data):
def in_response(option):
boxid = self.checkbox_id(option)
return boxid in data and data[boxid][0] == "on"
return {self.id: [o.value for o in self.options if in_response(o)]}
def input_classes(self):
return " ".join(["form-check", "form-control-sm"])
class ServicesCheckboxInput(MultiCheckboxInput):
def __init__(self, id, label, infotext=None):
services = [Option(s.modulation, s.name) for s in Modes.getAvailableServices()]
super().__init__(id, label, services, infotext)
class Js8ProfileCheckboxInput(MultiCheckboxInput):
def __init__(self, id, label, infotext=None):
profiles = [
Option("normal", "Normal (15s, 50Hz, ~16WPM)"),
Option("slow", "Slow (30s, 25Hz, ~8WPM"),
Option("fast", "Fast (10s, 80Hz, ~24WPM"),
Option("turbo", "Turbo (6s, 160Hz, ~40WPM"),
]
super().__init__(id, label, profiles, infotext)
class DropdownInput(Input):
def __init__(self, id, label, options, infotext=None):
super().__init__(id, label, infotext=infotext)
self.options = options
def render_input(self, value):
return """
<select class="{classes}" id="{id}" name="{id}">{options}</select>
""".format(
classes=self.input_classes(), id=self.id, options=self.render_options(value)
)
def render_options(self, value):
options = [
"""
<option value="{value}" {selected}>{text}</option>
""".format(
text=o.text,
value=o.value,
selected="selected" if o.value == value else "",
)
for o in self.options
]
return "".join(options)

15
owrx/form/error.py Normal file
View File

@ -0,0 +1,15 @@
class FormError(Exception):
def __init__(self, key, message):
super().__init__("Error processing form data for {}: {}".format(key, message))
self.key = key
self.message = message
def getKey(self):
return self.key
def getMessage(self):
return self.message
class ValidationError(FormError):
pass

411
owrx/form/input/__init__.py Normal file
View File

@ -0,0 +1,411 @@
from abc import ABC
from owrx.modes import Modes
from owrx.config import Config
from owrx.form.input.validator import Validator
from owrx.form.input.converter import Converter, NullConverter, IntConverter, FloatConverter, EnumConverter
from enum import Enum
class Input(ABC):
def __init__(self, id, label, infotext=None, converter: Converter = None, validator: Validator = None, disabled=False, removable=False):
self.id = id
self.label = label
self.infotext = infotext
self.converter = self.defaultConverter() if converter is None else converter
self.validator = validator
self.disabled = disabled
self.removable = removable
def setDisabled(self, disabled=True):
self.disabled = disabled
def setRemovable(self, removable=True):
self.removable = removable
def defaultConverter(self):
return NullConverter()
def bootstrap_decorate(self, input):
return """
<div class="form-group row" data-field="{id}">
<label class="col-form-label col-form-label-sm col-3" for="{id}">{label}</label>
<div class="col-9 p-0 removable-group {removable}">
<div class="removable-item">
{input}
{infotext}
</div>
{removebutton}
</div>
</div>
""".format(
id=self.id,
label=self.label,
input=input,
infotext="<small>{text}</small>".format(text=self.infotext) if self.infotext else "",
removable="removable" if self.removable else "",
removebutton='<button type="button" class="btn btn-sm btn-danger option-remove-button">Remove</button>'
if self.removable
else "",
)
def input_classes(self, errors):
classes = ["form-control", "form-control-sm"]
if errors:
classes.append("is-invalid")
return " ".join(classes)
def input_properties(self, value, errors):
props = {
"class": self.input_classes(errors),
"id": self.id,
"name": self.id,
"placeholder": self.label,
"value": value,
}
if self.disabled:
props["disabled"] = "disabled"
return props
def render_input_properties(self, value, error):
return " ".join('{}="{}"'.format(prop, value) for prop, value in self.input_properties(value, error).items())
def render_errors(self, errors):
return "".join("""<div class="invalid-feedback">{msg}</div>""".format(msg=e) for e in errors)
def render_input_group(self, value, errors):
return """
{input}
{errors}
""".format(
input=self.render_input(value, errors),
errors=self.render_errors(errors)
)
def render_input(self, value, errors):
return "<input {properties} />".format(properties=self.render_input_properties(value, errors))
def render(self, config, errors):
value = config[self.id] if self.id in config else None
error = errors[self.id] if self.id in errors else []
return self.bootstrap_decorate(self.render_input_group(self.converter.convert_to_form(value), error))
def parse(self, data):
if self.id in data:
value = self.converter.convert_from_form(data[self.id][0])
if self.validator is not None:
self.validator.validate(self.id, value)
return {self.id: value}
return {}
def getLabel(self):
return self.label
class TextInput(Input):
def input_properties(self, value, errors):
props = super().input_properties(value, errors)
props["type"] = "text"
return props
class NumberInput(Input):
def __init__(self, id, label, infotext=None, append="", converter: Converter = None, validator: Validator = None):
super().__init__(id, label, infotext, converter=converter, validator=validator)
self.step = None
self.append = append
def defaultConverter(self):
return IntConverter()
def input_properties(self, value, errors):
props = super().input_properties(value, errors)
props["type"] = "number"
if self.step:
props["step"] = self.step
return props
def render_input_group(self, value, errors):
if self.append:
append = """
<div class="input-group-append">
<span class="input-group-text">{append}</span>
</div>
""".format(
append=self.append
)
else:
append = ""
return """
<div class="input-group input-group-sm">
{input}
{append}
{errors}
</div>
""".format(
input=self.render_input(value, errors),
append=append,
errors=self.render_errors(errors)
)
class FloatInput(NumberInput):
def __init__(self, id, label, infotext=None, converter: Converter = None):
super().__init__(id, label, infotext, converter=converter)
self.step = "any"
def defaultConverter(self):
return FloatConverter()
class LocationInput(Input):
def render_input_group(self, value, errors):
return """
<div class="row {rowclass}">
{inputs}
</div>
{errors}
<div class="row">
<div class="col map-input" data-key="{key}" for="{id}"></div>
</div>
""".format(
id=self.id,
rowclass="is-invalid" if errors else "",
inputs=self.render_input(value, errors),
errors=self.render_errors(errors),
key=Config.get()["google_maps_api_key"],
)
def render_input(self, value, errors):
return "".join(self.render_sub_input(value, id, errors) for id in ["lat", "lon"])
def render_sub_input(self, value, id, errors):
return """
<div class="col">
<input type="number" class="{classes}" id="{id}" name="{id}" placeholder="{label}" value="{value}"
step="any" {disabled}>
</div>
""".format(
id="{0}-{1}".format(self.id, id),
label=self.label,
classes=self.input_classes(errors),
value=value[id],
disabled="disabled" if self.disabled else "",
)
def parse(self, data):
return {self.id: {k: float(data["{0}-{1}".format(self.id, k)][0]) for k in ["lat", "lon"]}}
class TextAreaInput(Input):
def render_input(self, value, errors):
return """
<textarea class="{classes}" id="{id}" name="{id}" style="height:200px;" {disabled}>{value}</textarea>
""".format(
id=self.id,
classes=self.input_classes(errors),
value=value,
disabled="disabled" if self.disabled else "",
)
class CheckboxInput(Input):
def __init__(self, id, checkboxText, infotext=None, converter: Converter = None):
super().__init__(id, "", infotext=infotext, converter=converter)
self.checkboxText = checkboxText
def render_input(self, value, errors):
return """
<div class="{classes}">
<input type="hidden" name="{id}" value="0" {disabled}>
<input class="form-check-input" type="checkbox" id="{id}" name="{id}" value="1" {checked} {disabled}>
<label class="form-check-label" for="{id}">
{checkboxText}
</label>
</div>
""".format(
id=self.id,
classes=self.input_classes(errors),
checked="checked" if value else "",
disabled="disabled" if self.disabled else "",
checkboxText=self.checkboxText,
)
def input_classes(self, error):
classes = ["form-check", "form-control-sm"]
if error:
classes.append("is-invalid")
return " ".join(classes)
def parse(self, data):
if self.id in data:
return {self.id: self.converter.convert_from_form("1" in data[self.id])}
return {}
def getLabel(self):
return self.checkboxText
class Option(object):
# used for both MultiCheckboxInput and DropdownInput
def __init__(self, value, text):
self.value = value
self.text = text
class MultiCheckboxInput(Input):
def __init__(self, id, label, options, infotext=None):
super().__init__(id, label, infotext=infotext)
self.options = options
def render_input(self, value, errors):
return "".join(self.render_checkbox(o, value, errors) for o in self.options)
def checkbox_id(self, option):
return "{0}-{1}".format(self.id, option.value)
def render_checkbox(self, option, value, errors):
return """
<div class="{classes}">
<input class="form-check-input" type="checkbox" id="{id}" name="{id}" {checked} {disabled}>
<label class="form-check-label" for="{id}">
{checkboxText}
</label>
</div>
""".format(
id=self.checkbox_id(option),
classes=self.input_classes(errors),
checked="checked" if option.value in value else "",
checkboxText=option.text,
disabled="disabled" if self.disabled else "",
)
def parse(self, data):
def in_response(option):
boxid = self.checkbox_id(option)
return boxid in data and data[boxid][0] == "on"
return {self.id: [o.value for o in self.options if in_response(o)]}
def input_classes(self, error):
classes = ["form-check", "form-control-sm"]
if error:
classes.append("is-invalid")
return " ".join(classes)
class ServicesCheckboxInput(MultiCheckboxInput):
def __init__(self, id, label, infotext=None):
services = [Option(s.modulation, s.name) for s in Modes.getAvailableServices()]
super().__init__(id, label, services, infotext)
class Js8ProfileCheckboxInput(MultiCheckboxInput):
def __init__(self, id, label, infotext=None):
profiles = [
Option("normal", "Normal (15s, 50Hz, ~16WPM)"),
Option("slow", "Slow (30s, 25Hz, ~8WPM"),
Option("fast", "Fast (10s, 80Hz, ~24WPM"),
Option("turbo", "Turbo (6s, 160Hz, ~40WPM"),
]
super().__init__(id, label, profiles, infotext)
class DropdownInput(Input):
def __init__(self, id, label, options, infotext=None, converter: Converter = None):
try:
isEnum = issubclass(options, DropdownEnum)
except TypeError:
isEnum = False
if isEnum:
self.options = [o.toOption() for o in options]
if converter is None:
converter = EnumConverter(options)
else:
self.options = options
super().__init__(id, label, infotext=infotext, converter=converter)
def render_input(self, value, errors):
return """
<select class="{classes}" id="{id}" name="{id}" {disabled}>{options}</select>
""".format(
classes=self.input_classes(errors),
id=self.id,
options=self.render_options(value),
disabled="disabled" if self.disabled else "",
)
def render_options(self, value):
options = [
"""
<option value="{value}" {selected}>{text}</option>
""".format(
text=o.text,
value=o.value,
selected="selected" if o.value == value else "",
)
for o in self.options
]
return "".join(options)
class DropdownEnum(Enum):
def toOption(self):
return Option(self.name, str(self))
class ModesInput(DropdownInput):
def __init__(self, id, label):
options = [Option(m.modulation, m.name) for m in Modes.getAvailableModes()]
super().__init__(id, label, options)
class ExponentialInput(Input):
def __init__(self, id, label, unit, infotext=None):
super().__init__(id, label, infotext=infotext)
self.unit = unit
def defaultConverter(self):
return IntConverter()
def input_properties(self, value, errors):
props = super().input_properties(value, errors)
props["type"] = "number"
props["step"] = "any"
return props
def render_input_group(self, value, errors):
append = """
<div class="input-group-append">
<select class="input-group-text exponent" name="{id}-exponent" tabindex="-1" {disabled}>
<option value="0" selected>{unit}</option>
<option value="3">k{unit}</option>
<option value="6">M{unit}</option>
<option value="9">G{unit}</option>
<option value="12">T{unit}</option>
</select>
</div>
""".format(
id=self.id,
disabled="disabled" if self.disabled else "",
unit=self.unit,
)
return """
<div class="input-group input-group-sm exponential-input">
{input}
{append}
{errors}
</div>
""".format(
input=self.render_input(value, errors),
append=append,
errors=self.render_errors(errors)
)
def parse(self, data):
exponent_id = "{}-exponent".format(self.id)
if self.id in data and exponent_id in data:
value = int(float(data[self.id][0]) * 10 ** int(data[exponent_id][0]))
return {self.id: value}
return {}

36
owrx/form/input/aprs.py Normal file
View File

@ -0,0 +1,36 @@
from owrx.form.input import DropdownEnum
class AprsBeaconSymbols(DropdownEnum):
BEACON_RECEIVE_ONLY = ("R&", "Receive only IGate")
BEACON_HF_GATEWAY = ("/&", "HF Gateway")
BEACON_IGATE_GENERIC = ("I&", "Igate Generic (please use more specific overlay)")
BEACON_PSKMAIL = ("P&", "PSKmail node")
BEACON_TX_1 = ("T&", "TX IGate with path set to 1 hop")
BEACON_WIRES_X = ("W&", "Wires-X")
BEACON_TX_2 = ("2&", "TX IGate with path set to 2 hops")
def __new__(cls, *args, **kwargs):
value, description = args
obj = object.__new__(cls)
obj._value_ = value
obj.description = description
return obj
def __str__(self):
return "{description} ({symbol})".format(description=self.description, symbol=self.value)
class AprsAntennaDirections(DropdownEnum):
DIRECTION_OMNI = None
DIRECTION_N = "N"
DIRECTION_NE = "NE"
DIRECTION_E = "E"
DIRECTION_SE = "SE"
DIRECTION_S = "S"
DIRECTION_SW = "SW"
DIRECTION_W = "W"
DIRECTION_NW = "NW"
def __str__(self):
return "omnidirectional" if self.value is None else self.value

View File

@ -0,0 +1,96 @@
from abc import ABC, abstractmethod
from owrx.jsons import Encoder
import json
class Converter(ABC):
@abstractmethod
def convert_to_form(self, value):
pass
@abstractmethod
def convert_from_form(self, value):
pass
class NullConverter(Converter):
def convert_to_form(self, value):
return value
def convert_from_form(self, value):
return value
class OptionalConverter(Converter):
"""
Transforms a special form value to None
The default is look for an empty string, but this can be used to adopt to other types.
If the default is not found, the actual value is passed to the sub_converter for further transformation.
useful for optional fields since None is not stored in the configuration
"""
def __init__(self, sub_converter: Converter = None, defaultFormValue=""):
self.sub_converter = NullConverter() if sub_converter is None else sub_converter
self.defaultFormValue = defaultFormValue
def convert_to_form(self, value):
return self.defaultFormValue if value is None else self.sub_converter.convert_to_form(value)
def convert_from_form(self, value):
return None if value == self.defaultFormValue else self.sub_converter.convert_from_form(value)
class IntConverter(Converter):
def convert_to_form(self, value):
return str(value)
def convert_from_form(self, value):
return int(value)
class FloatConverter(Converter):
def convert_to_form(self, value):
return str(value)
def convert_from_form(self, value):
return float(value)
class EnumConverter(Converter):
def __init__(self, enumCls):
self.enumCls = enumCls
def convert_to_form(self, value):
return None if value is None else self.enumCls(value).name
def convert_from_form(self, value):
return self.enumCls[value].value
class JsonConverter(Converter):
def convert_to_form(self, value):
return json.dumps(value, cls=Encoder)
def convert_from_form(self, value):
return json.loads(value)
class WaterfallColorsConverter(Converter):
def convert_to_form(self, value):
if value is None:
return ""
return "\n".join("#{:06x}".format(v) for v in value)
def convert_from_form(self, value):
def parseString(s):
try:
if s.startswith("#"):
return int(s[1:], 16)
# int() with base 0 can accept "0x" prefixed hex strings, or int numbers
return int(s, 0)
except ValueError:
return None
# \r\n or \n? this should work with both.
values = [parseString(v.strip("\r ")) for v in value.split("\n")]
return [v for v in values if v is not None]

434
owrx/form/input/device.py Normal file
View File

@ -0,0 +1,434 @@
from owrx.form.input import Input, CheckboxInput, DropdownInput, DropdownEnum, TextInput
from owrx.form.input.converter import OptionalConverter
from owrx.form.input.validator import RequiredValidator
from owrx.soapy import SoapySettings
class GainInput(Input):
def __init__(self, id, label, has_agc, gain_stages=None):
super().__init__(id, label)
self.has_agc = has_agc
self.gain_stages = gain_stages
def render_input(self, value, errors):
try:
display_value = float(value)
except (ValueError, TypeError):
display_value = "0.0"
return """
<select class="{classes}" id="{id}-select" name="{id}-select" {disabled}>
{options}
</select>
<div class="option manual" style="display: none;">
<input type="number" id="{id}-manual" name="{id}-manual" value="{value}" class="{classes}"
placeholder="Manual device gain" step="any" {disabled}>
</div>
{stageoption}
""".format(
id=self.id,
classes=self.input_classes(errors),
value=display_value,
label=self.label,
options=self.render_options(value),
stageoption="" if self.gain_stages is None else self.render_stage_option(value, errors),
disabled="disabled" if self.disabled else "",
)
def render_input_group(self, value, errors):
return """
<div id="{id}">
{input}
{errors}
</div>
""".format(
id=self.id, input=self.render_input(value, errors), errors=self.render_errors(errors)
)
def render_options(self, value):
options = []
if self.has_agc:
options.append(("auto", "Enable hardware AGC"))
options.append(("manual", "Specify manual gain")),
if self.gain_stages:
options.append(("stages", "Specify gain stages individually"))
mode = self.getMode(value)
return "".join(
"""
<option value="{value}" {selected}>{text}</option>
""".format(
value=v[0], text=v[1], selected="selected" if mode == v[0] else ""
)
for v in options
)
def getMode(self, value):
if value is None:
return "auto" if self.has_agc else "manual"
if value == "auto":
return "auto"
try:
float(value)
return "manual"
except (ValueError, TypeError):
pass
return "stages"
def render_stage_option(self, value, errors):
try:
value_dict = {k: v for item in SoapySettings.parse(value) for k, v in item.items()}
except (AttributeError, ValueError):
value_dict = {}
return """
<div class="option stages container container-fluid" style="display: none;">
{inputs}
</div>
""".format(
inputs="".join(
"""
<div class="row">
<label class="col-form-label col-form-label-sm col-3">{stage}</label>
<input type="number" id="{id}-{stage}" name="{id}-{stage}" value="{value}"
class="col-9 {classes}" placeholder="{stage}" step="any" {disabled}>
</div>
""".format(
id=self.id,
stage=stage,
value=value_dict[stage] if stage in value_dict else "",
classes=self.input_classes(errors),
disabled="disabled" if self.disabled else "",
)
for stage in self.gain_stages
)
)
def parse(self, data):
def getStageValue(stage):
input_id = "{id}-{stage}".format(id=self.id, stage=stage)
if input_id in data:
return data[input_id][0]
else:
return None
select_id = "{id}-select".format(id=self.id)
if select_id in data:
if self.has_agc and data[select_id][0] == "auto":
return {self.id: "auto"}
if data[select_id][0] == "manual":
input_id = "{id}-manual".format(id=self.id)
value = 0.0
if input_id in data:
try:
value = float(data[input_id][0])
except ValueError:
pass
return {self.id: value}
if self.gain_stages is not None and data[select_id][0] == "stages":
settings_dict = [{s: getStageValue(s)} for s in self.gain_stages]
# filter out empty ones
settings_dict = [s for s in settings_dict if next(iter(s.values()))]
return {self.id: SoapySettings.encode(settings_dict)}
return {}
class BiasTeeInput(CheckboxInput):
def __init__(self):
super().__init__("bias_tee", "Enable Bias-Tee power supply")
class DirectSamplingOptions(DropdownEnum):
DIRECT_SAMPLING_OFF = (0, "Off")
DIRECT_SAMPLING_I = (1, "Direct Sampling (I branch)")
DIRECT_SAMPLING_Q = (2, "Direct Sampling (Q branch)")
def __new__(cls, *args, **kwargs):
value, description = args
obj = object.__new__(cls)
obj._value_ = value
obj.description = description
return obj
def __str__(self):
return self.description
class DirectSamplingInput(DropdownInput):
def __init__(self):
super().__init__(
"direct_sampling",
"Direct Sampling",
DirectSamplingOptions,
)
class RemoteInput(TextInput):
def __init__(self):
super().__init__(
"remote",
"Remote IP and Port",
infotext="Remote hostname or IP and port to connect to. Format = IP:Port",
converter=OptionalConverter(),
validator=RequiredValidator(),
)
class SchedulerInput(Input):
def __init__(self, id, label):
super().__init__(id, label)
self.profiles = {}
def render(self, config, errors):
if "profiles" in config:
self.profiles = config["profiles"]
return super().render(config, errors)
def render_profiles_select(self, value, errors, config_key, stage, extra_classes="", allow_empty=False):
stage_value = ""
if value and "schedule" in value and config_key in value["schedule"]:
stage_value = value["schedule"][config_key]
options = "".join(
"""
<option value="{id}" {selected}>{name}</option>
""".format(
id=p_id,
name=p["name"],
selected="selected" if stage_value == p_id else "",
)
for p_id, p in self.profiles.items()
)
if allow_empty:
# prepend a special "off" option to allow a schedule slot to go unused (daylight scheduler)
options = """<option value="None" {selected}>Off</option>""".format(
selected="selected" if value is None else ""
) + options
return """
<select class="{extra_classes} {classes}" id="{id}" name="{id}" {disabled}>
{options}
</select>
""".format(
id="{}-{}".format(self.id, stage),
classes=self.input_classes(errors),
extra_classes=extra_classes,
disabled="disabled" if self.disabled else "",
options=options,
)
def render_static_entires(self, value, errors):
def render_time_inputs(v):
values = ["{}:{}".format(x[0:2], x[2:4]) for x in [v[0:4], v[5:9]]]
return '<div class="p-1">-</div>'.join(
"""
<input type="time" class="{classes}" id="{id}" name="{id}" {disabled} value="{value}">
""".format(
id="{}-{}-{}".format(self.id, "time", "start" if i == 0 else "end"),
classes=self.input_classes(errors),
disabled="disabled" if self.disabled else "",
value=v,
)
for i, v in enumerate(values)
)
schedule = {"0000-0000": ""}
if value is not None and value and "schedule" in value and "type" in value and value["type"] == "static":
schedule = value["schedule"]
rows = "".join(
"""
<div class="row scheduler-static-time-inputs">
{time_inputs}
{select}
<button type="button" class="btn btn-sm btn-danger remove-button">X</button>
</div>
""".format(
time_inputs=render_time_inputs(slot),
select=self.render_profiles_select(value, errors, slot, "profile"),
)
for slot, entry in schedule.items()
)
return """
{rows}
<div class="row scheduler-static-time-inputs template" style="display: none;">
{time_inputs}
{select}
<button type="button" class="btn btn-sm btn-danger remove-button">X</button>
</div>
<div class="row">
<button type="button" class="btn btn-sm btn-primary col-12 add-button">Add...</button>
</div>
""".format(
rows=rows,
time_inputs=render_time_inputs("0000-0000"),
select=self.render_profiles_select("", errors, "0000-0000", "profile"),
)
def render_daylight_entries(self, value, errors):
return "".join(
"""
<div class="row">
<label class="col-form-label col-form-label-sm col-3">{name}</label>
{select}
</div>
""".format(
name=name,
select=self.render_profiles_select(
value, errors, stage, stage, extra_classes="col-9", allow_empty=True
),
)
for stage, name in [("day", "Day"), ("night", "Night"), ("greyline", "Greyline")]
)
def render_input(self, value, errors):
return """
<div id="{id}">
<select class="{classes} mode" id="{id}-select" name="{id}-select" {disabled}>
{options}
</select>
<div class="option static container container-fluid" style="display: none;">
{entries}
</div>
<div class="option daylight container container-fluid" style="display: None;">
{stages}
</div>
</div>
""".format(
id=self.id,
classes=self.input_classes(errors),
disabled="disabled" if self.disabled else "",
options=self.render_options(value),
entries=self.render_static_entires(value, errors),
stages=self.render_daylight_entries(value, errors),
)
def _get_mode(self, value):
if value is not None and "type" in value:
return value["type"]
return ""
def render_options(self, value):
options = [
("static", "Static scheduler"),
("daylight", "Daylight scheduler"),
]
mode = self._get_mode(value)
return "".join(
"""
<option value="{value}" {selected}>{name}</option>
""".format(
value=value, name=name, selected="selected" if mode == value else ""
)
for value, name in options
)
def parse(self, data):
def getStageValue(stage):
input_id = "{id}-{stage}".format(id=self.id, stage=stage)
if input_id in data:
# special treatment for the "off" option
if data[input_id][0] == "None":
return None
return data[input_id][0]
else:
return None
select_id = "{id}-select".format(id=self.id)
if select_id in data:
if data[select_id][0] == "static":
keys = ["{}-{}".format(self.id, x) for x in ["time-start", "time-end", "profile"]]
lists = [data[key] for key in keys if key in data]
settings_dict = {
"{}{}-{}{}".format(start[0:2], start[3:5], end[0:2], end[3:5]): profile
for start, end, profile in zip(*lists)
}
# only apply scheduler if any slots are available
if settings_dict:
return {self.id: {"type": "static", "schedule": settings_dict}}
elif data[select_id][0] == "daylight":
settings_dict = {s: getStageValue(s) for s in ["day", "night", "greyline"]}
# filter out empty ones
settings_dict = {s: v for s, v in settings_dict.items() if v}
# only apply scheduler if any of the slots are in use
if settings_dict:
return {self.id: {"type": "daylight", "schedule": settings_dict}}
return {}
class WaterfallLevelsInput(Input):
def __init__(self, id, label, infotext=None):
super().__init__(id, label, infotext=infotext)
def render_input_group(self, value, errors):
return """
<div class="row {rowclass}" id="{id}">
{input}
</div>
{errors}
""".format(
rowclass="is-invalid" if errors else "",
id=self.id,
input=self.render_input(value, errors),
errors=self.render_errors(errors),
)
def getUnit(self):
return "dBFS"
def getFields(self):
return {"min": "Minimum", "max": "Maximum"}
def render_input(self, value, errors):
return "".join(
"""
<div class="col row">
<label class="col-3 col-form-label col-form-label-sm" for="{id}-{name}">{label}</label>
<div class="col-9 input-group input-group-sm">
<input type="number" step="any" class="{classes}" name="{id}-{name}" value="{value}" {disabled}>
<div class="input-group-append">
<span class="input-group-text">{unit}</span>
</div>
</div>
</div>
""".format(
id=self.id,
name=name,
label=label,
value=value[name] if value and name in value else "0",
classes=self.input_classes(errors),
disabled="disabled" if self.disabled else "",
unit=self.getUnit(),
)
for name, label in self.getFields().items()
)
def parse(self, data):
def getValue(name):
key = "{}-{}".format(self.id, name)
if key in data:
return {name: float(data[key][0])}
raise KeyError("waterfall key not found")
try:
return {self.id: {k: v for name in ["min", "max"] for k, v in getValue(name).items()}}
except KeyError:
return {}
class WaterfallAutoLevelsInput(WaterfallLevelsInput):
def getUnit(self):
return "dB"
def getFields(self):
return {"min": "Lower", "max": "Upper"}

67
owrx/form/input/gfx.py Normal file
View File

@ -0,0 +1,67 @@
from abc import ABCMeta, abstractmethod
from owrx.form.input import Input
from datetime import datetime
class ImageInput(Input, metaclass=ABCMeta):
def render_input(self, value, errors):
# TODO display errors
return """
<div class="imageupload" data-max-size="{maxsize}">
<input type="hidden" id="{id}" name="{id}">
<div class="image-container">
<img class="{classes}" src="{url}" alt="{label}"/>
</div>
<button type="button" class="btn btn-primary upload">Upload new image...</button>
<button type="button" class="btn btn-secondary restore">Restore original image</button>
</div>
""".format(
id=self.id,
label=self.label,
url=self.cachebuster(self.getUrl()),
classes=" ".join(self.getImgClasses()),
maxsize=self.getMaxSize(),
)
def cachebuster(self, url: str):
return "{url}{separator}cb={cachebuster}".format(
url=url,
cachebuster=datetime.now().timestamp(),
separator="&" if "?" in url else "?",
)
@abstractmethod
def getUrl(self) -> str:
pass
@abstractmethod
def getImgClasses(self) -> list:
pass
@abstractmethod
def getMaxSize(self) -> int:
pass
class AvatarInput(ImageInput):
def getUrl(self) -> str:
return "../static/gfx/openwebrx-avatar.png"
def getImgClasses(self) -> list:
return ["webrx-rx-avatar"]
def getMaxSize(self) -> int:
# 256 kB
return 250 * 1024
class TopPhotoInput(ImageInput):
def getUrl(self) -> str:
return "../static/gfx/openwebrx-top-photo.jpg"
def getImgClasses(self) -> list:
return ["webrx-top-photo"]
def getMaxSize(self) -> int:
# 2 MB
return 2 * 1024 * 1024

View File

@ -0,0 +1,10 @@
from owrx.form.input.converter import Converter
class ReceiverKeysConverter(Converter):
def convert_to_form(self, value):
return "" if value is None else "\n".join(value)
def convert_from_form(self, value):
# \r\n or \n? this should work with both.
return [v.strip("\r ") for v in value.split("\n")]

View File

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

16
owrx/form/input/wfm.py Normal file
View File

@ -0,0 +1,16 @@
from owrx.form.input import DropdownEnum
class WfmTauValues(DropdownEnum):
TAU_50_MICRO = (50e-6, "most regions")
TAU_75_MICRO = (75e-6, "Americas and South Korea")
def __new__(cls, *args, **kwargs):
value, description = args
obj = object.__new__(cls)
obj._value_ = value
obj.description = description
return obj
def __str__(self):
return "{}µs ({})".format(int(self.value * 1e6), self.description)

93
owrx/form/input/wsjt.py Normal file
View File

@ -0,0 +1,93 @@
from owrx.form.input import Input
from owrx.form.input.converter import JsonConverter
from owrx.wsjt import Q65Mode, Q65Interval
from owrx.modes import Modes, WsjtMode
import html
class Q65ModeMatrix(Input):
def checkbox_id(self, mode, interval):
return "{0}-{1}-{2}".format(self.id, mode.value, interval.value)
def render_checkbox(self, mode: Q65Mode, interval: Q65Interval, value, errors):
return """
<div class="{classes}">
<input class="form-check-input" type="checkbox" id="{id}" name="{id}" {checked} {disabled}>
<label class="form-check-label" for="{id}">
{checkboxText}
</label>
</div>
""".format(
classes=self.input_classes(errors),
id=self.checkbox_id(mode, interval),
checked="checked" if "{}{}".format(mode.name, interval.value) in value else "",
checkboxText="Mode {} interval {}s".format(mode.name, interval.value),
disabled="" if interval.is_available(mode) and not self.disabled else "disabled",
)
def render_input_group(self, value, errors):
return """
<div class="matrix q65-matrix">
{checkboxes}
{errors}
</div>
""".format(
checkboxes=self.render_input(value, errors),
errors=self.render_errors(errors),
)
def render_input(self, value, errors):
return "".join(
self.render_checkbox(mode, interval, value, errors) for interval in Q65Interval for mode in Q65Mode
)
def input_classes(self, error):
classes = ["form-check", "form-control-sm"]
if error:
classes.append("is-invalid")
return " ".join(classes)
def parse(self, data):
def in_response(mode, interval):
boxid = self.checkbox_id(mode, interval)
return boxid in data and data[boxid][0] == "on"
return {
self.id: [
"{}{}".format(mode.name, interval.value)
for interval in Q65Interval
for mode in Q65Mode
if in_response(mode, interval)
],
}
class WsjtDecodingDepthsInput(Input):
def defaultConverter(self):
return JsonConverter()
def render_input(self, value, errors):
def render_mode(m):
return """
<option value={mode}>{name}</option>
""".format(
mode=m.modulation,
name=m.name,
)
return """
<input type="hidden" class="{classes}" id="{id}" name="{id}" value="{value}" {disabled}>
<div class="inputs" style="display:none;">
<select class="form-control form-control-sm">{options}</select>
<input class="form-control form-control-sm" type="number" step="1">
</div>
""".format(
id=self.id,
classes=self.input_classes(errors),
value=html.escape(value),
options="".join(render_mode(m) for m in Modes.getAvailableModes() if isinstance(m, WsjtMode)),
disabled="disabled" if self.disabled else ""
)
def input_classes(self, error):
return super().input_classes(error) + " wsjt-decoding-depths"

124
owrx/form/section.py Normal file
View File

@ -0,0 +1,124 @@
from owrx.form.error import FormError
from owrx.form.input import Input
from typing import List
class Section(object):
def __init__(self, title, *inputs):
self.title = title
self.inputs = inputs
def render_input(self, input, data, errors):
return input.render(data, errors)
def render_inputs(self, data, errors):
return "".join([self.render_input(i, data, errors) for i in self.inputs])
def classes(self):
return ["col-12", "settings-section"]
def render(self, data, errors):
return """
<div class="{classes}">
<h3 class="settings-header">
{title}
</h3>
{inputs}
</div>
""".format(
classes=" ".join(self.classes()), title=self.title, inputs=self.render_inputs(data, errors)
)
def parse(self, data):
parsed_data = {}
errors = []
for i in self.inputs:
try:
parsed_data.update(i.parse(data))
except FormError as e:
errors.append(e)
except Exception as e:
errors.append(FormError(i.id, "{}: {}".format(type(e).__name__, e)))
return parsed_data, errors
class OptionalSection(Section):
def __init__(self, title, inputs: List[Input], mandatory, optional):
super().__init__(title, *inputs)
self.mandatory = mandatory
self.optional = optional
self.optional_inputs = []
def classes(self):
classes = super().classes()
classes.append("optional-section")
return classes
def _is_optional(self, input):
return input.id in self.optional
def render_optional_select(self):
return """
<hr class="row" />
<div class="form-group row">
<label class="col-form-label col-form-label-sm col-3">
Additional optional settings
</label>
<div class="add-group col-9 p-0">
<div class="add-group-select">
<select class="form-control form-control-sm optional-select">
{options}
</select>
</div>
<button type="button" class="btn btn-sm btn-success option-add-button">Add</button>
</div>
</div>
""".format(
options="".join(
"""
<option value="{value}">{name}</option>
""".format(
value=input.id,
name=input.getLabel(),
)
for input in self.optional_inputs
)
)
def render_optional_inputs(self, data, errors):
return """
<div class="optional-inputs" style="display: none;">
{inputs}
</div>
""".format(
inputs="".join(self.render_input(input, data, errors) for input in self.optional_inputs)
)
def render_inputs(self, data, errors):
return (
super().render_inputs(data, errors)
+ self.render_optional_select()
+ self.render_optional_inputs(data, errors)
)
def render(self, data, errors):
indexed_inputs = {input.id: input for input in self.inputs}
visible_keys = set(self.mandatory + [k for k in self.optional if k in data or k in errors])
optional_keys = set(k for k in self.optional if k not in data and k not in errors)
self.inputs = [input for k, input in indexed_inputs.items() if k in visible_keys]
for input in self.inputs:
if self._is_optional(input):
input.setRemovable()
self.optional_inputs = [input for k, input in indexed_inputs.items() if k in optional_keys]
for input in self.optional_inputs:
input.setRemovable()
input.setDisabled()
return super().render(data, errors)
def parse(self, data):
data, errors = super().parse(data)
# remove optional keys if they have been removed from the form by setting them to None
for k in self.optional:
if k not in data:
data[k] = None
return data, errors

View File

@ -1,11 +1,27 @@
from owrx.controllers.status import StatusController
from owrx.controllers.template import IndexController, MapController, FeatureController
from owrx.controllers.template import IndexController, MapController
from owrx.controllers.feature import FeatureController
from owrx.controllers.assets import OwrxAssetsController, AprsSymbolsController, CompiledAssetsController
from owrx.controllers.websocket import WebSocketController
from owrx.controllers.api import ApiController
from owrx.controllers.metrics import MetricsController
from owrx.controllers.settings import SettingsController, GeneralSettingsController, SdrSettingsController
from owrx.controllers.settings import SettingsController
from owrx.controllers.settings.general import GeneralSettingsController
from owrx.controllers.settings.sdr import (
SdrDeviceListController,
SdrDeviceController,
SdrProfileController,
NewSdrDeviceController,
NewProfileController,
)
from owrx.controllers.settings.reporting import ReportingController
from owrx.controllers.settings.backgrounddecoding import BackgroundDecodingController
from owrx.controllers.settings.decoding import DecodingSettingsController
from owrx.controllers.settings.bookmarks import BookmarksController
from owrx.controllers.session import SessionController
from owrx.controllers.profile import ProfileController
from owrx.controllers.imageupload import ImageUploadController
from owrx.controllers.robots import RobotsController
from http.server import BaseHTTPRequestHandler
from urllib.parse import urlparse, parse_qs
import re
@ -18,29 +34,11 @@ logger = logging.getLogger(__name__)
logger.setLevel(logging.INFO)
class RequestHandler(BaseHTTPRequestHandler):
def __init__(self, request, client_address, server):
self.router = Router()
super().__init__(request, client_address, server)
def log_message(self, format, *args):
logger.debug("%s - - [%s] %s", self.address_string(), self.log_date_time_string(), format % args)
def do_GET(self):
self.router.route(self, self.get_request("GET"))
def do_POST(self):
self.router.route(self, self.get_request("POST"))
def get_request(self, method):
url = urlparse(self.path)
return Request(url, method, self.headers)
class Request(object):
def __init__(self, url, method, headers):
self.path = url.path
self.query = parse_qs(url.query)
parsed_url = urlparse(url)
self.path = parsed_url.path
self.query = parse_qs(parsed_url.query)
self.matches = None
self.method = method
self.headers = headers
@ -88,26 +86,80 @@ class Router(object):
def __init__(self):
self.routes = [
StaticRoute("/", IndexController),
StaticRoute("/robots.txt", RobotsController),
StaticRoute("/status.json", StatusController),
RegexRoute("/static/(.+)", OwrxAssetsController),
RegexRoute("/compiled/(.+)", CompiledAssetsController),
RegexRoute("/aprs-symbols/(.+)", AprsSymbolsController),
RegexRoute("^/static/(.+)$", OwrxAssetsController),
RegexRoute("^/compiled/(.+)$", CompiledAssetsController),
RegexRoute("^/aprs-symbols/(.+)$", AprsSymbolsController),
StaticRoute("/ws/", WebSocketController),
RegexRoute("(/favicon.ico)", OwrxAssetsController),
RegexRoute("^(/favicon.ico)$", OwrxAssetsController),
StaticRoute("/map", MapController),
StaticRoute("/features", FeatureController),
StaticRoute("/api/features", ApiController),
StaticRoute("/api/receiverdetails", ApiController, options={"action": "receiverDetails"}),
StaticRoute("/metrics", MetricsController),
StaticRoute("/metrics", MetricsController, options={"action": "prometheusAction"}),
StaticRoute("/metrics.json", MetricsController),
StaticRoute("/settings", SettingsController),
StaticRoute("/generalsettings", GeneralSettingsController),
StaticRoute("/settings/general", GeneralSettingsController),
StaticRoute(
"/generalsettings", GeneralSettingsController, method="POST", options={"action": "processFormData"}
"/settings/general", GeneralSettingsController, method="POST", options={"action": "processFormData"}
),
StaticRoute("/settings/sdr", SdrDeviceListController),
StaticRoute("/settings/newsdr", NewSdrDeviceController),
StaticRoute(
"/settings/newsdr", NewSdrDeviceController, method="POST", options={"action": "processFormData"}
),
RegexRoute("^/settings/sdr/([^/]+)$", SdrDeviceController),
RegexRoute(
"^/settings/sdr/([^/]+)$", SdrDeviceController, method="POST", options={"action": "processFormData"}
),
RegexRoute("^/settings/deletesdr/([^/]+)$", SdrDeviceController, options={"action": "deleteDevice"}),
RegexRoute("^/settings/sdr/([^/]+)/newprofile$", NewProfileController),
RegexRoute(
"^/settings/sdr/([^/]+)/newprofile$",
NewProfileController,
method="POST",
options={"action": "processFormData"},
),
RegexRoute("^/settings/sdr/([^/]+)/profile/([^/]+)$", SdrProfileController),
RegexRoute(
"^/settings/sdr/([^/]+)/profile/([^/]+)$",
SdrProfileController,
method="POST",
options={"action": "processFormData"},
),
RegexRoute(
"^/settings/sdr/([^/]+)/deleteprofile/([^/]+)$",
SdrProfileController,
options={"action": "deleteProfile"},
),
StaticRoute("/settings/bookmarks", BookmarksController),
StaticRoute("/settings/bookmarks", BookmarksController, method="POST", options={"action": "new"}),
RegexRoute("^/settings/bookmarks/(.+)$", BookmarksController, method="POST", options={"action": "update"}),
RegexRoute(
"^/settings/bookmarks/(.+)$", BookmarksController, method="DELETE", options={"action": "delete"}
),
StaticRoute("/settings/reporting", ReportingController),
StaticRoute(
"/settings/reporting", ReportingController, method="POST", options={"action": "processFormData"}
),
StaticRoute("/settings/backgrounddecoding", BackgroundDecodingController),
StaticRoute(
"/settings/backgrounddecoding",
BackgroundDecodingController,
method="POST",
options={"action": "processFormData"},
),
StaticRoute("/settings/decoding", DecodingSettingsController),
StaticRoute(
"/settings/decoding", DecodingSettingsController, method="POST", options={"action": "processFormData"}
),
StaticRoute("/sdrsettings", SdrSettingsController),
StaticRoute("/login", SessionController, options={"action": "loginAction"}),
StaticRoute("/login", SessionController, method="POST", options={"action": "processLoginAction"}),
StaticRoute("/logout", SessionController, options={"action": "logoutAction"}),
StaticRoute("/pwchange", ProfileController),
StaticRoute("/pwchange", ProfileController, method="POST", options={"action": "processPwChange"}),
StaticRoute("/imageupload", ImageUploadController),
StaticRoute("/imageupload", ImageUploadController, method="POST", options={"action": "processImage"}),
]
def find_route(self, request):
@ -122,3 +174,23 @@ class Router(object):
controller(handler, request, route.controllerOptions).handle_request()
else:
handler.send_error(404, "Not Found", "The page you requested could not be found.")
class RequestHandler(BaseHTTPRequestHandler):
timeout = 30
router = Router()
def log_message(self, format, *args):
logger.debug("%s - - [%s] %s", self.address_string(), self.log_date_time_string(), format % args)
def do_GET(self):
self.router.route(self, self._build_request("GET"))
def do_POST(self):
self.router.route(self, self._build_request("POST"))
def do_DELETE(self):
self.router.route(self, self._build_request("DELETE"))
def _build_request(self, method):
return Request(self.path, method, self.headers)

View File

@ -1,32 +1,20 @@
from .audio import AudioChopperProfile
from .parser import Parser
from owrx.audio import AudioChopperProfile, ConfigWiredProfileSource
from owrx.parser import Parser
import re
from js8py import Js8
from js8py.frames import Js8FrameHeartbeat, Js8FrameCompound
from .map import Map, LocatorLocation
from .metrics import Metrics, CounterMetric
from .config import Config
from owrx.map import Map, LocatorLocation
from owrx.metrics import Metrics, CounterMetric
from owrx.config import Config
from abc import ABCMeta, abstractmethod
from owrx.reporting import ReportingEngine
from typing import List
import logging
logger = logging.getLogger(__name__)
class Js8Profiles(object):
@staticmethod
def getEnabledProfiles():
config = Config.get()
profiles = config["js8_enabled_profiles"] if "js8_enabled_profiles" in config else []
return [Js8Profiles.loadProfile(p) for p in profiles]
@staticmethod
def loadProfile(profileName):
className = "Js8{0}Profile".format(profileName[0].upper() + profileName[1:].lower())
return globals()[className]()
class Js8Profile(AudioChopperProfile, metaclass=ABCMeta):
def decoding_depth(self):
pm = Config.get()
@ -47,6 +35,20 @@ class Js8Profile(AudioChopperProfile, metaclass=ABCMeta):
pass
class Js8ProfileSource(ConfigWiredProfileSource):
def getPropertiesToWire(self) -> List[str]:
return ["js8_enabled_profiles"]
def getProfiles(self) -> List[AudioChopperProfile]:
config = Config.get()
profiles = config["js8_enabled_profiles"] if "js8_enabled_profiles" in config else []
return [self._loadProfile(p) for p in profiles]
def _loadProfile(self, profileName):
className = "Js8{0}Profile".format(profileName[0].upper() + profileName[1:].lower())
return globals()[className]()
class Js8NormalProfile(Js8Profile):
def getInterval(self):
return 15
@ -82,40 +84,39 @@ class Js8TurboProfile(Js8Profile):
class Js8Parser(Parser):
decoderRegex = re.compile(" ?<Decode(Started|Debug|Finished)>")
def parse(self, messages):
for raw in messages:
try:
profile, freq, raw_msg = raw
self.setDialFrequency(freq)
msg = raw_msg.decode().rstrip()
if Js8Parser.decoderRegex.match(msg):
return
if msg.startswith(" EOF on input file"):
return
def parse(self, raw):
try:
profile, freq, raw_msg = raw
self.setDialFrequency(freq)
msg = raw_msg.decode().rstrip()
if Js8Parser.decoderRegex.match(msg):
return
if msg.startswith(" EOF on input file"):
return
frame = Js8().parse_message(msg)
self.handler.write_js8_message(frame, self.dial_freq)
frame = Js8().parse_message(msg)
self.handler.write_js8_message(frame, self.dial_freq)
self.pushDecode()
self.pushDecode()
if (isinstance(frame, Js8FrameHeartbeat) or isinstance(frame, Js8FrameCompound)) and frame.grid:
Map.getSharedInstance().updateLocation(
frame.callsign, LocatorLocation(frame.grid), "JS8", self.band
)
ReportingEngine.getSharedInstance().spot(
{
"callsign": frame.callsign,
"mode": "JS8",
"locator": frame.grid,
"freq": self.dial_freq + frame.freq,
"db": frame.db,
"timestamp": frame.timestamp,
"msg": str(frame),
}
)
if (isinstance(frame, Js8FrameHeartbeat) or isinstance(frame, Js8FrameCompound)) and frame.grid:
Map.getSharedInstance().updateLocation(
frame.callsign, LocatorLocation(frame.grid), "JS8", self.band
)
ReportingEngine.getSharedInstance().spot(
{
"callsign": frame.callsign,
"mode": "JS8",
"locator": frame.grid,
"freq": self.dial_freq + frame.freq,
"db": frame.db,
"timestamp": frame.timestamp,
"msg": str(frame),
}
)
except Exception:
logger.exception("error while parsing js8 message")
except Exception:
logger.exception("error while parsing js8 message")
def pushDecode(self):
metrics = Metrics.getSharedInstance()

9
owrx/jsons.py Normal file
View File

@ -0,0 +1,9 @@
from owrx.property import PropertyManager
import json
class Encoder(json.JSONEncoder):
def default(self, o):
if isinstance(o, PropertyManager):
return o.__dict__()
return super().default(o)

View File

@ -3,6 +3,7 @@ import time
import logging
import random
from owrx.config import Config
from abc import ABC, abstractmethod
logger = logging.getLogger(__name__)
@ -14,8 +15,66 @@ TFESC = 0xDD
FEET_PER_METER = 3.28084
class DirewolfConfigSubscriber(ABC):
@abstractmethod
def onConfigChanged(self):
pass
class DirewolfConfig(object):
def getConfig(self, port, is_service):
config_keys = [
"aprs_callsign",
"aprs_igate_enabled",
"aprs_igate_server",
"aprs_igate_password",
"receiver_gps",
"aprs_igate_symbol",
"aprs_igate_beacon",
"aprs_igate_gain",
"aprs_igate_dir",
"aprs_igate_comment",
"aprs_igate_height",
]
def __init__(self):
self.subscribers = []
self.configSub = None
self.port = None
def wire(self, subscriber: DirewolfConfigSubscriber):
self.subscribers.append(subscriber)
if self.configSub is None:
pm = Config.get()
self.configSub = pm.filter(*DirewolfConfig.config_keys).wire(self._fireChanged)
def unwire(self, subscriber: DirewolfConfigSubscriber):
self.subscribers.remove(subscriber)
if not self.subscribers and self.configSub is not None:
self.configSub.cancel()
def _fireChanged(self, changes):
for sub in self.subscribers:
try:
sub.onConfigChanged()
except Exception:
logger.exception("Error while notifying Direwolf subscribers")
def getPort(self):
# direwolf has some strange hardcoded port ranges
while self.port is None:
try:
port = random.randrange(1024, 49151)
# test if port is available for use
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
s.bind(("localhost", port))
s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
s.close()
self.port = port
except OSError:
pass
return self.port
def getConfig(self, is_service):
pm = Config.get()
config = """
@ -29,16 +88,11 @@ MODEM 1200
KISSPORT {port}
AGWPORT off
""".format(
port=port, callsign=pm["aprs_callsign"]
port=self.getPort(), callsign=pm["aprs_callsign"]
)
if is_service and pm["aprs_igate_enabled"]:
config += """
IGSERVER {server}
IGLOGIN {callsign} {password}
""".format(
server=pm["aprs_igate_server"], callsign=pm["aprs_callsign"], password=pm["aprs_igate_password"]
)
pbeacon = ""
if pm["aprs_igate_beacon"]:
# Format beacon lat/lon
@ -51,12 +105,6 @@ IGLOGIN {callsign} {password}
lat = "{0:02d}^{1:05.2f}{2}".format(int(lat), (lat - int(lat)) * 60, direction_ns)
lon = "{0:03d}^{1:05.2f}{2}".format(int(lon), (lon - int(lon)) * 60, direction_we)
# Format beacon details
symbol = str(pm["aprs_igate_symbol"]) if "aprs_igate_symbol" in pm else "R&"
gain = "GAIN=" + str(pm["aprs_igate_gain"]) if "aprs_igate_gain" in pm else ""
adir = "DIR=" + str(pm["aprs_igate_dir"]) if "aprs_igate_dir" in pm else ""
comment = str(pm["aprs_igate_comment"]) if "aprs_igate_comment" in pm else '"OpenWebRX APRS gateway"'
# Convert height from meters to feet if specified
height = ""
if "aprs_igate_height" in pm:
@ -69,38 +117,33 @@ IGLOGIN {callsign} {password}
"Cannot parse 'aprs_igate_height', expected float: " + str(pm["aprs_igate_height"])
)
if (len(comment) > 0) and ((comment[0] != '"') or (comment[len(comment) - 1] != '"')):
comment = '"' + comment + '"'
elif len(comment) == 0:
comment = '""'
pbeacon = "PBEACON sendto=IG delay=0:30 every=60:00 symbol={symbol} lat={lat} long={lon} {height} {gain} {adir} comment={comment}".format(
symbol=symbol, lat=lat, lon=lon, height=height, gain=gain, adir=adir, comment=comment
pbeacon = 'PBEACON sendto=IG delay=0:30 every=60:00 symbol={symbol} lat={lat} long={lon} {height} {gain} {adir} comment="{comment}"'.format(
symbol=pm["aprs_igate_symbol"],
lat=lat,
lon=lon,
height=height,
gain="GAIN=" + str(pm["aprs_igate_gain"]) if "aprs_igate_gain" in pm else "",
adir="DIR=" + str(pm["aprs_igate_dir"]) if "aprs_igate_dir" in pm else "",
comment=pm["aprs_igate_comment"],
)
logger.info("APRS PBEACON String: " + pbeacon)
config += "\n" + pbeacon + "\n"
config += """
IGSERVER {server}
IGLOGIN {callsign} {password}
{pbeacon}
""".format(
server=pm["aprs_igate_server"],
callsign=pm["aprs_callsign"],
password=pm["aprs_igate_password"],
pbeacon=pbeacon,
)
return config
class KissClient(object):
@staticmethod
def getFreePort():
# direwolf has some strange hardcoded port ranges
while True:
try:
port = random.randrange(1024, 49151)
# test if port is available for use
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
s.bind(("localhost", port))
s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
s.close()
return port
except OSError:
pass
def __init__(self, port):
delay = 0.5
retries = 0

View File

@ -6,75 +6,100 @@ import logging
import threading
from owrx.map import Map, LatLngLocation
from owrx.parser import Parser
from owrx.aprs import AprsParser, AprsLocation
from abc import ABC, abstractmethod
logger = logging.getLogger(__name__)
class DmrCache(object):
class Enricher(ABC):
def __init__(self, parser):
self.parser = parser
@abstractmethod
def enrich(self, meta):
pass
class RadioIDCache(object):
sharedInstance = None
@staticmethod
def getSharedInstance():
if DmrCache.sharedInstance is None:
DmrCache.sharedInstance = DmrCache()
return DmrCache.sharedInstance
if RadioIDCache.sharedInstance is None:
RadioIDCache.sharedInstance = RadioIDCache()
return RadioIDCache.sharedInstance
def __init__(self):
self.cache = {}
self.cacheTimeout = timedelta(seconds=86400)
def isValid(self, key):
def isValid(self, mode, radio_id):
key = self.__key(mode, radio_id)
if key not in self.cache:
return False
entry = self.cache[key]
return entry["timestamp"] + self.cacheTimeout > datetime.now()
def put(self, key, value):
self.cache[key] = {"timestamp": datetime.now(), "data": value}
def __key(self, mode, radio_id):
return "{}-{}".format(mode, radio_id)
def get(self, key):
if not self.isValid(key):
def put(self, mode, radio_id, value):
self.cache[self.__key(mode, radio_id)] = {"timestamp": datetime.now(), "data": value}
def get(self, mode, radio_id):
if not self.isValid(mode, radio_id):
return None
return self.cache[key]["data"]
return self.cache[self.__key(mode, radio_id)]["data"]
class DmrMetaEnricher(object):
def __init__(self):
class RadioIDEnricher(Enricher):
def __init__(self, mode, parser):
super().__init__(parser)
self.mode = mode
self.threads = {}
def downloadRadioIdData(self, id):
cache = DmrCache.getSharedInstance()
try:
logger.debug("requesting DMR metadata for id=%s", id)
res = request.urlopen("https://www.radioid.net/api/dmr/user/?id={0}".format(id), timeout=30).read()
data = json.loads(res.decode("utf-8"))
cache.put(id, data)
except json.JSONDecodeError:
cache.put(id, None)
def _fillCache(self, id):
RadioIDCache.getSharedInstance().put(self.mode, id, self._downloadRadioIdData(id))
del self.threads[id]
def _downloadRadioIdData(self, id):
try:
logger.debug("requesting radioid metadata for mode=%s and id=%s", self.mode, id)
res = request.urlopen("https://www.radioid.net/api/{0}/user/?id={1}".format(self.mode, id), timeout=30)
if res.status != 200:
logger.warning("radioid API returned error %i for mode=%s and id=%s", res.status, self.mode, id)
return None
data = json.loads(res.read().decode("utf-8"))
if "count" in data and data["count"] > 0 and "results" in data:
for item in data["results"]:
if "id" in item and item["id"] == id:
return item
except json.JSONDecodeError:
logger.warning("unable to parse radioid response JSON")
return None
def enrich(self, meta):
if not Config.get()["digital_voice_dmr_id_lookup"]:
config_key = "digital_voice_{}_id_lookup".format(self.mode)
if not Config.get()[config_key]:
return meta
if "source" not in meta:
return meta
id = meta["source"]
cache = DmrCache.getSharedInstance()
if not cache.isValid(id):
id = int(meta["source"])
cache = RadioIDCache.getSharedInstance()
if not cache.isValid(self.mode, id):
if id not in self.threads:
self.threads[id] = threading.Thread(target=self.downloadRadioIdData, args=[id], daemon=True)
self.threads[id] = threading.Thread(target=self._fillCache, args=[id], daemon=True)
self.threads[id].start()
return meta
data = cache.get(id)
if "count" in data and data["count"] > 0 and "results" in data:
meta["additional"] = data["results"][0]
data = cache.get(self.mode, id)
if data is not None:
meta["additional"] = data
return meta
class YsfMetaEnricher(object):
def __init__(self, parser):
self.parser = parser
class YsfMetaEnricher(Enricher):
def enrich(self, meta):
for key in ["source", "up", "down", "target"]:
if key in meta:
@ -83,20 +108,56 @@ class YsfMetaEnricher(object):
if key in meta:
meta[key] = float(meta[key])
if "source" in meta and "lat" in meta and "lon" in meta:
# TODO parsing the float values should probably happen earlier
loc = LatLngLocation(meta["lat"], meta["lon"])
Map.getSharedInstance().updateLocation(meta["source"], loc, "YSF", self.parser.getBand())
return meta
class DStarEnricher(Enricher):
def enrich(self, meta):
for key in ["lat", "lon"]:
if key in meta:
meta[key] = float(meta[key])
if "ourcall" in meta and "lat" in meta and "lon" in meta:
loc = LatLngLocation(meta["lat"], meta["lon"])
Map.getSharedInstance().updateLocation(meta["ourcall"], loc, "D-Star", self.parser.getBand())
if "dprs" in meta:
try:
# we can send the DPRS stuff through our APRS parser to extract the information
# TODO: only third-party parsing accepts this format right now
# TODO: we also need to pass a handler, which is not needed
parser = AprsParser(None)
dprsData = parser.parseThirdpartyAprsData(meta["dprs"])
if "data" in dprsData:
data = dprsData["data"]
if "lat" in data and "lon" in data:
# TODO: we could actually get the symbols from the parsed APRS data and show that on the meta panel
meta["lat"] = data["lat"]
meta["lon"] = data["lon"]
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(meta["ourcall"], loc, "DPRS", self.parser.getBand())
except Exception:
logger.exception("Error while parsing DPRS data")
return meta
class MetaParser(Parser):
def __init__(self, handler):
super().__init__(handler)
self.enrichers = {"DMR": DmrMetaEnricher(), "YSF": YsfMetaEnricher(self)}
self.enrichers = {
"DMR": RadioIDEnricher("dmr", self),
"YSF": YsfMetaEnricher(self),
"DSTAR": DStarEnricher(self),
"NXDN": RadioIDEnricher("nxdn", self),
}
def parse(self, meta):
fields = meta.split(";")
meta = {v[0]: "".join(v[1:]) for v in map(lambda x: x.split(":"), fields) if v[0] != ""}
meta = {v[0]: ":".join(v[1:]) for v in map(lambda x: x.split(":"), fields) if v[0] != ""}
if "protocol" in meta:
protocol = meta["protocol"]

View File

@ -52,7 +52,10 @@ class Metrics(object):
return None
return self.metrics[name]
def getMetrics(self):
def getFlatMetrics(self):
return self.metrics
def getHierarchicalMetrics(self):
result = {}
for (key, metric) in self.metrics.items():

View File

@ -1,5 +1,7 @@
from owrx.feature import FeatureDetector
from owrx.audio import ProfileSource
from functools import reduce
from abc import ABCMeta, abstractmethod
class Bandpass(object):
@ -51,6 +53,41 @@ class DigitalMode(Mode):
return Modes.findByModulation(self.underlying[0]).get_modulation()
class AudioChopperMode(DigitalMode, metaclass=ABCMeta):
def __init__(self, modulation, name, bandpass=None, requirements=None):
if bandpass is None:
bandpass = Bandpass(0, 3000)
super().__init__(modulation, name, ["usb"], bandpass=bandpass, requirements=requirements, service=True)
@abstractmethod
def get_profile_source(self) -> ProfileSource:
pass
class WsjtMode(AudioChopperMode):
def __init__(self, modulation, name, bandpass=None, requirements=None):
if requirements is None:
requirements = ["wsjt-x"]
super().__init__(modulation, name, bandpass=bandpass, requirements=requirements)
def get_profile_source(self) -> ProfileSource:
# inline import due to circular dependencies
from owrx.wsjt import WsjtProfiles
return WsjtProfiles.getSource(self.modulation)
class Js8Mode(AudioChopperMode):
def __init__(self, modulation, name, bandpass=None, requirements=None):
if requirements is None:
requirements = ["js8call"]
super().__init__(modulation, name, bandpass, requirements)
def get_profile_source(self) -> ProfileSource:
# inline import due to circular dependencies
from owrx.js8 import Js8ProfileSource
return Js8ProfileSource()
class Modes(object):
mappings = [
AnalogMode("nfm", "FM", bandpass=Bandpass(-4000, 4000)),
@ -61,9 +98,9 @@ class Modes(object):
AnalogMode("cw", "CW", bandpass=Bandpass(700, 900)),
AnalogMode("dmr", "DMR", bandpass=Bandpass(-4000, 4000), requirements=["digital_voice_digiham"], squelch=False),
AnalogMode(
"dstar", "D-Star", bandpass=Bandpass(-3250, 3250), requirements=["digital_voice_dsd"], squelch=False
"dstar", "D-Star", bandpass=Bandpass(-3250, 3250), requirements=["digital_voice_digiham"], squelch=False
),
AnalogMode("nxdn", "NXDN", bandpass=Bandpass(-3250, 3250), requirements=["digital_voice_dsd"], 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(
@ -72,35 +109,15 @@ class Modes(object):
AnalogMode("drm", "DRM", bandpass=Bandpass(-5000, 5000), requirements=["drm"], squelch=False),
DigitalMode("bpsk31", "BPSK31", underlying=["usb"]),
DigitalMode("bpsk63", "BPSK63", underlying=["usb"]),
DigitalMode(
"ft8", "FT8", underlying=["usb"], bandpass=Bandpass(0, 3000), requirements=["wsjt-x"], service=True
),
DigitalMode(
"ft4", "FT4", underlying=["usb"], bandpass=Bandpass(0, 3000), requirements=["wsjt-x"], service=True
),
DigitalMode(
"jt65", "JT65", underlying=["usb"], bandpass=Bandpass(0, 3000), requirements=["wsjt-x"], service=True
),
DigitalMode(
"jt9", "JT9", underlying=["usb"], bandpass=Bandpass(0, 3000), requirements=["wsjt-x"], service=True
),
DigitalMode(
"wspr", "WSPR", underlying=["usb"], bandpass=Bandpass(1350, 1650), requirements=["wsjt-x"], service=True
),
DigitalMode(
"fst4", "FST4", underlying=["usb"], bandpass=Bandpass(0, 3000), requirements=["wsjt-x-2-3"], service=True
),
DigitalMode(
"fst4w",
"FST4W",
underlying=["usb"],
bandpass=Bandpass(1350, 1650),
requirements=["wsjt-x-2-3"],
service=True,
),
DigitalMode(
"js8", "JS8Call", underlying=["usb"], bandpass=Bandpass(0, 3000), requirements=["js8call"], service=True
),
WsjtMode("ft8", "FT8"),
WsjtMode("ft4", "FT4"),
WsjtMode("jt65", "JT65"),
WsjtMode("jt9", "JT9"),
WsjtMode("wspr", "WSPR", bandpass=Bandpass(1350, 1650)),
WsjtMode("fst4", "FST4", requirements=["wsjt-x-2-3"]),
WsjtMode("fst4w", "FST4W", bandpass=Bandpass(1350, 1650), requirements=["wsjt-x-2-3"]),
WsjtMode("q65", "Q65", requirements=["wsjt-x-2-4"]),
Js8Mode("js8", "JS8Call"),
DigitalMode(
"packet",
"Packet",

View File

@ -1,9 +1,25 @@
from abc import ABC, abstractmethod
from owrx.property.validators import Validator
from owrx.property.filter import Filter, ByPropertyName
import logging
logger = logging.getLogger(__name__)
class PropertyError(Exception):
pass
class PropertyDeletion(object):
def __bool__(self):
return False
# a special object that will be sent in events when a deletion occured
# it can also represent deletion of a key in internal storage, but should not be return from standard dict apis
PropertyDeleted = PropertyDeletion()
class Subscription(object):
def __init__(self, subscriptee, name, subscriber):
self.subscriptee = subscriptee
@ -48,8 +64,22 @@ class PropertyManager(ABC):
def keys(self):
pass
@abstractmethod
def values(self):
pass
@abstractmethod
def items(self):
pass
def __len__(self):
return self.__dict__().__len__()
def filter(self, *props):
return PropertyFilter(self, *props)
return PropertyFilter(self, ByPropertyName(*props))
def readonly(self):
return PropertyReadOnly(self)
def wire(self, callback):
sub = Subscription(self, None, callback)
@ -74,14 +104,15 @@ class PropertyManager(ABC):
def _fireCallbacks(self, changes):
if not changes:
return
for c in self.subscribers:
subscribers = self.subscribers.copy()
for c in subscribers:
try:
if c.getName() is None:
c.call(changes)
except Exception:
logger.exception("exception while firing changes")
for name in changes:
for c in self.subscribers:
for c in subscribers:
try:
if c.getName() == name:
c.call(changes[name])
@ -90,9 +121,10 @@ class PropertyManager(ABC):
class PropertyLayer(PropertyManager):
def __init__(self):
def __init__(self, **kwargs):
super().__init__()
self.properties = {}
# copy, don't re-use
self.properties = {k: v for k, v in kwargs.items()}
def __contains__(self, name):
return name in self.properties
@ -110,48 +142,132 @@ class PropertyLayer(PropertyManager):
return {k: v for k, v in self.properties.items()}
def __delitem__(self, key):
return self.properties.__delitem__(key)
self.properties.__delitem__(key)
self._fireCallbacks({key: PropertyDeleted})
def keys(self):
return self.properties.keys()
def values(self):
return self.properties.values()
def items(self):
return self.properties.items()
class PropertyFilter(PropertyManager):
def __init__(self, pm: PropertyManager, *props: str):
def __init__(self, pm: PropertyManager, filter: Filter):
super().__init__()
self.pm = pm
self.props = props
self._filter = filter
self.pm.wire(self.receiveEvent)
def receiveEvent(self, changes):
changesToForward = {name: value for name, value in changes.items() if name in self.props}
changesToForward = {name: value for name, value in changes.items() if self._filter.apply(name)}
self._fireCallbacks(changesToForward)
def __getitem__(self, item):
if item not in self.props:
if not self._filter.apply(item):
raise KeyError(item)
return self.pm.__getitem__(item)
def __setitem__(self, key, value):
if key not in self.props:
if not self._filter.apply(key):
raise KeyError(key)
return self.pm.__setitem__(key, value)
def __contains__(self, item):
if item not in self.props:
if not self._filter.apply(item):
return False
return self.pm.__contains__(item)
def __dict__(self):
return {k: v for k, v in self.pm.__dict__().items() if k in self.props}
return {k: v for k, v in self.pm.__dict__().items() if self._filter.apply(k)}
def __delitem__(self, key):
if key not in self.props:
if not self._filter.apply(key):
raise KeyError(key)
return self.pm.__delitem__(key)
def keys(self):
return [k for k in self.pm.keys() if k in self.props]
return [k for k in self.pm.keys() if self._filter.apply(k)]
def values(self):
return [v for k, v in self.pm.items() if self._filter.apply(k)]
def items(self):
return self.__dict__().items()
class PropertyDelegator(PropertyManager):
def __init__(self, pm: PropertyManager):
self.pm = pm
self.subscription = self.pm.wire(self._fireCallbacks)
super().__init__()
def __getitem__(self, item):
return self.pm.__getitem__(item)
def __setitem__(self, key, value):
return self.pm.__setitem__(key, value)
def __contains__(self, item):
return self.pm.__contains__(item)
def __dict__(self):
return self.pm.__dict__()
def __delitem__(self, key):
return self.pm.__delitem__(key)
def keys(self):
return self.pm.keys()
def values(self):
return self.pm.values()
def items(self):
return self.pm.items()
class PropertyValidationError(PropertyError):
def __init__(self, key, value):
super().__init__('Invalid value for property "{key}": "{value}"'.format(key=key, value=str(value)))
class PropertyValidator(PropertyDelegator):
def __init__(self, pm: PropertyManager, validators=None):
super().__init__(pm)
if validators is None:
self.validators = {}
else:
self.validators = {k: Validator.of(v) for k, v in validators.items()}
def validate(self, key, value):
if key not in self.validators:
return
if not self.validators[key].isValid(value):
raise PropertyValidationError(key, value)
def setValidator(self, key, validator):
self.validators[key] = Validator.of(validator)
def __setitem__(self, key, value):
self.validate(key, value)
return self.pm.__setitem__(key, value)
class PropertyWriteError(PropertyError):
def __init__(self, key):
super().__init__('Key "{key}" is not writeable'.format(key=key))
class PropertyReadOnly(PropertyDelegator):
def __setitem__(self, key, value):
raise PropertyWriteError(key)
def __delitem__(self, key):
raise PropertyWriteError(key)
class PropertyStack(PropertyManager):
@ -180,6 +296,11 @@ class PropertyStack(PropertyManager):
return changes
def removeLayerByPriority(self, priority):
for layer in self.layers:
if layer["priority"] == priority:
self.removeLayer(layer["props"])
def removeLayer(self, pm: PropertyManager):
for layer in self.layers:
if layer["props"] == pm:
@ -195,7 +316,7 @@ class PropertyStack(PropertyManager):
if self[key] != pm[key]:
changes[key] = self[key]
else:
changes[key] = None
changes[key] = PropertyDeleted
return changes
def replaceLayer(self, priority: int, pm: PropertyManager):
@ -211,15 +332,23 @@ class PropertyStack(PropertyManager):
def receiveEvent(self, layer, changes):
changesToForward = {name: value for name, value in changes.items() if layer == self._getTopLayer(name)}
self._fireCallbacks(changesToForward)
# deletions need to be handled separately:
# * send a deletion if the key was deleted in all layers
# * send lower value if the key is still present in a lower layer
deletionsToForward = {
name: PropertyDeleted if self._getTopLayer(name, False) is None else self[name]
for name, value in changes.items()
if value is PropertyDeleted
}
self._fireCallbacks({**changesToForward, **deletionsToForward})
def _getTopLayer(self, item):
def _getTopLayer(self, item, fallback=True):
layers = [la["props"] for la in sorted(self.layers, key=lambda l: l["priority"])]
for m in layers:
if item in m:
return m
# return top layer by default
if layers:
# return top layer as fallback
if fallback and layers:
return layers[0]
def __getitem__(self, item):
@ -241,7 +370,52 @@ class PropertyStack(PropertyManager):
def __delitem__(self, key):
for layer in self.layers:
layer["props"].__delitem__(key)
if layer["props"].__contains__(key):
layer["props"].__delitem__(key)
def keys(self):
return set([key for l in self.layers for key in l["props"].keys()])
def values(self):
return [self.__getitem__(k) for k in self.keys()]
def items(self):
return self.__dict__().items()
class PropertyCarousel(PropertyDelegator):
def __init__(self):
# start with an empty dummy layer
self.emptyLayer = PropertyLayer().readonly()
super().__init__(self.emptyLayer)
self.layers = {}
def _getDefaultLayer(self):
return self.emptyLayer
def addLayer(self, key, value):
if key in self.layers and self.layers[key] is self.pm:
self.layers[key] = value
# switch after introducing the new value
self.switch(key)
else:
self.layers[key] = value
def removeLayer(self, key):
if key in self.layers and self.layers[key] is self.pm:
self.switch()
del self.layers[key]
def switch(self, key=None):
before = self.pm
self.subscription.cancel()
self.pm = self._getDefaultLayer() if key is None else self.layers[key]
self.subscription = self.pm.wire(self._fireCallbacks)
changes = {}
for key in set(list(before.keys()) + list(self.keys())):
if key not in self:
changes[key] = PropertyDeleted
else:
if key not in before or before[key] != self[key]:
changes[key] = self[key]
self._fireCallbacks(changes)

23
owrx/property/filter.py Normal file
View File

@ -0,0 +1,23 @@
from abc import ABC, abstractmethod
class Filter(ABC):
@abstractmethod
def apply(self, prop) -> bool:
pass
class ByPropertyName(Filter):
def __init__(self, *props):
self.props = props
def apply(self, prop) -> bool:
return prop in self.props
class ByLambda(Filter):
def __init__(self, func):
self.func = func
def apply(self, prop) -> bool:
return self.func(prop)

View File

@ -0,0 +1,97 @@
from abc import ABC, abstractmethod
from functools import reduce
from operator import or_
class ValidatorException(Exception):
pass
class Validator(ABC):
@staticmethod
def of(x):
if isinstance(x, Validator):
return x
if callable(x):
return LambdaValidator(x)
if x in validator_types:
return validator_types[x]()
raise ValidatorException("Cannot create validator")
@abstractmethod
def isValid(self, value):
pass
class LambdaValidator(Validator):
def __init__(self, c):
self.callable = c
def isValid(self, value):
return self.callable(value)
class TypeValidator(Validator):
def __init__(self, type):
self.type = type
super().__init__()
def isValid(self, value):
return isinstance(value, self.type)
class IntegerValidator(TypeValidator):
def __init__(self):
super().__init__(int)
class FloatValidator(TypeValidator):
def __init__(self):
super().__init__(float)
class StringValidator(TypeValidator):
def __init__(self):
super().__init__(str)
class BoolValidator(TypeValidator):
def __init__(self):
super().__init__(bool)
class OrValidator(Validator):
def __init__(self, *validators):
self.validators = validators
super().__init__()
def isValid(self, value):
return reduce(
or_,
[v.isValid(value) for v in self.validators],
False
)
class NumberValidator(OrValidator):
def __init__(self):
super().__init__(IntegerValidator(), FloatValidator())
class RegexValidator(StringValidator):
def __init__(self, regex):
self.regex = regex
super().__init__()
def isValid(self, value):
return super().isValid(value) and self.regex.match(value) is not None
validator_types = {
"string": StringValidator,
"str": StringValidator,
"integer": IntegerValidator,
"int": IntegerValidator,
"number": NumberValidator,
"num": NumberValidator,
}

View File

@ -1,58 +0,0 @@
import threading
from abc import ABC, abstractmethod
from owrx.config import Config
class Reporter(ABC):
@abstractmethod
def stop(self):
pass
@abstractmethod
def spot(self, spot):
pass
@abstractmethod
def getSupportedModes(self):
return []
class ReportingEngine(object):
creationLock = threading.Lock()
sharedInstance = None
@staticmethod
def getSharedInstance():
with ReportingEngine.creationLock:
if ReportingEngine.sharedInstance is None:
ReportingEngine.sharedInstance = ReportingEngine()
return ReportingEngine.sharedInstance
@staticmethod
def stopAll():
with ReportingEngine.creationLock:
if ReportingEngine.sharedInstance is not None:
ReportingEngine.sharedInstance.stop()
def __init__(self):
self.reporters = []
config = Config.get()
if "pskreporter_enabled" in config and config["pskreporter_enabled"]:
# inline import due to circular dependencies
from owrx.pskreporter import PskReporter
self.reporters += [PskReporter()]
if "wsprnet_enabled" in config and config["wsprnet_enabled"]:
# inline import due to circular dependencies
from owrx.wsprnet import WsprnetReporter
self.reporters += [WsprnetReporter()]
def stop(self):
for r in self.reporters:
r.stop()
def spot(self, spot):
for r in self.reporters:
if spot["mode"] in r.getSupportedModes():
r.spot(spot)

View File

@ -0,0 +1,57 @@
import threading
from owrx.config import Config
from owrx.reporting.reporter import Reporter
from owrx.reporting.pskreporter import PskReporter
from owrx.reporting.wsprnet import WsprnetReporter
import logging
logger = logging.getLogger(__name__)
class ReportingEngine(object):
creationLock = threading.Lock()
sharedInstance = None
reporterClasses = {
"pskreporter_enabled": PskReporter,
"wsprnet_enabled": WsprnetReporter,
}
@staticmethod
def getSharedInstance():
with ReportingEngine.creationLock:
if ReportingEngine.sharedInstance is None:
ReportingEngine.sharedInstance = ReportingEngine()
return ReportingEngine.sharedInstance
@staticmethod
def stopAll():
with ReportingEngine.creationLock:
if ReportingEngine.sharedInstance is not None:
ReportingEngine.sharedInstance.stop()
def __init__(self):
self.reporters = []
self.configSub = Config.get().filter(*ReportingEngine.reporterClasses.keys()).wire(self.setupReporters)
self.setupReporters()
def setupReporters(self, *args):
config = Config.get()
for configKey, reporterClass in ReportingEngine.reporterClasses.items():
if configKey in config and config[configKey]:
if not any(isinstance(r, reporterClass) for r in self.reporters):
self.reporters += [reporterClass()]
else:
for reporter in [r for r in self.reporters if isinstance(r, reporterClass)]:
reporter.stop()
self.reporters.remove(reporter)
def stop(self):
for r in self.reporters:
r.stop()
self.configSub.cancel()
def spot(self, spot):
for r in self.reporters:
if spot["mode"] in r.getSupportedModes():
r.spot(spot)

View File

@ -9,7 +9,7 @@ from owrx.config import Config
from owrx.version import openwebrx_version
from owrx.locator import Locator
from owrx.metrics import Metrics, CounterMetric
from owrx.reporting import Reporter
from owrx.reporting.reporter import Reporter
logger = logging.getLogger(__name__)
@ -18,10 +18,12 @@ class PskReporter(Reporter):
interval = 300
def getSupportedModes(self):
return ["FT8", "FT4", "JT9", "JT65", "FST4", "JS8"]
return ["FT8", "FT4", "JT9", "JT65", "FST4", "JS8", "Q65"]
def stop(self):
self.cancelTimer()
with self.spotLock:
self.spots = []
def __init__(self):
self.spots = []

View File

@ -0,0 +1,15 @@
from abc import ABC, abstractmethod
class Reporter(ABC):
@abstractmethod
def stop(self):
pass
@abstractmethod
def spot(self, spot):
pass
@abstractmethod
def getSupportedModes(self):
return []

View File

@ -1,4 +1,4 @@
from owrx.reporting import Reporter
from owrx.reporting.reporter import Reporter
from owrx.version import openwebrx_version
from owrx.config import Config
from owrx.locator import Locator
@ -12,14 +12,13 @@ from datetime import datetime, timezone
logger = logging.getLogger(__name__)
PoisonPill = object()
class Worker(threading.Thread):
def __init__(self, queue: Queue):
self.queue = queue
self.doRun = True
# some constants that we don't expect to change
config = Config.get()
self.callsign = config["wsprnet_callsign"]
self.locator = Locator.fromCoordinates(config["receiver_gps"])
super().__init__(daemon=True)
@ -27,8 +26,11 @@ class Worker(threading.Thread):
while self.doRun:
try:
spot = self.queue.get()
self.uploadSpot(spot)
self.queue.task_done()
if spot is PoisonPill:
self.doRun = False
else:
self.uploadSpot(spot)
self.queue.task_done()
except Exception:
logger.exception("Exception while uploading WSPRNet spot")
@ -40,6 +42,7 @@ class Worker(threading.Thread):
return interval
def uploadSpot(self, spot):
config = Config.get()
# function=wspr&date=210114&time=1732&sig=-15&dt=0.5&drift=0&tqrg=7.040019&tcall=DF2UU&tgrid=JN48&dbm=37&version=2.3.0-rc3&rcall=DD5JFK&rgrid=JN58SC&rqrg=7.040047&mode=2
# {'timestamp': 1610655960000, 'db': -23.0, 'dt': 0.3, 'freq': 7040048, 'drift': -1, 'msg': 'LA3JJ JO59 37', 'callsign': 'LA3JJ', 'locator': 'JO59', 'mode': 'WSPR'}
date = datetime.fromtimestamp(spot["timestamp"] / 1000, tz=timezone.utc)
@ -57,13 +60,12 @@ class Worker(threading.Thread):
"tgrid": spot["locator"],
"dbm": spot["dbm"],
"version": openwebrx_version,
"rcall": self.callsign,
"rgrid": self.locator,
# mode 2 = WSPR 2 minutes
"rcall": config["wsprnet_callsign"],
"rgrid": Locator.fromCoordinates(config["receiver_gps"]),
"mode": self._getMode(spot),
}
).encode()
request.urlopen("http://wsprnet.org/post/", data)
request.urlopen("http://wsprnet.org/post/", data, timeout=60)
class WsprnetReporter(Reporter):
@ -79,7 +81,10 @@ class WsprnetReporter(Reporter):
metrics.addMetric("wsprnet.spots", self.spotCounter)
def stop(self):
pass
while not self.queue.empty():
self.queue.get(timeout=1)
self.queue.task_done()
self.queue.put(PoisonPill)
def spot(self, spot):
try:

View File

@ -1,58 +1,233 @@
from owrx.config import Config
from owrx.property import PropertyLayer
from owrx.property import PropertyManager, PropertyDeleted, PropertyDelegator, PropertyLayer, PropertyReadOnly
from owrx.feature import FeatureDetector, UnknownFeatureException
from owrx.source import SdrSource, SdrSourceEventClient
from functools import partial
import logging
logger = logging.getLogger(__name__)
class SdrService(object):
sdrProps = None
sources = {}
lastPort = None
class MappedSdrSources(PropertyDelegator):
def __init__(self, pm: PropertyManager):
self.subscriptions = {}
super().__init__(PropertyLayer())
for key, value in pm.items():
self._addSource(key, value)
pm.wire(self.handleSdrDeviceChange)
@staticmethod
def _loadProps():
if SdrService.sdrProps is None:
pm = Config.get()
featureDetector = FeatureDetector()
def handleSdrDeviceChange(self, changes):
for key, value in changes.items():
if value is PropertyDeleted:
if key in self:
del self[key]
else:
if key not in self:
self._addSource(key, value)
def loadIntoPropertyManager(dict: dict):
propertyManager = PropertyLayer()
for (name, value) in dict.items():
propertyManager[name] = value
return propertyManager
def handleDeviceUpdate(self, key, value, *args):
if key not in self and self.isDeviceValid(value):
self[key] = self.buildNewSource(key, value)
elif key in self and not self.isDeviceValid(value):
del self[key]
def sdrTypeAvailable(value):
try:
if not featureDetector.is_available(value["type"]):
logger.error(
'The SDR source type "{0}" is not available. please check requirements.'.format(
value["type"]
)
)
return False
return True
except UnknownFeatureException:
logger.error(
'The SDR source type "{0}" is invalid. Please check your configuration'.format(value["type"])
def _addSource(self, key, value):
self.handleDeviceUpdate(key, value)
updateMethod = partial(self.handleDeviceUpdate, key, value)
self.subscriptions[key] = [
value.filter("type", "profiles").wire(updateMethod),
value["profiles"].wire(updateMethod)
]
def isDeviceValid(self, device):
return self._sdrTypeAvailable(device) and self._hasProfiles(device)
def _hasProfiles(self, device):
return "profiles" in device and device["profiles"] and len(device["profiles"]) > 0
def _sdrTypeAvailable(self, value):
featureDetector = FeatureDetector()
try:
if not featureDetector.is_available(value["type"]):
logger.error(
'The SDR source type "{0}" is not available. please check the feature report for details.'.format(
value["type"]
)
return False
# transform all dictionary items into PropertyManager object, filtering out unavailable ones
SdrService.sdrProps = {
name: loadIntoPropertyManager(value) for (name, value) in pm["sdrs"].items() if sdrTypeAvailable(value)
}
logger.info(
"SDR sources loaded. Available SDRs: {0}".format(
", ".join(map(lambda x: x["name"], SdrService.sdrProps.values()))
)
return False
return True
except UnknownFeatureException:
logger.error(
'The SDR source type "{0}" is invalid. Please check your configuration'.format(value["type"])
)
return False
def buildNewSource(self, id, props):
sdrType = props["type"]
className = "".join(x for x in sdrType.title() if x.isalnum()) + "Source"
module = __import__("owrx.source.{0}".format(sdrType), fromlist=[className])
cls = getattr(module, className)
return cls(id, props)
def _removeSource(self, key, source):
source.shutdown()
for sub in self.subscriptions[key]:
sub.cancel()
del self.subscriptions[key]
def __setitem__(self, key, value):
source = self[key] if key in self else None
if source is value:
return
super().__setitem__(key, value)
if source is not None:
self._removeSource(key, source)
def __delitem__(self, key):
source = self[key] if key in self else None
super().__delitem__(key)
if source is not None:
self._removeSource(key, source)
class SourceStateHandler(SdrSourceEventClient):
def __init__(self, pm, key, source: SdrSource):
self.pm = pm
self.key = key
self.source = source
def selfDestruct(self):
self.source.removeClient(self)
def onFail(self):
del self.pm[self.key]
def onDisable(self):
del self.pm[self.key]
def onEnable(self):
self.pm[self.key] = self.source
def onShutdown(self):
del self.pm[self.key]
class ActiveSdrSources(PropertyReadOnly):
def __init__(self, pm: PropertyManager):
self.handlers = {}
self._layer = PropertyLayer()
super().__init__(self._layer)
for key, value in pm.items():
self._addSource(key, value)
pm.wire(self.handleSdrDeviceChange)
def handleSdrDeviceChange(self, changes):
for key, value in changes.items():
if value is PropertyDeleted:
self._removeSource(key)
else:
self._addSource(key, value)
def isAvailable(self, source: SdrSource):
return source.isEnabled() and not source.isFailed()
def _addSource(self, key, source: SdrSource):
if self.isAvailable(source):
self._layer[key] = source
self.handlers[key] = SourceStateHandler(self._layer, key, source)
source.addClient(self.handlers[key])
def _removeSource(self, key):
self.handlers[key].selfDestruct()
del self.handlers[key]
if key in self._layer:
del self._layer[key]
class AvailableProfiles(PropertyReadOnly):
def __init__(self, pm: PropertyManager):
self.subscriptions = {}
self.profileSubscriptions = {}
self._layer = PropertyLayer()
super().__init__(self._layer)
for key, value in pm.items():
self._addSource(key, value)
pm.wire(self.handleSdrDeviceChange)
def handleSdrDeviceChange(self, changes):
for key, value in changes.items():
if value is PropertyDeleted:
self._removeSource(key)
else:
self._addSource(key, value)
def handleSdrNameChange(self, s_id, source, name):
profiles = source.getProfiles()
for p_id in list(self._layer.keys()):
source_id, profile_id = p_id.split("|")
if source_id == s_id:
profile = profiles[profile_id]
self._layer[p_id] = "{} {}".format(name, profile["name"])
def handleProfileChange(self, source_id, source: SdrSource, changes):
for key, value in changes.items():
if value is PropertyDeleted:
self._removeProfile(source_id, key)
else:
self._addProfile(source_id, source, key, value)
def handleProfileNameChange(self, s_id, source: SdrSource, p_id, name):
for concat_p_id in list(self._layer.keys()):
source_id, profile_id = concat_p_id.split("|")
if source_id == s_id and profile_id == p_id:
self._layer[concat_p_id] = "{} {}".format(source.getName(), name)
def _addSource(self, key, source: SdrSource):
for p_id, p in source.getProfiles().items():
self._addProfile(key, source, p_id, p)
self.subscriptions[key] = [
source.getProps().wireProperty("name", partial(self.handleSdrNameChange, key, source)),
source.getProfiles().wire(partial(self.handleProfileChange, key, source)),
]
def _addProfile(self, s_id, source: SdrSource, p_id, profile):
self._layer["{}|{}".format(s_id, p_id)] = "{} {}".format(source.getName(), profile["name"])
if s_id not in self.profileSubscriptions:
self.profileSubscriptions[s_id] = {}
self.profileSubscriptions[s_id][p_id] = profile.wireProperty("name", partial(self.handleProfileNameChange, s_id, source, p_id))
def _removeSource(self, key):
for profile_id in list(self._layer.keys()):
if profile_id.startswith("{}|".format(key)):
del self._layer[profile_id]
if key in self.subscriptions:
while self.subscriptions[key]:
self.subscriptions[key].pop().cancel()
del self.subscriptions[key]
if key in self.profileSubscriptions:
for p_id in self.profileSubscriptions[key].keys():
self.profileSubscriptions[key][p_id].cancel()
del self.profileSubscriptions[key]
def _removeProfile(self, s_id, p_id):
for concat_p_id in list(self._layer.keys()):
source_id, profile_id = concat_p_id.split("|")
if source_id == s_id and profile_id == p_id:
del self._layer[concat_p_id]
if s_id in self.profileSubscriptions and p_id in self.profileSubscriptions[s_id]:
self.profileSubscriptions[s_id][p_id].cancel()
del self.profileSubscriptions[s_id][p_id]
class SdrService(object):
sources = None
activeSources = None
availableProfiles = None
@staticmethod
def getFirstSource():
sources = SdrService.getSources()
sources = SdrService.getActiveSources()
if not sources:
return None
# TODO: configure default sdr in config? right now it will pick the first one off the list.
@ -60,22 +235,27 @@ class SdrService(object):
@staticmethod
def getSource(id):
sources = SdrService.getSources()
sources = SdrService.getActiveSources()
if not sources:
return None
if not id in sources:
if id not in sources:
return None
return sources[id]
@staticmethod
def getSources():
SdrService._loadProps()
for id in SdrService.sdrProps.keys():
if not id in SdrService.sources:
props = SdrService.sdrProps[id]
sdrType = props["type"]
className = "".join(x for x in sdrType.title() if x.isalnum()) + "Source"
module = __import__("owrx.source.{0}".format(sdrType), fromlist=[className])
cls = getattr(module, className)
SdrService.sources[id] = cls(id, props)
return {key: s for key, s in SdrService.sources.items() if not s.isFailed()}
def getAllSources():
if SdrService.sources is None:
SdrService.sources = MappedSdrSources(Config.get()["sdrs"])
return SdrService.sources
@staticmethod
def getActiveSources():
if SdrService.activeSources is None:
SdrService.activeSources = ActiveSdrSources(SdrService.getAllSources())
return SdrService.activeSources
@staticmethod
def getAvailableProfiles():
if SdrService.availableProfiles is None:
SdrService.availableProfiles = AvailableProfiles(SdrService.getActiveSources())
return SdrService.availableProfiles

View File

@ -1,17 +1,19 @@
import threading
from owrx.source import SdrSource, SdrSourceEventClient
from owrx.source import SdrSourceEventClient, SdrSourceState, SdrClientClass
from owrx.sdr import SdrService
from owrx.bands import Bandplan
from csdr.csdr import dsp, output
from csdr.output import Output
from csdr import Dsp
from owrx.wsjt import WsjtParser
from owrx.aprs import AprsParser
from owrx.js8 import Js8Parser
from owrx.config.core import CoreConfig
from owrx.config import Config
from owrx.source.resampler import Resampler
from owrx.property import PropertyLayer
from owrx.property import PropertyLayer, PropertyDeleted
from js8py import Js8Frame
from abc import ABCMeta, abstractmethod
from .schedule import ServiceScheduler
from owrx.service.schedule import ServiceScheduler
from owrx.modes import Modes
import logging
@ -19,7 +21,7 @@ import logging
logger = logging.getLogger(__name__)
class ServiceOutput(output, metaclass=ABCMeta):
class ServiceOutput(Output, metaclass=ABCMeta):
def __init__(self, frequency):
self.frequency = frequency
@ -65,32 +67,68 @@ class ServiceHandler(SdrSourceEventClient):
self.services = []
self.source = source
self.startupTimer = None
self.scheduler = None
self.activitySub = None
self.running = False
props = self.source.getProps()
self.enabledSub = props.wireProperty("services", self._receiveEvent)
self.decodersSub = None
# need to call _start() manually if property is not set since the default is True, but the initial call is only
# made if the property is present
if "services" not in props:
self._start()
def _receiveEvent(self, state):
# deletion means fall back to default, which is True
if state is PropertyDeleted:
state = True
if self.running == state:
return
if state:
self._start()
else:
self._stop()
def _start(self):
self.running = True
self.source.addClient(self)
props = self.source.getProps()
props.filter("center_freq", "samp_rate").wire(self.onFrequencyChange)
self.activitySub = props.filter("center_freq", "samp_rate").wire(self.onFrequencyChange)
self.decodersSub = Config.get().wireProperty("services_decoders", self.onFrequencyChange)
if self.source.isAvailable():
self.scheduleServiceStartup()
if "schedule" in props or "scheduler" in props:
self.scheduler = ServiceScheduler(self.source)
self._scheduleServiceStartup()
def getClientClass(self):
return SdrSource.CLIENT_INACTIVE
def _stop(self):
if self.activitySub is not None:
self.activitySub.cancel()
self.activitySub = None
if self.decodersSub is not None:
self.decodersSub.cancel()
self.decodersSub = None
self._cancelStartupTimer()
self.source.removeClient(self)
self.stopServices()
self.running = False
def onStateChange(self, state):
if state == SdrSource.STATE_RUNNING:
self.scheduleServiceStartup()
elif state == SdrSource.STATE_STOPPING:
def getClientClass(self) -> SdrClientClass:
return SdrClientClass.INACTIVE
def onStateChange(self, state: SdrSourceState):
if state is SdrSourceState.RUNNING:
self._scheduleServiceStartup()
elif state is SdrSourceState.STOPPING:
logger.debug("sdr source becoming unavailable; stopping services.")
self.stopServices()
elif state == SdrSource.STATE_FAILED:
logger.debug("sdr source failed; stopping services.")
self.stopServices()
if self.scheduler:
self.scheduler.shutdown()
def onBusyStateChange(self, state):
pass
def onFail(self):
logger.debug("sdr source failed; stopping services.")
self.stopServices()
def onShutdown(self):
logger.debug("sdr source is shutting down; shutting down service handler, too.")
self.shutdown()
def onEnable(self):
self._scheduleServiceStartup()
def isSupported(self, mode):
configured = Config.get()["services_decoders"]
@ -98,10 +136,10 @@ class ServiceHandler(SdrSourceEventClient):
return mode in configured and mode in available
def shutdown(self):
self.stopServices()
self.source.removeClient(self)
if self.scheduler:
self.scheduler.shutdown()
self._stop()
if self.enabledSub is not None:
self.enabledSub.cancel()
self.enabledSub = None
def stopServices(self):
with self.lock:
@ -115,11 +153,15 @@ class ServiceHandler(SdrSourceEventClient):
self.stopServices()
if not self.source.isAvailable():
return
self.scheduleServiceStartup()
self._scheduleServiceStartup()
def scheduleServiceStartup(self):
def _cancelStartupTimer(self):
if self.startupTimer:
self.startupTimer.cancel()
self.startupTimer = None
def _scheduleServiceStartup(self):
self._cancelStartupTimer()
self.startupTimer = threading.Timer(10, self.updateServices)
self.startupTimer.start()
@ -151,20 +193,25 @@ class ServiceHandler(SdrSourceEventClient):
self.services.append(self.setupService(dial["mode"], dial["frequency"], self.source))
else:
for group in groups:
cf = self.get_center_frequency(group)
bw = self.get_bandwidth(group)
logger.debug("group center frequency: {0}, bandwidth: {1}".format(cf, bw))
resampler_props = PropertyLayer()
resampler_props["center_freq"] = cf
resampler_props["samp_rate"] = bw
resampler = Resampler(resampler_props, self.source)
resampler.start()
if len(group) > 1:
cf = self.get_center_frequency(group)
bw = self.get_bandwidth(group)
logger.debug("group center frequency: {0}, bandwidth: {1}".format(cf, bw))
resampler_props = PropertyLayer()
resampler_props["center_freq"] = cf
resampler_props["samp_rate"] = bw
resampler = Resampler(resampler_props, self.source)
resampler.start()
for dial in group:
self.services.append(self.setupService(dial["mode"], dial["frequency"], resampler))
for dial in group:
self.services.append(self.setupService(dial["mode"], dial["frequency"], resampler))
# resampler goes in after the services since it must not be shutdown as long as the services are still running
self.services.append(resampler)
# resampler goes in after the services since it must not be shutdown as long as the services are
# still running
self.services.append(resampler)
else:
dial = group[0]
self.services.append(self.setupService(dial["mode"], dial["frequency"], self.source))
def get_min_max(self, group):
frequencies = sorted(group, key=lambda f: f["frequency"])
@ -181,7 +228,7 @@ class ServiceHandler(SdrSourceEventClient):
def get_bandwidth(self, group):
minFreq, maxFreq = self.get_min_max(group)
# minimum bandwidth for a resampler: 25kHz
return max(maxFreq - minFreq, 25000)
return max((maxFreq - minFreq) * 1.15, 25000)
def optimizeResampling(self, freqs, bandwidth):
freqs = sorted(freqs, key=lambda f: f["frequency"])
@ -207,7 +254,10 @@ class ServiceHandler(SdrSourceEventClient):
groups.append([f for f in freqs if previous < f["frequency"]])
def get_total_bandwidth(group):
return bandwidth + len(group) * self.get_bandwidth(group)
if len(group) > 1:
return bandwidth + len(group) * self.get_bandwidth(group)
else:
return bandwidth
total_bandwidth = sum([get_total_bandwidth(group) for group in groups])
return {
@ -244,7 +294,7 @@ class ServiceHandler(SdrSourceEventClient):
output = Js8ServiceOutput(frequency)
else:
output = WsjtServiceOutput(frequency)
d = dsp(output)
d = Dsp(output)
d.nc_port = source.getPort()
center_freq = source.getProps()["center_freq"]
d.set_offset_freq(frequency - center_freq)
@ -255,7 +305,7 @@ class ServiceHandler(SdrSourceEventClient):
d.set_secondary_demodulator(mode)
d.set_audio_compression("none")
d.set_samp_rate(source.getProps()["samp_rate"])
d.set_temporary_directory(Config.get()["temporary_directory"])
d.set_temporary_directory(CoreConfig().get_temporary_directory())
d.set_service()
d.start()
return d
@ -277,19 +327,48 @@ class Js8Handler(object):
class Services(object):
handlers = []
handlers = {}
schedulers = {}
@staticmethod
def start():
if not Config.get()["services_enabled"]:
return
for source in SdrService.getSources().values():
props = source.getProps()
if "services" not in props or props["services"] is not False:
Services.handlers.append(ServiceHandler(source))
config = Config.get()
config.wireProperty("services_enabled", Services._receiveEnabledEvent)
activeSources = SdrService.getActiveSources()
activeSources.wire(Services._receiveDeviceEvent)
for key, source in activeSources.items():
Services.schedulers[key] = ServiceScheduler(source)
@staticmethod
def _receiveEnabledEvent(state):
if state:
for key, source in SdrService.getActiveSources().__dict__().items():
Services.handlers[key] = ServiceHandler(source)
else:
for handler in list(Services.handlers.values()):
handler.shutdown()
Services.handlers = {}
@staticmethod
def _receiveDeviceEvent(changes):
for key, source in changes.items():
if source is PropertyDeleted:
if key in Services.handlers:
Services.handlers[key].shutdown()
del Services.handlers[key]
if key in Services.schedulers:
Services.schedulers[key].shutdown()
del Services.schedulers[key]
else:
Services.schedulers[key] = ServiceScheduler(source)
if Config.get()["services_enabled"]:
Services.handlers[key] = ServiceHandler(source)
@staticmethod
def stop():
for handler in Services.handlers:
for handler in list(Services.handlers.values()):
handler.shutdown()
Services.handlers = []
Services.handlers = {}
for scheduler in list(Services.schedulers.values()):
scheduler.shutdown()
Services.schedulers = {}

View File

@ -1,5 +1,5 @@
from datetime import datetime, timezone, timedelta
from owrx.source import SdrSource, SdrSourceEventClient
from owrx.source import SdrSourceEventClient, SdrSourceState, SdrClientClass, SdrBusyState
from owrx.config import Config
import threading
import math
@ -72,10 +72,7 @@ class DatetimeScheduleEntry(ScheduleEntry):
class Schedule(ABC):
@staticmethod
def parse(props):
# downwards compatibility
if "schedule" in props:
return StaticSchedule(props["schedule"])
elif "scheduler" in props:
if "scheduler" in props:
sc = props["scheduler"]
t = sc["type"] if "type" in sc else "static"
if t == "static":
@ -84,6 +81,9 @@ class Schedule(ABC):
return DaylightSchedule(sc["schedule"])
else:
logger.warning("Invalid scheduler type: %s", t)
# downwards compatibility
elif "schedule" in props:
return StaticSchedule(props["schedule"])
@abstractmethod
def getCurrentEntry(self):
@ -192,7 +192,7 @@ class DaylightSchedule(TimerangeSchedule):
for event in events:
# night profile _until_ sunrise, day profile _until_ sunset
stype = "night" if event["type"] == "sunrise" else "day"
if previousEvent is not None or event["time"] - delta > now:
if stype in self.schedule and (previousEvent is not None or event["time"] - delta > now):
start = now if previousEvent is None else previousEvent
entries.append(DatetimeScheduleEntry(start, event["time"] - delta, self.schedule[stype]))
if useGreyline:
@ -209,18 +209,29 @@ class ServiceScheduler(SdrSourceEventClient):
def __init__(self, source):
self.source = source
self.selectionTimer = None
self.currentEntry = None
self.source.addClient(self)
self.schedule = None
props = self.source.getProps()
self.subscriptions = []
self.subscriptions.append(props.filter("center_freq", "samp_rate").wire(self.onFrequencyChange))
self.subscriptions.append(props.wireProperty("scheduler", self.parseSchedule))
# wireProperty calls parseSchedule with the initial value
# self.parseSchedule()
def parseSchedule(self, *args):
props = self.source.getProps()
self.schedule = Schedule.parse(props)
props.filter("center_freq", "samp_rate").wire(self.onFrequencyChange)
self.scheduleSelection()
def shutdown(self):
while self.subscriptions:
self.subscriptions.pop().cancel()
self.cancelTimer()
self.source.removeClient(self)
def scheduleSelection(self, time=None):
if self.source.getState() == SdrSource.STATE_FAILED:
if not self.source.isEnabled() or self.source.isFailed():
return
seconds = 10
if time is not None:
@ -234,41 +245,71 @@ class ServiceScheduler(SdrSourceEventClient):
if self.selectionTimer:
self.selectionTimer.cancel()
def getClientClass(self):
return SdrSource.CLIENT_BACKGROUND
def getClientClass(self) -> SdrClientClass:
if self.currentEntry is None:
return SdrClientClass.INACTIVE
else:
return SdrClientClass.BACKGROUND
def onStateChange(self, state):
if state == SdrSource.STATE_STOPPING:
def onStateChange(self, state: SdrSourceState):
if state is SdrSourceState.STOPPING:
self.scheduleSelection()
elif state == SdrSource.STATE_FAILED:
self.cancelTimer()
def onBusyStateChange(self, state):
if state == SdrSource.BUSYSTATE_IDLE:
def onFail(self):
self.shutdown()
def onShutdown(self):
self.shutdown()
def onDisable(self):
self.cancelTimer()
def onEnable(self):
self.scheduleSelection()
def onBusyStateChange(self, state: SdrBusyState):
if state is SdrBusyState.IDLE:
self.scheduleSelection()
def onFrequencyChange(self, changes):
self.scheduleSelection()
def _setCurrentEntry(self, entry):
self.currentEntry = entry
if entry is not None:
logger.debug("selected profile %s until %s", entry.getProfile(), entry.getScheduledEnd())
self.scheduleSelection(entry.getScheduledEnd())
try:
self.source.activateProfile(entry.getProfile())
self.source.start()
except KeyError:
pass
# tell the source to re-evaluate its current status
# this should make it shut down if there's no other activity
# TODO this is an improvised solution, should probably be integrated / improved in SdrSourceEventClient
self.source.checkStatus()
def selectProfile(self):
if self.source.hasClients(SdrSource.CLIENT_USER):
if self.source.hasClients(SdrClientClass.USER):
logger.debug("source has active users; not touching")
return
logger.debug("source seems to be idle, selecting profile for background services")
entry = self.schedule.getCurrentEntry()
if entry is None:
logger.debug("schedule did not return a profile. checking next entry...")
if self.schedule is None:
self._setCurrentEntry(None)
logger.debug("no active schedule, scheduler standing by for external events.")
return
logger.debug("source seems to be idle, selecting profile for background services")
self._setCurrentEntry(self.schedule.getCurrentEntry())
if self.currentEntry is None:
logger.debug("schedule did not return a current profile. checking next (future) entry...")
nextEntry = self.schedule.getNextEntry()
if nextEntry is not None:
self.scheduleSelection(nextEntry.getNextActivation())
else:
logger.debug("no next entry available, scheduler standing by for external events.")
return
logger.debug("selected profile %s until %s", entry.getProfile(), entry.getScheduledEnd())
self.scheduleSelection(entry.getScheduledEnd())
try:
self.source.activateProfile(entry.getProfile())
self.source.start()
except KeyError:
pass

21
owrx/soapy.py Normal file
View File

@ -0,0 +1,21 @@
class SoapySettings(object):
@staticmethod
def parse(dstr):
def decodeComponent(c):
kv = c.split("=", 1)
if len(kv) < 2:
return c
else:
return {kv[0]: kv[1]}
return [decodeComponent(c) for c in dstr.split(",")]
@staticmethod
def encode(dobj):
def encodeComponent(c):
if isinstance(c, str):
return c
else:
return ",".join(["{0}={1}".format(key, value) for key, value in c.items()])
return ",".join([encodeComponent(c) for c in dobj])

View File

@ -6,10 +6,20 @@ import socket
import shlex
import time
import signal
import pkgutil
from abc import ABC, abstractmethod
from owrx.command import CommandMapper
from owrx.socket import getAvailablePort
from owrx.property import PropertyStack, PropertyLayer
from owrx.property import PropertyStack, PropertyLayer, PropertyFilter, PropertyCarousel, PropertyDeleted
from owrx.property.filter import ByLambda
from owrx.form.input import Input, TextInput, NumberInput, CheckboxInput, ModesInput, ExponentialInput
from owrx.form.input.converter import OptionalConverter
from owrx.form.input.device import GainInput, SchedulerInput, WaterfallLevelsInput
from owrx.form.input.validator import RequiredValidator
from owrx.form.section import OptionalSection
from owrx.feature import FeatureDetector
from typing import List
from enum import Enum
from pycsdr.modules import SocketClient, Buffer
@ -18,34 +28,85 @@ import logging
logger = logging.getLogger(__name__)
class SdrSourceEventClient(ABC):
@abstractmethod
def onStateChange(self, state):
class SdrSourceState(Enum):
STOPPED = "Stopped"
STARTING = "Starting"
RUNNING = "Running"
STOPPING = "Stopping"
TUNING = "Tuning"
def __str__(self):
return self.value
class SdrBusyState(Enum):
IDLE = 1
BUSY = 2
class SdrClientClass(Enum):
INACTIVE = 1
BACKGROUND = 2
USER = 3
class SdrSourceEventClient(object):
def onStateChange(self, state: SdrSourceState):
pass
@abstractmethod
def onBusyStateChange(self, state):
def onBusyStateChange(self, state: SdrBusyState):
pass
def getClientClass(self):
return SdrSource.CLIENT_INACTIVE
def onFail(self):
pass
def onShutdown(self):
pass
def onDisable(self):
pass
def onEnable(self):
pass
def getClientClass(self) -> SdrClientClass:
return SdrClientClass.INACTIVE
class SdrProfileCarousel(PropertyCarousel):
def __init__(self, props):
super().__init__()
if "profiles" not in props:
return
for profile_id, profile in props["profiles"].items():
self.addLayer(profile_id, profile)
# activate first available profile
self.switch()
props["profiles"].wire(self.handleProfileUpdate)
def addLayer(self, profile_id, profile):
profile_stack = PropertyStack()
profile_stack.addLayer(0, PropertyLayer(profile_id=profile_id).readonly())
profile_stack.addLayer(1, profile)
super().addLayer(profile_id, profile_stack)
def handleProfileUpdate(self, changes):
for profile_id, profile in changes.items():
if profile is PropertyDeleted:
self.removeLayer(profile_id)
else:
self.addLayer(profile_id, profile)
def _getDefaultLayer(self):
# return the first available profile, or the default empty layer if we don't have any
if self.layers:
return next(iter(self.layers.values()))
return super()._getDefaultLayer()
class SdrSource(ABC):
STATE_STOPPED = 0
STATE_STARTING = 1
STATE_RUNNING = 2
STATE_STOPPING = 3
STATE_TUNING = 4
STATE_FAILED = 5
BUSYSTATE_IDLE = 0
BUSYSTATE_BUSY = 1
CLIENT_INACTIVE = 0
CLIENT_BACKGROUND = 1
CLIENT_USER = 2
def __init__(self, id, props):
self.id = id
@ -54,13 +115,24 @@ class SdrSource(ABC):
self.buffer = None
self.props = PropertyStack()
# layer 0 reserved for profile properties
self.profileCarousel = SdrProfileCarousel(props)
# prevent profile names from overriding the device name
self.props.addLayer(0, PropertyFilter(self.profileCarousel, ByLambda(lambda x: x != "name")))
# props from our device config
self.props.addLayer(1, props)
self.props.addLayer(2, Config.get())
# the sdr_id is constant, so we put it in a separate layer
# this is used to detect device changes, that are then sent to the client
self.props.addLayer(2, PropertyLayer(sdr_id=id).readonly())
# finally, accept global config properties from the top-level config
self.props.addLayer(3, Config.get())
self.sdrProps = self.props.filter(*self.getEventNames())
self.profile_id = None
self.activateProfile()
self.wireEvents()
self.port = getAvailablePort()
@ -71,20 +143,46 @@ class SdrSource(ABC):
self.spectrumLock = threading.Lock()
self.process = None
self.modificationLock = threading.Lock()
self.state = SdrSourceState.STOPPED
self.enabled = "enabled" not in props or props["enabled"]
props.filter("enabled").wire(self._handleEnableChanged)
self.failed = False
self.state = SdrSource.STATE_STOPPED
self.busyState = SdrSource.BUSYSTATE_IDLE
self.busyState = SdrBusyState.IDLE
self.validateProfiles()
if self.isAlwaysOn():
if self.isAlwaysOn() and self.isEnabled():
self.start()
def isEnabled(self):
return self.enabled
def _handleEnableChanged(self, changes):
if "enabled" in changes and changes["enabled"] is not PropertyDeleted:
self.enabled = changes["enabled"]
else:
self.enabled = True
if not self.enabled:
self.stop()
for c in self.clients.copy():
if self.isEnabled():
c.onEnable()
else:
c.onDisable()
def isFailed(self):
return self.failed
def fail(self):
self.failed = True
for c in self.clients.copy():
c.onFail()
def validateProfiles(self):
props = PropertyStack()
props.addLayer(1, self.props)
for id, p in self.props["profiles"].items():
props.replaceLayer(0, self._getProfilePropertyLayer(p))
props.replaceLayer(0, p)
if "center_freq" not in props:
logger.warning('Profile "%s" does not specify a center_freq', id)
continue
@ -98,15 +196,6 @@ class SdrSource(ABC):
if start_freq < center_freq - srh or start_freq > center_freq + srh:
logger.warning('start_freq for profile "%s" is out of range', id)
def _getProfilePropertyLayer(self, profile):
layer = PropertyLayer()
for (key, value) in profile.items():
# skip the name, that would overwrite the source name.
if key == "name":
continue
layer[key] = value
return layer
def isAlwaysOn(self):
return "always-on" in self.props and self.props["always-on"]
@ -134,28 +223,18 @@ class SdrSource(ABC):
def getCommand(self):
return [self.getCommandMapper().map(self.getCommandValues())]
def activateProfile(self, profile_id=None):
profiles = self.props["profiles"]
if profile_id is None:
profile_id = list(profiles.keys())[0]
if profile_id not in profiles:
logger.warning("invalid profile %s for sdr %s. ignoring", profile_id, self.id)
return
if profile_id == self.profile_id:
return
logger.debug("activating profile {0}".format(profile_id))
self.props["profile_id"] = profile_id
profile = profiles[profile_id]
self.profile_id = profile_id
layer = self._getProfilePropertyLayer(profile)
self.props.replaceLayer(0, layer)
def activateProfile(self, profile_id):
logger.debug("activating profile {0} for {1}".format(profile_id, self.getId()))
try:
self.profileCarousel.switch(profile_id)
except KeyError:
logger.warning("invalid profile %s for sdr %s. ignoring", profile_id, self.getId())
def getId(self):
return self.id
def getProfileId(self):
return self.profile_id
return self.props["profile_id"]
def getProfiles(self):
return self.props["profiles"]
@ -219,22 +298,25 @@ class SdrSource(ABC):
logger.info("Started sdr source: " + cmd)
available = False
failed = False
def wait_for_process_to_end():
nonlocal failed
rc = self.process.wait()
logger.debug("shut down with RC={0}".format(rc))
self.process = None
self.monitor = None
if self.getState() == SdrSource.STATE_RUNNING:
self.failed = True
self.setState(SdrSource.STATE_FAILED)
if self.getState() is SdrSourceState.RUNNING:
self.fail()
else:
self.setState(SdrSource.STATE_STOPPED)
failed = True
self.setState(SdrSourceState.STOPPED)
self.monitor = threading.Thread(target=wait_for_process_to_end, name="source_monitor")
self.monitor.start()
retries = 1000
while retries > 0 and not self.isFailed():
while retries > 0 and not failed:
retries -= 1
if self.monitor is None:
break
@ -248,15 +330,18 @@ class SdrSource(ABC):
time.sleep(0.1)
if not available:
self.failed = True
failed = True
try:
self.postStart()
except Exception:
logger.exception("Exception during postStart()")
self.failed = True
failed = True
self.setState(SdrSource.STATE_FAILED if self.failed else SdrSource.STATE_RUNNING)
if failed:
self.fail()
else:
self.setState(SdrSourceState.RUNNING)
def preStart(self):
"""
@ -273,15 +358,10 @@ class SdrSource(ABC):
def isAvailable(self):
return self.monitor is not None
def isFailed(self):
return self.failed
def stop(self):
self.setState(SdrSource.STATE_STOPPING)
with self.modificationLock:
if self.process is not None:
self.setState(SdrSourceState.STOPPING)
try:
os.killpg(os.getpgid(self.process.pid), signal.SIGTERM)
except ProcessLookupError:
@ -294,33 +374,47 @@ class SdrSource(ABC):
self.socketClient = None
self.buffer = None
def shutdown(self):
self.stop()
for c in self.clients.copy():
c.onShutdown()
def getClients(self, *args):
if not args:
return self.clients
return [c for c in self.clients if c.getClientClass() in args]
def hasClients(self, *args):
clients = [c for c in self.clients if c.getClientClass() in args]
return len(clients) > 0
return len(self.getClients(*args)) > 0
def addClient(self, c: SdrSourceEventClient):
if c in self.clients:
return
self.clients.append(c)
c.onStateChange(self.getState())
hasUsers = self.hasClients(SdrSource.CLIENT_USER)
hasBackgroundTasks = self.hasClients(SdrSource.CLIENT_BACKGROUND)
hasUsers = self.hasClients(SdrClientClass.USER)
hasBackgroundTasks = self.hasClients(SdrClientClass.BACKGROUND)
if hasUsers or hasBackgroundTasks:
self.start()
self.setBusyState(SdrSource.BUSYSTATE_BUSY if hasUsers else SdrSource.BUSYSTATE_IDLE)
self.setBusyState(SdrBusyState.BUSY if hasUsers else SdrBusyState.IDLE)
def removeClient(self, c: SdrSourceEventClient):
try:
self.clients.remove(c)
except ValueError:
pass
if c not in self.clients:
return
hasUsers = self.hasClients(SdrSource.CLIENT_USER)
self.setBusyState(SdrSource.BUSYSTATE_BUSY if hasUsers else SdrSource.BUSYSTATE_IDLE)
self.clients.remove(c)
self.checkStatus()
def checkStatus(self):
hasUsers = self.hasClients(SdrClientClass.USER)
self.setBusyState(SdrBusyState.BUSY if hasUsers else SdrBusyState.IDLE)
# no need to check for users if we are always-on
if self.isAlwaysOn():
return
hasBackgroundTasks = self.hasClients(SdrSource.CLIENT_BACKGROUND)
hasBackgroundTasks = self.hasClients(SdrClientClass.BACKGROUND)
if not hasUsers and not hasBackgroundTasks:
self.stop()
@ -351,19 +445,142 @@ class SdrSource(ABC):
for c in self.spectrumClients:
c.write_spectrum_data(data)
def getState(self):
def getState(self) -> SdrSourceState:
return self.state
def setState(self, state):
def setState(self, state: SdrSourceState):
if state == self.state:
return
self.state = state
for c in self.clients:
for c in self.clients.copy():
c.onStateChange(state)
def setBusyState(self, state):
def setBusyState(self, state: SdrBusyState):
if state == self.busyState:
return
self.busyState = state
for c in self.clients:
for c in self.clients.copy():
c.onBusyStateChange(state)
class SdrDeviceDescriptionMissing(Exception):
pass
class SdrDeviceDescription(object):
@staticmethod
def getByType(sdr_type: str) -> "SdrDeviceDescription":
try:
className = "".join(x for x in sdr_type.title() if x.isalnum()) + "DeviceDescription"
module = __import__("owrx.source.{0}".format(sdr_type), fromlist=[className])
cls = getattr(module, className)
return cls()
except (ModuleNotFoundError, AttributeError):
raise SdrDeviceDescriptionMissing("Device description for type {} not available".format(sdr_type))
@staticmethod
def getTypes():
def get_description(module_name):
try:
description = SdrDeviceDescription.getByType(module_name)
return description.getName()
except SdrDeviceDescriptionMissing:
return None
descriptions = {
module_name: get_description(module_name) for _, module_name, _ in pkgutil.walk_packages(__path__)
}
# filter out empty names and unavailable types
fd = FeatureDetector()
return {k: v for k, v in descriptions.items() if v is not None and fd.is_available(k)}
def getName(self):
"""
must be overridden with a textual representation of the device, to be used for device type selection
:return: str
"""
return None
def getDeviceInputs(self) -> List[Input]:
keys = self.getDeviceMandatoryKeys() + self.getDeviceOptionalKeys()
return [TextInput("name", "Device name", validator=RequiredValidator())] + [
i for i in self.getInputs() if i.id in keys
]
def getProfileInputs(self) -> List[Input]:
keys = self.getProfileMandatoryKeys() + self.getProfileOptionalKeys()
return [TextInput("name", "Profile name", validator=RequiredValidator())] + [
i for i in self.getInputs() if i.id in keys
]
def getInputs(self) -> List[Input]:
return [
CheckboxInput("enabled", "Enable this device", converter=OptionalConverter(defaultFormValue=True)),
GainInput("rf_gain", "Device gain", self.hasAgc()),
NumberInput(
"ppm",
"Frequency correction",
append="ppm",
),
CheckboxInput(
"always-on",
"Keep device running at all times",
infotext="Prevents shutdown of the device when idle. Useful for devices with unreliable startup.",
),
CheckboxInput(
"services",
"Run background services on this device",
),
ExponentialInput(
"lfo_offset",
"Oscillator offset",
"Hz",
infotext="Use this when the actual receiving frequency differs from the frequency to be tuned on the"
+ " device. <br/> Formula: Center frequency + oscillator offset = sdr tune frequency",
),
WaterfallLevelsInput("waterfall_levels", "Waterfall levels"),
SchedulerInput("scheduler", "Scheduler"),
ExponentialInput("center_freq", "Center frequency", "Hz"),
ExponentialInput("samp_rate", "Sample rate", "S/s"),
ExponentialInput("start_freq", "Initial frequency", "Hz"),
ModesInput("start_mod", "Initial modulation"),
NumberInput("initial_squelch_level", "Initial squelch level", append="dBFS"),
]
def hasAgc(self):
# default is True since most devices have agc. override in subclasses if agc is not available
return True
def getDeviceMandatoryKeys(self):
return ["name", "enabled"]
def getDeviceOptionalKeys(self):
return [
"ppm",
"always-on",
"services",
"rf_gain",
"lfo_offset",
"waterfall_levels",
"scheduler",
]
def getProfileMandatoryKeys(self):
return ["name", "center_freq", "samp_rate", "start_freq", "start_mod"]
def getProfileOptionalKeys(self):
return ["initial_squelch_level", "rf_gain", "lfo_offset", "waterfall_levels"]
def getDeviceSection(self):
return OptionalSection(
"Device settings", self.getDeviceInputs(), self.getDeviceMandatoryKeys(), self.getDeviceOptionalKeys()
)
def getProfileSection(self):
return OptionalSection(
"Profile settings",
self.getProfileInputs(),
self.getProfileMandatoryKeys(),
self.getProfileOptionalKeys(),
)

View File

@ -1,5 +1,7 @@
from owrx.command import Flag
from .soapy import SoapyConnectorSource
from owrx.source.soapy import SoapyConnectorSource, SoapyConnectorDeviceDescription
from owrx.form.input import Input, CheckboxInput
from owrx.form.input.device import BiasTeeInput
from typing import List
class AirspySource(SoapyConnectorSource):
@ -15,3 +17,28 @@ class AirspySource(SoapyConnectorSource):
def getDriver(self):
return "airspy"
class AirspyDeviceDescription(SoapyConnectorDeviceDescription):
def getName(self):
return "Airspy R2 or Mini"
def getInputs(self) -> List[Input]:
return super().getInputs() + [
BiasTeeInput(),
CheckboxInput(
"bitpack",
"Enable bit-packing",
infotext="Packs two 12-bit samples into 3 bytes."
+ " Lowers USB bandwidth consumption, increases CPU load",
),
]
def getDeviceOptionalKeys(self):
return super().getDeviceOptionalKeys() + ["bias_tee", "bitpack"]
def getProfileOptionalKeys(self):
return super().getProfileOptionalKeys() + ["bias_tee"]
def getGainStages(self):
return ["LNA", "MIX", "VGA"]

View File

@ -1,6 +1,11 @@
from .soapy import SoapyConnectorSource
from owrx.source.soapy import SoapyConnectorSource, SoapyConnectorDeviceDescription
class AirspyhfSource(SoapyConnectorSource):
def getDriver(self):
return "airspyhf"
class AirspyhfDeviceDescription(SoapyConnectorDeviceDescription):
def getName(self):
return "Airspy HF+ or Discovery"

View File

@ -1,7 +1,10 @@
from . import SdrSource
from owrx.source import SdrSource, SdrDeviceDescription
from owrx.socket import getAvailablePort
from owrx.property import PropertyDeleted
import socket
from owrx.command import CommandMapper, Flag, Option
from owrx.command import Flag, Option
from typing import List
from owrx.form.input import Input, NumberInput, CheckboxInput
import logging
@ -35,6 +38,8 @@ class ConnectorSource(SdrSource):
def sendControlMessage(self, changes):
for prop, value in changes.items():
if value is PropertyDeleted:
value = None
logger.debug("sending property change over control socket: {0} changed to {1}".format(prop, value))
self.controlSocket.sendall("{prop}:{value}\n".format(prop=prop, value=value).encode())
@ -69,3 +74,27 @@ class ConnectorSource(SdrSource):
values["port"] = self.getPort()
values["controlPort"] = self.getControlPort()
return values
class ConnectorDeviceDescription(SdrDeviceDescription):
def getInputs(self) -> List[Input]:
return super().getInputs() + [
NumberInput(
"rtltcp_compat",
"Port for rtl_tcp compatible data",
infotext="Activate an rtl_tcp compatible interface on the port number specified.<br />"
+ "Note: Port is only available on the local machine, not on the network.<br />"
+ "Note: IQ data may be degraded by the downsampling process to 8 bits.",
),
CheckboxInput(
"iqswap",
"Swap I and Q channels",
infotext="Swapping inverts the spectrum, so this is useful in combination with an inverting mixer",
),
]
def getDeviceOptionalKeys(self):
return super().getDeviceOptionalKeys() + ["rtltcp_compat", "iqswap"]
def getProfileOptionalKeys(self):
return super().getProfileOptionalKeys() + ["iqswap"]

View File

@ -1,5 +1,5 @@
from abc import ABCMeta
from . import SdrSource
from owrx.source import SdrSource, SdrDeviceDescription
import logging
@ -13,10 +13,9 @@ class DirectSource(SdrSource, metaclass=ABCMeta):
self.sleepOnRestart()
self.start()
def getEventNames(self):
return super().getEventNames() + [
"nmux_memory",
]
def nmux_memory(self):
# in megabytes. This sets the approximate size of the circular buffer used by nmux.
return 50
def getNmuxCommand(self):
props = self.sdrProps
@ -24,13 +23,10 @@ class DirectSource(SdrSource, metaclass=ABCMeta):
nmux_bufcnt = nmux_bufsize = 0
while nmux_bufsize < props["samp_rate"] / 4:
nmux_bufsize += 4096
while nmux_bufsize * nmux_bufcnt < props["nmux_memory"] * 1e6:
while nmux_bufsize * nmux_bufcnt < self.nmux_memory() * 1e6:
nmux_bufcnt += 1
if nmux_bufcnt == 0 or nmux_bufsize == 0:
raise ValueError(
"Error: nmux_bufsize or nmux_bufcnt is zero. "
"These depend on nmux_memory and samp_rate options in config_webrx.py"
)
raise ValueError("Error: unable to calculate nmux buffer parameters.")
return [
"nmux --bufsize %d --bufcnt %d --port %d --address 127.0.0.1"
@ -51,3 +47,7 @@ class DirectSource(SdrSource, metaclass=ABCMeta):
# override this in subclasses, if necessary
def sleepOnRestart(self):
pass
class DirectSourceDeviceDescription(SdrDeviceDescription):
pass

View File

@ -1,17 +0,0 @@
from owrx.source.connector import ConnectorSource
from owrx.command import Argument, Flag
class Eb200Source(ConnectorSource):
def getCommandMapper(self):
return (
super()
.getCommandMapper()
.setBase("eb200_connector")
.setMappings(
{
"long": Flag("-l"),
"remote": Argument(),
}
)
)

View File

@ -1,6 +1,11 @@
from owrx.source.soapy import SoapyConnectorSource
from owrx.source.soapy import SoapyConnectorSource, SoapyConnectorDeviceDescription
class FcdppSource(SoapyConnectorSource):
def getDriver(self):
return "fcdpp"
class FcdppDeviceDescription(SoapyConnectorDeviceDescription):
def getName(self):
return "FunCube Dongle Pro+"

View File

@ -1,5 +1,5 @@
from owrx.command import Option
from .direct import DirectSource
from owrx.source.direct import DirectSource, DirectSourceDeviceDescription
from subprocess import Popen
import logging
@ -37,3 +37,8 @@ class FifiSdrSource(DirectSource):
def onPropertyChange(self, changes):
if "center_freq" in changes:
self.sendRockProgFrequency(changes["center_freq"])
class FifiSdrDeviceDescription(DirectSourceDeviceDescription):
def getName(self):
return "FiFi SDR"

View File

@ -1,4 +1,7 @@
from .soapy import SoapyConnectorSource
from owrx.source.soapy import SoapyConnectorSource, SoapyConnectorDeviceDescription
from owrx.form.input import Input
from owrx.form.input.device import BiasTeeInput
from typing import List
class HackrfSource(SoapyConnectorSource):
@ -9,3 +12,20 @@ class HackrfSource(SoapyConnectorSource):
def getDriver(self):
return "hackrf"
class HackrfDeviceDescription(SoapyConnectorDeviceDescription):
def getName(self):
return "HackRF"
def getInputs(self) -> List[Input]:
return super().getInputs() + [BiasTeeInput()]
def getDeviceOptionalKeys(self):
return super().getDeviceOptionalKeys() + ["bias_tee"]
def getProfileOptionalKeys(self):
return super().getProfileOptionalKeys() + ["bias_tee"]
def getGainStages(self):
return ["LNA", "AMP", "VGA"]

View File

@ -1,5 +1,9 @@
from .connector import ConnectorSource
from owrx.command import Flag, Option
from owrx.source.connector import ConnectorSource, ConnectorDeviceDescription
from owrx.command import Option
from owrx.form.error import ValidationError
from owrx.form.input import Input, NumberInput, TextInput
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:
@ -33,3 +37,26 @@ class HpsdrSource(ConnectorSource):
}
)
)
class RemoteInput(TextInput):
def __init__(self):
super().__init__(
"remote", "Remote IP", infotext="Remote IP address to connect to."
)
class HpsdrDeviceDescription(ConnectorDeviceDescription):
def getName(self):
return "HPSDR devices (Hermes / Hermes Lite 2 / Red Pitaya)"
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)),
]
def getDeviceOptionalKeys(self):
return list(filter(lambda x : x not in ["rtltcp_compat", "iqswap"], super().getDeviceOptionalKeys())) + ["remote"]
def getProfileOptionalKeys(self):
return list(filter(lambda x : x != "iqswap", super().getProfileOptionalKeys()))

View File

@ -1,6 +1,11 @@
from .soapy import SoapyConnectorSource
from owrx.source.soapy import SoapyConnectorSource, SoapyConnectorDeviceDescription
class LimeSdrSource(SoapyConnectorSource):
def getDriver(self):
return "lime"
class LimeSdrDeviceDescription(SoapyConnectorDeviceDescription):
def getName(self):
return "LimeSDR device"

View File

@ -1,5 +1,7 @@
from .direct import DirectSource
from owrx.command import Flag, Option
from owrx.source.direct import DirectSource, DirectSourceDeviceDescription
from owrx.command import Option, Flag
from owrx.form.input import Input, DropdownEnum, DropdownInput, CheckboxInput
from typing import List
#
@ -29,9 +31,49 @@ class PerseussdrSource(DirectSource):
"samp_rate": Option("-s"),
"tuner_freq": Option("-f"),
"attenuator": Option("-u"),
"adc_preamp": Option("-m"),
"adc_dither": Option("-x"),
"wideband": Option("-w"),
"adc_preamp": Flag("-m"),
"adc_dither": Flag("-x"),
"wideband": Flag("-w"),
}
)
)
class AttenuatorOptions(DropdownEnum):
ATTENUATOR_0 = 0
ATTENUATOR_10 = -10
ATTENUATOR_20 = -20
ATTENUATOR_30 = -30
def __str__(self):
return "{value} dB".format(value=self.value)
class PerseussdrDeviceDescription(DirectSourceDeviceDescription):
def getName(self):
return "Perseus SDR"
def getInputs(self) -> List[Input]:
return super().getInputs() + [
DropdownInput("attenuator", "Attenuator", options=AttenuatorOptions),
CheckboxInput("adc_preamp", "Activate ADC preamp"),
CheckboxInput("adc_dither", "Enable ADC dithering"),
CheckboxInput("wideband", "Disable analog filters"),
]
def getDeviceOptionalKeys(self):
# no rf_gain
return [key for key in super().getDeviceOptionalKeys() if key != "rf_gain"] + [
"attenuator",
"adc_preamp",
"adc_dither",
"wideband",
]
def getProfileOptionalKeys(self):
return [key for key in super().getProfileOptionalKeys() if key != "rf_gain"] + [
"attenuator",
"adc_preamp",
"adc_dither",
"wideband",
]

View File

@ -1,6 +1,11 @@
from .soapy import SoapyConnectorSource
from owrx.source.soapy import SoapyConnectorSource, SoapyConnectorDeviceDescription
class PlutoSdrSource(SoapyConnectorSource):
def getDriver(self):
return "plutosdr"
class PlutoSdrDeviceDescription(SoapyConnectorDeviceDescription):
def getName(self):
return "PlutoSDR"

View File

@ -1,6 +1,11 @@
from .soapy import SoapyConnectorSource
from owrx.source.soapy import SoapyConnectorSource, SoapyConnectorDeviceDescription
class RadioberrySource(SoapyConnectorSource):
def getDriver(self):
return "radioberry"
class RadioberryDeviceDescription(SoapyConnectorDeviceDescription):
def getName(self):
return "RadioBerry"

View File

@ -1,6 +0,0 @@
from .soapy import SoapyConnectorSource
class RedPitayaSource(SoapyConnectorSource):
def getDriver(self):
return "redpitaya"

View File

@ -1,5 +1,8 @@
from .connector import ConnectorSource
from owrx.source.connector import ConnectorSource, ConnectorDeviceDescription
from owrx.command import Flag, Option
from typing import List
from owrx.form.input import Input, TextInput
from owrx.form.input.device import BiasTeeInput, DirectSamplingInput
class RtlSdrSource(ConnectorSource):
@ -10,3 +13,25 @@ class RtlSdrSource(ConnectorSource):
.setBase("rtl_connector")
.setMappings({"bias_tee": Flag("-b"), "direct_sampling": Option("-e")})
)
class RtlSdrDeviceDescription(ConnectorDeviceDescription):
def getName(self):
return "RTL-SDR device"
def getInputs(self) -> List[Input]:
return super().getInputs() + [
TextInput(
"device",
"Device identifier",
infotext="Device serial number or index",
),
BiasTeeInput(),
DirectSamplingInput(),
]
def getDeviceOptionalKeys(self):
return super().getDeviceOptionalKeys() + ["device", "bias_tee", "direct_sampling"]
def getProfileOptionalKeys(self):
return super().getProfileOptionalKeys() + ["bias_tee", "direct_sampling"]

View File

@ -1,4 +1,7 @@
from .soapy import SoapyConnectorSource
from owrx.source.soapy import SoapyConnectorSource, SoapyConnectorDeviceDescription
from owrx.form.input import Input
from owrx.form.input.device import BiasTeeInput, DirectSamplingInput
from typing import List
class RtlSdrSoapySource(SoapyConnectorSource):
@ -9,3 +12,17 @@ class RtlSdrSoapySource(SoapyConnectorSource):
def getDriver(self):
return "rtlsdr"
class RtlSdrSoapyDeviceDescription(SoapyConnectorDeviceDescription):
def getName(self):
return "RTL-SDR device (via SoapySDR)"
def getInputs(self) -> List[Input]:
return super().getInputs() + [BiasTeeInput(), DirectSamplingInput()]
def getDeviceOptionalKeys(self):
return super().getDeviceOptionalKeys() + ["bias_tee", "direct_sampling"]
def getProfileOptionalKeys(self):
return super().getProfileOptionalKeys() + ["bias_tee", "direct_sampling"]

View File

@ -1,5 +1,8 @@
from .connector import ConnectorSource
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 typing import List
class RtlTcpSource(ConnectorSource):
@ -16,3 +19,14 @@ class RtlTcpSource(ConnectorSource):
}
)
)
class RtlTcpDeviceDescription(ConnectorDeviceDescription):
def getName(self):
return "RTL-SDR device (via rtl_tcp)"
def getInputs(self) -> List[Input]:
return super().getInputs() + [RemoteInput()]
def getDeviceMandatoryKeys(self):
return super().getDeviceMandatoryKeys() + ["remote"]

54
owrx/source/runds.py Normal file
View File

@ -0,0 +1,54 @@
from owrx.source.connector import ConnectorSource, ConnectorDeviceDescription
from owrx.command import Argument, Flag, Option
from owrx.form.input import Input, DropdownInput, DropdownEnum, CheckboxInput
from owrx.form.input.device import RemoteInput
from typing import List
class RundsSource(ConnectorSource):
def getCommandMapper(self):
return (
super()
.getCommandMapper()
.setBase("runds_connector")
.setMappings(
{
"long": Flag("-l"),
"remote": Argument(),
"protocol": Option("-m"),
}
)
)
class ProtocolOptions(DropdownEnum):
PROTOCOL_EB200 = ("eb200", "EB200 protocol")
PROTOCOL_AMMOS = ("ammos", "Ammos protocol")
def __new__(cls, *args, **kwargs):
value, description = args
obj = object.__new__(cls)
obj._value_ = value
obj.description = description
return obj
def __str__(self):
return self.description
class RundsDeviceDescription(ConnectorDeviceDescription):
def getName(self):
return "R&S device using EB200 or Ammos protocol"
def getInputs(self) -> List[Input]:
return super().getInputs() + [
RemoteInput(),
DropdownInput("protocol", "Protocol", ProtocolOptions),
CheckboxInput("long", "Use 32-bit sample size (LONG)"),
]
def getDeviceMandatoryKeys(self):
return super().getDeviceMandatoryKeys() + ["remote"]
def getDeviceOptionalKeys(self):
return super().getDeviceOptionalKeys() + ["protocol", "long"]

View File

@ -1,6 +1,14 @@
from owrx.source.connector import ConnectorSource
from owrx.source.connector import ConnectorSource, ConnectorDeviceDescription
class SddcSource(ConnectorSource):
def getCommandMapper(self):
return super().getCommandMapper().setBase("sddc_connector")
class SddcDeviceDescription(ConnectorDeviceDescription):
def getName(self):
return "BBRF103 / RX666 / RX888 device (libsddc)"
def hasAgc(self):
return False

View File

@ -1,4 +1,7 @@
from .soapy import SoapyConnectorSource
from owrx.source.soapy import SoapyConnectorSource, SoapyConnectorDeviceDescription
from owrx.form.input import Input, CheckboxInput, DropdownInput, DropdownEnum
from owrx.form.input.device import BiasTeeInput
from typing import List
class SdrplaySource(SoapyConnectorSource):
@ -17,3 +20,45 @@ class SdrplaySource(SoapyConnectorSource):
def getDriver(self):
return "sdrplay"
class IfModeOptions(DropdownEnum):
IFMODE_ZERO_IF = "Zero-IF"
IFMODE_450 = "450kHz"
IFMODE_1620 = "1620kHz"
IFMODE_2048 = "2048kHz"
def __str__(self):
return self.value
class SdrplayDeviceDescription(SoapyConnectorDeviceDescription):
def getName(self):
return "SDRPlay device (RSP1, RSP2, RSPDuo, RSPDx)"
def getGainStages(self):
return ["RFGR", "IFGR"]
def getInputs(self) -> List[Input]:
return super().getInputs() + [
BiasTeeInput(),
CheckboxInput(
"rf_notch",
"Enable RF notch filter",
),
CheckboxInput(
"dab_notch",
"Enable DAB notch filter",
),
DropdownInput(
"if_mode",
"IF Mode",
IfModeOptions,
),
]
def getDeviceOptionalKeys(self):
return super().getDeviceOptionalKeys() + ["bias_tee", "rf_notch", "dab_notch", "if_mode"]
def getProfileOptionalKeys(self):
return super().getProfileOptionalKeys() + ["bias_tee", "rf_notch", "dab_notch", "if_mode"]

View File

@ -1,6 +1,10 @@
from abc import ABCMeta, abstractmethod
from owrx.command import Option
from .connector import ConnectorSource
from owrx.source.connector import ConnectorSource, ConnectorDeviceDescription
from typing import List
from owrx.form.input import Input, TextInput
from owrx.form.input.device import GainInput
from owrx.soapy import SoapySettings
class SoapyConnectorSource(ConnectorSource, metaclass=ABCMeta):
@ -29,25 +33,6 @@ class SoapyConnectorSource(ConnectorSource, metaclass=ABCMeta):
def getEventNames(self):
return super().getEventNames() + list(self.getSoapySettingsMappings().keys())
def parseDeviceString(self, dstr):
def decodeComponent(c):
kv = c.split("=", 1)
if len(kv) < 2:
return c
else:
return {kv[0]: kv[1]}
return [decodeComponent(c) for c in dstr.split(",")]
def encodeDeviceString(self, dobj):
def encodeComponent(c):
if isinstance(c, str):
return c
else:
return ",".join(["{0}={1}".format(key, value) for key, value in c.items()])
return ",".join([encodeComponent(c) for c in dobj])
def buildSoapyDeviceParameters(self, parsed, values):
"""
this method always attempts to inject a driver= part into the soapysdr query, depending on what connector was used.
@ -75,11 +60,11 @@ class SoapyConnectorSource(ConnectorSource, metaclass=ABCMeta):
def getCommandValues(self):
values = super().getCommandValues()
if "device" in values and values["device"] is not None:
parsed = self.parseDeviceString(values["device"])
parsed = SoapySettings.parse(values["device"])
else:
parsed = []
modified = self.buildSoapyDeviceParameters(parsed, values)
values["device"] = self.encodeDeviceString(modified)
values["device"] = SoapySettings.encode(modified)
settings = ",".join(["{0}={1}".format(k, v) for k, v in self.buildSoapySettings(values).items()])
if len(settings):
values["soapy_settings"] = settings
@ -94,3 +79,30 @@ class SoapyConnectorSource(ConnectorSource, metaclass=ABCMeta):
if settings:
changes["settings"] = ",".join("{0}={1}".format(k, v) for k, v in settings.items())
super().onPropertyChange(changes)
class SoapyConnectorDeviceDescription(ConnectorDeviceDescription):
def getInputs(self) -> List[Input]:
return super().getInputs() + [
TextInput(
"device",
"Device identifier",
infotext='SoapySDR device identifier string (example: "serial=123456789")',
),
GainInput(
"rf_gain",
"Device Gain",
gain_stages=self.getGainStages(),
has_agc=self.hasAgc(),
),
TextInput("antenna", "Antenna"),
]
def getDeviceOptionalKeys(self):
return super().getDeviceOptionalKeys() + ["device", "rf_gain", "antenna"]
def getProfileOptionalKeys(self):
return super().getProfileOptionalKeys() + ["antenna"]
def getGainStages(self):
return None

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