show sdr device log messages in the web configuration

This commit is contained in:
Jakob Ketterl 2022-12-10 19:50:26 +01:00
parent ab40a2934f
commit 13e323cdd2
13 changed files with 133 additions and 34 deletions

View File

@ -1,4 +1,5 @@
**unreleased** **unreleased**
- SDR device log messages are now available in the web configuration to simplify troubleshooting
**1.2.1** **1.2.1**
- FifiSDR support fixed (pipeline formats now line up correctly) - FifiSDR support fixed (pipeline formats now line up correctly)

2
debian/changelog vendored
View File

@ -1,4 +1,6 @@
openwebrx (1.3.0) UNRELEASED; urgency=low openwebrx (1.3.0) UNRELEASED; urgency=low
* SDR device log messages are now available in the web configuration to
simplify troubleshooting
-- Jakob Ketterl <jakob.ketterl@gmx.de> Fri, 30 Sep 2022 16:47:00 +0000 -- Jakob Ketterl <jakob.ketterl@gmx.de> Fri, 30 Sep 2022 16:47:00 +0000

View File

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

View File

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

View File

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

View File

@ -158,6 +158,7 @@ class CompiledAssetsController(GzipMixin, ModificationAwareController):
"lib/settings/OptionalSection.js", "lib/settings/OptionalSection.js",
"lib/settings/SchedulerInput.js", "lib/settings/SchedulerInput.js",
"lib/settings/ExponentialInput.js", "lib/settings/ExponentialInput.js",
"lib/settings/LogMessages.js",
"settings.js", "settings.js",
], ],
} }

View File

@ -12,6 +12,7 @@ from owrx.form.input import TextInput, DropdownInput, Option
from owrx.form.input.validator import RequiredValidator from owrx.form.input.validator import RequiredValidator
from owrx.property import PropertyLayer from owrx.property import PropertyLayer
from owrx.breadcrumb import BreadcrumbMixin, Breadcrumb, BreadcrumbItem from owrx.breadcrumb import BreadcrumbMixin, Breadcrumb, BreadcrumbItem
from owrx.log import HistoryHandler
from abc import ABCMeta, abstractmethod from abc import ABCMeta, abstractmethod
from uuid import uuid4 from uuid import uuid4
@ -279,6 +280,21 @@ class SdrDeviceController(SdrFormControllerWithModal):
config.store() config.store()
return self.send_redirect("{}settings/sdr".format(self.get_document_root())) return self.send_redirect("{}settings/sdr".format(self.get_document_root()))
def render_sections(self):
handler = HistoryHandler.getHandler("owrx.source.{id}".format(id=self.device_id))
return """
{sections}
<div class="card mt-2">
<div class="card-header">Recent device log messages</div>
<div class="card-body">
<pre class="card-text device-log-messages">{messages}</pre>
</div>
</div>
""".format(
sections=super().render_sections(),
messages=handler.getFormattedHistory(),
)
class NewSdrDeviceController(SettingsFormController): class NewSdrDeviceController(SettingsFormController):
def __init__(self, handler, request, options): def __init__(self, handler, request, options):

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

@ -0,0 +1,52 @@
import threading
import os
from logging import Logger, Handler, LogRecord, Formatter
class LogPipe(threading.Thread):
def __init__(self, level: int, logger: Logger, prefix: str = ""):
threading.Thread.__init__(self)
self.daemon = False
self.level = level
self.logger = logger
self.prefix = prefix
self.fdRead, self.fdWrite = os.pipe()
self.pipeReader = os.fdopen(self.fdRead)
self.start()
def fileno(self):
return self.fdWrite
def run(self):
for line in iter(self.pipeReader.readline, ''):
self.logger.log(self.level, "{}: {}".format(self.prefix, line.strip('\n')))
self.pipeReader.close()
def close(self):
os.close(self.fdWrite)
class HistoryHandler(Handler):
handlers = {}
@staticmethod
def getHandler(name: str):
if name not in HistoryHandler.handlers:
HistoryHandler.handlers[name] = HistoryHandler()
return HistoryHandler.handlers[name]
def __init__(self, maxRecords: int = 200):
super().__init__()
self.history = []
self.maxRecords = maxRecords
self.setFormatter(Formatter(fmt="%(asctime)s - %(name)s - %(levelname)s - %(message)s"))
def emit(self, record: LogRecord) -> None:
self.history.append(record)
# truncate
self.history = self.history[-self.maxRecords:]
def getFormattedHistory(self) -> str:
return "\n".join([self.format(r) for r in self.history])

View File

