Merge branch 'develop' into pycsdr

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

View File

@@ -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(),
)