Merge branch 'develop' into pycsdr
This commit is contained in:
@ -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
60
owrx/admin/__init__.py
Normal 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
115
owrx/admin/commands.py
Normal 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)
|
301
owrx/audio.py
301
owrx/audio.py
@ -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
86
owrx/audio/__init__.py
Normal 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
90
owrx/audio/chopper.py
Normal 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
172
owrx/audio/queue.py
Normal 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
139
owrx/audio/wav.py
Normal 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
|
@ -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)]
|
||||
|
@ -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
44
owrx/breadcrumb.py
Normal 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
|
@ -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()
|
||||
|
142
owrx/config.py
142
owrx/config.py
@ -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
43
owrx/config/__init__.py
Normal 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
36
owrx/config/classic.py
Normal 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
30
owrx/config/commands.py
Normal 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
59
owrx/config/core.py
Normal 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
177
owrx/config/defaults.py
Normal 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
62
owrx/config/dynamic.py
Normal 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
3
owrx/config/error.py
Normal 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
134
owrx/config/migration.py
Normal 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)
|
@ -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
|
||||
|
@ -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):
|
||||
|
@ -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)
|
||||
|
@ -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")
|
||||
|
@ -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):
|
||||
|
11
owrx/controllers/feature.py
Normal file
11
owrx/controllers/feature.py
Normal 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())
|
79
owrx/controllers/imageupload.py
Normal file
79
owrx/controllers/imageupload.py
Normal 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")
|
@ -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")
|
||||
|
24
owrx/controllers/profile.py
Normal file
24
owrx/controllers/profile.py
Normal 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)
|
16
owrx/controllers/robots.py
Normal file
16
owrx/controllers/robots.py
Normal 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",
|
||||
)
|
@ -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")
|
||||
|
@ -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")
|
147
owrx/controllers/settings/__init__.py
Normal file
147
owrx/controllers/settings/__init__.py
Normal 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"))
|
25
owrx/controllers/settings/backgrounddecoding.py
Normal file
25
owrx/controllers/settings/backgrounddecoding.py
Normal 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"),
|
||||
),
|
||||
]
|
148
owrx/controllers/settings/bookmarks.py
Normal file
148
owrx/controllers/settings/bookmarks.py
Normal 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())
|
97
owrx/controllers/settings/decoding.py
Normal file
97
owrx/controllers/settings/decoding.py
Normal 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"),
|
||||
),
|
||||
]
|
218
owrx/controllers/settings/general.py
Normal file
218
owrx/controllers/settings/general.py
Normal 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)
|
93
owrx/controllers/settings/reporting.py
Normal file
93
owrx/controllers/settings/reporting.py
Normal 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",
|
||||
),
|
||||
),
|
||||
]
|
433
owrx/controllers/settings/sdr.py
Normal file
433
owrx/controllers/settings/sdr.py
Normal 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">×</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 ""
|
@ -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")
|
||||
|
@ -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())
|
||||
|
@ -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()
|
||||
|
@ -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):
|
||||
|
127
owrx/dsp.py
127
owrx/dsp.py
@ -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()
|
||||
|
@ -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
|
||||
|
23
owrx/fft.py
23
owrx/fft.py
@ -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()
|
||||
|
@ -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
15
owrx/form/error.py
Normal 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
411
owrx/form/input/__init__.py
Normal 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
36
owrx/form/input/aprs.py
Normal 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
|
96
owrx/form/input/converter.py
Normal file
96
owrx/form/input/converter.py
Normal 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
434
owrx/form/input/device.py
Normal 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
67
owrx/form/input/gfx.py
Normal 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
|
10
owrx/form/input/receiverid.py
Normal file
10
owrx/form/input/receiverid.py
Normal 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")]
|
26
owrx/form/input/validator.py
Normal file
26
owrx/form/input/validator.py
Normal 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
16
owrx/form/input/wfm.py
Normal 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
93
owrx/form/input/wsjt.py
Normal 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
124
owrx/form/section.py
Normal 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
|
136
owrx/http.py
136
owrx/http.py
@ -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)
|
||||
|
97
owrx/js8.py
97
owrx/js8.py
@ -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
9
owrx/jsons.py
Normal 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)
|
117
owrx/kiss.py
117
owrx/kiss.py
@ -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
|
||||
|
133
owrx/meta.py
133
owrx/meta.py
@ -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"]
|
||||
|
@ -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():
|
||||
|
@ -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",
|
||||
|
@ -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
23
owrx/property/filter.py
Normal 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)
|
97
owrx/property/validators.py
Normal file
97
owrx/property/validators.py
Normal 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,
|
||||
}
|
@ -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)
|
57
owrx/reporting/__init__.py
Normal file
57
owrx/reporting/__init__.py
Normal 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)
|
@ -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 = []
|
15
owrx/reporting/reporter.py
Normal file
15
owrx/reporting/reporter.py
Normal 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 []
|
@ -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:
|
282
owrx/sdr.py
282
owrx/sdr.py
@ -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
|
||||
|
@ -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 = {}
|
||||
|
@ -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
21
owrx/soapy.py
Normal 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])
|
@ -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(),
|
||||
)
|
||||
|
@ -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"]
|
||||
|
@ -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"
|
||||
|
@ -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"]
|
||||
|
@ -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
|
||||
|
@ -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(),
|
||||
}
|
||||
)
|
||||
)
|
@ -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+"
|
||||
|
@ -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"
|
||||
|
@ -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"]
|
||||
|
@ -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()))
|
||||
|
||||
|
@ -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"
|
||||
|
@ -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",
|
||||
]
|
||||
|
@ -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"
|
||||
|
@ -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"
|
||||
|
@ -1,6 +0,0 @@
|
||||
from .soapy import SoapyConnectorSource
|
||||
|
||||
|
||||
class RedPitayaSource(SoapyConnectorSource):
|
||||
def getDriver(self):
|
||||
return "redpitaya"
|
@ -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"]
|
||||
|
@ -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"]
|
||||
|
@ -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
54
owrx/source/runds.py
Normal 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"]
|
@ -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
|
||||
|
@ -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"]
|
||||
|
@ -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
Reference in New Issue
Block a user