@ -18,6 +18,7 @@ from owrx.form.input.device import GainInput, SchedulerInput, WaterfallLevelsInp
from owrx.form.input.validator import RequiredValidator from owrx.form.input.validator import RequiredValidator
from owrx.form.section import OptionalSection from owrx.form.section import OptionalSection
from owrx.feature import FeatureDetector from owrx.feature import FeatureDetector
from owrx.log import LogPipe, HistoryHandler
from typing import List from typing import List
from enum import Enum from enum import Enum
@ -114,6 +115,10 @@ class SdrSource(ABC):
self.commandMapper = None self.commandMapper = None
self.tcpSource = None self.tcpSource = None
self.buffer = None self.buffer = None
self.logger = logger.getChild(id) if id is not None else logger
self.logger.addHandler(HistoryHandler.getHandler(self.logger.name))
self.stdoutPipe = None
self.stderrPipe = None
self.props = PropertyStack() self.props = PropertyStack()
@ -185,17 +190,17 @@ class SdrSource(ABC):
for id, p in self.props["profiles"].items(): for id, p in self.props["profiles"].items():
props.replaceLayer(0, p) props.replaceLayer(0, p)
if "center_freq" not in props: if "center_freq" not in props:
logger.warning('Profile "%s" does not specify a center_freq', id) self.logger.warning('Profile "%s" does not specify a center_freq', id)
continue continue
if "samp_rate" not in props: if "samp_rate" not in props:
logger.warning('Profile "%s" does not specify a samp_rate', id) self.logger.warning('Profile "%s" does not specify a samp_rate', id)
continue continue
if "start_freq" in props: if "start_freq" in props:
start_freq = props["start_freq"] start_freq = props["start_freq"]
srh = props["samp_rate"] / 2 srh = props["samp_rate"] / 2
center_freq = props["center_freq"] center_freq = props["center_freq"]
if start_freq < center_freq - srh or start_freq > center_freq + srh: if start_freq < center_freq - srh or start_freq > center_freq + srh:
logger.warning('start_freq for profile "%s" is out of range', id) self.logger.warning('start_freq for profile "%s" is out of range', id)
def isAlwaysOn(self): def isAlwaysOn(self):
return "always-on" in self.props and self.props["always-on"] return "always-on" in self.props and self.props["always-on"]
@ -225,11 +230,11 @@ class SdrSource(ABC):
return [self.getCommandMapper().map(self.getCommandValues())] return [self.getCommandMapper().map(self.getCommandValues())]
def activateProfile(self, profile_id): def activateProfile(self, profile_id):
logger.debug("activating profile {0} for {1}".format(profile_id, self.getId())) self.logger.debug("activating profile {0} for {1}".format(profile_id, self.getId()))
try: try:
self.profileCarousel.switch(profile_id) self.profileCarousel.switch(profile_id)
except KeyError: except KeyError:
logger.warning("invalid profile %s for sdr %s. ignoring", profile_id, self.getId()) self.logger.warning("invalid profile %s for sdr %s. ignoring", profile_id, self.getId())
def getId(self): def getId(self):
return self.id return self.id
@ -283,23 +288,37 @@ class SdrSource(ABC):
try: try:
self.preStart() self.preStart()
except Exception: except Exception:
logger.exception("Exception during preStart()") self.logger.exception("Exception during preStart()")
cmd = self.getCommand() cmd = self.getCommand()
cmd = [c for c in cmd if c is not None] cmd = [c for c in cmd if c is not None]
self.stdoutPipe = LogPipe(logging.INFO, self.logger, "STDOUT")
self.stderrPipe = LogPipe(logging.WARNING, self.logger, "STDERR")
# don't use shell mode for commands without piping # don't use shell mode for commands without piping
if len(cmd) > 1: if len(cmd) > 1:
# multiple commands with pipes # multiple commands with pipes
cmd = "|".join(cmd) cmd = "|".join(cmd)
self.process = subprocess.Popen(cmd, shell=True, start_new_session=True) self.process = subprocess.Popen(
cmd,
shell=True,
start_new_session=True,
stdout=self.stdoutPipe,
stderr=self.stderrPipe
)
else: else:
# single command # single command
cmd = cmd[0] cmd = cmd[0]
# start_new_session can go as soon as there's no piped commands left # start_new_session can go as soon as there's no piped commands left
# the os.killpg call must be replaced with something more reasonable at the same time # the os.killpg call must be replaced with something more reasonable at the same time
self.process = subprocess.Popen(shlex.split(cmd), start_new_session=True) self.process = subprocess.Popen(
logger.info("Started sdr source: " + cmd) shlex.split(cmd),
start_new_session=True,
stdout=self.stdoutPipe,
stderr=self.stderrPipe
)
self.logger.info("Started sdr source: " + cmd)
available = False available = False
failed = False failed = False
@ -307,9 +326,13 @@ class SdrSource(ABC):
def wait_for_process_to_end(): def wait_for_process_to_end():
nonlocal failed nonlocal failed
rc = self.process.wait() rc = self.process.wait()
logger.debug("shut down with RC={0}".format(rc)) self.logger.debug("shut down with RC={0}".format(rc))
self.process = None self.process = None
self.monitor = None self.monitor = None
self.stdoutPipe.close()
self.stdoutPipe = None
self.stderrPipe.close()
self.stderrPipe = None
if self.getState() is SdrSourceState.RUNNING: if self.getState() is SdrSourceState.RUNNING:
self.fail() self.fail()
else: else:
@ -340,7 +363,7 @@ class SdrSource(ABC):
try: try:
self.postStart() self.postStart()
except Exception: except Exception:
logger.exception("Exception during postStart()") self.logger.exception("Exception during postStart()")
failed = True failed = True
if failed: if failed:
@ -374,7 +397,7 @@ class SdrSource(ABC):
self.monitor.join(10) self.monitor.join(10)
# if the monitor is still running, the process still hasn't ended, so kill it # if the monitor is still running, the process still hasn't ended, so kill it
if self.monitor: if self.monitor:
logger.warning("source has not shut down normally within 10 seconds, sending SIGKILL") self.logger.warning("source has not shut down normally within 10 seconds, sending SIGKILL")
os.killpg(os.getpgid(self.process.pid), signal.SIGKILL) os.killpg(os.getpgid(self.process.pid), signal.SIGKILL)
except ProcessLookupError: except ProcessLookupError:
# been killed by something else, ignore # been killed by something else, ignore

View File

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

View File

@ -5,10 +5,6 @@ from typing import Optional
from pycsdr.modules import Buffer from pycsdr.modules import Buffer
from pycsdr.types import Format from pycsdr.types import Format
import logging
logger = logging.getLogger(__name__)
class DirectSource(SdrSource, metaclass=ABCMeta): class DirectSource(SdrSource, metaclass=ABCMeta):
def __init__(self, id, props): def __init__(self, id, props):
@ -16,7 +12,7 @@ class DirectSource(SdrSource, metaclass=ABCMeta):
super().__init__(id, props) super().__init__(id, props)
def onPropertyChange(self, changes): def onPropertyChange(self, changes):
logger.debug("restarting sdr source due to property changes: {0}".format(changes)) self.logger.debug("restarting sdr source due to property changes: {0}".format(changes))
self.stop() self.stop()
self.sleepOnRestart() self.sleepOnRestart()
self.start() self.start()

View File

@ -1,16 +1,14 @@
from owrx.command import Option from owrx.command import Option
from owrx.source.direct import DirectSource, DirectSourceDeviceDescription from owrx.source.direct import DirectSource, DirectSourceDeviceDescription
from owrx.log import LogPipe
from subprocess import Popen from subprocess import Popen
from csdr.chain import Chain from csdr.chain import Chain
from pycsdr.modules import Convert, Gain from pycsdr.modules import Convert, Gain
from pycsdr.types import Format from pycsdr.types import Format
from typing import List from typing import List
from owrx.form.input import Input, TextInput from owrx.form.input import Input, TextInput
import logging import logging
logger = logging.getLogger(__name__)
class FifiSdrSource(DirectSource): class FifiSdrSource(DirectSource):
def getCommandMapper(self): def getCommandMapper(self):
@ -29,11 +27,19 @@ class FifiSdrSource(DirectSource):
return Chain([Convert(Format.COMPLEX_SHORT, Format.COMPLEX_FLOAT), Gain(Format.COMPLEX_FLOAT, 5.0)]) return Chain([Convert(Format.COMPLEX_SHORT, Format.COMPLEX_FLOAT), Gain(Format.COMPLEX_FLOAT, 5.0)])
def sendRockProgFrequency(self, frequency): def sendRockProgFrequency(self, frequency):
process = Popen(["rockprog", "--vco", "-w", "--freq={}".format(frequency / 1e6)]) stdoutPipe = LogPipe(logging.DEBUG, self.logger, "STDOUT")
stderrPipe = LogPipe(logging.DEBUG, self.logger, "STDERR")
process = Popen(
["rockprog", "--vco", "-w", "--freq={}".format(frequency / 1e6)],
stdout=stdoutPipe,
stderr=stderrPipe
)
process.communicate() process.communicate()
rc = process.wait() rc = process.wait()
if rc != 0: if rc != 0:
logger.warning("rockprog failed to set frequency; rc=%i", rc) self.logger.warning("rockprog failed to set frequency; rc=%i", rc)
stdoutPipe.close()
stderrPipe.close()
def preStart(self): def preStart(self):
values = self.getCommandValues() values = self.getCommandValues()

View File

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