From 862a251295aa70ab41e54fe236fc7cb6ab89c023 Mon Sep 17 00:00:00 2001 From: Jakob Ketterl Date: Sun, 24 Jan 2021 20:10:37 +0100 Subject: [PATCH 001/577] allow only limited parameters to be set on the dsp --- owrx/dsp.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/owrx/dsp.py b/owrx/dsp.py index 72e6667..13cc542 100644 --- a/owrx/dsp.py +++ b/owrx/dsp.py @@ -28,7 +28,7 @@ class DspManager(csdr.output, SdrSourceEventClient): self.props = PropertyStack() # local demodulator properties not forwarded to the sdr - self.props.addLayer(0, PropertyLayer().filter( + self.localProps = PropertyLayer().filter( "output_rate", "hd_output_rate", "squelch_level", @@ -39,7 +39,8 @@ class DspManager(csdr.output, SdrSourceEventClient): "mod", "secondary_offset_freq", "dmr_filter", - )) + ) + self.props.addLayer(0, self.localProps) # properties that we inherit from the sdr self.props.addLayer(1, self.sdrSource.getProps().filter( "audio_compression", @@ -177,7 +178,7 @@ 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 From 8b52988dcd81159b87b19d535882efdc16c5709d Mon Sep 17 00:00:00 2001 From: Jakob Ketterl Date: Sun, 24 Jan 2021 20:15:02 +0100 Subject: [PATCH 002/577] add a test that makes sure that writing to a filtered property fails --- test/property/test_property_filter.py | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/test/property/test_property_filter.py b/test/property/test_property_filter.py index 66eff3b..8553552 100644 --- a/test/property/test_property_filter.py +++ b/test/property/test_property_filter.py @@ -11,7 +11,7 @@ class PropertyFilterTest(TestCase): pf = PropertyFilter(pm, "testkey") self.assertEqual(pf["testkey"], "testvalue") - def testMissesPropert(self): + def testMissesProperty(self): pm = PropertyLayer() pm["testkey"] = "testvalue" pf = PropertyFilter(pm, "other_key") @@ -49,3 +49,11 @@ class PropertyFilterTest(TestCase): pf["testkey"] = "new value" self.assertEqual(pm["testkey"], "new value") self.assertEqual(pf["testkey"], "new value") + + def testRejectsWrite(self): + pm = PropertyLayer() + pm["testkey"] = "old value" + pf = PropertyFilter(pm, "otherkey") + with self.assertRaises(KeyError): + pf["testkey"] = "new value" + self.assertEqual(pm["testkey"], "old value") From 40e531c0daa6bb9ac00eef01cbe4130265d80ed4 Mon Sep 17 00:00:00 2001 From: Jakob Ketterl Date: Sun, 24 Jan 2021 20:53:51 +0100 Subject: [PATCH 003/577] start implementing a validation layer, refs #215 --- owrx/property/__init__.py | 5 +++ owrx/property/validators.py | 42 +++++++++++++++++++ test/property/validators/__init__.py | 0 .../validators/test_integer_validator.py | 15 +++++++ .../validators/test_lambda_validator.py | 21 ++++++++++ .../validators/test_number_validator.py | 18 ++++++++ .../validators/test_string_validator.py | 14 +++++++ test/property/validators/test_validator.py | 16 +++++++ 8 files changed, 131 insertions(+) create mode 100644 owrx/property/validators.py create mode 100644 test/property/validators/__init__.py create mode 100644 test/property/validators/test_integer_validator.py create mode 100644 test/property/validators/test_lambda_validator.py create mode 100644 test/property/validators/test_number_validator.py create mode 100644 test/property/validators/test_string_validator.py create mode 100644 test/property/validators/test_validator.py diff --git a/owrx/property/__init__.py b/owrx/property/__init__.py index f3560fa..fed36a5 100644 --- a/owrx/property/__init__.py +++ b/owrx/property/__init__.py @@ -1,4 +1,5 @@ from abc import ABC, abstractmethod +from owrx.property.validators import Validator import logging logger = logging.getLogger(__name__) @@ -23,6 +24,7 @@ class Subscription(object): class PropertyManager(ABC): def __init__(self): self.subscribers = [] + self.validators = {} @abstractmethod def __getitem__(self, item): @@ -81,6 +83,9 @@ class PropertyManager(ABC): except Exception as e: logger.exception(e) + def setValidator(self, name, validator): + self.validators[name] = Validator.of(validator) + class PropertyLayer(PropertyManager): def __init__(self): diff --git a/owrx/property/validators.py b/owrx/property/validators.py new file mode 100644 index 0000000..fce2955 --- /dev/null +++ b/owrx/property/validators.py @@ -0,0 +1,42 @@ +from abc import ABC, abstractmethod + + +class ValidatorException(Exception): + pass + + +class Validator(ABC): + @staticmethod + def of(x): + if isinstance(x, Validator): + return x + if callable(x): + return LambdaValidator(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 NumberValidator(Validator): + def isValid(self, value): + return isinstance(value, int) or isinstance(value, float) + + +class IntegerValidator(Validator): + def isValid(self, value): + return isinstance(value, int) + + +class StringValidator(Validator): + def isValid(self, value): + return isinstance(value, str) diff --git a/test/property/validators/__init__.py b/test/property/validators/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/test/property/validators/test_integer_validator.py b/test/property/validators/test_integer_validator.py new file mode 100644 index 0000000..454918a --- /dev/null +++ b/test/property/validators/test_integer_validator.py @@ -0,0 +1,15 @@ +from unittest import TestCase +from owrx.property.validators import IntegerValidator + + +class IntegerValidatorTest(TestCase): + def testPassesIntegers(self): + validator = IntegerValidator() + self.assertTrue(validator.isValid(123)) + self.assertTrue(validator.isValid(-2)) + + def testDoesntPassOthers(self): + validator = IntegerValidator() + self.assertFalse(validator.isValid(.5)) + self.assertFalse(validator.isValid("text")) + self.assertFalse(validator.isValid(object())) diff --git a/test/property/validators/test_lambda_validator.py b/test/property/validators/test_lambda_validator.py new file mode 100644 index 0000000..969dce6 --- /dev/null +++ b/test/property/validators/test_lambda_validator.py @@ -0,0 +1,21 @@ +from unittest import TestCase +from unittest.mock import Mock +from owrx.property.validators import LambdaValidator + + +class LambdaValidatorTest(TestCase): + def testPassesValue(self): + mock = Mock() + validator = LambdaValidator(mock.method) + validator.isValid("test") + mock.method.assert_called_once_with("test") + + def testReturnsTrue(self): + validator = LambdaValidator(lambda x: True) + self.assertTrue(validator.isValid("any value")) + self.assertTrue(validator.isValid(3.1415926)) + + def testReturnsFalse(self): + validator = LambdaValidator(lambda x: False) + self.assertFalse(validator.isValid("any value")) + self.assertFalse(validator.isValid(42)) diff --git a/test/property/validators/test_number_validator.py b/test/property/validators/test_number_validator.py new file mode 100644 index 0000000..3eff11b --- /dev/null +++ b/test/property/validators/test_number_validator.py @@ -0,0 +1,18 @@ +from unittest import TestCase +from owrx.property.validators import NumberValidator + + +class NumberValidatorTest(TestCase): + def testPassesNumbers(self): + validator = NumberValidator() + self.assertTrue(validator.isValid(123)) + self.assertTrue(validator.isValid(-2)) + self.assertTrue(validator.isValid(.5)) + + def testDoesntPassOthers(self): + validator = NumberValidator() + # bool is a subclass of int, so it passes this test. + # not sure if we need to be more specific or if this is alright. + # self.assertFalse(validator.isValid(True)) + self.assertFalse(validator.isValid("text")) + self.assertFalse(validator.isValid(object())) diff --git a/test/property/validators/test_string_validator.py b/test/property/validators/test_string_validator.py new file mode 100644 index 0000000..d285f1a --- /dev/null +++ b/test/property/validators/test_string_validator.py @@ -0,0 +1,14 @@ +from unittest import TestCase +from owrx.property.validators import StringValidator + +class StringValidatorTest(TestCase): + def testPassesStrings(self): + validator = StringValidator() + self.assertTrue(validator.isValid("text")) + + def testDoesntPassOthers(self): + validator = StringValidator() + self.assertFalse(validator.isValid(123)) + self.assertFalse(validator.isValid(-2)) + self.assertFalse(validator.isValid(.5)) + self.assertFalse(validator.isValid(object())) diff --git a/test/property/validators/test_validator.py b/test/property/validators/test_validator.py new file mode 100644 index 0000000..a083889 --- /dev/null +++ b/test/property/validators/test_validator.py @@ -0,0 +1,16 @@ +from unittest import TestCase +from owrx.property.validators import Validator, NumberValidator, LambdaValidator + + +class ValidatorTest(TestCase): + + def testReturnsValidator(self): + validator = NumberValidator() + self.assertIs(validator, Validator.of(validator)) + + def testTransformsLambda(self): + def my_callable(v): + return True + validator = Validator.of(my_callable) + self.assertIsInstance(validator, LambdaValidator) + self.assertTrue(validator.isValid("test")) From ad0a5c27db0d4aaf254acb467a46eaf133f53c8b Mon Sep 17 00:00:00 2001 From: Jakob Ketterl Date: Sun, 24 Jan 2021 21:19:45 +0100 Subject: [PATCH 004/577] introduce PropertyValidator (wrapper) --- owrx/property/__init__.py | 47 ++++++++++++++++++++++-- test/property/test_property_validator.py | 37 +++++++++++++++++++ 2 files changed, 80 insertions(+), 4 deletions(-) create mode 100644 test/property/test_property_validator.py diff --git a/owrx/property/__init__.py b/owrx/property/__init__.py index fed36a5..6acb68e 100644 --- a/owrx/property/__init__.py +++ b/owrx/property/__init__.py @@ -24,7 +24,6 @@ class Subscription(object): class PropertyManager(ABC): def __init__(self): self.subscribers = [] - self.validators = {} @abstractmethod def __getitem__(self, item): @@ -83,9 +82,6 @@ class PropertyManager(ABC): except Exception as e: logger.exception(e) - def setValidator(self, name, validator): - self.validators[name] = Validator.of(validator) - class PropertyLayer(PropertyManager): def __init__(self): @@ -153,6 +149,49 @@ class PropertyFilter(PropertyManager): return [k for k in self.pm.keys() if k in self.props] +class PropertyValidationError(Exception): + def __init__(self, key, value): + super().__init__('Invalid value for property "{key}": "{value}"'.format(key=key, value=str(value))) + + +class PropertyValidator(PropertyManager): + def __init__(self, pm: PropertyManager, validators=None): + self.pm = pm + if validators is None: + self.validators = {} + else: + self.validators = {k: Validator.of(v) for k, v in validators.items()} + super().__init__() + + 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 __getitem__(self, item): + return self.pm.__getitem__(item) + + def __setitem__(self, key, value): + self.validate(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() + + class PropertyStack(PropertyManager): def __init__(self): super().__init__() diff --git a/test/property/test_property_validator.py b/test/property/test_property_validator.py new file mode 100644 index 0000000..a246031 --- /dev/null +++ b/test/property/test_property_validator.py @@ -0,0 +1,37 @@ +from unittest import TestCase +from owrx.property import PropertyLayer, PropertyValidator, PropertyValidationError +from owrx.property.validators import NumberValidator, StringValidator + + +class PropertyValidatorTest(TestCase): + def testPassesUnvalidated(self): + pm = PropertyLayer() + pv = PropertyValidator(pm) + pv["testkey"] = "testvalue" + self.assertEqual(pv["testkey"], "testvalue") + self.assertEqual(pm["testkey"], "testvalue") + + def testPassesValidValue(self): + pv = PropertyValidator(PropertyLayer(), {"testkey": NumberValidator()}) + pv["testkey"] = 42 + self.assertEqual(pv["testkey"], 42) + + def testThrowsErrorOnInvalidValue(self): + pv = PropertyValidator(PropertyLayer(), {"testkey": NumberValidator()}) + with self.assertRaises(PropertyValidationError): + pv["testkey"] = "text" + + def testSetValidator(self): + pv = PropertyValidator(PropertyLayer()) + pv.setValidator("testkey", NumberValidator()) + with self.assertRaises(PropertyValidationError): + pv["testkey"] = "text" + + def testUpdateValidator(self): + pv = PropertyValidator(PropertyLayer(), {"testkey": StringValidator()}) + # this should pass + pv["testkey"] = "text" + pv.setValidator("testkey", NumberValidator()) + # this should raise + with self.assertRaises(PropertyValidationError): + pv["testkey"] = "text" From 66dc4e5772054eb426524da018652cf8e83e6ae1 Mon Sep 17 00:00:00 2001 From: Jakob Ketterl Date: Sun, 24 Jan 2021 21:25:26 +0100 Subject: [PATCH 005/577] get validator by string --- owrx/property/validators.py | 12 ++++++++++++ test/property/validators/test_validator.py | 6 +++++- 2 files changed, 17 insertions(+), 1 deletion(-) diff --git a/owrx/property/validators.py b/owrx/property/validators.py index fce2955..1812fb2 100644 --- a/owrx/property/validators.py +++ b/owrx/property/validators.py @@ -12,6 +12,8 @@ class Validator(ABC): return x if callable(x): return LambdaValidator(x) + if x in validator_types: + return validator_types[x]() raise ValidatorException("Cannot create validator") @abstractmethod @@ -40,3 +42,13 @@ class IntegerValidator(Validator): class StringValidator(Validator): def isValid(self, value): return isinstance(value, str) + + +validator_types = { + "string": StringValidator, + "str": StringValidator, + "integer": IntegerValidator, + "int": IntegerValidator, + "number": NumberValidator, + "num": NumberValidator, +} diff --git a/test/property/validators/test_validator.py b/test/property/validators/test_validator.py index a083889..6fbbf78 100644 --- a/test/property/validators/test_validator.py +++ b/test/property/validators/test_validator.py @@ -1,5 +1,5 @@ from unittest import TestCase -from owrx.property.validators import Validator, NumberValidator, LambdaValidator +from owrx.property.validators import Validator, NumberValidator, LambdaValidator, StringValidator class ValidatorTest(TestCase): @@ -14,3 +14,7 @@ class ValidatorTest(TestCase): validator = Validator.of(my_callable) self.assertIsInstance(validator, LambdaValidator) self.assertTrue(validator.isValid("test")) + + def testGetsValidatorByKey(self): + validator = Validator.of("str") + self.assertIsInstance(validator, StringValidator) From 4b03ced1f778ff7f475b635c97ef15ac54ded2c9 Mon Sep 17 00:00:00 2001 From: Jakob Ketterl Date: Sun, 24 Jan 2021 21:58:02 +0100 Subject: [PATCH 006/577] add more validators --- owrx/property/validators.py | 48 ++++++++++++++++--- .../validators/test_bool_validator.py | 17 +++++++ .../validators/test_float_validator.py | 15 ++++++ test/property/validators/test_or_validator.py | 17 +++++++ 4 files changed, 90 insertions(+), 7 deletions(-) create mode 100644 test/property/validators/test_bool_validator.py create mode 100644 test/property/validators/test_float_validator.py create mode 100644 test/property/validators/test_or_validator.py diff --git a/owrx/property/validators.py b/owrx/property/validators.py index 1812fb2..a893b79 100644 --- a/owrx/property/validators.py +++ b/owrx/property/validators.py @@ -1,4 +1,6 @@ from abc import ABC, abstractmethod +from functools import reduce +from operator import or_ class ValidatorException(Exception): @@ -29,19 +31,51 @@ class LambdaValidator(Validator): return self.callable(value) -class NumberValidator(Validator): +class TypeValidator(Validator): + def __init__(self, type): + self.type = type + super().__init__() + def isValid(self, value): - return isinstance(value, int) or isinstance(value, float) + return isinstance(value, self.type) -class IntegerValidator(Validator): +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 isinstance(value, int) + return reduce( + or_, + [v.isValid(value) for v in self.validators], + False + ) -class StringValidator(Validator): - def isValid(self, value): - return isinstance(value, str) +class NumberValidator(OrValidator): + def __init__(self): + super().__init__(IntegerValidator(), FloatValidator()) validator_types = { diff --git a/test/property/validators/test_bool_validator.py b/test/property/validators/test_bool_validator.py new file mode 100644 index 0000000..08cfea6 --- /dev/null +++ b/test/property/validators/test_bool_validator.py @@ -0,0 +1,17 @@ +from unittest import TestCase +from owrx.property.validators import BoolValidator + + +class NumberValidatorTest(TestCase): + def testPassesNumbers(self): + validator = BoolValidator() + self.assertTrue(validator.isValid(True)) + self.assertTrue(validator.isValid(False)) + + def testDoesntPassOthers(self): + validator = BoolValidator() + self.assertFalse(validator.isValid(123)) + self.assertFalse(validator.isValid(-2)) + self.assertFalse(validator.isValid(.5)) + self.assertFalse(validator.isValid("text")) + self.assertFalse(validator.isValid(object())) diff --git a/test/property/validators/test_float_validator.py b/test/property/validators/test_float_validator.py new file mode 100644 index 0000000..f4e43ec --- /dev/null +++ b/test/property/validators/test_float_validator.py @@ -0,0 +1,15 @@ +from unittest import TestCase +from owrx.property.validators import FloatValidator + + +class FloatValidatorTest(TestCase): + def testPassesNumbers(self): + validator = FloatValidator() + self.assertTrue(validator.isValid(.5)) + + def testDoesntPassOthers(self): + validator = FloatValidator() + self.assertFalse(validator.isValid(123)) + self.assertFalse(validator.isValid(-2)) + self.assertFalse(validator.isValid("text")) + self.assertFalse(validator.isValid(object())) diff --git a/test/property/validators/test_or_validator.py b/test/property/validators/test_or_validator.py new file mode 100644 index 0000000..0f7f79d --- /dev/null +++ b/test/property/validators/test_or_validator.py @@ -0,0 +1,17 @@ +from unittest import TestCase +from owrx.property.validators import OrValidator, IntegerValidator, StringValidator + + +class OrValidatorTest(TestCase): + def testPassesAnyValidators(self): + validator = OrValidator(IntegerValidator(), StringValidator()) + self.assertTrue(validator.isValid(42)) + self.assertTrue(validator.isValid("text")) + + def testRejectsOtherTypes(self): + validator = OrValidator(IntegerValidator(), StringValidator()) + self.assertFalse(validator.isValid(.5)) + + def testRejectsIfNoValidator(self): + validator = OrValidator() + self.assertFalse(validator.isValid("any value")) From 49577953c67f140726d491db7c0455d51de66c3c Mon Sep 17 00:00:00 2001 From: Jakob Ketterl Date: Sun, 24 Jan 2021 21:58:15 +0100 Subject: [PATCH 007/577] fix events --- owrx/property/__init__.py | 1 + 1 file changed, 1 insertion(+) diff --git a/owrx/property/__init__.py b/owrx/property/__init__.py index 6acb68e..25977da 100644 --- a/owrx/property/__init__.py +++ b/owrx/property/__init__.py @@ -157,6 +157,7 @@ class PropertyValidationError(Exception): class PropertyValidator(PropertyManager): def __init__(self, pm: PropertyManager, validators=None): self.pm = pm + self.pm.wire(self._fireCallbacks) if validators is None: self.validators = {} else: From a880b1f6f9a8ea8a80d296c43bd6d86dc570d9fa Mon Sep 17 00:00:00 2001 From: Jakob Ketterl Date: Sun, 24 Jan 2021 22:03:53 +0100 Subject: [PATCH 008/577] add regex validator --- owrx/property/validators.py | 9 +++++++++ test/property/validators/test_regex_validator.py | 13 +++++++++++++ 2 files changed, 22 insertions(+) create mode 100644 test/property/validators/test_regex_validator.py diff --git a/owrx/property/validators.py b/owrx/property/validators.py index a893b79..5d474bc 100644 --- a/owrx/property/validators.py +++ b/owrx/property/validators.py @@ -1,6 +1,7 @@ from abc import ABC, abstractmethod from functools import reduce from operator import or_ +import re class ValidatorException(Exception): @@ -78,6 +79,14 @@ class NumberValidator(OrValidator): super().__init__(IntegerValidator(), FloatValidator()) +class RegexValidator(Validator): + def __init__(self, regex: re.Pattern): + self.regex = regex + + def isValid(self, value): + return self.regex.match(value) is not None + + validator_types = { "string": StringValidator, "str": StringValidator, diff --git a/test/property/validators/test_regex_validator.py b/test/property/validators/test_regex_validator.py new file mode 100644 index 0000000..1bdfb6c --- /dev/null +++ b/test/property/validators/test_regex_validator.py @@ -0,0 +1,13 @@ +from unittest import TestCase +from owrx.property.validators import RegexValidator +import re + + +class RegexValidatorTest(TestCase): + def testMatchesRegex(self): + validator = RegexValidator(re.compile("abc")) + self.assertTrue(validator.isValid("abc")) + + def testDoesntMatchRegex(self): + validator = RegexValidator(re.compile("abc")) + self.assertFalse(validator.isValid("xyz")) From d126c3acefa1a8251d162f319c33ca3d7612ca48 Mon Sep 17 00:00:00 2001 From: Jakob Ketterl Date: Sun, 24 Jan 2021 22:28:00 +0100 Subject: [PATCH 009/577] allow regexes only on strings --- owrx/property/validators.py | 2 +- test/property/validators/test_regex_validator.py | 4 ++++ 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/owrx/property/validators.py b/owrx/property/validators.py index 5d474bc..9a9ebfe 100644 --- a/owrx/property/validators.py +++ b/owrx/property/validators.py @@ -84,7 +84,7 @@ class RegexValidator(Validator): self.regex = regex def isValid(self, value): - return self.regex.match(value) is not None + return isinstance(value, str) and self.regex.match(value) is not None validator_types = { diff --git a/test/property/validators/test_regex_validator.py b/test/property/validators/test_regex_validator.py index 1bdfb6c..2151d1b 100644 --- a/test/property/validators/test_regex_validator.py +++ b/test/property/validators/test_regex_validator.py @@ -11,3 +11,7 @@ class RegexValidatorTest(TestCase): def testDoesntMatchRegex(self): validator = RegexValidator(re.compile("abc")) self.assertFalse(validator.isValid("xyz")) + + def testFailsIfValueIsNoString(self): + validator = RegexValidator(re.compile("abc")) + self.assertFalse(validator.isValid(42)) From 15940d0a2eac60f905b1ab8f0907f180932da95c Mon Sep 17 00:00:00 2001 From: Jakob Ketterl Date: Sun, 24 Jan 2021 22:28:48 +0100 Subject: [PATCH 010/577] extend StringValidator instead --- owrx/property/validators.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/owrx/property/validators.py b/owrx/property/validators.py index 9a9ebfe..23008d4 100644 --- a/owrx/property/validators.py +++ b/owrx/property/validators.py @@ -79,12 +79,13 @@ class NumberValidator(OrValidator): super().__init__(IntegerValidator(), FloatValidator()) -class RegexValidator(Validator): +class RegexValidator(StringValidator): def __init__(self, regex: re.Pattern): self.regex = regex + super().__init__() def isValid(self, value): - return isinstance(value, str) and self.regex.match(value) is not None + return super().isValid(value) and self.regex.match(value) is not None validator_types = { From 7e60efeae249c270d1b37ec91e74d93d9c44fc02 Mon Sep 17 00:00:00 2001 From: Jakob Ketterl Date: Sun, 24 Jan 2021 22:29:23 +0100 Subject: [PATCH 011/577] validate all parameters sent to dsp, refs #215 --- owrx/dsp.py | 41 ++++++++++++++++++++++++++++------------- 1 file changed, 28 insertions(+), 13 deletions(-) diff --git a/owrx/dsp.py b/owrx/dsp.py index 13cc542..b75220f 100644 --- a/owrx/dsp.py +++ b/owrx/dsp.py @@ -4,16 +4,26 @@ 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.property import PropertyStack, PropertyLayer, PropertyValidator +from owrx.property.validators import OrValidator, RegexValidator, BoolValidator from owrx.modes import Modes from csdr import csdr import threading +import re import logging logger = logging.getLogger(__name__) +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(csdr.output, SdrSourceEventClient): def __init__(self, handler, sdrSource): self.handler = handler @@ -27,19 +37,24 @@ class DspManager(csdr.output, SdrSourceEventClient): } self.props = PropertyStack() + # local demodulator properties not forwarded to the sdr - self.localProps = 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, self.sdrSource.getProps().filter( From 366f7247f26760929caf4aa1704e828d2e549689 Mon Sep 17 00:00:00 2001 From: Jakob Ketterl Date: Sun, 24 Jan 2021 22:54:58 +0100 Subject: [PATCH 012/577] code style --- owrx/dsp.py | 1 + 1 file changed, 1 insertion(+) diff --git a/owrx/dsp.py b/owrx/dsp.py index b75220f..9b2a539 100644 --- a/owrx/dsp.py +++ b/owrx/dsp.py @@ -20,6 +20,7 @@ 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]+$"))) From 4a86af69d1b97bad2c647e9212d0f41266486c6d Mon Sep 17 00:00:00 2001 From: Jakob Ketterl Date: Sun, 24 Jan 2021 23:20:17 +0100 Subject: [PATCH 013/577] Fix merging error --- owrx/dsp.py | 17 ----------------- 1 file changed, 17 deletions(-) diff --git a/owrx/dsp.py b/owrx/dsp.py index 76775b6..20d377e 100644 --- a/owrx/dsp.py +++ b/owrx/dsp.py @@ -56,23 +56,6 @@ class DspManager(csdr.output, SdrSourceEventClient): } self.localProps = PropertyValidator(PropertyLayer().filter(*validators.keys()), validators) - self.props.addLayer(0, self.localProps) - # 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( From b997e8309545d4e3fd3111daa53483086bd3548c Mon Sep 17 00:00:00 2001 From: Jakob Ketterl Date: Sun, 24 Jan 2021 23:51:01 +0100 Subject: [PATCH 014/577] update changelog --- CHANGELOG.md | 4 ++++ debian/changelog | 8 ++++++++ 2 files changed, 12 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 453141a..af5b3f2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,7 @@ +**unreleased** +- Fix a security problem that allowed arbitrary commands to be executed on the receiver + ([See github issue #215](https://github.com/jketterl/openwebrx/issues/215)) + **0.20.1** - Remove broken OSM map fallback diff --git a/debian/changelog b/debian/changelog index b2e2435..247df3d 100644 --- a/debian/changelog +++ b/debian/changelog @@ -1,3 +1,11 @@ +openwebrx (0.20.2) buster focal; urgency=high + + * Fix a security problem that allowed arbitrary commands to be executed on + the receiver (See github issue #215: + https://github.com/jketterl/openwebrx/issues/215) + + -- Jakob Ketterl Sun, 24 Jan 2021 22:50:00 +0000 + openwebrx (0.20.1) buster focal; urgency=low * Remove broken OSM map fallback From b2e8fc5ad5a9450bea153a892f860aeee25ff425 Mon Sep 17 00:00:00 2001 From: Jakob Ketterl Date: Sun, 24 Jan 2021 23:52:20 +0100 Subject: [PATCH 015/577] release version 0.20.2 --- CHANGELOG.md | 2 +- owrx/version.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index af5b3f2..f0ce5e5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,4 +1,4 @@ -**unreleased** +**0.20.2** - Fix a security problem that allowed arbitrary commands to be executed on the receiver ([See github issue #215](https://github.com/jketterl/openwebrx/issues/215)) diff --git a/owrx/version.py b/owrx/version.py index 7b33bd5..7d476be 100644 --- a/owrx/version.py +++ b/owrx/version.py @@ -1,5 +1,5 @@ from distutils.version import LooseVersion -_versionstring = "0.20.1" +_versionstring = "0.20.2" looseversion = LooseVersion(_versionstring) openwebrx_version = "v{0}".format(looseversion) From f81cf3570ab4f53cfc4a68bfa1770b87f444ffdd Mon Sep 17 00:00:00 2001 From: Jakob Ketterl Date: Mon, 25 Jan 2021 19:36:55 +0100 Subject: [PATCH 016/577] don't check the type since older python doesn't have re.Pattern --- owrx/property/validators.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/owrx/property/validators.py b/owrx/property/validators.py index 23008d4..4384b04 100644 --- a/owrx/property/validators.py +++ b/owrx/property/validators.py @@ -80,7 +80,7 @@ class NumberValidator(OrValidator): class RegexValidator(StringValidator): - def __init__(self, regex: re.Pattern): + def __init__(self, regex): self.regex = regex super().__init__() From ae0748952fe15ced0da733e509f35c90cf1be67a Mon Sep 17 00:00:00 2001 From: Jakob Ketterl Date: Mon, 25 Jan 2021 19:40:06 +0100 Subject: [PATCH 017/577] remove unused import, too --- owrx/property/validators.py | 1 - 1 file changed, 1 deletion(-) diff --git a/owrx/property/validators.py b/owrx/property/validators.py index 4384b04..f1c197e 100644 --- a/owrx/property/validators.py +++ b/owrx/property/validators.py @@ -1,7 +1,6 @@ from abc import ABC, abstractmethod from functools import reduce from operator import or_ -import re class ValidatorException(Exception): From 58b35ec0f96e476f28c839a415202fccbdfa6944 Mon Sep 17 00:00:00 2001 From: Jakob Ketterl Date: Tue, 26 Jan 2021 16:28:56 +0100 Subject: [PATCH 018/577] update changelogs for 0.20.3 --- CHANGELOG.md | 3 +++ debian/changelog | 6 ++++++ 2 files changed, 9 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index f0ce5e5..a7823f6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,6 @@ +**0.20.3** +- Fix a compatibility issue with python versions <= 3.6 + **0.20.2** - Fix a security problem that allowed arbitrary commands to be executed on the receiver ([See github issue #215](https://github.com/jketterl/openwebrx/issues/215)) diff --git a/debian/changelog b/debian/changelog index 247df3d..a91b912 100644 --- a/debian/changelog +++ b/debian/changelog @@ -1,3 +1,9 @@ +openwebrx (0.20.3) buster focal; urgency=low + + * Fix a compatibility issue with python versions <= 3.6 + + -- Jakob Ketterl Tue, 26 Jan 2021 15:28:00 +0000 + openwebrx (0.20.2) buster focal; urgency=high * Fix a security problem that allowed arbitrary commands to be executed on From 477b457be9a4e620cc1656870cf8f4326bb60a2f Mon Sep 17 00:00:00 2001 From: Jakob Ketterl Date: Tue, 26 Jan 2021 16:53:22 +0100 Subject: [PATCH 019/577] update the version --- owrx/version.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/owrx/version.py b/owrx/version.py index 7d476be..f91abc2 100644 --- a/owrx/version.py +++ b/owrx/version.py @@ -1,5 +1,5 @@ from distutils.version import LooseVersion -_versionstring = "0.20.2" +_versionstring = "0.20.3" looseversion = LooseVersion(_versionstring) openwebrx_version = "v{0}".format(looseversion) From 2a5448f5c111f459cb7e2e10428b1e27dddb1a06 Mon Sep 17 00:00:00 2001 From: Jakob Ketterl Date: Sat, 30 Jan 2021 15:03:52 +0100 Subject: [PATCH 020/577] update dsd feature detection to avoid start-up hangs --- owrx/feature.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/owrx/feature.py b/owrx/feature.py index 8c940d2..3a89542 100644 --- a/owrx/feature.py +++ b/owrx/feature.py @@ -425,7 +425,7 @@ class FeatureDetector(object): 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") + return self.command_is_runnable("dsd -h") def has_m17_demod(self): """ From 8372f198dbdd850dd8976121013973a21565816b Mon Sep 17 00:00:00 2001 From: Jakob Ketterl Date: Sat, 30 Jan 2021 16:03:35 +0100 Subject: [PATCH 021/577] add the ability to make a layer readonly --- owrx/property/__init__.py | 67 +++++++++++++++++-------- test/property/test_property_readonly.py | 15 ++++++ 2 files changed, 61 insertions(+), 21 deletions(-) create mode 100644 test/property/test_property_readonly.py diff --git a/owrx/property/__init__.py b/owrx/property/__init__.py index ee1d614..ace4d32 100644 --- a/owrx/property/__init__.py +++ b/owrx/property/__init__.py @@ -5,6 +5,10 @@ import logging logger = logging.getLogger(__name__) +class PropertyError(Exception): + pass + + class Subscription(object): def __init__(self, subscriptee, name, subscriber): self.subscriptee = subscriptee @@ -52,6 +56,9 @@ class PropertyManager(ABC): def filter(self, *props): return PropertyFilter(self, *props) + def readonly(self): + return PropertyReadOnly(self) + def wire(self, callback): sub = Subscription(self, None, callback) self.subscribers.append(sub) @@ -155,35 +162,16 @@ class PropertyFilter(PropertyManager): return [k for k in self.pm.keys() if k in self.props] -class PropertyValidationError(Exception): - def __init__(self, key, value): - super().__init__('Invalid value for property "{key}": "{value}"'.format(key=key, value=str(value))) - - -class PropertyValidator(PropertyManager): - def __init__(self, pm: PropertyManager, validators=None): +class PropertyDelegator(PropertyManager): + def __init__(self, pm: PropertyManager): self.pm = pm self.pm.wire(self._fireCallbacks) - if validators is None: - self.validators = {} - else: - self.validators = {k: Validator.of(v) for k, v in validators.items()} super().__init__() - 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 __getitem__(self, item): return self.pm.__getitem__(item) def __setitem__(self, key, value): - self.validate(key, value) return self.pm.__setitem__(key, value) def __contains__(self, item): @@ -199,6 +187,43 @@ class PropertyValidator(PropertyManager): return self.pm.keys() +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) + + class PropertyStack(PropertyManager): def __init__(self): super().__init__() diff --git a/test/property/test_property_readonly.py b/test/property/test_property_readonly.py new file mode 100644 index 0000000..a6e74f7 --- /dev/null +++ b/test/property/test_property_readonly.py @@ -0,0 +1,15 @@ +from unittest import TestCase +from owrx.property import PropertyLayer, PropertyReadOnly, PropertyWriteError + + +class PropertyReadOnlyTest(TestCase): + def testPreventsWrites(self): + layer = PropertyLayer() + layer["testkey"] = "initial value" + ro = PropertyReadOnly(layer) + with self.assertRaises(PropertyWriteError): + ro["testkey"] = "new value" + with self.assertRaises(PropertyWriteError): + ro["otherkey"] = "testvalue" + self.assertEqual(ro["testkey"], "initial value") + self.assertNotIn("otherkey", ro) From ad8ff1c2f7c97c874af5ccdd50cdf9fa1d285de9 Mon Sep 17 00:00:00 2001 From: Jakob Ketterl Date: Sat, 30 Jan 2021 16:04:13 +0100 Subject: [PATCH 022/577] send "sdr_id" to be able to detect changes --- owrx/connection.py | 1 + owrx/source/__init__.py | 7 ++++++- 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/owrx/connection.py b/owrx/connection.py index e324cd2..445d73d 100644 --- a/owrx/connection.py +++ b/owrx/connection.py @@ -116,6 +116,7 @@ class OpenWebRxReceiverClient(OpenWebRxClient, SdrSourceEventClient): "start_freq", "center_freq", "initial_squelch_level", + "sdr_id", "profile_id", "squelch_auto_margin", ] diff --git a/owrx/source/__init__.py b/owrx/source/__init__.py index 111f507..540b7f4 100644 --- a/owrx/source/__init__.py +++ b/owrx/source/__init__.py @@ -52,7 +52,12 @@ class SdrSource(ABC): self.props = PropertyStack() # layer 0 reserved for profile properties 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 + sdrIdLayer = PropertyLayer() + sdrIdLayer["sdr_id"] = id + self.props.addLayer(2, sdrIdLayer.readonly()) + self.props.addLayer(3, Config.get()) self.sdrProps = self.props.filter(*self.getEventNames()) self.profile_id = None From 142ca578ec620e5f0a9b6b9245ecbc3252eaf6df Mon Sep 17 00:00:00 2001 From: Jakob Ketterl Date: Sat, 30 Jan 2021 16:04:29 +0100 Subject: [PATCH 023/577] truncate waterfall only when profile has changed --- htdocs/openwebrx.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/htdocs/openwebrx.js b/htdocs/openwebrx.js index 3dd754f..759f316 100644 --- a/htdocs/openwebrx.js +++ b/htdocs/openwebrx.js @@ -759,9 +759,9 @@ function on_ws_recv(evt) { if ('sdr_id' in config && 'profile_id' in config) { currentprofile = config['sdr_id'] + '|' + config['profile_id']; $('#openwebrx-sdr-profiles-listbox').val(currentprofile); - } - waterfall_clear(); + waterfall_clear(); + } if ('frequency_display_precision' in config) $('#openwebrx-panel-receiver').demodulatorPanel().setFrequencyPrecision(config['frequency_display_precision']); From 881637811f195bc8bd512149d3b68c653439ba25 Mon Sep 17 00:00:00 2001 From: Jakob Ketterl Date: Sat, 30 Jan 2021 16:17:05 +0100 Subject: [PATCH 024/577] switch when profile OR sdr has changed --- htdocs/openwebrx.js | 17 ++++++++++------- 1 file changed, 10 insertions(+), 7 deletions(-) diff --git a/htdocs/openwebrx.js b/htdocs/openwebrx.js index 759f316..44acfa9 100644 --- a/htdocs/openwebrx.js +++ b/htdocs/openwebrx.js @@ -684,7 +684,11 @@ function zoom_calc() { } var networkSpeedMeasurement; -var currentprofile; +var currentprofile = { + toString: function() { + return this['sdr_id'] + '|' + this['profile_id']; + } +}; var COMPRESS_FFT_PAD_N = 10; //should be the same as in csdr.c @@ -756,9 +760,10 @@ function on_ws_recv(evt) { demodulatorPanel.setSquelchMargin(config['squelch_auto_margin']); bookmarks.loadLocalBookmarks(); - if ('sdr_id' in config && 'profile_id' in config) { - currentprofile = config['sdr_id'] + '|' + config['profile_id']; - $('#openwebrx-sdr-profiles-listbox').val(currentprofile); + if ('sdr_id' in config || 'profile_id' in config) { + currentprofile['sdr_id'] = config['sdr_id'] || current_profile['sdr_id']; + currentprofile['profile_id'] = config['profile_id'] || current_profile['profile_id']; + $('#openwebrx-sdr-profiles-listbox').val(currentprofile.toString()); waterfall_clear(); } @@ -792,9 +797,7 @@ function on_ws_recv(evt) { listbox.html(json['value'].map(function (profile) { return '"; }).join("")); - if (currentprofile) { - $('#openwebrx-sdr-profiles-listbox').val(currentprofile); - } + $('#openwebrx-sdr-profiles-listbox').val(currentprofile.toString()); break; case "features": Modes.setFeatures(json['value']); From 61a52507925b2077eb6fc7217c0c4c9eabdf7ff4 Mon Sep 17 00:00:00 2001 From: Jakob Ketterl Date: Sat, 30 Jan 2021 16:18:30 +0100 Subject: [PATCH 025/577] fix typos --- htdocs/openwebrx.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/htdocs/openwebrx.js b/htdocs/openwebrx.js index 44acfa9..24d7a13 100644 --- a/htdocs/openwebrx.js +++ b/htdocs/openwebrx.js @@ -761,8 +761,8 @@ function on_ws_recv(evt) { bookmarks.loadLocalBookmarks(); if ('sdr_id' in config || 'profile_id' in config) { - currentprofile['sdr_id'] = config['sdr_id'] || current_profile['sdr_id']; - currentprofile['profile_id'] = config['profile_id'] || current_profile['profile_id']; + currentprofile['sdr_id'] = config['sdr_id'] || currentprofile['sdr_id']; + currentprofile['profile_id'] = config['profile_id'] || currentprofile['profile_id']; $('#openwebrx-sdr-profiles-listbox').val(currentprofile.toString()); waterfall_clear(); From 3c91f3cc2fa67c04a01db25c4583e3af1241a84a Mon Sep 17 00:00:00 2001 From: Jakob Ketterl Date: Sun, 31 Jan 2021 20:31:54 +0100 Subject: [PATCH 026/577] add a timeout to wspr uploads --- owrx/wsprnet.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/owrx/wsprnet.py b/owrx/wsprnet.py index aff4fa8..2a1e9d0 100644 --- a/owrx/wsprnet.py +++ b/owrx/wsprnet.py @@ -63,7 +63,7 @@ class Worker(threading.Thread): "mode": self._getMode(spot), } ).encode() - request.urlopen("http://wsprnet.org/post/", data) + request.urlopen("http://wsprnet.org/post/", data, timeout=60) class WsprnetReporter(Reporter): From 081b63def3b6f2c026c18f6d5172918583fb7088 Mon Sep 17 00:00:00 2001 From: Jakob Ketterl Date: Sun, 31 Jan 2021 23:05:36 +0100 Subject: [PATCH 027/577] update connector with 32bit fixes --- docker/scripts/install-connectors.sh | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docker/scripts/install-connectors.sh b/docker/scripts/install-connectors.sh index be76988..352de33 100755 --- a/docker/scripts/install-connectors.sh +++ b/docker/scripts/install-connectors.sh @@ -24,8 +24,8 @@ apt-get update apt-get -y install --no-install-recommends $BUILD_PACKAGES git clone https://github.com/jketterl/owrx_connector.git -# latest develop as of 2020-11-28 (int32 samples; debhelper) -cmakebuild owrx_connector 87a2fcc54e221aad71ec0700737ca7f385c388de +# latest develop as of 2021-01-31 (fix for 32bit overflows) +cmakebuild owrx_connector 47872fada2871b4b8ee8ba7ca1e8c98a2340be2b apt-get -y purge --autoremove $BUILD_PACKAGES apt-get clean From e3f99d69851fad7050aa54ed1b5d73bb37904503 Mon Sep 17 00:00:00 2001 From: Jakob Ketterl Date: Sun, 31 Jan 2021 23:35:05 +0100 Subject: [PATCH 028/577] update eb200_connector, too --- docker/scripts/install-dependencies-eb200.sh | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docker/scripts/install-dependencies-eb200.sh b/docker/scripts/install-dependencies-eb200.sh index ce0aba1..2eefdbe 100755 --- a/docker/scripts/install-dependencies-eb200.sh +++ b/docker/scripts/install-dependencies-eb200.sh @@ -25,8 +25,8 @@ apt-get update apt-get -y install --no-install-recommends $STATIC_PACKAGES $BUILD_PACKAGES git clone https://github.com/jketterl/eb200_connector.git -# latest from develop as of 2020-12-01 -cmakebuild eb200_connector 9c8313770c1072df72d2fdb85307ca206c29c60a +# latest from develop as of 2021-01-31 +cmakebuild eb200_connector bb7f75be6e7fb4b987eea4b81821663ca4b9f19f apt-get -y purge --autoremove $BUILD_PACKAGES apt-get clean From dea07cd49b65e03a2a98ec86dd867f074ba5e3ba Mon Sep 17 00:00:00 2001 From: Jakob Ketterl Date: Mon, 1 Feb 2021 13:37:01 +0100 Subject: [PATCH 029/577] update connectors again --- docker/scripts/install-connectors.sh | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docker/scripts/install-connectors.sh b/docker/scripts/install-connectors.sh index 352de33..54ee737 100755 --- a/docker/scripts/install-connectors.sh +++ b/docker/scripts/install-connectors.sh @@ -24,8 +24,8 @@ apt-get update apt-get -y install --no-install-recommends $BUILD_PACKAGES git clone https://github.com/jketterl/owrx_connector.git -# latest develop as of 2021-01-31 (fix for 32bit overflows) -cmakebuild owrx_connector 47872fada2871b4b8ee8ba7ca1e8c98a2340be2b +# latest develop as of 2021-02-01 (string parsing fixes) +cmakebuild owrx_connector 4789a82015a66f11440bdfd4a935bafa7da22fbe apt-get -y purge --autoremove $BUILD_PACKAGES apt-get clean From 998092f3776527a96745f67d8572a555f8cf9496 Mon Sep 17 00:00:00 2001 From: Jakob Ketterl Date: Mon, 1 Feb 2021 18:26:26 +0100 Subject: [PATCH 030/577] reroute /metrics to /metrics.json --- owrx/http.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/owrx/http.py b/owrx/http.py index 743f2c9..c31802e 100644 --- a/owrx/http.py +++ b/owrx/http.py @@ -98,7 +98,7 @@ class Router(object): StaticRoute("/features", FeatureController), StaticRoute("/api/features", ApiController), StaticRoute("/api/receiverdetails", ApiController, options={"action": "receiverDetails"}), - StaticRoute("/metrics", MetricsController), + StaticRoute("/metrics.json", MetricsController), StaticRoute("/settings", SettingsController), StaticRoute("/generalsettings", GeneralSettingsController), StaticRoute( From 5e1c4391c65b49eddf5db78a6390585b752d4328 Mon Sep 17 00:00:00 2001 From: Jakob Ketterl Date: Mon, 1 Feb 2021 18:43:14 +0100 Subject: [PATCH 031/577] include prometheus metrics, refs #200 --- owrx/controllers/metrics.py | 25 +++++++++++++++++++++++-- owrx/http.py | 1 + owrx/metrics.py | 5 ++++- 3 files changed, 28 insertions(+), 3 deletions(-) diff --git a/owrx/controllers/metrics.py b/owrx/controllers/metrics.py index e817e9b..4d41a2a 100644 --- a/owrx/controllers/metrics.py +++ b/owrx/controllers/metrics.py @@ -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") diff --git a/owrx/http.py b/owrx/http.py index c31802e..5ced309 100644 --- a/owrx/http.py +++ b/owrx/http.py @@ -98,6 +98,7 @@ class Router(object): StaticRoute("/features", FeatureController), StaticRoute("/api/features", ApiController), StaticRoute("/api/receiverdetails", ApiController, options={"action": "receiverDetails"}), + StaticRoute("/metrics", MetricsController, options={"action": "prometheusAction"}), StaticRoute("/metrics.json", MetricsController), StaticRoute("/settings", SettingsController), StaticRoute("/generalsettings", GeneralSettingsController), diff --git a/owrx/metrics.py b/owrx/metrics.py index 48e0db0..6600e85 100644 --- a/owrx/metrics.py +++ b/owrx/metrics.py @@ -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(): From 1ee75295e53ea0aa9e4fefbdb97041a2faed29ac Mon Sep 17 00:00:00 2001 From: Jakob Ketterl Date: Mon, 1 Feb 2021 23:56:09 +0100 Subject: [PATCH 032/577] update to wsjtx 2.3.0 --- docker/files/wsjtx/wsjtx-hamlib.patch | 27 +++++--- docker/files/wsjtx/wsjtx.patch | 88 +++++++++++++++++--------- docker/scripts/install-dependencies.sh | 6 +- 3 files changed, 78 insertions(+), 43 deletions(-) diff --git a/docker/files/wsjtx/wsjtx-hamlib.patch b/docker/files/wsjtx/wsjtx-hamlib.patch index 09a8a88..21b451f 100644 --- a/docker/files/wsjtx/wsjtx-hamlib.patch +++ b/docker/files/wsjtx/wsjtx-hamlib.patch @@ -1,17 +1,16 @@ ---- CMakeLists.txt.orig 2020-07-21 20:59:55.982026645 +0200 -+++ CMakeLists.txt 2020-07-21 21:01:25.444836112 +0200 -@@ -80,24 +80,6 @@ +--- CMakeLists.txt.orig 2021-02-01 21:00:44.879969236 +0100 ++++ CMakeLists.txt 2021-02-01 21:04:58.184642042 +0100 +@@ -106,24 +106,6 @@ - include (ExternalProject) -- --# + # -# build and install hamlib locally so it can be referenced by the -# WSJT-X build -# -ExternalProject_Add (hamlib - GIT_REPOSITORY ${hamlib_repo} - GIT_TAG ${hamlib_TAG} +- GIT_SHALLOW False - URL ${CMAKE_CURRENT_SOURCE_DIR}/src/${__hamlib_upstream} - URL_HASH MD5=${hamlib_md5sum} - #UPDATE_COMMAND ${CMAKE_COMMAND} -E env "[ -f ./bootstrap ] && ./bootstrap" @@ -22,10 +21,11 @@ - STEP_TARGETS update install - ) - - # +-# # custom target to make a hamlib source tarball # -@@ -136,7 +118,6 @@ + add_custom_target (hamlib_sources +@@ -161,7 +143,6 @@ # build and optionally install WSJT-X using the hamlib package built # above # @@ -33,11 +33,18 @@ ExternalProject_Add (wsjtx GIT_REPOSITORY ${wsjtx_repo} GIT_TAG ${WSJTX_TAG} -@@ -160,7 +141,6 @@ +@@ -186,14 +167,8 @@ DEPENDEES build ) -set_target_properties (hamlib PROPERTIES EXCLUDE_FROM_ALL 1) set_target_properties (wsjtx PROPERTIES EXCLUDE_FROM_ALL 1) - add_dependencies (wsjtx-configure hamlib-install) +-add_dependencies (wsjtx-configure hamlib-install) +-add_dependencies (wsjtx-build hamlib-install) +-add_dependencies (wsjtx-install hamlib-install) +-add_dependencies (wsjtx-package hamlib-install) +- + # export traditional targets + add_custom_target (build ALL DEPENDS wsjtx-build) + add_custom_target (install DEPENDS wsjtx-install) diff --git a/docker/files/wsjtx/wsjtx.patch b/docker/files/wsjtx/wsjtx.patch index 59ac6b7..b7c02df 100644 --- a/docker/files/wsjtx/wsjtx.patch +++ b/docker/files/wsjtx/wsjtx.patch @@ -1,6 +1,6 @@ diff -ur wsjtx-orig/CMake/Modules/Findhamlib.cmake wsjtx/CMake/Modules/Findhamlib.cmake ---- wsjtx-orig/CMake/Modules/Findhamlib.cmake 2020-07-21 21:10:43.124810140 +0200 -+++ wsjtx/CMake/Modules/Findhamlib.cmake 2020-07-21 21:11:03.368019114 +0200 +--- wsjtx-orig/CMake/Modules/Findhamlib.cmake 2021-02-01 20:38:00.947536514 +0100 ++++ wsjtx/CMake/Modules/Findhamlib.cmake 2021-02-01 20:39:06.273680932 +0100 @@ -85,4 +85,4 @@ # Handle the QUIETLY and REQUIRED arguments and set HAMLIB_FOUND to # TRUE if all listed variables are TRUE @@ -8,9 +8,18 @@ diff -ur wsjtx-orig/CMake/Modules/Findhamlib.cmake wsjtx/CMake/Modules/Findhamli -find_package_handle_standard_args (hamlib DEFAULT_MSG hamlib_INCLUDE_DIRS hamlib_LIBRARIES hamlib_LIBRARY_DIRS) +find_package_handle_standard_args (hamlib DEFAULT_MSG hamlib_INCLUDE_DIRS hamlib_LIBRARIES) diff -ur wsjtx-orig/CMakeLists.txt wsjtx/CMakeLists.txt ---- wsjtx-orig/CMakeLists.txt 2020-07-21 21:10:43.124810140 +0200 -+++ wsjtx/CMakeLists.txt 2020-07-21 22:14:04.454639589 +0200 -@@ -871,7 +871,7 @@ +--- wsjtx-orig/CMakeLists.txt 2021-02-01 20:38:00.947536514 +0100 ++++ wsjtx/CMakeLists.txt 2021-02-01 23:02:22.503027275 +0100 +@@ -122,7 +122,7 @@ + option (WSJT_QDEBUG_TO_FILE "Redirect Qt debuging messages to a trace file.") + option (WSJT_SOFT_KEYING "Apply a ramp to CW keying envelope to reduce transients." ON) + option (WSJT_SKIP_MANPAGES "Skip *nix manpage generation.") +-option (WSJT_GENERATE_DOCS "Generate documentation files." ON) ++option (WSJT_GENERATE_DOCS "Generate documentation files.") + option (WSJT_RIG_NONE_CAN_SPLIT "Allow split operation with \"None\" as rig.") + option (WSJT_TRACE_UDP "Debugging option that turns on UDP message protocol diagnostics.") + option (WSJT_BUILD_UTILS "Build simulators and code demonstrators." ON) +@@ -856,7 +856,7 @@ # # libhamlib setup # @@ -19,31 +28,37 @@ diff -ur wsjtx-orig/CMakeLists.txt wsjtx/CMakeLists.txt find_package (hamlib 3 REQUIRED) find_program (RIGCTL_EXE rigctl) find_program (RIGCTLD_EXE rigctld) -@@ -1348,53 +1348,10 @@ - - endif(WSJT_BUILD_UTILS) +@@ -1376,60 +1376,6 @@ + target_link_libraries (jt9 wsjt_fort wsjt_cxx fort_qt) + endif (${OPENMP_FOUND} OR APPLE) -# build the main application +-generate_version_info (wsjtx_VERSION_RESOURCES +- NAME wsjtx +- BUNDLE ${PROJECT_BUNDLE_NAME} +- ICON ${WSJTX_ICON_FILE} +- ) +- -add_executable (wsjtx MACOSX_BUNDLE - ${wsjtx_CXXSRCS} - ${wsjtx_GENUISRCS} -- wsjtx.rc - ${WSJTX_ICON_FILE} - ${wsjtx_RESOURCES_RCC} +- ${wsjtx_VERSION_RESOURCES} - ) - - if (WSJT_CREATE_WINMAIN) - set_target_properties (wsjtx PROPERTIES WIN32_EXECUTABLE ON) - endif (WSJT_CREATE_WINMAIN) - +-if (WSJT_CREATE_WINMAIN) +- set_target_properties (wsjtx PROPERTIES WIN32_EXECUTABLE ON) +-endif (WSJT_CREATE_WINMAIN) +- -set_target_properties (wsjtx PROPERTIES - MACOSX_BUNDLE_INFO_PLIST "${CMAKE_CURRENT_SOURCE_DIR}/Darwin/Info.plist.in" -- MACOSX_BUNDLE_INFO_STRING "${WSJTX_DESCRIPTION_SUMMARY}" +- MACOSX_BUNDLE_INFO_STRING "${PROJECT_DESCRIPTION}" - MACOSX_BUNDLE_ICON_FILE "${WSJTX_ICON_FILE}" -- MACOSX_BUNDLE_BUNDLE_VERSION ${wsjtx_VERSION} -- MACOSX_BUNDLE_SHORT_VERSION_STRING "v${wsjtx_VERSION}" -- MACOSX_BUNDLE_LONG_VERSION_STRING "Version ${wsjtx_VERSION}" -- MACOSX_BUNDLE_BUNDLE_NAME "${PROJECT_NAME}" +- MACOSX_BUNDLE_BUNDLE_VERSION ${PROJECT_VERSION_MAJOR}.${PROJECT_VERSION_MINOR}.${PROJECT_VERSION_PATCH} +- MACOSX_BUNDLE_SHORT_VERSION_STRING "v${PROJECT_VERSION_MAJOR}.${PROJECT_VERSION_MINOR}.${PROJECT_VERSION_PATCH}" +- MACOSX_BUNDLE_LONG_VERSION_STRING "Version ${PROJECT_VERSION_MAJOR}.${PROJECT_VERSION_MINOR}.${PROJECT_VERSION_PATCH}${SCS_VERSION_STR}" +- MACOSX_BUNDLE_BUNDLE_NAME "${PROJECT_BUNDLE_NAME}" - MACOSX_BUNDLE_BUNDLE_EXECUTABLE_NAME "${PROJECT_NAME}" - MACOSX_BUNDLE_COPYRIGHT "${PROJECT_COPYRIGHT}" - MACOSX_BUNDLE_GUI_IDENTIFIER "org.k1jt.wsjtx" @@ -51,9 +66,9 @@ diff -ur wsjtx-orig/CMakeLists.txt wsjtx/CMakeLists.txt - -target_include_directories (wsjtx PRIVATE ${FFTW3_INCLUDE_DIRS}) -if (APPLE) -- target_link_libraries (wsjtx Qt5::SerialPort wsjt_fort wsjt_cxx wsjt_qt wsjt_qtmm ${hamlib_LIBRARIES} ${FFTW3_LIBRARIES}) +- target_link_libraries (wsjtx wsjt_fort) -else () -- target_link_libraries (wsjtx Qt5::SerialPort wsjt_fort_omp wsjt_cxx wsjt_qt wsjt_qtmm ${hamlib_LIBRARIES} ${FFTW3_LIBRARIES}) +- target_link_libraries (wsjtx wsjt_fort_omp) - if (OpenMP_C_FLAGS) - set_target_properties (wsjtx PROPERTIES - COMPILE_FLAGS "${OpenMP_C_FLAGS}" @@ -65,15 +80,16 @@ diff -ur wsjtx-orig/CMakeLists.txt wsjtx/CMakeLists.txt - ) - if (WIN32) - set_target_properties (wsjtx PROPERTIES -- LINK_FLAGS -Wl,--stack,16777216 +- LINK_FLAGS -Wl,--stack,0x1000000,--heap,0x20000000 - ) - endif () -endif () +-target_link_libraries (wsjtx Qt5::SerialPort wsjt_cxx wsjt_qt wsjt_qtmm ${hamlib_LIBRARIES} ${FFTW3_LIBRARIES} ${LIBM_LIBRARIES}) - # make a library for WSJT-X UDP servers # add_library (wsjtx_udp SHARED ${UDP_library_CXXSRCS}) add_library (wsjtx_udp-static STATIC ${UDP_library_CXXSRCS}) -@@ -1437,24 +1394,9 @@ +@@ -1492,24 +1438,9 @@ set_target_properties (message_aggregator PROPERTIES WIN32_EXECUTABLE ON) endif (WSJT_CREATE_WINMAIN) @@ -98,21 +114,21 @@ diff -ur wsjtx-orig/CMakeLists.txt wsjtx/CMakeLists.txt # install (TARGETS wsjtx_udp EXPORT udp # RUNTIME DESTINATION ${CMAKE_INSTALL_BINDIR} -@@ -1473,12 +1415,7 @@ +@@ -1528,12 +1459,7 @@ # DESTINATION ${CMAKE_INSTALL_LIBDIR}/cmake/wsjtx # ) --install (TARGETS udp_daemon message_aggregator +-install (TARGETS udp_daemon message_aggregator wsjtx_app_version - RUNTIME DESTINATION ${CMAKE_INSTALL_BINDIR} COMPONENT runtime - BUNDLE DESTINATION ${CMAKE_INSTALL_BINDIR} COMPONENT runtime - ) - -install (TARGETS jt9 wsprd fmtave fcal fmeasure -+install (TARGETS jt9 wsprd ++install (TARGETS wsjtx_app_version jt9 wsprd RUNTIME DESTINATION ${CMAKE_INSTALL_BINDIR} COMPONENT runtime BUNDLE DESTINATION ${CMAKE_INSTALL_BINDIR} COMPONENT runtime ) -@@ -1491,39 +1428,6 @@ +@@ -1546,38 +1472,6 @@ ) endif(WSJT_BUILD_UTILS) @@ -143,13 +159,25 @@ diff -ur wsjtx-orig/CMakeLists.txt wsjtx/CMakeLists.txt - AUTHORS - THANKS - NEWS -- INSTALL - BUGS - DESTINATION ${CMAKE_INSTALL_DOCDIR} - #COMPONENT runtime - ) - install (FILES - contrib/Ephemeris/JPLEPH - DESTINATION ${CMAKE_INSTALL_DATADIR}/${CMAKE_PROJECT_NAME} -Only in wsjtx: .idea + cty.dat + cty.dat_copyright.txt +@@ -1586,13 +1480,6 @@ + #COMPONENT runtime + ) + +-install (DIRECTORY +- example_log_configurations +- DESTINATION ${CMAKE_INSTALL_DOCDIR} +- FILES_MATCHING REGEX "^.*[^~]$" +- #COMPONENT runtime +- ) +- + # + # Mac installer files + # diff --git a/docker/scripts/install-dependencies.sh b/docker/scripts/install-dependencies.sh index 55a95e7..afe5b8e 100755 --- a/docker/scripts/install-dependencies.sh +++ b/docker/scripts/install-dependencies.sh @@ -18,8 +18,8 @@ function cmakebuild() { cd /tmp -STATIC_PACKAGES="sox libfftw3-bin python3 python3-setuptools netcat-openbsd libsndfile1 liblapack3 libusb-1.0-0 libqt5core5a libreadline7 libgfortran4 libgomp1 libasound2 libudev1 ca-certificates libqt5gui5 libqt5sql5 libqt5printsupport5 libpulse0 libfaad2 libopus0 libboost-program-options1.67.0" -BUILD_PACKAGES="wget git libsndfile1-dev libfftw3-dev cmake make gcc g++ liblapack-dev texinfo gfortran libusb-1.0-0-dev qtbase5-dev qtmultimedia5-dev qttools5-dev libqt5serialport5-dev qttools5-dev-tools asciidoctor asciidoc libasound2-dev libudev-dev libhamlib-dev patch xsltproc qt5-default libfaad-dev libopus-dev libgtest-dev libboost-dev libboost-program-options-dev" +STATIC_PACKAGES="sox libfftw3-bin python3 python3-setuptools netcat-openbsd libsndfile1 liblapack3 libusb-1.0-0 libqt5core5a libreadline7 libgfortran4 libgomp1 libasound2 libudev1 ca-certificates libqt5gui5 libqt5sql5 libqt5printsupport5 libpulse0 libfaad2 libopus0 libboost-program-options1.67.0 libboost-log1.67.0" +BUILD_PACKAGES="wget git libsndfile1-dev libfftw3-dev cmake make gcc g++ liblapack-dev texinfo gfortran libusb-1.0-0-dev qtbase5-dev qtmultimedia5-dev qttools5-dev libqt5serialport5-dev qttools5-dev-tools asciidoctor asciidoc libasound2-dev libudev-dev libhamlib-dev patch xsltproc qt5-default libfaad-dev libopus-dev libgtest-dev libboost-dev libboost-program-options-dev libboost-log-dev libboost-regex-dev" apt-get update apt-get -y install auto-apt-proxy apt-get -y install --no-install-recommends $STATIC_PACKAGES $BUILD_PACKAGES @@ -60,7 +60,7 @@ rm /js8call-hamlib.patch CMAKE_ARGS="-D CMAKE_CXX_FLAGS=-DJS8_USE_HAMLIB_THREE" cmakebuild ${JS8CALL_DIR} rm ${JS8CALL_TGZ} -WSJT_DIR=wsjtx-2.2.2 +WSJT_DIR=wsjtx-2.3.0 WSJT_TGZ=${WSJT_DIR}.tgz wget http://physics.princeton.edu/pulsar/k1jt/${WSJT_TGZ} tar xfz ${WSJT_TGZ} From bb680293a13a3fb3d0364b5d055159accaa2c8e9 Mon Sep 17 00:00:00 2001 From: Jakob Ketterl Date: Mon, 1 Feb 2021 23:56:35 +0100 Subject: [PATCH 033/577] update m17 --- docker/scripts/install-dependencies.sh | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docker/scripts/install-dependencies.sh b/docker/scripts/install-dependencies.sh index afe5b8e..85f64c3 100755 --- a/docker/scripts/install-dependencies.sh +++ b/docker/scripts/install-dependencies.sh @@ -111,8 +111,8 @@ rm -rf dream rm dream-2.1.1-svn808.tar.gz git clone https://github.com/mobilinkd/m17-cxx-demod.git -# latest master as of 2020-12-27 (new sync words) -cmakebuild m17-cxx-demod 2b84657676efb3b07b33de3ab3d0a6218e9d88b5 +# latest master as of 2021-02-01 (new sync words) +cmakebuild m17-cxx-demod bab44d625cfd6ab9d8c2ca7ace6ebc76aaab28fc git clone https://github.com/hessu/aprs-symbols /usr/share/aprs-symbols pushd /usr/share/aprs-symbols From 44270af88f95574012750660d62fbdcd72e78759 Mon Sep 17 00:00:00 2001 From: Jakob Ketterl Date: Mon, 1 Feb 2021 23:56:47 +0100 Subject: [PATCH 034/577] remove unused files to save space --- docker/scripts/install-dependencies.sh | 2 ++ 1 file changed, 2 insertions(+) diff --git a/docker/scripts/install-dependencies.sh b/docker/scripts/install-dependencies.sh index 85f64c3..f833bb5 100755 --- a/docker/scripts/install-dependencies.sh +++ b/docker/scripts/install-dependencies.sh @@ -117,6 +117,8 @@ cmakebuild m17-cxx-demod bab44d625cfd6ab9d8c2ca7ace6ebc76aaab28fc git clone https://github.com/hessu/aprs-symbols /usr/share/aprs-symbols pushd /usr/share/aprs-symbols git checkout 5c2abe2658ee4d2563f3c73b90c6f59124839802 +# remove unused files (including git meta information) +rm -rf .git aprs-symbols.ai aprs-sym-export.js popd apt-get -y purge --autoremove $BUILD_PACKAGES From 13eaee5ee9dbed23ffba382373bbfc5a865e4629 Mon Sep 17 00:00:00 2001 From: Jakob Ketterl Date: Wed, 3 Feb 2021 03:21:09 +0100 Subject: [PATCH 035/577] replace eb200 with runds --- CHANGELOG.md | 2 +- config_webrx.py | 2 +- debian/changelog | 2 +- debian/control | 2 +- docker.sh | 2 +- docker/Dockerfiles/Dockerfile-full | 2 +- .../Dockerfiles/{Dockerfile-eb200 => Dockerfile-runds} | 6 +++--- ...ndencies-eb200.sh => install-dependencies-runds.sh} | 6 +++--- owrx/feature.py | 10 +++++----- owrx/source/{eb200.py => runds.py} | 7 ++++--- 10 files changed, 21 insertions(+), 20 deletions(-) rename docker/Dockerfiles/{Dockerfile-eb200 => Dockerfile-runds} (57%) rename docker/scripts/{install-dependencies-eb200.sh => install-dependencies-runds.sh} (70%) rename owrx/source/{eb200.py => runds.py} (65%) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9aed5aa..1a5e3bb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,7 +8,7 @@ - New devices supported: - HPSDR devices (Hermes Lite 2) - BBRF103 / RX666 / RX888 devices supported by libsddc - - Devices using the EB200 protocol + - R&S devices using the EB200 or Ammos protocols **0.20.3** - Fix a compatibility issue with python versions <= 3.6 diff --git a/config_webrx.py b/config_webrx.py index b5f1702..b5b37a8 100644 --- a/config_webrx.py +++ b/config_webrx.py @@ -117,7 +117,7 @@ Note: if you experience audio underruns while CPU usage is 100%, you can: # Currently supported types of sdr receivers: # "rtl_sdr", "rtl_sdr_soapy", "sdrplay", "hackrf", "airspy", "airspyhf", "fifi_sdr", # "perseussdr", "lime_sdr", "pluto_sdr", "soapy_remote", "hpsdr", "red_pitaya", "uhd", -# "radioberry", "fcdpp", "rtl_tcp", "sddc", "eb200" +# "radioberry", "fcdpp", "rtl_tcp", "sddc", "runds" # For more details on specific types, please checkout the wiki: # https://github.com/jketterl/openwebrx/wiki/Supported-Hardware#sdr-devices diff --git a/debian/changelog b/debian/changelog index 1a86f2c..0bb4e95 100644 --- a/debian/changelog +++ b/debian/changelog @@ -13,7 +13,7 @@ openwebrx (0.21.0) UNRELEASED; urgency=low * New devices supported: - HPSDR devices (Hermes Lite 2) (`"type": "hpsdr"`) - BBRF103 / RX666 / RX888 devices supported by libsddc (`"type": "sddc"`) - - Devices using the EB200 protocol (`"type": "eb200"`) + - R&S devices using the EB200 or Ammos protocols (`"type": "runds"`) -- Jakob Ketterl Sun, 11 Oct 2020 21:12:00 +0000 diff --git a/debian/control b/debian/control index 6654e6a..a6f8eb1 100644 --- a/debian/control +++ b/debian/control @@ -11,6 +11,6 @@ Vcs-Git: https://github.com/jketterl/openwebrx.git Package: openwebrx Architecture: all Depends: adduser, python3 (>= 3.5), python3-pkg-resources, csdr (>= 0.17), netcat, owrx-connector (>= 0.4), soapysdr-tools, python3-js8py (>= 0.1), ${python3:Depends}, ${misc:Depends} -Recommends: digiham (>= 0.3), dsd (>= 1.7), sox, direwolf (>= 1.4), wsjtx, eb200-connector, hpsdrconnector, aprs-symbols, m17-demod +Recommends: digiham (>= 0.3), dsd (>= 1.7), sox, direwolf (>= 1.4), wsjtx, runds-connector, hpsdrconnector, aprs-symbols, m17-demod Description: multi-user web sdr Open source, multi-user SDR receiver with a web interface \ No newline at end of file diff --git a/docker.sh b/docker.sh index 1583af6..33d971f 100755 --- a/docker.sh +++ b/docker.sh @@ -2,7 +2,7 @@ set -euo pipefail ARCH=$(uname -m) -IMAGES="openwebrx-rtlsdr openwebrx-sdrplay openwebrx-hackrf openwebrx-airspy openwebrx-rtlsdr-soapy openwebrx-plutosdr openwebrx-limesdr openwebrx-soapyremote openwebrx-perseus openwebrx-fcdpp openwebrx-radioberry openwebrx-uhd openwebrx-redpitaya openwebrx-rtltcp openwebrx-eb200 openwebrx-hpsdr openwebrx-full openwebrx" +IMAGES="openwebrx-rtlsdr openwebrx-sdrplay openwebrx-hackrf openwebrx-airspy openwebrx-rtlsdr-soapy openwebrx-plutosdr openwebrx-limesdr openwebrx-soapyremote openwebrx-perseus openwebrx-fcdpp openwebrx-radioberry openwebrx-uhd openwebrx-redpitaya openwebrx-rtltcp openwebrx-runds openwebrx-hpsdr openwebrx-full openwebrx" ALL_ARCHS="x86_64 armv7l aarch64" TAG=${TAG:-"latest"} ARCHTAG="$TAG-$ARCH" diff --git a/docker/Dockerfiles/Dockerfile-full b/docker/Dockerfiles/Dockerfile-full index b68d18a..aab4b69 100644 --- a/docker/Dockerfiles/Dockerfile-full +++ b/docker/Dockerfiles/Dockerfile-full @@ -21,7 +21,7 @@ RUN /install-dependencies-rtlsdr.sh &&\ /install-dependencies-redpitaya.sh &&\ /install-dependencies-hpsdr.sh &&\ /install-connectors.sh &&\ - /install-dependencies-eb200.sh &&\ + /install-dependencies-runds.sh &&\ rm /install-dependencies-*.sh &&\ rm /install-lib.*.patch && \ rm /install-connectors.sh diff --git a/docker/Dockerfiles/Dockerfile-eb200 b/docker/Dockerfiles/Dockerfile-runds similarity index 57% rename from docker/Dockerfiles/Dockerfile-eb200 rename to docker/Dockerfiles/Dockerfile-runds index f2f5181..2a087e1 100644 --- a/docker/Dockerfiles/Dockerfile-eb200 +++ b/docker/Dockerfiles/Dockerfile-runds @@ -2,11 +2,11 @@ ARG ARCHTAG FROM openwebrx-base:$ARCHTAG COPY docker/scripts/install-connectors.sh \ - docker/scripts/install-dependencies-eb200.sh / + docker/scripts/install-dependencies-runds.sh / RUN /install-connectors.sh &&\ rm /install-connectors.sh && \ - /install-dependencies-eb200.sh && \ - rm /install-dependencies-eb200.sh + /install-dependencies-runds.sh && \ + rm /install-dependencies-runds.sh COPY . /opt/openwebrx diff --git a/docker/scripts/install-dependencies-eb200.sh b/docker/scripts/install-dependencies-runds.sh similarity index 70% rename from docker/scripts/install-dependencies-eb200.sh rename to docker/scripts/install-dependencies-runds.sh index 2eefdbe..6c5bdc8 100755 --- a/docker/scripts/install-dependencies-eb200.sh +++ b/docker/scripts/install-dependencies-runds.sh @@ -24,9 +24,9 @@ BUILD_PACKAGES="git cmake make gcc g++ pkg-config" apt-get update apt-get -y install --no-install-recommends $STATIC_PACKAGES $BUILD_PACKAGES -git clone https://github.com/jketterl/eb200_connector.git -# latest from develop as of 2021-01-31 -cmakebuild eb200_connector bb7f75be6e7fb4b987eea4b81821663ca4b9f19f +git clone https://github.com/jketterl/runds_connector.git +# latest from develop as of 2021-02-03 (first working ammos implementation) +cmakebuild runds_connector 19531c4e3e46107960656b3eed9952adebf1ec65 apt-get -y purge --autoremove $BUILD_PACKAGES apt-get clean diff --git a/owrx/feature.py b/owrx/feature.py index 3a89542..ca72a03 100644 --- a/owrx/feature.py +++ b/owrx/feature.py @@ -70,7 +70,7 @@ class FeatureDetector(object): "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"], @@ -547,10 +547,10 @@ 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") diff --git a/owrx/source/eb200.py b/owrx/source/runds.py similarity index 65% rename from owrx/source/eb200.py rename to owrx/source/runds.py index 50ef622..f12f003 100644 --- a/owrx/source/eb200.py +++ b/owrx/source/runds.py @@ -1,17 +1,18 @@ from owrx.source.connector import ConnectorSource -from owrx.command import Argument, Flag +from owrx.command import Argument, Flag, Option -class Eb200Source(ConnectorSource): +class RundsSource(ConnectorSource): def getCommandMapper(self): return ( super() .getCommandMapper() - .setBase("eb200_connector") + .setBase("runds_connector") .setMappings( { "long": Flag("-l"), "remote": Argument(), + "protocol": Option("-m"), } ) ) From bda718cbeeeb3074c09835c98ff92587e647dd15 Mon Sep 17 00:00:00 2001 From: Jakob Ketterl Date: Wed, 3 Feb 2021 17:09:51 +0100 Subject: [PATCH 036/577] update runds_connector --- docker/scripts/install-dependencies-runds.sh | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docker/scripts/install-dependencies-runds.sh b/docker/scripts/install-dependencies-runds.sh index 6c5bdc8..08b9b10 100755 --- a/docker/scripts/install-dependencies-runds.sh +++ b/docker/scripts/install-dependencies-runds.sh @@ -25,8 +25,8 @@ apt-get update apt-get -y install --no-install-recommends $STATIC_PACKAGES $BUILD_PACKAGES git clone https://github.com/jketterl/runds_connector.git -# latest from develop as of 2021-02-03 (first working ammos implementation) -cmakebuild runds_connector 19531c4e3e46107960656b3eed9952adebf1ec65 +# latest from develop as of 2021-02-03 (clean-up after ammos) +cmakebuild runds_connector b10f3d9a8c98483db0b11b055e357a9dea54de1b apt-get -y purge --autoremove $BUILD_PACKAGES apt-get clean From 56a42498a544cef294c700c64fdb4aa1e3969754 Mon Sep 17 00:00:00 2001 From: Jakob Ketterl Date: Wed, 3 Feb 2021 19:26:41 +0100 Subject: [PATCH 037/577] add frequencies for Q65 on available bands --- bands.json | 34 ++++++++++++++++++++++++++-------- 1 file changed, 26 insertions(+), 8 deletions(-) diff --git a/bands.json b/bands.json index ad7cc8c..f111aa1 100644 --- a/bands.json +++ b/bands.json @@ -164,7 +164,8 @@ "jt65": 50310000, "jt9": 50312000, "ft4": 50318000, - "js8": 50318000 + "js8": 50318000, + "q65": [50211000, 50275000] } }, { @@ -184,7 +185,8 @@ "ft8": 144174000, "ft4": 144170000, "jt65": 144120000, - "packet": 144800000 + "packet": 144800000, + "q65": 144116000 } }, { @@ -192,33 +194,49 @@ "lower_bound": 430000000, "upper_bound": 440000000, "frequencies": { - "pocsag": 439987500 + "pocsag": 439987500, + "q65": 432065000 } }, { "name": "23cm", "lower_bound": 1240000000, - "upper_bound": 1300000000 + "upper_bound": 1300000000, + "frequencies": { + "q65": 1296065000 + } }, { "name": "13cm", "lower_bound": 2320000000, - "upper_bound": 2450000000 + "upper_bound": 2450000000, + "frequencies": { + "q65": [2301065000, 2304065000, 2320065000] + } }, { "name": "9cm", "lower_bound": 3400000000, - "upper_bound": 3475000000 + "upper_bound": 3475000000, + "frequencies": { + "q65": 3400065000 + } }, { "name": "6cm", "lower_bound": 5650000000, - "upper_bound": 5850000000 + "upper_bound": 5850000000, + "frequencies": { + "q65": 5760200000 + } }, { "name": "3cm", "lower_bound": 10000000000, - "upper_bound": 10500000000 + "upper_bound": 10500000000, + "frequencies": { + "q65": 10368200000 + } }, { "name": "120m Broadcast", From e66be7c12d7ba1b67f511c32a5f6ee2e3b08ee4c Mon Sep 17 00:00:00 2001 From: Jakob Ketterl Date: Wed, 3 Feb 2021 19:33:02 +0100 Subject: [PATCH 038/577] add feature definition for wsjt-x 2.4 --- owrx/feature.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/owrx/feature.py b/owrx/feature.py index ca72a03..a918371 100644 --- a/owrx/feature.py +++ b/owrx/feature.py @@ -78,6 +78,7 @@ class FeatureDetector(object): "digital_voice_m17": ["m17_demod", "sox"], "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"], @@ -486,6 +487,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/) From d6d6d97a1346351f6d2ba679588dfc07f6c50eae Mon Sep 17 00:00:00 2001 From: Jakob Ketterl Date: Wed, 3 Feb 2021 20:11:07 +0100 Subject: [PATCH 039/577] add Q65 mode integration --- CHANGELOG.md | 3 ++- config_webrx.py | 4 ++++ csdr/csdr.py | 6 ++++-- debian/changelog | 4 ++-- htdocs/css/openwebrx.css | 7 +++++-- htdocs/lib/DemodulatorPanel.js | 2 +- htdocs/lib/MessagePanel.js | 2 +- owrx/modes.py | 3 +++ owrx/pskreporter.py | 2 +- owrx/wsjt.py | 32 ++++++++++++++++++++++++++++++++ 10 files changed, 55 insertions(+), 10 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1a5e3bb..8d258e4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,7 +1,8 @@ **unreleased** - Introduced `squelch_auto_margin` config option that allows configuring the auto squelch level - Removed `port` configuration option; `rtltcp_compat` takes the port number with the new connectors -- Added support for new WSJT-X modes FST4 and FST4W (only available with WSJT-X 2.3) +- Added support for new WSJT-X modes FST4, FST4W (only available with WSJT-X 2.3) and Q65 (only avilable with + WSJT-X 2.4) - Added support for demodulating M17 digital voice signals using m17-cxx-demod - New reporting infrastructure, allowing WSPR and FST4W spots to be sent to wsprnet.org - Add some basic filtering capabilities to the map diff --git a/config_webrx.py b/config_webrx.py index b5b37a8..f85f2d5 100644 --- a/config_webrx.py +++ b/config_webrx.py @@ -318,6 +318,10 @@ fst4_enabled_intervals = [15, 30] # available values (in seconds): 120, 300, 900, 1800 fst4w_enabled_intervals = [120, 300] +# Q65 allows many combinations of intervals and submodes. This setting determines which combinations will be decoded. +# Please use python tuples of (interval: int, mode: str) to specify the combinations. For example: +q65_enabled_combinations = [(30, "A"), (120, "E"), (60, "C")] + # JS8 comes in different speeds: normal, slow, fast, turbo. This setting controls which ones are enabled. js8_enabled_profiles = ["normal", "slow"] # JS8 decoding depth; higher value will get more results, but will also consume more cpu diff --git a/csdr/csdr.py b/csdr/csdr.py index 7ea2690..7e50267 100644 --- a/csdr/csdr.py +++ b/csdr/csdr.py @@ -29,7 +29,7 @@ import math from functools import partial from owrx.kiss import KissClient, DirewolfConfig -from owrx.wsjt import Ft8Profile, WsprProfile, Jt9Profile, Jt65Profile, Ft4Profile, Fst4Profile, Fst4wProfile +from owrx.wsjt import Ft8Profile, WsprProfile, Jt9Profile, Jt65Profile, Ft4Profile, Fst4Profile, Fst4wProfile, Q65Profile from owrx.js8 import Js8Profiles from owrx.audio import AudioChopper @@ -433,6 +433,8 @@ class dsp(object): chopper_profiles = Fst4Profile.getEnabledProfiles() elif smd == "fst4w": chopper_profiles = Fst4wProfile.getEnabledProfiles() + elif smd == "q65": + chopper_profiles = Q65Profile.getEnabledProfiles() if chopper_profiles is not None and len(chopper_profiles): chopper = AudioChopper(self, self.secondary_process_demod.stdout, *chopper_profiles) chopper.start() @@ -573,7 +575,7 @@ class dsp(object): def isWsjtMode(self, demodulator=None): if demodulator is None: demodulator = self.get_secondary_demodulator() - return demodulator in ["ft8", "wspr", "jt65", "jt9", "ft4", "fst4", "fst4w"] + return demodulator in ["ft8", "wspr", "jt65", "jt9", "ft4", "fst4", "fst4w", "q65"] def isJs8(self, demodulator=None): if demodulator is None: diff --git a/debian/changelog b/debian/changelog index 0bb4e95..549d7b8 100644 --- a/debian/changelog +++ b/debian/changelog @@ -3,8 +3,8 @@ openwebrx (0.21.0) UNRELEASED; urgency=low auto squelch level * Removed `port` configuration option; `rtltcp_compat` takes the port number with the new connectors - * Added support for new WSJT-X modes FST4 and FST4W (only available with - WSJT-X 2.3) + * Added support for new WSJT-X modes FST4, FST4W (only available with WSJT-X + 2.3) and Q65 (only available with WSJT-X 2.4) * Added support for demodulating M17 digital voice signals using m17-cxx-demod * New reporting infrastructure, allowing WSPR and FST4W spots to be sent to diff --git a/htdocs/css/openwebrx.css b/htdocs/css/openwebrx.css index bb0bb39..ffd0419 100644 --- a/htdocs/css/openwebrx.css +++ b/htdocs/css/openwebrx.css @@ -1192,6 +1192,7 @@ img.openwebrx-mirror-img #openwebrx-panel-digimodes[data-mode="js8"] #openwebrx-digimode-content-container, #openwebrx-panel-digimodes[data-mode="fst4"] #openwebrx-digimode-content-container, #openwebrx-panel-digimodes[data-mode="fst4w"] #openwebrx-digimode-content-container, +#openwebrx-panel-digimodes[data-mode="q65"] #openwebrx-digimode-content-container, #openwebrx-panel-digimodes[data-mode="ft8"] #openwebrx-digimode-select-channel, #openwebrx-panel-digimodes[data-mode="wspr"] #openwebrx-digimode-select-channel, #openwebrx-panel-digimodes[data-mode="jt65"] #openwebrx-digimode-select-channel, @@ -1201,7 +1202,8 @@ img.openwebrx-mirror-img #openwebrx-panel-digimodes[data-mode="pocsag"] #openwebrx-digimode-select-channel, #openwebrx-panel-digimodes[data-mode="js8"] #openwebrx-digimode-select-channel, #openwebrx-panel-digimodes[data-mode="fst4"] #openwebrx-digimode-select-channel, -#openwebrx-panel-digimodes[data-mode="fst4w"] #openwebrx-digimode-select-channel +#openwebrx-panel-digimodes[data-mode="fst4w"] #openwebrx-digimode-select-channel, +#openwebrx-panel-digimodes[data-mode="q65"] #openwebrx-digimode-select-channel { display: none; } @@ -1215,7 +1217,8 @@ img.openwebrx-mirror-img #openwebrx-panel-digimodes[data-mode="pocsag"] #openwebrx-digimode-canvas-container, #openwebrx-panel-digimodes[data-mode="js8"] #openwebrx-digimode-canvas-container, #openwebrx-panel-digimodes[data-mode="fst4"] #openwebrx-digimode-canvas-container, -#openwebrx-panel-digimodes[data-mode="fst4w"] #openwebrx-digimode-canvas-container +#openwebrx-panel-digimodes[data-mode="fst4w"] #openwebrx-digimode-canvas-container, +#openwebrx-panel-digimodes[data-mode="q65"] #openwebrx-digimode-canvas-container { height: 200px; margin: -10px; diff --git a/htdocs/lib/DemodulatorPanel.js b/htdocs/lib/DemodulatorPanel.js index aed1b17..be3269f 100644 --- a/htdocs/lib/DemodulatorPanel.js +++ b/htdocs/lib/DemodulatorPanel.js @@ -158,7 +158,7 @@ DemodulatorPanel.prototype.updatePanels = function() { var modulation = this.getDemodulator().get_secondary_demod(); $('#openwebrx-panel-digimodes').attr('data-mode', modulation); toggle_panel("openwebrx-panel-digimodes", !!modulation); - toggle_panel("openwebrx-panel-wsjt-message", ['ft8', 'wspr', 'jt65', 'jt9', 'ft4', 'fst4', 'fst4w'].indexOf(modulation) >= 0); + toggle_panel("openwebrx-panel-wsjt-message", ['ft8', 'wspr', 'jt65', 'jt9', 'ft4', 'fst4', 'fst4w', "q65"].indexOf(modulation) >= 0); toggle_panel("openwebrx-panel-js8-message", modulation == "js8"); toggle_panel("openwebrx-panel-packet-message", modulation === "packet"); toggle_panel("openwebrx-panel-pocsag-message", modulation === "pocsag"); diff --git a/htdocs/lib/MessagePanel.js b/htdocs/lib/MessagePanel.js index dc8995e..83ec91d 100644 --- a/htdocs/lib/MessagePanel.js +++ b/htdocs/lib/MessagePanel.js @@ -78,7 +78,7 @@ WsjtMessagePanel.prototype.pushMessage = function(msg) { return $('
').text(input).html() }; - if (['FT8', 'JT65', 'JT9', 'FT4', 'FST4'].indexOf(msg['mode']) >= 0) { + if (['FT8', 'JT65', 'JT9', 'FT4', 'FST4', 'Q65'].indexOf(msg['mode']) >= 0) { matches = linkedmsg.match(/(.*\s[A-Z0-9]+\s)([A-R]{2}[0-9]{2})$/); if (matches && matches[2] !== 'RR73') { linkedmsg = html_escape(matches[1]) + '' + matches[2] + ''; diff --git a/owrx/modes.py b/owrx/modes.py index f544e19..0df4747 100644 --- a/owrx/modes.py +++ b/owrx/modes.py @@ -98,6 +98,9 @@ class Modes(object): requirements=["wsjt-x-2-3"], service=True, ), + DigitalMode( + "q65", "Q65", underlying=["usb"], bandpass=Bandpass(0, 3000), requirements=["wsjt-x-2-4"], service=True + ), DigitalMode( "js8", "JS8Call", underlying=["usb"], bandpass=Bandpass(0, 3000), requirements=["js8call"], service=True ), diff --git a/owrx/pskreporter.py b/owrx/pskreporter.py index 4106648..6f0ee11 100644 --- a/owrx/pskreporter.py +++ b/owrx/pskreporter.py @@ -18,7 +18,7 @@ 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() diff --git a/owrx/wsjt.py b/owrx/wsjt.py index 6d9a919..7903b72 100644 --- a/owrx/wsjt.py +++ b/owrx/wsjt.py @@ -7,6 +7,7 @@ from owrx.parser import Parser from owrx.audio import AudioChopperProfile from abc import ABC, ABCMeta, abstractmethod from owrx.config import Config +from enum import Enum import logging @@ -142,6 +143,37 @@ class Fst4wProfile(WsjtProfile): return [Fst4wProfile(i) for i in profiles if i in Fst4wProfile.availableIntervals] +class Q65Mode(Enum): + A = 1 + B = 2 + C = 3 + D = 4 + E = 5 + + +class Q65Profile(WsjtProfile): + availableIntervals = [15, 30, 60, 120, 300] + + def __init__(self, interval, mode: Q65Mode): + self.interval = interval + self.mode = mode + + def getMode(self): + return "Q65" + + def getInterval(self): + return self.interval + + def decoder_commandline(self, file): + return ["jt9", "--q65", "-p", str(self.interval), "-b", self.mode.name, "-d", str(self.decoding_depth()), file] + + @staticmethod + def getEnabledProfiles(): + config = Config.get() + profiles = config["q65_enabled_combinations"] if "q65_enabled_combinations" in config else [] + return [Q65Profile(i, Q65Mode[m]) for i, m in profiles if i in Fst4wProfile.availableIntervals] + + class WsjtParser(Parser): def parse(self, messages): for data in messages: From e8fca853df7dcd6bd14722ea610c9a78bfbb7c6b Mon Sep 17 00:00:00 2001 From: Jakob Ketterl Date: Thu, 4 Feb 2021 18:00:03 +0100 Subject: [PATCH 040/577] unsubscribe on close; self-referencing prevents unsubscription --- owrx/connection.py | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/owrx/connection.py b/owrx/connection.py index 445d73d..102b227 100644 --- a/owrx/connection.py +++ b/owrx/connection.py @@ -161,11 +161,6 @@ class OpenWebRxReceiverClient(OpenWebRxClient, SdrSourceEventClient): 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 @@ -317,6 +312,8 @@ class OpenWebRxReceiverClient(OpenWebRxClient, SdrSourceEventClient): self.stopDsp() CpuUsageThread.getSharedInstance().remove_client(self) ClientRegistry.getSharedInstance().removeClient(self) + while self.configSubs: + self.configSubs.pop().cancel() super().close() def stopDsp(self): From 8e4716f2418ff448e957389865e388afdcbdefc6 Mon Sep 17 00:00:00 2001 From: Jakob Ketterl Date: Fri, 5 Feb 2021 01:07:09 +0100 Subject: [PATCH 041/577] drop empty Q65 decodes --- owrx/wsjt.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/owrx/wsjt.py b/owrx/wsjt.py index 7903b72..60a5dc1 100644 --- a/owrx/wsjt.py +++ b/owrx/wsjt.py @@ -197,6 +197,9 @@ class WsjtParser(Parser): else: decoder = Jt9Decoder(profile, messageParser) out = decoder.parse(msg, freq) + if isinstance(profile, Q65Profile) and not out["msg"]: + # all efforts in vain, it's just a potential signal indicator + return out["mode"] = mode out["interval"] = profile.getInterval() From c23acc1513d20a10c113f44c98ea4dfe3aa57fa4 Mon Sep 17 00:00:00 2001 From: Jakob Ketterl Date: Fri, 5 Feb 2021 17:22:43 +0100 Subject: [PATCH 042/577] automatically align --- htdocs/css/openwebrx-header.css | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/htdocs/css/openwebrx-header.css b/htdocs/css/openwebrx-header.css index 83061f3..cfe9be2 100644 --- a/htdocs/css/openwebrx-header.css +++ b/htdocs/css/openwebrx-header.css @@ -79,6 +79,7 @@ /* will be getting wider with flex */ flex: 1; overflow: hidden; + margin: auto 0; } #webrx-rx-texts div { @@ -90,10 +91,6 @@ color: #909090; } -#webrx-rx-texts div:first-child { - margin-top: 10px; -} - #webrx-rx-title { font-family: "DejaVu Sans", Verdana, Geneva, sans-serif; font-size: 11pt; From 53faca64c08698c09819bcd1ca53ba1a45631237 Mon Sep 17 00:00:00 2001 From: Jakob Ketterl Date: Fri, 5 Feb 2021 17:56:02 +0100 Subject: [PATCH 043/577] clean up header styles --- htdocs/css/map.css | 4 -- htdocs/css/openwebrx-header.css | 59 +++++++++++++++--------------- htdocs/include/header.include.html | 27 ++++++-------- htdocs/lib/Header.js | 24 ++++++------ htdocs/map.js | 2 +- htdocs/openwebrx.js | 2 +- 6 files changed, 54 insertions(+), 64 deletions(-) diff --git a/htdocs/css/map.css b/htdocs/css/map.css index 796f442..70702b9 100644 --- a/htdocs/css/map.css +++ b/htdocs/css/map.css @@ -6,10 +6,6 @@ body { flex-direction: column; } -#webrx-top-container { - flex: none; -} - .openwebrx-map { flex: 1 1 auto; } diff --git a/htdocs/css/openwebrx-header.css b/htdocs/css/openwebrx-header.css index cfe9be2..bc1ee2d 100644 --- a/htdocs/css/openwebrx-header.css +++ b/htdocs/css/openwebrx-header.css @@ -1,4 +1,4 @@ -#webrx-top-container { +.webrx-top-container { position: relative; z-index:1000; background-color: #575757; @@ -12,7 +12,7 @@ overflow: hidden; } -#openwebrx-description-container { +.openwebrx-description-container { transition-property: height, opacity; transition-duration: 1s; transition-timing-function: ease-out; @@ -23,7 +23,7 @@ overflow: hidden; } -#openwebrx-description-container.expanded { +.openwebrx-description-container.expanded { opacity: 1; height: 283px; } @@ -50,22 +50,21 @@ flex: 0; } -#webrx-top-container, #webrx-top-container * { +.webrx-top-container, .webrx-top-container * { line-height: initial; box-sizing: initial; } -#webrx-top-logo { +.webrx-top-logo { padding: 12px; /* overwritten by media queries */ display: none; } -#webrx-rx-avatar { +.webrx-rx-avatar { background-color: rgba(154, 154, 154, .5); margin: 7px; - cursor:pointer; width: 46px; height: 46px; padding: 4px; @@ -73,7 +72,7 @@ box-sizing: content-box; } -#webrx-rx-texts { +.webrx-rx-texts { /* minimum layout width */ width: 0; /* will be getting wider with flex */ @@ -82,70 +81,66 @@ margin: auto 0; } -#webrx-rx-texts div { +.webrx-rx-texts div { margin: 0 10px; padding: 3px; white-space:nowrap; overflow: hidden; - cursor:pointer; color: #909090; } -#webrx-rx-title { +.webrx-rx-title { font-family: "DejaVu Sans", Verdana, Geneva, sans-serif; font-size: 11pt; font-weight: bold; } -#webrx-rx-desc { +.webrx-rx-desc { font-size: 10pt; } -#openwebrx-rx-details-arrow { - cursor:pointer; +.openwebrx-rx-details-arrow { position: absolute; bottom: 0; left: 50%; transform: translate(-50%, 0); -} -#openwebrx-rx-details-arrow a { margin: 0; padding: 0; line-height: 0; display: block; } -#openwebrx-main-buttons .button { +.openwebrx-main-buttons .button { display: block; width: 55px; cursor:pointer; } -#openwebrx-main-buttons .button[data-toggle-panel] { +.openwebrx-main-buttons .button[data-toggle-panel] { /* will be enabled by javascript if the panel is present in the DOM */ display: none; } -#openwebrx-main-buttons .button img { +.openwebrx-main-buttons .button img { height: 38px; } -#openwebrx-main-buttons a { +.openwebrx-main-buttons a { color: inherit; text-decoration: inherit; } -#openwebrx-main-buttons .button:hover { +.openwebrx-main-buttons .button:hover { background-color: rgba(255, 255, 255, 0.3); } -#openwebrx-main-buttons .button:active { +.openwebrx-main-buttons .button:active { background-color: rgba(255, 255, 255, 0.55); } -#openwebrx-main-buttons { +.openwebrx-main-buttons { padding: 5px 15px; display: flex; list-style: none; @@ -157,7 +152,7 @@ font-weight: bold; } -#webrx-rx-photo-title { +.webrx-rx-photo-title { margin: 10px 15px; color: white; font-size: 16pt; @@ -165,7 +160,7 @@ opacity: 1; } -#webrx-rx-photo-desc { +.webrx-rx-photo-desc { margin: 10px 15px; color: white; font-size: 10pt; @@ -175,17 +170,21 @@ line-height: 1.5em; } -#webrx-rx-photo-desc a { +.webrx-rx-photo-desc a { color: #5ca8ff; text-shadow: none; } +.openwebrx-photo-trigger { + cursor: pointer; +} + /* * Responsive stuff */ @media (min-width: 576px) { - #webrx-rx-texts { + .webrx-rx-texts { display: initial; } } @@ -194,7 +193,7 @@ } @media (min-width: 992px) { - #webrx-top-logo { + .webrx-top-logo { display: initial; } } @@ -236,13 +235,13 @@ height: 38px; } -.sprite-rx-details-arrow-down { +.openwebrx-rx-details-arrow--down .sprite-rx-details-arrow { background-position: 0 -65px; width: 43px; height: 12px; } -.sprite-rx-details-arrow-up { +.openwebrx-rx-details-arrow--up .sprite-rx-details-arrow { background-position: -43px -65px; width: 43px; height: 12px; diff --git a/htdocs/include/header.include.html b/htdocs/include/header.include.html index ec31619..a991d47 100644 --- a/htdocs/include/header.include.html +++ b/htdocs/include/header.include.html @@ -1,12 +1,12 @@ -
-
- - Receiver avatar -
-
-
+
+
+ + Receiver avatar +
+
+
-
+

Status

Log

Receiver
@@ -14,12 +14,9 @@ ${settingslink}
-
-
-
-
-
- - +
+
+
+
diff --git a/htdocs/lib/Header.js b/htdocs/lib/Header.js index 746c282..b9ff471 100644 --- a/htdocs/lib/Header.js +++ b/htdocs/lib/Header.js @@ -1,7 +1,7 @@ function Header(el) { this.el = el; - var $buttons = this.el.find('#openwebrx-main-buttons').find('[data-toggle-panel]').filter(function(){ + var $buttons = this.el.find('.openwebrx-main-buttons').find('[data-toggle-panel]').filter(function(){ // ignore buttons when the corresponding panel is not in the DOM return $('#' + $(this).data('toggle-panel'))[0]; }); @@ -15,10 +15,10 @@ function Header(el) { }; Header.prototype.setDetails = function(details) { - this.el.find('#webrx-rx-title').html(details['receiver_name']); - this.el.find('#webrx-rx-desc').html(details['receiver_location'] + ' | Loc: ' + details['locator'] + ', ASL: ' + details['receiver_asl'] + ' m'); - this.el.find('#webrx-rx-photo-title').html(details['photo_title']); - this.el.find('#webrx-rx-photo-desc').html(details['photo_desc']); + this.el.find('.webrx-rx-title').html(details['receiver_name']); + this.el.find('.webrx-rx-desc').html(details['receiver_location'] + ' | Loc: ' + details['locator'] + ', ASL: ' + details['receiver_asl'] + ' m'); + this.el.find('.webrx-rx-photo-title').html(details['photo_title']); + this.el.find('.webrx-rx-photo-desc').html(details['photo_desc']); }; Header.prototype.init_rx_photo = function() { @@ -30,21 +30,19 @@ Header.prototype.init_rx_photo = function() { } }); - $('#webrx-top-container').find('.openwebrx-photo-trigger').click(this.toggle_rx_photo.bind(this)); + $('.webrx-top-container').find('.openwebrx-photo-trigger').click(this.toggle_rx_photo.bind(this)); }; Header.prototype.close_rx_photo = function() { this.rx_photo_state = 0; - this.el.find('#openwebrx-description-container').removeClass('expanded'); - this.el.find("#openwebrx-rx-details-arrow-down").show(); - this.el.find("#openwebrx-rx-details-arrow-up").hide(); + this.el.find('.openwebrx-description-container').removeClass('expanded'); + this.el.find(".openwebrx-rx-details-arrow").removeClass('openwebrx-rx-details-arrow--up').addClass('openwebrx-rx-details-arrow--down'); } Header.prototype.open_rx_photo = function() { this.rx_photo_state = 1; - this.el.find('#openwebrx-description-container').addClass('expanded'); - this.el.find("#openwebrx-rx-details-arrow-down").hide(); - this.el.find("#openwebrx-rx-details-arrow-up").show(); + this.el.find('.openwebrx-description-container').addClass('expanded'); + this.el.find(".openwebrx-rx-details-arrow").removeClass('openwebrx-rx-details-arrow--down').addClass('openwebrx-rx-details-arrow--up'); } Header.prototype.toggle_rx_photo = function(ev) { @@ -73,5 +71,5 @@ $.fn.header = function() { }; $(function(){ - $('#webrx-top-container').header(); + $('.webrx-top-container').header(); }); diff --git a/htdocs/map.js b/htdocs/map.js index 5c31759..8b2003f 100644 --- a/htdocs/map.js +++ b/htdocs/map.js @@ -282,7 +282,7 @@ processUpdates(json.value); break; case 'receiver_details': - $('#webrx-top-container').header().setDetails(json['value']); + $('.webrx-top-container').header().setDetails(json['value']); break; default: console.warn('received message of unknown type: ' + json['type']); diff --git a/htdocs/openwebrx.js b/htdocs/openwebrx.js index 24d7a13..8acc66c 100644 --- a/htdocs/openwebrx.js +++ b/htdocs/openwebrx.js @@ -780,7 +780,7 @@ function on_ws_recv(evt) { secondary_demod_init_canvases(); break; case "receiver_details": - $('#webrx-top-container').header().setDetails(json['value']); + $('.webrx-top-container').header().setDetails(json['value']); break; case "smeter": smeter_level = json['value']; From d9b662106c124d211c85a0cdae9271e3ab04666e Mon Sep 17 00:00:00 2001 From: Jakob Ketterl Date: Fri, 5 Feb 2021 17:58:27 +0100 Subject: [PATCH 044/577] rename class --- htdocs/css/openwebrx-header.css | 4 ++-- htdocs/include/header.include.html | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/htdocs/css/openwebrx-header.css b/htdocs/css/openwebrx-header.css index bc1ee2d..f9a765e 100644 --- a/htdocs/css/openwebrx-header.css +++ b/htdocs/css/openwebrx-header.css @@ -28,7 +28,7 @@ height: 283px; } -.webrx-top-bar-parts { +.webrx-top-bar { height:67px; background: rgba(128, 128, 128, 0.15); @@ -46,7 +46,7 @@ flex-direction: row; } -.webrx-top-bar-parts > * { +.webrx-top-bar > * { flex: 0; } diff --git a/htdocs/include/header.include.html b/htdocs/include/header.include.html index a991d47..56aac5c 100644 --- a/htdocs/include/header.include.html +++ b/htdocs/include/header.include.html @@ -1,5 +1,5 @@
-
+
Receiver avatar
From 54fb58755d03cabc12bab8a5047b468da2e50640 Mon Sep 17 00:00:00 2001 From: Jakob Ketterl Date: Sat, 6 Feb 2021 15:50:50 +0100 Subject: [PATCH 045/577] add openwebrx data directory for persistent files --- debian/openwebrx.postinst | 22 ++++++++++++++++++++++ debian/postinst | 7 ------- 2 files changed, 22 insertions(+), 7 deletions(-) create mode 100755 debian/openwebrx.postinst delete mode 100755 debian/postinst diff --git a/debian/openwebrx.postinst b/debian/openwebrx.postinst new file mode 100755 index 0000000..fdb757f --- /dev/null +++ b/debian/openwebrx.postinst @@ -0,0 +1,22 @@ +#!/bin/bash +set -euxo pipefail + +OWRX_USER="openwebrx" +OWRX_DATADIR="/var/lib/openwebrx" + +case "$1" in + configure) + adduser --system --group --no-create-home --home /nonexistent --quiet "${OWRX_USER}" + usermod -aG plugdev openwebrx + + # create OpenWebRX data directory and set the correct permissions + if [ ! -d "${OWRX_DATADIR}" ] && [ ! -L "${OWRX_DATADIR}" ]; then mkdir "${OWRX_DATADIR}"; fi + chown -R "${OWRX_USER}". ${OWRX_DATADIR} + ;; + *) + echo "postinst called with unknown argument '$1'" 1>&2 + exit 1 + ;; +esac + +#DEBHELPER# diff --git a/debian/postinst b/debian/postinst deleted file mode 100755 index 423db2c..0000000 --- a/debian/postinst +++ /dev/null @@ -1,7 +0,0 @@ -#!/bin/bash -set -euxo pipefail - -adduser --system --group --no-create-home --home /nonexistent --quiet openwebrx -usermod -aG plugdev openwebrx - -#DEBHELPER# From 3226c01f602cdee19e29a22e1ff717ecf452a292 Mon Sep 17 00:00:00 2001 From: Jakob Ketterl Date: Sat, 6 Feb 2021 16:38:03 +0100 Subject: [PATCH 046/577] introduce core config file (settings that cannot be edited from the web) --- debian/openwebrx.install | 1 + docker/scripts/run.sh | 3 +++ openwebrx.conf | 5 +++++ owrx/__main__.py | 4 ++-- owrx/config.py | 35 +++++++++++++++++++++++++++++++++-- 5 files changed, 44 insertions(+), 4 deletions(-) create mode 100644 openwebrx.conf diff --git a/debian/openwebrx.install b/debian/openwebrx.install index 84b1f9c..99af884 100644 --- a/debian/openwebrx.install +++ b/debian/openwebrx.install @@ -1,5 +1,6 @@ config_webrx.py etc/openwebrx/ bands.json etc/openwebrx/ bookmarks.json etc/openwebrx/ +openwebrx.conf etc/openwebrx/ users.json etc/openwebrx/ systemd/openwebrx.service lib/systemd/system/ \ No newline at end of file diff --git a/docker/scripts/run.sh b/docker/scripts/run.sh index 0f8bd32..a82d8cd 100755 --- a/docker/scripts/run.sh +++ b/docker/scripts/run.sh @@ -15,6 +15,9 @@ fi if [[ ! -f /etc/openwebrx/users.json ]] ; then cp users.json /etc/openwebrx/ fi +if [[ ! -f /etc/openwebrx/openwebrx.conf ]] ; then + cp openwebrx.conf /etc/openwebrx/ +fi _term() { diff --git a/openwebrx.conf b/openwebrx.conf new file mode 100644 index 0000000..072d3e4 --- /dev/null +++ b/openwebrx.conf @@ -0,0 +1,5 @@ +[core] +data_directory = /var/lib/openwebrx + +[web] +port = 8073 diff --git a/owrx/__main__.py b/owrx/__main__.py index 1a39cb1..9512db8 100644 --- a/owrx/__main__.py +++ b/owrx/__main__.py @@ -1,6 +1,6 @@ from http.server import HTTPServer from owrx.http import RequestHandler -from owrx.config import Config +from owrx.config import Config, CoreConfig from owrx.feature import FeatureDetector from owrx.sdr import SdrService from socketserver import ThreadingMixIn @@ -73,7 +73,7 @@ Support and info: https://groups.io/g/openwebrx 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() diff --git a/owrx/config.py b/owrx/config.py index f668f8e..35f6328 100644 --- a/owrx/config.py +++ b/owrx/config.py @@ -4,6 +4,7 @@ import os import logging import json from abc import ABC, abstractmethod +from configparser import ConfigParser logger = logging.getLogger(__name__) @@ -56,6 +57,31 @@ class ConfigMigratorVersion2(ConfigMigrator): return config +class CoreConfig(object): + defaults = { + "core": { + "data_directory": "/var/lib/openwebrx", + }, + "web": { + "port": 8073, + }, + } + + def __init__(self): + config = ConfigParser() + config.read(["./openwebrx.conf", "/etc/openwebrx/openwebrx.conf"]) + self.data_directory = config.get( + "core", "data_directory", fallback=CoreConfig.defaults["core"]["data_directory"] + ) + self.web_port = config.getint("web", "port", fallback=CoreConfig.defaults["web"]["port"]) + + def get_web_port(self): + return self.web_port + + def get_data_directory(self): + return self.data_directory + + class Config: sharedConfig = None currentVersion = 3 @@ -84,9 +110,14 @@ class Config: pm[k] = v return pm + @staticmethod + def _getSettingsFile(): + coreConfig = CoreConfig() + return "{data_directory}/settings.json".format(data_directory=coreConfig.get_data_directory()) + @staticmethod def _loadConfig(): - for file in ["./settings.json", "/etc/openwebrx/config_webrx.py", "./config_webrx.py"]: + for file in [Config._getSettingsFile(), "/etc/openwebrx/config_webrx.py", "./config_webrx.py"]: try: if file.endswith(".py"): return Config._loadPythonFile(file) @@ -106,7 +137,7 @@ class Config: @staticmethod def store(): - with open("settings.json", "w") as file: + with open(Config._getSettingsFile(), "w") as file: json.dump(Config.get().__dict__(), file, indent=4) @staticmethod From ffcf5c0c27ec2d0c8a746bb002807a97023d4b9c Mon Sep 17 00:00:00 2001 From: Jakob Ketterl Date: Sat, 6 Feb 2021 16:43:54 +0100 Subject: [PATCH 047/577] create owrxadmin --- owrxadmin/__main__.py | 2 ++ setup.py | 3 ++- 2 files changed, 4 insertions(+), 1 deletion(-) create mode 100644 owrxadmin/__main__.py diff --git a/owrxadmin/__main__.py b/owrxadmin/__main__.py new file mode 100644 index 0000000..65c4765 --- /dev/null +++ b/owrxadmin/__main__.py @@ -0,0 +1,2 @@ +def main(): + print("OpenWebRX admin") diff --git a/setup.py b/setup.py index 076750c..60519f1 100644 --- a/setup.py +++ b/setup.py @@ -22,10 +22,11 @@ setup( "owrx.form", "csdr", "htdocs", + "owrxadmin", ] ), package_data={"htdocs": [f[len("htdocs/") :] for f in glob("htdocs/**/*", recursive=True)]}, - entry_points={"console_scripts": ["openwebrx=owrx.__main__:main"]}, + entry_points={"console_scripts": ["openwebrx=owrx.__main__:main", "owrxadmin=owrxadmin.__main__:main"]}, url="https://www.openwebrx.de/", author="Jakob Ketterl", author_email="jakob.ketterl@gmx.de", From dd2f0629d3ccf70db56df1a1849f54ff7213212e Mon Sep 17 00:00:00 2001 From: Jakob Ketterl Date: Sat, 6 Feb 2021 16:44:40 +0100 Subject: [PATCH 048/577] rename --- setup.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/setup.py b/setup.py index 60519f1..695e658 100644 --- a/setup.py +++ b/setup.py @@ -26,7 +26,7 @@ setup( ] ), package_data={"htdocs": [f[len("htdocs/") :] for f in glob("htdocs/**/*", recursive=True)]}, - entry_points={"console_scripts": ["openwebrx=owrx.__main__:main", "owrxadmin=owrxadmin.__main__:main"]}, + entry_points={"console_scripts": ["openwebrx=owrx.__main__:main", "openwebrx-admin=owrxadmin.__main__:main"]}, url="https://www.openwebrx.de/", author="Jakob Ketterl", author_email="jakob.ketterl@gmx.de", From 99fe232a214eb048c42b3998749a6371696c4aa2 Mon Sep 17 00:00:00 2001 From: Jakob Ketterl Date: Sat, 6 Feb 2021 18:04:32 +0100 Subject: [PATCH 049/577] include command to create a user --- openwebrx-admin.py | 6 ++++ owrx/users.py | 84 +++++++++++++++++++++++++++++++++---------- owrxadmin/__main__.py | 22 +++++++++++- owrxadmin/commands.py | 46 ++++++++++++++++++++++++ users.json | 11 ------ 5 files changed, 138 insertions(+), 31 deletions(-) create mode 100755 openwebrx-admin.py create mode 100644 owrxadmin/commands.py delete mode 100644 users.json diff --git a/openwebrx-admin.py b/openwebrx-admin.py new file mode 100755 index 0000000..c1f6caa --- /dev/null +++ b/openwebrx-admin.py @@ -0,0 +1,6 @@ +#!/usr/bin/env python3 + +from owrxadmin.__main__ import main + +if __name__ == "__main__": + main() diff --git a/owrx/users.py b/owrx/users.py index 0398108..6c4e518 100644 --- a/owrx/users.py +++ b/owrx/users.py @@ -1,4 +1,5 @@ from abc import ABC, abstractmethod +from owrx.config import CoreConfig import json import logging @@ -19,17 +20,32 @@ class Password(ABC): return CleartextPassword(d) raise PasswordException("invalid passord encoding: {0}".format(d["type"])) - def __init__(self, pwinfo: dict): - self.pwinfo = pwinfo - @abstractmethod def is_valid(self, inp: str): pass + @abstractmethod + def toJson(self): + pass + class CleartextPassword(Password): + def __init__(self, pwinfo): + if isinstance(pwinfo, str): + self._value = pwinfo + elif isinstance(pwinfo, dict): + self._value = pwinfo["value"] + else: + raise ValueError("invalid argument to ClearTextPassword()") + def is_valid(self, inp: str): - return self.pwinfo["value"] == inp + return self._value == inp + + def toJson(self): + return { + "encoding": "string", + "value": self._value + } class User(object): @@ -38,6 +54,13 @@ class User(object): self.enabled = enabled self.password = password + def toJson(self): + return { + "user": self.name, + "enabled": self.enabled, + "password": self.password.toJson() + } + class UserList(object): sharedInstance = None @@ -51,30 +74,53 @@ class UserList(object): def __init__(self): self.users = self._loadUsers() + def _getUsersFile(self): + config = CoreConfig() + return "{data_directory}/users.json".format(data_directory=config.get_data_directory()) + def _loadUsers(self): - for file in ["/etc/openwebrx/users.json", "users.json"]: - try: - f = open(file, "r") + usersFile = self._getUsersFile() + try: + with open(usersFile, "r") as f: users_json = json.load(f) - f.close() - return {u.name: u for u in [self.buildUser(d) for d in users_json]} - except FileNotFoundError: - pass - except json.JSONDecodeError: - logger.exception("error while parsing users file %s", file) - return {} - except Exception: - logger.exception("error while processing users from %s", file) - return {} - return {} + return {u.name: u for u in [self._jsonToUser(d) for d in users_json]} + except FileNotFoundError: + return {} + except json.JSONDecodeError: + logger.exception("error while parsing users file %s", usersFile) + return {} + except Exception: + logger.exception("error while processing users from %s", usersFile) + return {} - def buildUser(self, d): + def _jsonToUser(self, d): if "user" in d and "password" in d and "enabled" in d: return User(d["user"], d["enabled"], Password.from_dict(d["password"])) + def _userToJson(self, u): + return u.toJson() + + def _store(self): + usersFile = self._getUsersFile() + users = [u.toJson() for u in self.users.values()] + try: + with open(usersFile, "w") as f: + json.dump(users, f, indent=4) + except Exception: + logger.exception("error while writing users file %s", usersFile) + + def addUser(self, user: User): + self[user.name] = user + def __getitem__(self, item): return self.users[item] def __contains__(self, item): return item in self.users + + def __setitem__(self, key, value): + if key in self.users: + raise KeyError("User {user} already exists".format(user=key)) + self.users[key] = value + self._store() diff --git a/owrxadmin/__main__.py b/owrxadmin/__main__.py index 65c4765..227d881 100644 --- a/owrxadmin/__main__.py +++ b/owrxadmin/__main__.py @@ -1,2 +1,22 @@ +from owrx.version import openwebrx_version +from owrxadmin.commands import NewUserCommand +import argparse +import sys + + def main(): - print("OpenWebRX admin") + print("OpenWebRX admin version {version}".format(version=openwebrx_version)) + + parser = argparse.ArgumentParser() + parser.add_argument("command", help="One of the following commands: adduser, removeuser") + parser.add_argument("--noninteractive", action="store_true", help="Don't ask for any user input (useful for automation)") + parser.add_argument("-u", "--user") + args = parser.parse_args() + + if args.command == "adduser": + NewUserCommand().run(args) + elif args.command == "removeuser": + print("removing user") + else: + print("Unknown command: {command}".format(command=args.command)) + sys.exit(1) diff --git a/owrxadmin/commands.py b/owrxadmin/commands.py new file mode 100644 index 0000000..182a10e --- /dev/null +++ b/owrxadmin/commands.py @@ -0,0 +1,46 @@ +from abc import ABC, abstractmethod +from getpass import getpass +from owrx.users import UserList, User, CleartextPassword +import sys +import random +import string + + +class Command(ABC): + @abstractmethod + def run(self, args): + pass + + +class NewUserCommand(Command): + def run(self, args): + if args.user: + username = args.user + else: + if args.noninteractive: + print("ERROR: User name not specified") + sys.exit(1) + else: + username = input("Please enter the user name: ") + if args.noninteractive: + print("Generating password for user {username}...".format(username=username)) + password = self.getRandomPassword() + print('Password for {username} is "{password}".'.format(username=username, password=password)) + # TODO implement this threat + 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 note it down now.') + else: + password = getpass("Please enter the password for {username}: ".format(username=username)) + confirm = getpass("Please confirm password: ") + if password != confirm: + print("ERROR: Password mismatch.") + sys.exit(1) + + print("Creating user {username}...".format(username=username)) + userList = UserList() + user = User(name=username, enabled=True, password=CleartextPassword(password)) + userList.addUser(user) + + def getRandomPassword(self, length=10): + printable = list(string.ascii_letters) + list(string.digits) + return ''.join(random.choices(printable, k=length)) diff --git a/users.json b/users.json deleted file mode 100644 index 298d7f2..0000000 --- a/users.json +++ /dev/null @@ -1,11 +0,0 @@ -[ - { - "user": "admin", - "password": { - "encoding": "string", - "value": "password", - "force_change": true - }, - "enabled": true - } -] \ No newline at end of file From d72027e630d763aab6964237188523b36225d3db Mon Sep 17 00:00:00 2001 From: Jakob Ketterl Date: Sat, 6 Feb 2021 18:15:02 +0100 Subject: [PATCH 050/577] implement user deletion --- owrx/users.py | 13 +++++++++++++ owrxadmin/__main__.py | 6 +++--- owrxadmin/commands.py | 24 +++++++++++++++++++----- 3 files changed, 35 insertions(+), 8 deletions(-) diff --git a/owrx/users.py b/owrx/users.py index 6c4e518..86173e1 100644 --- a/owrx/users.py +++ b/owrx/users.py @@ -113,6 +113,19 @@ class UserList(object): def addUser(self, user: User): self[user.name] = user + def deleteUser(self, user): + if isinstance(user, User): + username = user.name + else: + username = user + del self[username] + + def __delitem__(self, key): + if key not in self.users: + raise KeyError("User {user} doesn't exist".format(user=key)) + del self.users[key] + self._store() + def __getitem__(self, item): return self.users[item] diff --git a/owrxadmin/__main__.py b/owrxadmin/__main__.py index 227d881..1e3a653 100644 --- a/owrxadmin/__main__.py +++ b/owrxadmin/__main__.py @@ -1,5 +1,5 @@ from owrx.version import openwebrx_version -from owrxadmin.commands import NewUserCommand +from owrxadmin.commands import NewUser, DeleteUser import argparse import sys @@ -14,9 +14,9 @@ def main(): args = parser.parse_args() if args.command == "adduser": - NewUserCommand().run(args) + NewUser().run(args) elif args.command == "removeuser": - print("removing user") + DeleteUser().run(args) else: print("Unknown command: {command}".format(command=args.command)) sys.exit(1) diff --git a/owrxadmin/commands.py b/owrxadmin/commands.py index 182a10e..bf2f1b8 100644 --- a/owrxadmin/commands.py +++ b/owrxadmin/commands.py @@ -1,4 +1,4 @@ -from abc import ABC, abstractmethod +from abc import ABC, ABCMeta, abstractmethod from getpass import getpass from owrx.users import UserList, User, CleartextPassword import sys @@ -12,16 +12,22 @@ class Command(ABC): pass -class NewUserCommand(Command): - def run(self, args): +class UserCommand(Command, metaclass=ABCMeta): + def getUser(self, args): if args.user: - username = args.user + return args.user else: if args.noninteractive: print("ERROR: User name not specified") sys.exit(1) else: - username = input("Please enter the user name: ") + return input("Please enter the user name: ") + + +class NewUser(UserCommand): + def run(self, args): + username = self.getUser(args) + if args.noninteractive: print("Generating password for user {username}...".format(username=username)) password = self.getRandomPassword() @@ -44,3 +50,11 @@ class NewUserCommand(Command): def getRandomPassword(self, length=10): printable = list(string.ascii_letters) + list(string.digits) return ''.join(random.choices(printable, k=length)) + + +class DeleteUser(UserCommand): + def run(self, args): + username = self.getUser(args) + print("Deleting user {username}...".format(username=username)) + userList = UserList() + userList.deleteUser(username) From 7054ec5d597a3062c995e883b8816d9692a21afe Mon Sep 17 00:00:00 2001 From: Jakob Ketterl Date: Sat, 6 Feb 2021 18:15:55 +0100 Subject: [PATCH 051/577] remove old users file from distribution --- debian/openwebrx.install | 1 - docker/scripts/run.sh | 3 --- 2 files changed, 4 deletions(-) diff --git a/debian/openwebrx.install b/debian/openwebrx.install index 99af884..41dd9a7 100644 --- a/debian/openwebrx.install +++ b/debian/openwebrx.install @@ -2,5 +2,4 @@ config_webrx.py etc/openwebrx/ bands.json etc/openwebrx/ bookmarks.json etc/openwebrx/ openwebrx.conf etc/openwebrx/ -users.json etc/openwebrx/ systemd/openwebrx.service lib/systemd/system/ \ No newline at end of file diff --git a/docker/scripts/run.sh b/docker/scripts/run.sh index a82d8cd..cbbb0c7 100755 --- a/docker/scripts/run.sh +++ b/docker/scripts/run.sh @@ -12,9 +12,6 @@ fi if [[ ! -f /etc/openwebrx/bookmarks.json ]] ; then cp bookmarks.json /etc/openwebrx/ fi -if [[ ! -f /etc/openwebrx/users.json ]] ; then - cp users.json /etc/openwebrx/ -fi if [[ ! -f /etc/openwebrx/openwebrx.conf ]] ; then cp openwebrx.conf /etc/openwebrx/ fi From 1d9ab1494f3d59c4eae7e439dc8d3bbd1f08a019 Mon Sep 17 00:00:00 2001 From: Jakob Ketterl Date: Sat, 6 Feb 2021 18:17:37 +0100 Subject: [PATCH 052/577] remove web_port from config --- config_webrx.py | 1 - 1 file changed, 1 deletion(-) diff --git a/config_webrx.py b/config_webrx.py index f85f2d5..f8aa237 100644 --- a/config_webrx.py +++ b/config_webrx.py @@ -39,7 +39,6 @@ version = 3 # https://github.com/jketterl/openwebrx/wiki/Configuration-guide # ==== Server settings ==== -web_port = 8073 max_clients = 20 # ==== Web GUI configuration ==== From f6f01ebee5cc1061c2323fa75335a86329770e05 Mon Sep 17 00:00:00 2001 From: Jakob Ketterl Date: Sat, 6 Feb 2021 18:22:13 +0100 Subject: [PATCH 053/577] default password implementation --- owrx/users.py | 3 +++ owrxadmin/commands.py | 4 ++-- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/owrx/users.py b/owrx/users.py index 86173e1..03a5372 100644 --- a/owrx/users.py +++ b/owrx/users.py @@ -48,6 +48,9 @@ class CleartextPassword(Password): } +DefaultPasswordClass = CleartextPassword + + class User(object): def __init__(self, name: str, enabled: bool, password: Password): self.name = name diff --git a/owrxadmin/commands.py b/owrxadmin/commands.py index bf2f1b8..8be9d5a 100644 --- a/owrxadmin/commands.py +++ b/owrxadmin/commands.py @@ -1,6 +1,6 @@ from abc import ABC, ABCMeta, abstractmethod from getpass import getpass -from owrx.users import UserList, User, CleartextPassword +from owrx.users import UserList, User, DefaultPasswordClass import sys import random import string @@ -44,7 +44,7 @@ class NewUser(UserCommand): print("Creating user {username}...".format(username=username)) userList = UserList() - user = User(name=username, enabled=True, password=CleartextPassword(password)) + user = User(name=username, enabled=True, password=DefaultPasswordClass(password)) userList.addUser(user) def getRandomPassword(self, length=10): From 8806dc538eb52f65dd9bb2aad46f27f59db0e093 Mon Sep 17 00:00:00 2001 From: Jakob Ketterl Date: Sat, 6 Feb 2021 18:38:49 +0100 Subject: [PATCH 054/577] implement hashed passwords --- owrx/users.py | 48 +++++++++++++++++++++++++++++++++++++++++++----- 1 file changed, 43 insertions(+), 5 deletions(-) diff --git a/owrx/users.py b/owrx/users.py index 03a5372..c7a2bed 100644 --- a/owrx/users.py +++ b/owrx/users.py @@ -1,6 +1,7 @@ from abc import ABC, abstractmethod from owrx.config import CoreConfig import json +import hashlib import logging @@ -18,14 +19,16 @@ class Password(ABC): raise PasswordException("password encoding not set") if d["encoding"] == "string": return CleartextPassword(d) + elif d["encoding"] == "hash": + return HashedPassword(d) raise PasswordException("invalid passord encoding: {0}".format(d["type"])) @abstractmethod - def is_valid(self, inp: str): + def is_valid(self, inp: str) -> bool: pass @abstractmethod - def toJson(self): + def toJson(self) -> dict: pass @@ -38,17 +41,52 @@ class CleartextPassword(Password): else: raise ValueError("invalid argument to ClearTextPassword()") - def is_valid(self, inp: str): + def is_valid(self, inp: str) -> bool: return self._value == inp - def toJson(self): + def toJson(self) -> dict: return { "encoding": "string", "value": self._value } -DefaultPasswordClass = CleartextPassword +class HashedPassword(Password): + def __init__(self, pwinfo, algorithm="sha256"): + self.iterations = 100000 + if (isinstance(pwinfo, str)): + self._createFromString(pwinfo, algorithm) + else: + self._loadFromDict(pwinfo) + + def _createFromString(self, pw: str, algorithm: str): + self._algorithm = algorithm + # TODO: random salt + self._salt = "constant" + dk = hashlib.pbkdf2_hmac(self._algorithm, pw.encode(), self._salt.encode(), self.iterations) + self._hash = dk.hex() + pass + + def _loadFromDict(self, d: dict): + self._hash = d["value"] + self._algorithm = d["algorithm"] + self._salt = d["salt"] + pass + + def is_valid(self, inp: str) -> bool: + dk = hashlib.pbkdf2_hmac(self._algorithm, inp.encode(), self._salt.encode(), self.iterations) + return dk.hex() == self._hash + + def toJson(self) -> dict: + return { + "encoding": "hash", + "value": self._hash, + "algorithm": self._algorithm, + "salt": self._salt, + } + + +DefaultPasswordClass = HashedPassword class User(object): From e548d6a5de3713a8dfccace2eb8d6ede3a2c5bce Mon Sep 17 00:00:00 2001 From: Jakob Ketterl Date: Sat, 6 Feb 2021 18:43:37 +0100 Subject: [PATCH 055/577] random salt for passwords --- owrx/users.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/owrx/users.py b/owrx/users.py index c7a2bed..7e4f6bc 100644 --- a/owrx/users.py +++ b/owrx/users.py @@ -2,6 +2,7 @@ from abc import ABC, abstractmethod from owrx.config import CoreConfig import json import hashlib +import os import logging @@ -61,20 +62,19 @@ class HashedPassword(Password): def _createFromString(self, pw: str, algorithm: str): self._algorithm = algorithm - # TODO: random salt - self._salt = "constant" - dk = hashlib.pbkdf2_hmac(self._algorithm, pw.encode(), self._salt.encode(), self.iterations) + self._salt = os.urandom(32) + dk = hashlib.pbkdf2_hmac(self._algorithm, pw.encode(), self._salt, self.iterations) self._hash = dk.hex() pass def _loadFromDict(self, d: dict): self._hash = d["value"] self._algorithm = d["algorithm"] - self._salt = d["salt"] + self._salt = bytes.fromhex(d["salt"]) pass def is_valid(self, inp: str) -> bool: - dk = hashlib.pbkdf2_hmac(self._algorithm, inp.encode(), self._salt.encode(), self.iterations) + dk = hashlib.pbkdf2_hmac(self._algorithm, inp.encode(), self._salt, self.iterations) return dk.hex() == self._hash def toJson(self) -> dict: @@ -82,7 +82,7 @@ class HashedPassword(Password): "encoding": "hash", "value": self._hash, "algorithm": self._algorithm, - "salt": self._salt, + "salt": self._salt.hex(), } From d99669b3aabe0ca9c7b84a723d3e27de80a21ec0 Mon Sep 17 00:00:00 2001 From: Jakob Ketterl Date: Sat, 6 Feb 2021 18:57:51 +0100 Subject: [PATCH 056/577] add "silent" flag to openwebrx-admin --- owrx/users.py | 14 +++++++++----- owrxadmin/__main__.py | 21 +++++++++++++++++---- owrxadmin/commands.py | 5 ++++- 3 files changed, 30 insertions(+), 10 deletions(-) diff --git a/owrx/users.py b/owrx/users.py index 7e4f6bc..935480f 100644 --- a/owrx/users.py +++ b/owrx/users.py @@ -151,15 +151,19 @@ class UserList(object): except Exception: logger.exception("error while writing users file %s", usersFile) + def _getUsername(self, user): + if isinstance(user, User): + return user.name + elif isinstance(user, str): + return user + else: + raise ValueError("invalid user type") + def addUser(self, user: User): self[user.name] = user def deleteUser(self, user): - if isinstance(user, User): - username = user.name - else: - username = user - del self[username] + del self[self._getUsername(user)] def __delitem__(self, key): if key not in self.users: diff --git a/owrxadmin/__main__.py b/owrxadmin/__main__.py index 1e3a653..6cbb5b0 100644 --- a/owrxadmin/__main__.py +++ b/owrxadmin/__main__.py @@ -2,6 +2,7 @@ from owrx.version import openwebrx_version from owrxadmin.commands import NewUser, DeleteUser import argparse import sys +import traceback def main(): @@ -10,13 +11,25 @@ def main(): parser = argparse.ArgumentParser() parser.add_argument("command", help="One of the following commands: adduser, removeuser") parser.add_argument("--noninteractive", action="store_true", help="Don't ask for any user input (useful for automation)") + parser.add_argument("--silent", action="store_true", help="Ignore errors (useful for automation)") parser.add_argument("-u", "--user") args = parser.parse_args() if args.command == "adduser": - NewUser().run(args) + command = NewUser() elif args.command == "removeuser": - DeleteUser().run(args) + command = DeleteUser() else: - print("Unknown command: {command}".format(command=args.command)) - sys.exit(1) + if not args.silent: + print("Unknown command: {command}".format(command=args.command)) + sys.exit(1) + sys.exit(0) + + try: + command.run(args) + except Exception: + if not args.silent: + print("Error running command:") + traceback.print_exc() + sys.exit(1) + sys.exit(0) diff --git a/owrxadmin/commands.py b/owrxadmin/commands.py index 8be9d5a..9a631a6 100644 --- a/owrxadmin/commands.py +++ b/owrxadmin/commands.py @@ -27,6 +27,10 @@ class UserCommand(Command, metaclass=ABCMeta): class NewUser(UserCommand): def run(self, args): username = self.getUser(args) + 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)) if args.noninteractive: print("Generating password for user {username}...".format(username=username)) @@ -43,7 +47,6 @@ class NewUser(UserCommand): sys.exit(1) print("Creating user {username}...".format(username=username)) - userList = UserList() user = User(name=username, enabled=True, password=DefaultPasswordClass(password)) userList.addUser(user) From 1fed499b7feaa19895224cb5f7d6381b45244b6d Mon Sep 17 00:00:00 2001 From: Jakob Ketterl Date: Sat, 6 Feb 2021 18:59:01 +0100 Subject: [PATCH 057/577] create initial user in postinst script --- debian/openwebrx.postinst | 3 +++ 1 file changed, 3 insertions(+) diff --git a/debian/openwebrx.postinst b/debian/openwebrx.postinst index fdb757f..7220378 100755 --- a/debian/openwebrx.postinst +++ b/debian/openwebrx.postinst @@ -12,6 +12,9 @@ case "$1" in # create OpenWebRX data directory and set the correct permissions if [ ! -d "${OWRX_DATADIR}" ] && [ ! -L "${OWRX_DATADIR}" ]; then mkdir "${OWRX_DATADIR}"; fi chown -R "${OWRX_USER}". ${OWRX_DATADIR} + + # create initial openwebrx user + openwebrx-admin adduser --noninteractive --silent --user admin ;; *) echo "postinst called with unknown argument '$1'" 1>&2 From 9c5858e1e5a516db821192d569f4ef4196902189 Mon Sep 17 00:00:00 2001 From: Jakob Ketterl Date: Sat, 6 Feb 2021 19:01:14 +0100 Subject: [PATCH 058/577] change wording --- owrxadmin/commands.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/owrxadmin/commands.py b/owrxadmin/commands.py index 9a631a6..078809d 100644 --- a/owrxadmin/commands.py +++ b/owrxadmin/commands.py @@ -38,7 +38,7 @@ class NewUser(UserCommand): print('Password for {username} is "{password}".'.format(username=username, password=password)) # TODO implement this threat 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 note it down now.') + print('This password cannot be recovered from the system, please copy it now.') else: password = getpass("Please enter the password for {username}: ".format(username=username)) confirm = getpass("Please confirm password: ") From 732985c529e7c2582edb988c8c2f2701d9fee9c3 Mon Sep 17 00:00:00 2001 From: Jakob Ketterl Date: Sat, 6 Feb 2021 19:02:50 +0100 Subject: [PATCH 059/577] add help --- owrxadmin/__main__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/owrxadmin/__main__.py b/owrxadmin/__main__.py index 6cbb5b0..c5342a3 100644 --- a/owrxadmin/__main__.py +++ b/owrxadmin/__main__.py @@ -12,7 +12,7 @@ def main(): parser.add_argument("command", help="One of the following commands: adduser, removeuser") parser.add_argument("--noninteractive", action="store_true", help="Don't ask for any user input (useful for automation)") parser.add_argument("--silent", action="store_true", help="Ignore errors (useful for automation)") - parser.add_argument("-u", "--user") + parser.add_argument("-u", "--user", help="User name to perform action upon") args = parser.parse_args() if args.command == "adduser": From 635bf55465986a23505a32169b6aa78f0fe159e4 Mon Sep 17 00:00:00 2001 From: Jakob Ketterl Date: Sat, 6 Feb 2021 19:03:28 +0100 Subject: [PATCH 060/577] format --- owrxadmin/__main__.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/owrxadmin/__main__.py b/owrxadmin/__main__.py index c5342a3..4435523 100644 --- a/owrxadmin/__main__.py +++ b/owrxadmin/__main__.py @@ -10,7 +10,9 @@ def main(): parser = argparse.ArgumentParser() parser.add_argument("command", help="One of the following commands: adduser, removeuser") - parser.add_argument("--noninteractive", action="store_true", help="Don't ask for any user input (useful for automation)") + parser.add_argument( + "--noninteractive", action="store_true", help="Don't ask for any user input (useful for automation)" + ) parser.add_argument("--silent", action="store_true", help="Ignore errors (useful for automation)") parser.add_argument("-u", "--user", help="User name to perform action upon") args = parser.parse_args() From 01c58327aa98865c18f49a710fd20649aa438b83 Mon Sep 17 00:00:00 2001 From: Jakob Ketterl Date: Sat, 6 Feb 2021 19:12:44 +0100 Subject: [PATCH 061/577] implement password reset command --- owrx/users.py | 9 +++++--- owrxadmin/__main__.py | 6 ++++-- owrxadmin/commands.py | 49 ++++++++++++++++++++++++++++--------------- 3 files changed, 42 insertions(+), 22 deletions(-) diff --git a/owrx/users.py b/owrx/users.py index 935480f..4ee0a50 100644 --- a/owrx/users.py +++ b/owrx/users.py @@ -102,6 +102,9 @@ class User(object): "password": self.password.toJson() } + def setPassword(self, password: Password): + self.password = password + class UserList(object): sharedInstance = None @@ -142,7 +145,7 @@ class UserList(object): def _userToJson(self, u): return u.toJson() - def _store(self): + def store(self): usersFile = self._getUsersFile() users = [u.toJson() for u in self.users.values()] try: @@ -169,7 +172,7 @@ class UserList(object): if key not in self.users: raise KeyError("User {user} doesn't exist".format(user=key)) del self.users[key] - self._store() + self.store() def __getitem__(self, item): return self.users[item] @@ -181,4 +184,4 @@ class UserList(object): if key in self.users: raise KeyError("User {user} already exists".format(user=key)) self.users[key] = value - self._store() + self.store() diff --git a/owrxadmin/__main__.py b/owrxadmin/__main__.py index 4435523..ea38ca0 100644 --- a/owrxadmin/__main__.py +++ b/owrxadmin/__main__.py @@ -1,5 +1,5 @@ from owrx.version import openwebrx_version -from owrxadmin.commands import NewUser, DeleteUser +from owrxadmin.commands import NewUser, DeleteUser, ResetPassword import argparse import sys import traceback @@ -9,7 +9,7 @@ def main(): print("OpenWebRX admin version {version}".format(version=openwebrx_version)) parser = argparse.ArgumentParser() - parser.add_argument("command", help="One of the following commands: adduser, removeuser") + parser.add_argument("command", help="One of the following commands: adduser, removeuser, resetpassword") parser.add_argument( "--noninteractive", action="store_true", help="Don't ask for any user input (useful for automation)" ) @@ -21,6 +21,8 @@ def main(): command = NewUser() elif args.command == "removeuser": command = DeleteUser() + elif args.command == "resetpassword": + command = ResetPassword() else: if not args.silent: print("Unknown command: {command}".format(command=args.command)) diff --git a/owrxadmin/commands.py b/owrxadmin/commands.py index 078809d..3793463 100644 --- a/owrxadmin/commands.py +++ b/owrxadmin/commands.py @@ -23,6 +23,26 @@ class UserCommand(Command, metaclass=ABCMeta): else: return input("Please enter the user name: ") + def getPassword(self, args, username): + if args.noninteractive: + print("Generating password for user {username}...".format(username=username)) + password = self.getRandomPassword() + print('Password for {username} is "{password}".'.format(username=username, password=password)) + # TODO implement this threat + 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) + return password + + 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): @@ -32,28 +52,12 @@ class NewUser(UserCommand): if username in userList: raise KeyError("User {username} already exists".format(username=username)) - if args.noninteractive: - print("Generating password for user {username}...".format(username=username)) - password = self.getRandomPassword() - print('Password for {username} is "{password}".'.format(username=username, password=password)) - # TODO implement this threat - 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 password for {username}: ".format(username=username)) - confirm = getpass("Please confirm password: ") - if password != confirm: - print("ERROR: Password mismatch.") - sys.exit(1) + password = self.getPassword(args, username) print("Creating user {username}...".format(username=username)) user = User(name=username, enabled=True, password=DefaultPasswordClass(password)) userList.addUser(user) - def getRandomPassword(self, length=10): - printable = list(string.ascii_letters) + list(string.digits) - return ''.join(random.choices(printable, k=length)) - class DeleteUser(UserCommand): def run(self, args): @@ -61,3 +65,14 @@ class DeleteUser(UserCommand): print("Deleting user {username}...".format(username=username)) userList = UserList() userList.deleteUser(username) + + +class ResetPassword(UserCommand): + def run(self, args): + username = self.getUser(args) + password = self.getPassword(args, username) + userList = UserList() + userList[username].setPassword(DefaultPasswordClass(password)) + # this is a change to an object in the list, not the list itself + # in this case, store() is explicit + userList.store() From 5d291b5b36c99f92aafe6bd45b762ec10323b8a9 Mon Sep 17 00:00:00 2001 From: Jakob Ketterl Date: Sat, 6 Feb 2021 21:01:59 +0100 Subject: [PATCH 062/577] add pskreporter settings mappings --- owrx/controllers/settings.py | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/owrx/controllers/settings.py b/owrx/controllers/settings.py index cd68549..906ee6f 100644 --- a/owrx/controllers/settings.py +++ b/owrx/controllers/settings.py @@ -251,6 +251,19 @@ class GeneralSettingsController(AdminController): infotext="This callsign will be used to send spots to pskreporter.info", ), ), + Section( + "WSPRnet settings", + CheckboxInput( + "wsprnet_enabled", + "Reporting", + checkboxText="Enable sending spots to wsprnet.org", + ), + TextInput( + "wsprnet_callsign", + "wsprnet callsign", + infotext="This callsign will be used to send spots to pskreporter.info", + ), + ), ] def render_sections(self): From 9357d57a2810f6ced7b924238ff0b9cabff5739f Mon Sep 17 00:00:00 2001 From: Jakob Ketterl Date: Sat, 6 Feb 2021 21:55:47 +0100 Subject: [PATCH 063/577] move temporary_directyr to core config; implement override logic --- docker/scripts/run.sh | 11 +++++++-- openwebrx.conf | 1 + owrx/audio.py | 4 ++-- owrx/config.py | 50 ++++++++++++++++++++++++---------------- owrx/dsp.py | 4 ++-- owrx/feature.py | 4 ++-- owrx/fft.py | 5 ++-- owrx/service/__init__.py | 4 ++-- 8 files changed, 50 insertions(+), 33 deletions(-) diff --git a/docker/scripts/run.sh b/docker/scripts/run.sh index cbbb0c7..052f4ca 100755 --- a/docker/scripts/run.sh +++ b/docker/scripts/run.sh @@ -1,10 +1,17 @@ #!/bin/bash set -euo pipefail -mkdir -p /etc/openwebrx/ +mkdir -p /etc/openwebrx/openwebrx.conf.d +mkdir -p /var/lib/openwebrx mkdir -p /tmp/openwebrx/ if [[ ! -f /etc/openwebrx/config_webrx.py ]] ; then - sed 's/temporary_directory = "\/tmp"/temporary_directory = "\/tmp\/openwebrx"/' < "/opt/openwebrx/config_webrx.py" > "/etc/openwebrx/config_webrx.py" + cp config_webrx.py /etc/openwebrx +fi +if [[ ! -f /etc/openwebrx/openwebrx.conf.d/20-temporary-directory.conf ]] ; then + cat << EOF > /etc/openwebrx/openwebrx.conf.d/20-temporary-directory.conf +[core] +temporary_directory = /tmp/openwebrx +EOF fi if [[ ! -f /etc/openwebrx/bands.json ]] ; then cp bands.json /etc/openwebrx/ diff --git a/openwebrx.conf b/openwebrx.conf index 072d3e4..b0d8500 100644 --- a/openwebrx.conf +++ b/openwebrx.conf @@ -1,5 +1,6 @@ [core] data_directory = /var/lib/openwebrx +temporary_directory = /tmp [web] port = 8073 diff --git a/owrx/audio.py b/owrx/audio.py index 7ac3733..092b55d 100644 --- a/owrx/audio.py +++ b/owrx/audio.py @@ -1,5 +1,5 @@ from abc import ABC, ABCMeta, abstractmethod -from owrx.config import Config +from owrx.config import Config, CoreConfig from owrx.metrics import Metrics, CounterMetric, DirectMetric import threading import wave @@ -151,7 +151,7 @@ class AudioWriter(object): self.dsp = dsp self.source = source self.profile = profile - self.tmp_dir = Config.get()["temporary_directory"] + self.tmp_dir = CoreConfig().get_temporary_directory() self.wavefile = None self.wavefilename = None self.switchingLock = threading.Lock() diff --git a/owrx/config.py b/owrx/config.py index 35f6328..7761a11 100644 --- a/owrx/config.py +++ b/owrx/config.py @@ -1,8 +1,9 @@ -from owrx.property import PropertyManager, PropertyLayer +from owrx.property import PropertyLayer import importlib.util import os import logging import json +from glob import glob from abc import ABC, abstractmethod from configparser import ConfigParser @@ -28,7 +29,7 @@ class ConfigMigrator(ABC): pass def renameKey(self, config, old, new): - if old in config and not new in config: + if old in config and new not in config: config[new] = config[old] del config[old] @@ -61,6 +62,7 @@ class CoreConfig(object): defaults = { "core": { "data_directory": "/var/lib/openwebrx", + "temporary_directory": "/tmp", }, "web": { "port": 8073, @@ -69,18 +71,41 @@ class CoreConfig(object): def __init__(self): config = ConfigParser() - config.read(["./openwebrx.conf", "/etc/openwebrx/openwebrx.conf"]) + 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", fallback=CoreConfig.defaults["core"]["data_directory"] ) + CoreConfig.checkDirectory(self.data_directory, "data_directory") + self.temporary_directory = config.get( + "core", "temporary_directory", fallback=CoreConfig.defaults["core"]["temporary_directory"] + ) + CoreConfig.checkDirectory(self.temporary_directory, "temporary_directory") self.web_port = config.getint("web", "port", fallback=CoreConfig.defaults["web"]["port"]) + @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 + class Config: sharedConfig = None @@ -142,23 +167,8 @@ class Config: @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 + # no config check atm + return [] @staticmethod def _migrate(config): diff --git a/owrx/dsp.py b/owrx/dsp.py index 20d377e..2b076d8 100644 --- a/owrx/dsp.py +++ b/owrx/dsp.py @@ -7,6 +7,7 @@ from owrx.source import SdrSource, SdrSourceEventClient from owrx.property import PropertyStack, PropertyLayer, PropertyValidator from owrx.property.validators import OrValidator, RegexValidator, BoolValidator from owrx.modes import Modes +from owrx.config import CoreConfig from csdr import csdr import threading import re @@ -70,7 +71,6 @@ class DspManager(csdr.output, SdrSourceEventClient): "digimodes_enable", "samp_rate", "digital_voice_unvoiced_quality", - "temporary_directory", "center_freq", "start_mod", "start_freq", @@ -132,7 +132,6 @@ 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.filter("center_freq", "offset_freq").wire(set_dial_freq), ] @@ -140,6 +139,7 @@ class DspManager(csdr.output, SdrSourceEventClient): 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"]: diff --git a/owrx/feature.py b/owrx/feature.py index a918371..0a50dc2 100644 --- a/owrx/feature.py +++ b/owrx/feature.py @@ -4,7 +4,7 @@ from operator import and_ import re from distutils.version import LooseVersion import inspect -from owrx.config import Config +from owrx.config import CoreConfig import shlex import os from datetime import datetime, timedelta @@ -147,7 +147,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 diff --git a/owrx/fft.py b/owrx/fft.py index c434f7a..c76d1ff 100644 --- a/owrx/fft.py +++ b/owrx/fft.py @@ -1,4 +1,4 @@ -from owrx.config import Config +from owrx.config import Config, CoreConfig from csdr import csdr import threading from owrx.source import SdrSource, SdrSourceEventClient @@ -26,7 +26,6 @@ class SpectrumThread(csdr.output, SdrSourceEventClient): "csdr_dynamic_bufsize", "csdr_print_bufsizes", "csdr_through", - "temporary_directory", ) self.dsp = dsp = csdr.dsp(self) @@ -50,7 +49,6 @@ class SpectrumThread(csdr.output, SdrSourceEventClient): props.wireProperty("fft_size", dsp.set_fft_size), props.wireProperty("fft_fps", dsp.set_fft_fps), props.wireProperty("fft_compression", dsp.set_fft_compression), - props.wireProperty("temporary_directory", dsp.set_temporary_directory), props.filter("samp_rate", "fft_size", "fft_fps", "fft_voverlap_factor").wire(set_fft_averages), ] @@ -59,6 +57,7 @@ class SpectrumThread(csdr.output, SdrSourceEventClient): dsp.csdr_dynamic_bufsize = props["csdr_dynamic_bufsize"] dsp.csdr_print_bufsizes = props["csdr_print_bufsizes"] dsp.csdr_through = props["csdr_through"] + dsp.set_temporary_directory(CoreConfig().get_temporary_directory()) logger.debug("Spectrum thread initialized successfully.") def start(self): diff --git a/owrx/service/__init__.py b/owrx/service/__init__.py index 540e3c3..89b048a 100644 --- a/owrx/service/__init__.py +++ b/owrx/service/__init__.py @@ -6,7 +6,7 @@ from csdr.csdr import dsp, output from owrx.wsjt import WsjtParser from owrx.aprs import AprsParser from owrx.js8 import Js8Parser -from owrx.config import Config +from owrx.config import Config, CoreConfig from owrx.source.resampler import Resampler from owrx.property import PropertyLayer from js8py import Js8Frame @@ -255,7 +255,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 From 617bed91c4d8cc4e60ba04db5a0f8f4c577ab836 Mon Sep 17 00:00:00 2001 From: Jakob Ketterl Date: Sat, 6 Feb 2021 22:08:27 +0100 Subject: [PATCH 064/577] fix config verification --- owrx/__main__.py | 13 ++++--------- owrx/config.py | 13 +++++-------- 2 files changed, 9 insertions(+), 17 deletions(-) diff --git a/owrx/__main__.py b/owrx/__main__.py index 9512db8..c8d2dd2 100644 --- a/owrx/__main__.py +++ b/owrx/__main__.py @@ -48,14 +48,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"): @@ -73,7 +68,7 @@ Support and info: https://groups.io/g/openwebrx Services.start() try: - server = ThreadedHttpServer(("0.0.0.0", CoreConfig().get_web_port()), RequestHandler) + server = ThreadedHttpServer(("0.0.0.0", coreConfig.get_web_port()), RequestHandler) server.serve_forever() except SignalException: WebSocketConnection.closeAll() diff --git a/owrx/config.py b/owrx/config.py index 7761a11..89efd2a 100644 --- a/owrx/config.py +++ b/owrx/config.py @@ -14,13 +14,9 @@ class ConfigNotFoundException(Exception): pass -class ConfigError(object): +class ConfigError(Exception): 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) + super().__init__("Configuration Error (key: {0}): {1}".format(key, message)) class ConfigMigrator(ABC): @@ -167,8 +163,9 @@ class Config: @staticmethod def validateConfig(): - # no config check atm - return [] + # no config checks atm + # just basic loading verification + Config.get() @staticmethod def _migrate(config): From 8a25718d29d560777611de2ba0ab76a1fb033fa4 Mon Sep 17 00:00:00 2001 From: Jakob Ketterl Date: Sat, 6 Feb 2021 22:31:02 +0100 Subject: [PATCH 065/577] create config overrides directory --- debian/openwebrx.dirs | 1 + 1 file changed, 1 insertion(+) create mode 100644 debian/openwebrx.dirs diff --git a/debian/openwebrx.dirs b/debian/openwebrx.dirs new file mode 100644 index 0000000..c87b1b2 --- /dev/null +++ b/debian/openwebrx.dirs @@ -0,0 +1 @@ +/etc/openwebrx/openwebrx.conf.d \ No newline at end of file From b318b5e88ac6c6237d57bb37e2e0abdc4cfc2a16 Mon Sep 17 00:00:00 2001 From: Jakob Ketterl Date: Sat, 6 Feb 2021 22:53:12 +0100 Subject: [PATCH 066/577] remove temporary directory from old config --- config_webrx.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/config_webrx.py b/config_webrx.py index f8aa237..7ec053b 100644 --- a/config_webrx.py +++ b/config_webrx.py @@ -326,8 +326,6 @@ js8_enabled_profiles = ["normal", "slow"] # JS8 decoding depth; higher value will get more results, but will also consume more cpu js8_decoding_depth = 3 -temporary_directory = "/tmp" - # Enable background service for decoding digital data. You can find more information at: # https://github.com/jketterl/openwebrx/wiki/Background-decoding services_enabled = False From ee687d4e27d2804d1e876caa81c08b8679b12d37 Mon Sep 17 00:00:00 2001 From: Jakob Ketterl Date: Sat, 6 Feb 2021 23:17:43 +0100 Subject: [PATCH 067/577] fix copy&paste fail --- owrx/controllers/settings.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/owrx/controllers/settings.py b/owrx/controllers/settings.py index 906ee6f..805af5b 100644 --- a/owrx/controllers/settings.py +++ b/owrx/controllers/settings.py @@ -261,7 +261,7 @@ class GeneralSettingsController(AdminController): TextInput( "wsprnet_callsign", "wsprnet callsign", - infotext="This callsign will be used to send spots to pskreporter.info", + infotext="This callsign will be used to send spots to wsprnet.org", ), ), ] From 88020b894ee2074e7a9e2833d3d0df65ffafbea9 Mon Sep 17 00:00:00 2001 From: Jakob Ketterl Date: Sun, 7 Feb 2021 00:21:57 +0100 Subject: [PATCH 068/577] move aprs_symbols_path to new config --- config_webrx.py | 3 --- openwebrx.conf | 4 ++++ owrx/config.py | 20 +++++++++++++------- owrx/controllers/assets.py | 5 ++--- 4 files changed, 19 insertions(+), 13 deletions(-) diff --git a/config_webrx.py b/config_webrx.py index 7ec053b..46efb19 100644 --- a/config_webrx.py +++ b/config_webrx.py @@ -341,9 +341,6 @@ aprs_igate_password = "" # beacon uses the receiver_gps setting, so if you enable this, make sure the location is correct there aprs_igate_beacon = False -# path to the aprs symbols repository (get it here: https://github.com/hessu/aprs-symbols) -aprs_symbols_path = "/usr/share/aprs-symbols/png" - # Uncomment the following to customize gateway beacon details reported to the aprs network # Plese see Dire Wolf's documentation on PBEACON configuration for complete details: # https://github.com/wb2osz/direwolf/raw/master/doc/User-Guide.pdf diff --git a/openwebrx.conf b/openwebrx.conf index b0d8500..32a0f77 100644 --- a/openwebrx.conf +++ b/openwebrx.conf @@ -4,3 +4,7 @@ temporary_directory = /tmp [web] port = 8073 + +[aprs] +# path to the aprs symbols repository (get it here: https://github.com/hessu/aprs-symbols) +symbols_path = /usr/share/aprs-symbols/png diff --git a/owrx/config.py b/owrx/config.py index 89efd2a..b634477 100644 --- a/owrx/config.py +++ b/owrx/config.py @@ -63,10 +63,16 @@ class CoreConfig(object): "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") @@ -74,15 +80,12 @@ class CoreConfig(object): overrides = [] # sequence things together config.read(["./openwebrx.conf", "/etc/openwebrx/openwebrx.conf"] + overrides) - self.data_directory = config.get( - "core", "data_directory", fallback=CoreConfig.defaults["core"]["data_directory"] - ) + self.data_directory = config.get("core", "data_directory") CoreConfig.checkDirectory(self.data_directory, "data_directory") - self.temporary_directory = config.get( - "core", "temporary_directory", fallback=CoreConfig.defaults["core"]["temporary_directory"] - ) + self.temporary_directory = config.get("core", "temporary_directory") CoreConfig.checkDirectory(self.temporary_directory, "temporary_directory") - self.web_port = config.getint("web", "port", fallback=CoreConfig.defaults["web"]["port"]) + self.web_port = config.getint("web", "port") + self.aprs_symbols_path = config.get("aprs", "symbols_path") @staticmethod def checkDirectory(dir, key): @@ -102,6 +105,9 @@ class CoreConfig(object): def get_temporary_directory(self): return self.temporary_directory + def get_aprs_symbols_path(self): + return self.aprs_symbols_path + class Config: sharedConfig = None diff --git a/owrx/controllers/assets.py b/owrx/controllers/assets.py index 803dec9..88338b5 100644 --- a/owrx/controllers/assets.py +++ b/owrx/controllers/assets.py @@ -1,5 +1,5 @@ from . import Controller -from owrx.config import Config +from owrx.config import CoreConfig from datetime import datetime, timezone import mimetypes import os @@ -95,8 +95,7 @@ class OwrxAssetsController(AssetsController): 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 From 25db7c716d208c3bc5526fa3b29f2ac87ad3ae40 Mon Sep 17 00:00:00 2001 From: Jakob Ketterl Date: Sun, 7 Feb 2021 17:36:44 +0100 Subject: [PATCH 069/577] change heading --- owrx/controllers/settings.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/owrx/controllers/settings.py b/owrx/controllers/settings.py index 805af5b..fc3a3f5 100644 --- a/owrx/controllers/settings.py +++ b/owrx/controllers/settings.py @@ -87,7 +87,7 @@ class SdrSettingsController(AdminController): class GeneralSettingsController(AdminController): sections = [ Section( - "General settings", + "Receiver information", TextInput("receiver_name", "Receiver name"), TextInput("receiver_location", "Receiver location"), NumberInput( From 8de70cd52365fad1992892e56d6eba96adce1667 Mon Sep 17 00:00:00 2001 From: Jakob Ketterl Date: Sun, 7 Feb 2021 18:04:46 +0100 Subject: [PATCH 070/577] add receiver_keys to the settings page --- owrx/controllers/settings.py | 12 +++++++++++- owrx/form/__init__.py | 26 +++++++++++++++++++------- 2 files changed, 30 insertions(+), 8 deletions(-) diff --git a/owrx/controllers/settings.py b/owrx/controllers/settings.py index fc3a3f5..0ff8ba5 100644 --- a/owrx/controllers/settings.py +++ b/owrx/controllers/settings.py @@ -12,6 +12,7 @@ from owrx.form import ( Option, ServicesCheckboxInput, Js8ProfileCheckboxInput, + ReceiverKeysInput, ) from urllib.parse import quote import json @@ -100,6 +101,15 @@ class GeneralSettingsController(AdminController): TextInput("photo_title", "Photo title"), TextAreaInput("photo_desc", "Photo description"), ), + Section( + "Receiver listings", + ReceiverKeysInput( + "receiver_keys", + "Receiver keys", + infotext="Put the keys you receive on listing sites (e.g. " + + 'Receiverbook) here, one per line', + ), + ), Section( "Waterfall settings", NumberInput( @@ -294,4 +304,4 @@ class GeneralSettingsController(AdminController): for k, v in data.items(): config[k] = v Config.store() - self.send_redirect("/admin") + self.send_redirect("/generalsettings") diff --git a/owrx/form/__init__.py b/owrx/form/__init__.py index 8b1fd9f..89c343d 100644 --- a/owrx/form/__init__.py +++ b/owrx/form/__init__.py @@ -30,11 +30,17 @@ class Input(ABC): def render_input(self, value): pass + def convert_to_form(self, value): + return value + + def convert_from_form(self, value): + return value + def render(self, config): - return self.bootstrap_decorate(self.render_input(config[self.id])) + return self.bootstrap_decorate(self.render_input(self.convert_to_form(config[self.id]))) def parse(self, data): - return {self.id: data[self.id][0]} if self.id in data else {} + return {self.id: self.convert_from_form(data[self.id][0])} if self.id in data else {} class TextInput(Input): @@ -62,19 +68,16 @@ class NumberInput(Input): step='step="{0}"'.format(self.step) if self.step else "", ) - def convert_value(self, v): + def convert_from_form(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): + def convert_from_form(self, v): return float(v) @@ -118,6 +121,15 @@ class TextAreaInput(Input): ) +class ReceiverKeysInput(TextAreaInput): + def convert_to_form(self, value): + return "\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")] + + class CheckboxInput(Input): def __init__(self, id, label, checkboxText, infotext=None): super().__init__(id, label, infotext=infotext) From b60a8a1af0256cbbd1385c18270529bf61aa8a15 Mon Sep 17 00:00:00 2001 From: Jakob Ketterl Date: Sun, 7 Feb 2021 18:21:57 +0100 Subject: [PATCH 071/577] add the ability to put append a unit to inputs --- owrx/controllers/settings.py | 15 +++++++++------ owrx/form/__init__.py | 20 ++++++++++++++++++-- 2 files changed, 27 insertions(+), 8 deletions(-) diff --git a/owrx/controllers/settings.py b/owrx/controllers/settings.py index 0ff8ba5..0c9f00e 100644 --- a/owrx/controllers/settings.py +++ b/owrx/controllers/settings.py @@ -95,6 +95,7 @@ class GeneralSettingsController(AdminController): "receiver_asl", "Receiver elevation", infotext="Elevation in meters above mean see level", + append="m MSL", ), TextInput("receiver_admin", "Receiver admin"), LocationInput("receiver_gps", "Receiver coordinates"), @@ -114,19 +115,20 @@ class GeneralSettingsController(AdminController): "Waterfall settings", NumberInput( "fft_fps", - "FFT frames per second", + "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"), + 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.", ), - NumberInput("waterfall_min_level", "Lowest waterfall level"), - NumberInput("waterfall_max_level", "Highest waterfall level"), + NumberInput("waterfall_min_level", "Lowest waterfall level", append="dBFS"), + NumberInput("waterfall_max_level", "Highest waterfall level", append="dBFS"), ), Section( "Compression", @@ -150,7 +152,7 @@ class GeneralSettingsController(AdminController): Section( "Digimodes", CheckboxInput("digimodes_enable", "", checkboxText="Enable Digimodes"), - NumberInput("digimodes_fft_size", "Digimodes FFT size"), + NumberInput("digimodes_fft_size", "Digimodes FFT size", append="bins"), ), Section( "Digital voice", @@ -199,7 +201,8 @@ class GeneralSettingsController(AdminController): NumberInput( "map_position_retention_time", "Map retention time", - infotext="Unit is seconds
Specifies how log markers / grids will remain visible on the map", + infotext="Specifies how log markers / grids will remain visible on the map", + append="s", ), ), Section( diff --git a/owrx/form/__init__.py b/owrx/form/__init__.py index 89c343d..8ed4099 100644 --- a/owrx/form/__init__.py +++ b/owrx/form/__init__.py @@ -53,19 +53,35 @@ class TextInput(Input): class NumberInput(Input): - def __init__(self, id, label, infotext=None): + def __init__(self, id, label, infotext=None, append=""): super().__init__(id, label, infotext) self.step = None + self.append = append def render_input(self, value): + if self.append: + append = """ +
+ {append} +
+ """.format( + append=self.append + ) + else: + append = "" + return """ - +
+ + {append} +
""".format( id=self.id, label=self.label, classes=self.input_classes(), value=value, step='step="{0}"'.format(self.step) if self.step else "", + append=append, ) def convert_from_form(self, v): From 689cd49694d4ddd5ac601de96367c8638396aa8b Mon Sep 17 00:00:00 2001 From: Jakob Ketterl Date: Sun, 7 Feb 2021 18:23:17 +0100 Subject: [PATCH 072/577] drop "experimental pipe settings" (will become unavailable in the future) --- owrx/controllers/settings.py | 21 --------------------- 1 file changed, 21 deletions(-) diff --git a/owrx/controllers/settings.py b/owrx/controllers/settings.py index 0c9f00e..150745f 100644 --- a/owrx/controllers/settings.py +++ b/owrx/controllers/settings.py @@ -168,27 +168,6 @@ class GeneralSettingsController(AdminController): 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( From 47ecc26f28b23f07eb75e45c68392e549a3633d8 Mon Sep 17 00:00:00 2001 From: Jakob Ketterl Date: Sun, 7 Feb 2021 21:36:08 +0100 Subject: [PATCH 073/577] add a wfm tau dropdown to the web settings --- owrx/controllers/settings.py | 18 ++++++- owrx/form/__init__.py | 99 ++++++++++++++++++++++++++++-------- 2 files changed, 95 insertions(+), 22 deletions(-) diff --git a/owrx/controllers/settings.py b/owrx/controllers/settings.py index 150745f..a787a2f 100644 --- a/owrx/controllers/settings.py +++ b/owrx/controllers/settings.py @@ -12,7 +12,9 @@ from owrx.form import ( Option, ServicesCheckboxInput, Js8ProfileCheckboxInput, - ReceiverKeysInput, + ReceiverKeysConverter, + WfmTauValues, + WfmTauConverter ) from urllib.parse import quote import json @@ -104,9 +106,10 @@ class GeneralSettingsController(AdminController): ), Section( "Receiver listings", - ReceiverKeysInput( + TextInput( "receiver_keys", "Receiver keys", + converter=ReceiverKeysConverter(), infotext="Put the keys you receive on listing sites (e.g. " + 'Receiverbook) here, one per line', ), @@ -154,6 +157,17 @@ class GeneralSettingsController(AdminController): CheckboxInput("digimodes_enable", "", checkboxText="Enable Digimodes"), NumberInput("digimodes_fft_size", "Digimodes FFT size", append="bins"), ), + Section( + "Wideband FM settings", + DropdownInput( + "wfm_deemphasis_tau", + "Tau setting for WFM (broadcast FM) deemphasis", + options=[o.toOption() for o in WfmTauValues], + converter=WfmTauConverter(), + infotext='See ' + + "this Wikipedia article for more information", + ), + ), Section( "Digital voice", NumberInput( diff --git a/owrx/form/__init__.py b/owrx/form/__init__.py index 8ed4099..37f6537 100644 --- a/owrx/form/__init__.py +++ b/owrx/form/__init__.py @@ -1,13 +1,36 @@ from abc import ABC, abstractmethod from owrx.modes import Modes from owrx.config import Config +from enum import Enum + + +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 Input(ABC): - def __init__(self, id, label, infotext=None): + def __init__(self, id, label, infotext=None, converter: Converter = None): self.id = id self.label = label self.infotext = infotext + self.converter = self.defaultConverter() if converter is None else converter + + def defaultConverter(self): + return NullConverter() def bootstrap_decorate(self, input): infotext = "{text}".format(text=self.infotext) if self.infotext else "" @@ -30,17 +53,11 @@ class Input(ABC): def render_input(self, value): pass - def convert_to_form(self, value): - return value - - def convert_from_form(self, value): - return value - def render(self, config): - return self.bootstrap_decorate(self.render_input(self.convert_to_form(config[self.id]))) + return self.bootstrap_decorate(self.render_input(self.converter.convert_to_form(config[self.id]))) def parse(self, data): - return {self.id: self.convert_from_form(data[self.id][0])} if self.id in data else {} + return {self.id: self.converter.convert_from_form(data[self.id][0])} if self.id in data else {} class TextInput(Input): @@ -52,12 +69,23 @@ class TextInput(Input): ) +class IntConverter(Converter): + def convert_to_form(self, value): + return str(value) + + def convert_from_form(self, value): + return int(value) + + class NumberInput(Input): - def __init__(self, id, label, infotext=None, append=""): - super().__init__(id, label, infotext) + def __init__(self, id, label, infotext=None, append="", converter: Converter = None): + super().__init__(id, label, infotext, converter=converter) self.step = None self.append = append + def defaultConverter(self): + return IntConverter() + def render_input(self, value): if self.append: append = """ @@ -84,17 +112,22 @@ class NumberInput(Input): append=append, ) - def convert_from_form(self, v): - return int(v) + +class FloatConverter(Converter): + def convert_to_form(self, value): + return str(value) + + def convert_from_form(self, value): + return float(value) class FloatInput(NumberInput): - def __init__(self, id, label, infotext=None): - super().__init__(id, label, infotext) + def __init__(self, id, label, infotext=None, converter: Converter = None): + super().__init__(id, label, infotext, converter=converter) self.step = "any" - def convert_from_form(self, v): - return float(v) + def defaultConverter(self): + return FloatConverter() class LocationInput(Input): @@ -137,7 +170,7 @@ class TextAreaInput(Input): ) -class ReceiverKeysInput(TextAreaInput): +class ReceiverKeysConverter(Converter): def convert_to_form(self, value): return "\n".join(value) @@ -235,8 +268,8 @@ class Js8ProfileCheckboxInput(MultiCheckboxInput): class DropdownInput(Input): - def __init__(self, id, label, options, infotext=None): - super().__init__(id, label, infotext=infotext) + def __init__(self, id, label, options, infotext=None, converter: Converter = None): + super().__init__(id, label, infotext=infotext, converter=converter) self.options = options def render_input(self, value): @@ -258,3 +291,29 @@ class DropdownInput(Input): for o in self.options ] return "".join(options) + + +class WfmTauValues(Enum): + TAU_50_MICRO = (50, "most regions") + TAU_75_MICRO = (75, "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(self.value, self.description) + + def toOption(self): + return Option(self.name, str(self)) + + +class WfmTauConverter(Converter): + def convert_to_form(self, value): + return WfmTauValues(value * 1e6).name + + def convert_from_form(self, value): + return WfmTauValues[value].value / 1e6 From d920540021cfa294ccc1f788271a8a0af2cf6b5f Mon Sep 17 00:00:00 2001 From: Jakob Ketterl Date: Sun, 7 Feb 2021 21:45:02 +0100 Subject: [PATCH 074/577] fix receiver_keys textarea --- owrx/controllers/settings.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/owrx/controllers/settings.py b/owrx/controllers/settings.py index a787a2f..72a291a 100644 --- a/owrx/controllers/settings.py +++ b/owrx/controllers/settings.py @@ -106,7 +106,7 @@ class GeneralSettingsController(AdminController): ), Section( "Receiver listings", - TextInput( + TextAreaInput( "receiver_keys", "Receiver keys", converter=ReceiverKeysConverter(), From ba3a68c3fa2e6e3df15739d931100da5f54af1bf Mon Sep 17 00:00:00 2001 From: Jakob Ketterl Date: Sun, 7 Feb 2021 22:09:06 +0100 Subject: [PATCH 075/577] a bit of styling for the settings --- htdocs/css/admin.css | 13 +++++++++++++ htdocs/css/features.css | 7 ------- htdocs/features.html | 1 - owrx/controllers/settings.py | 2 +- 4 files changed, 14 insertions(+), 9 deletions(-) delete mode 100644 htdocs/css/features.css diff --git a/htdocs/css/admin.css b/htdocs/css/admin.css index b6ebcea..1244728 100644 --- a/htdocs/css/admin.css +++ b/htdocs/css/admin.css @@ -12,3 +12,16 @@ .device { margin-top: 20px; } + +.settings-section { + margin-top: 3em; +} + +.settings-section h3 { + margin-bottom: 1em; +} + +h1 { + margin: 1em 0; + text-align: center; +} \ No newline at end of file diff --git a/htdocs/css/features.css b/htdocs/css/features.css deleted file mode 100644 index be41fef..0000000 --- a/htdocs/css/features.css +++ /dev/null @@ -1,7 +0,0 @@ -@import url("openwebrx-header.css"); -@import url("openwebrx-globals.css"); - -h1 { - text-align: center; - margin: 50px 0; -} diff --git a/htdocs/features.html b/htdocs/features.html index 90d156e..485e373 100644 --- a/htdocs/features.html +++ b/htdocs/features.html @@ -3,7 +3,6 @@ - diff --git a/owrx/controllers/settings.py b/owrx/controllers/settings.py index 72a291a..59c0b72 100644 --- a/owrx/controllers/settings.py +++ b/owrx/controllers/settings.py @@ -34,7 +34,7 @@ class Section(object): def render(self): return """ -
+

{title}

From 0517a593085835878d99d9d92a7ee4c06548fd67 Mon Sep 17 00:00:00 2001 From: Jakob Ketterl Date: Sun, 7 Feb 2021 22:36:03 +0100 Subject: [PATCH 076/577] fix login page layout --- htdocs/css/login.css | 10 ++++++++++ htdocs/login.html | 26 ++++++++++++++------------ 2 files changed, 24 insertions(+), 12 deletions(-) diff --git a/htdocs/css/login.css b/htdocs/css/login.css index 5e51dc9..ccd6c02 100644 --- a/htdocs/css/login.css +++ b/htdocs/css/login.css @@ -1,6 +1,16 @@ @import url("openwebrx-header.css"); @import url("openwebrx-globals.css"); +body { + display: flex; + flex-direction: column; +} + +.login-container { + flex: 1; + position: relative; +} + .login { position: absolute; left: 50%; diff --git a/htdocs/login.html b/htdocs/login.html index 4f4c554..6c05ef9 100644 --- a/htdocs/login.html +++ b/htdocs/login.html @@ -11,17 +11,19 @@ ${header} - """.format( id=self.id, label=self.label, url=self.cachebuster(self.getUrl()), classes=" ".join(self.getImgClasses()) From 1e72485425cb8b3f7a622ed2d0885c2481cf576f Mon Sep 17 00:00:00 2001 From: Jakob Ketterl Date: Wed, 10 Feb 2021 22:24:43 +0100 Subject: [PATCH 106/577] implement temporary file cleanup --- owrx/controllers/imageupload.py | 1 - owrx/controllers/settings.py | 8 ++++++-- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/owrx/controllers/imageupload.py b/owrx/controllers/imageupload.py index 8545020..2b51064 100644 --- a/owrx/controllers/imageupload.py +++ b/owrx/controllers/imageupload.py @@ -30,7 +30,6 @@ class ImageUploadController(AuthorizationMixin, AssetsController): # TODO: limit file size # TODO: check image mime type, if possible contents = self.get_body() - # TODO: clean up files after timeout or on shutdown with open(self.getFilePath(), 'wb') as f: f.write(contents) self.send_response(json.dumps({"uuid": self.uuid}), content_type="application/json") diff --git a/owrx/controllers/settings.py b/owrx/controllers/settings.py index f45d4da..27851be 100644 --- a/owrx/controllers/settings.py +++ b/owrx/controllers/settings.py @@ -27,6 +27,7 @@ import json import logging import shutil import os +from glob import glob logger = logging.getLogger(__name__) @@ -377,9 +378,12 @@ class GeneralSettingsController(AuthorizationMixin, WebpageController): if data[image_id] == "restore": os.unlink(data_file) elif data[image_id]: - filename = "{}-{}".format(image_id, data[image_id]) - shutil.copy(config.get_temporary_directory() + "/" + filename, data_file) + temporary_file = "{}/{}-{}".format(config.get_temporary_directory(), image_id, 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 processFormData(self): data = parse_qs(self.get_body().decode("utf-8"), keep_blank_values=True) From 64f827d235c2f3743433e4798688590807a5e84a Mon Sep 17 00:00:00 2001 From: Jakob Ketterl Date: Wed, 10 Feb 2021 22:25:43 +0100 Subject: [PATCH 107/577] loopify --- owrx/controllers/settings.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/owrx/controllers/settings.py b/owrx/controllers/settings.py index 27851be..680d301 100644 --- a/owrx/controllers/settings.py +++ b/owrx/controllers/settings.py @@ -389,8 +389,8 @@ class GeneralSettingsController(AuthorizationMixin, WebpageController): data = parse_qs(self.get_body().decode("utf-8"), keep_blank_values=True) data = {k: v for i in GeneralSettingsController.sections for k, v in i.parse(data).items()} # Image handling - self.handle_image(data, "receiver_avatar") - self.handle_image(data, "receiver_top_photo") + for img in ["receiver_avatar", "receiver_top_photo"]: + self.handle_image(data, img) config = Config.get() for k, v in data.items(): if v is None: From 0fd172edc3db3ea7c37e0ba4bba8200ffdd69b49 Mon Sep 17 00:00:00 2001 From: Jakob Ketterl Date: Thu, 11 Feb 2021 00:20:17 +0100 Subject: [PATCH 108/577] check file contents; work with file extensions --- htdocs/lib/settings/ImageUpload.js | 4 +-- owrx/controllers/assets.py | 11 ++++---- owrx/controllers/imageupload.py | 43 ++++++++++++++++++++---------- owrx/controllers/settings.py | 18 ++++++++++--- 4 files changed, 51 insertions(+), 25 deletions(-) diff --git a/htdocs/lib/settings/ImageUpload.js b/htdocs/lib/settings/ImageUpload.js index 051267c..02edbce 100644 --- a/htdocs/lib/settings/ImageUpload.js +++ b/htdocs/lib/settings/ImageUpload.js @@ -24,8 +24,8 @@ $.fn.imageUpload = function() { processData: false, contentType: 'application/octet-stream', }).done(function(data){ - $input.val(data.uuid); - $img.prop('src', "/imageupload?id=" + id + "&uuid=" + data.uuid); + $input.val(data.file); + $img.prop('src', '/imageupload?file=' + data.file); }).always(function(){ $uploadButton.prop('disabled', false); }); diff --git a/owrx/controllers/assets.py b/owrx/controllers/assets.py index c0ee0c6..87b551a 100644 --- a/owrx/controllers/assets.py +++ b/owrx/controllers/assets.py @@ -78,7 +78,7 @@ class AssetsController(GzipMixin, ModificationAwareController, metaclass=ABCMeta f.close() if content_type is None: - (content_type, encoding) = mimetypes.MimeTypes().guess_type(self.getFilePath(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) @@ -96,9 +96,10 @@ class OwrxAssetsController(AssetsController): } if file in mappedFiles and ("mapped" not in self.request.query or self.request.query["mapped"][0] != "false"): config = CoreConfig() - user_file = config.get_data_directory() + "/" + mappedFiles[file] - if os.path.exists(user_file) and os.path.isfile(user_file): - return user_file + for ext in ["png", "jpg"]: + 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) @@ -168,7 +169,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): diff --git a/owrx/controllers/imageupload.py b/owrx/controllers/imageupload.py index 2b51064..30e71a5 100644 --- a/owrx/controllers/imageupload.py +++ b/owrx/controllers/imageupload.py @@ -8,28 +8,43 @@ import json class ImageUploadController(AuthorizationMixin, AssetsController): def __init__(self, handler, request, options): super().__init__(handler, request, options) - self.uuid = request.query["uuid"][0] if "uuid" in request.query else None - self.id = request.query["id"][0] if "id" in request.query else None + self.file = request.query["file"][0] if "file" in request.query else None def getFilePath(self, file=None): - if self.uuid is None: - raise FileNotFoundError("missing uuid") - if self.id is None: - raise FileNotFoundError("missing id") - return "{tmp}/{file}-{uuid}".format( + if self.file is None: + raise FileNotFoundError("missing filename") + return "{tmp}/{file}".format( tmp=CoreConfig().get_temporary_directory(), - file=self.id, - uuid=self.uuid, + 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 processImage(self): - self.uuid = uuid.uuid4().hex + if "id" not in self.request.query: + self.send_response("{}", content_type="application/json", code=400) # TODO: limit file size - # TODO: check image mime type, if possible contents = self.get_body() - with open(self.getFilePath(), 'wb') as f: - f.write(contents) - self.send_response(json.dumps({"uuid": self.uuid}), content_type="application/json") + filetype = None + if self._is_png(contents): + filetype = "png" + if self._is_jpg(contents): + filetype = "jpg" + if filetype is None: + self.send_response("{}", content_type="application/json", code=400) + else: + self.file = "{id}-{uuid}.{ext}".format( + id=self.request.query["id"][0], + uuid=uuid.uuid4().hex, + ext=filetype, + ) + with open(self.getFilePath(), "wb") as f: + f.write(contents) + self.send_response(json.dumps({"file": self.file}), content_type="application/json") diff --git a/owrx/controllers/settings.py b/owrx/controllers/settings.py index 680d301..c9050bd 100644 --- a/owrx/controllers/settings.py +++ b/owrx/controllers/settings.py @@ -374,12 +374,22 @@ class GeneralSettingsController(AuthorizationMixin, WebpageController): def handle_image(self, data, image_id): if image_id in data: config = CoreConfig() - data_file = config.get_data_directory() + "/" + image_id if data[image_id] == "restore": - os.unlink(data_file) + # remove all possible file extensions + for ext in ["png", "jpg"]: + try: + os.unlink("{}/{}.{}".format(config.get_data_directory(), image_id, ext)) + except FileNotFoundError: + pass elif data[image_id]: - temporary_file = "{}/{}-{}".format(config.get_temporary_directory(), image_id, data[image_id]) - shutil.copy(temporary_file, data_file) + if not data[image_id].startswith(image_id): + logger.warning("invalid file name: %s", data[image_id]) + else: + # get file extension (luckily, all options are three characters long) + ext = data[image_id][-3:] + 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)): From 0cf67d5e2c0086075c44a43789f668b76b1fc250 Mon Sep 17 00:00:00 2001 From: Jakob Ketterl Date: Thu, 11 Feb 2021 00:24:02 +0100 Subject: [PATCH 109/577] don't use recursive (lintian) --- debian/openwebrx.postinst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/debian/openwebrx.postinst b/debian/openwebrx.postinst index 7220378..3f86ea7 100755 --- a/debian/openwebrx.postinst +++ b/debian/openwebrx.postinst @@ -11,7 +11,7 @@ case "$1" in # create OpenWebRX data directory and set the correct permissions if [ ! -d "${OWRX_DATADIR}" ] && [ ! -L "${OWRX_DATADIR}" ]; then mkdir "${OWRX_DATADIR}"; fi - chown -R "${OWRX_USER}". ${OWRX_DATADIR} + chown "${OWRX_USER}". ${OWRX_DATADIR} # create initial openwebrx user openwebrx-admin adduser --noninteractive --silent --user admin From fdfaed005b4975c3aeb5ded31f3313fdd07d298b Mon Sep 17 00:00:00 2001 From: Jakob Ketterl Date: Thu, 11 Feb 2021 00:25:31 +0100 Subject: [PATCH 110/577] add data directory volume definition (for whatever it's worth) --- docker/Dockerfiles/Dockerfile-base | 1 + 1 file changed, 1 insertion(+) diff --git a/docker/Dockerfiles/Dockerfile-base b/docker/Dockerfiles/Dockerfile-base index c338dc0..2658727 100644 --- a/docker/Dockerfiles/Dockerfile-base +++ b/docker/Dockerfiles/Dockerfile-base @@ -18,6 +18,7 @@ ENTRYPOINT ["/init"] WORKDIR /opt/openwebrx VOLUME /etc/openwebrx +VOLUME /var/lib/openwebrx CMD [ "/opt/openwebrx/docker/scripts/run.sh" ] From 1cc4b13ba62552e4e788ff2f3e4907ab6380eef1 Mon Sep 17 00:00:00 2001 From: Jakob Ketterl Date: Thu, 11 Feb 2021 00:29:31 +0100 Subject: [PATCH 111/577] add newline (lintian) --- debian/control | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/debian/control b/debian/control index a6f8eb1..00eb3f2 100644 --- a/debian/control +++ b/debian/control @@ -13,4 +13,4 @@ Architecture: all Depends: adduser, python3 (>= 3.5), python3-pkg-resources, csdr (>= 0.17), netcat, owrx-connector (>= 0.4), soapysdr-tools, python3-js8py (>= 0.1), ${python3:Depends}, ${misc:Depends} Recommends: digiham (>= 0.3), dsd (>= 1.7), sox, direwolf (>= 1.4), wsjtx, runds-connector, hpsdrconnector, aprs-symbols, m17-demod Description: multi-user web sdr - Open source, multi-user SDR receiver with a web interface \ No newline at end of file + Open source, multi-user SDR receiver with a web interface From e9266113074041592e8174b0213b406a9930cd58 Mon Sep 17 00:00:00 2001 From: Jakob Ketterl Date: Thu, 11 Feb 2021 13:55:06 +0100 Subject: [PATCH 112/577] break config module apart --- owrx/{config.py => config/__init__.py} | 52 +++----------------------- owrx/config/error.py | 7 ++++ owrx/config/migration.py | 36 ++++++++++++++++++ setup.py | 1 + 4 files changed, 49 insertions(+), 47 deletions(-) rename owrx/{config.py => config/__init__.py} (76%) create mode 100644 owrx/config/error.py create mode 100644 owrx/config/migration.py diff --git a/owrx/config.py b/owrx/config/__init__.py similarity index 76% rename from owrx/config.py rename to owrx/config/__init__.py index b634477..d3ef341 100644 --- a/owrx/config.py +++ b/owrx/config/__init__.py @@ -1,59 +1,17 @@ +from configparser import ConfigParser from owrx.property import PropertyLayer import importlib.util import os -import logging import json from glob import glob -from abc import ABC, abstractmethod -from configparser import ConfigParser +from owrx.config.error import ConfigError, ConfigNotFoundException +from owrx.config.migration import ConfigMigratorVersion1, ConfigMigratorVersion2 + +import logging logger = logging.getLogger(__name__) -class ConfigNotFoundException(Exception): - pass - - -class ConfigError(Exception): - def __init__(self, key, message): - super().__init__("Configuration Error (key: {0}): {1}".format(key, message)) - - -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 - 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 CoreConfig(object): defaults = { "core": { diff --git a/owrx/config/error.py b/owrx/config/error.py new file mode 100644 index 0000000..9a77bcc --- /dev/null +++ b/owrx/config/error.py @@ -0,0 +1,7 @@ +class ConfigNotFoundException(Exception): + pass + + +class ConfigError(Exception): + def __init__(self, key, message): + super().__init__("Configuration Error (key: {0}): {1}".format(key, message)) diff --git a/owrx/config/migration.py b/owrx/config/migration.py new file mode 100644 index 0000000..39ded6d --- /dev/null +++ b/owrx/config/migration.py @@ -0,0 +1,36 @@ +from abc import ABC, abstractmethod + + +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 + 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 diff --git a/setup.py b/setup.py index 695e658..f15522a 100644 --- a/setup.py +++ b/setup.py @@ -20,6 +20,7 @@ setup( "owrx.controllers", "owrx.property", "owrx.form", + "owrx.config", "csdr", "htdocs", "owrxadmin", From f23fa59ac31fc3a8ea6aaee756849acfa9325ac3 Mon Sep 17 00:00:00 2001 From: Jakob Ketterl Date: Thu, 11 Feb 2021 19:31:44 +0100 Subject: [PATCH 113/577] implement config layering --- owrx/__main__.py | 3 +- owrx/audio.py | 3 +- owrx/config/__init__.py | 149 ++---------- owrx/config/classic.py | 30 +++ owrx/config/core.py | 59 +++++ owrx/config/defaults.py | 323 +++++++++++++++++++++++++++ owrx/config/dynamic.py | 25 +++ owrx/config/error.py | 4 - owrx/config/migration.py | 27 ++- owrx/controllers/assets.py | 2 +- owrx/controllers/imageupload.py | 2 +- owrx/controllers/settings.py | 5 +- owrx/dsp.py | 2 +- owrx/feature.py | 2 +- owrx/fft.py | 3 +- owrx/property/__init__.py | 8 +- owrx/service/__init__.py | 3 +- owrx/users.py | 2 +- test/property/test_property_layer.py | 9 + test/property/test_property_stack.py | 10 + 20 files changed, 524 insertions(+), 147 deletions(-) create mode 100644 owrx/config/classic.py create mode 100644 owrx/config/core.py create mode 100644 owrx/config/defaults.py create mode 100644 owrx/config/dynamic.py diff --git a/owrx/__main__.py b/owrx/__main__.py index c8d2dd2..1bc62c6 100644 --- a/owrx/__main__.py +++ b/owrx/__main__.py @@ -1,6 +1,7 @@ from http.server import HTTPServer from owrx.http import RequestHandler -from owrx.config import Config, CoreConfig +from owrx.config.core import CoreConfig +from owrx.config import Config from owrx.feature import FeatureDetector from owrx.sdr import SdrService from socketserver import ThreadingMixIn diff --git a/owrx/audio.py b/owrx/audio.py index 092b55d..ef77b76 100644 --- a/owrx/audio.py +++ b/owrx/audio.py @@ -1,5 +1,6 @@ from abc import ABC, ABCMeta, abstractmethod -from owrx.config import Config, CoreConfig +from owrx.config.core import CoreConfig +from owrx.config import Config from owrx.metrics import Metrics, CounterMetric, DirectMetric import threading import wave diff --git a/owrx/config/__init__.py b/owrx/config/__init__.py index d3ef341..46db653 100644 --- a/owrx/config/__init__.py +++ b/owrx/config/__init__.py @@ -1,129 +1,32 @@ -from configparser import ConfigParser -from owrx.property import PropertyLayer -import importlib.util -import os -import json -from glob import glob -from owrx.config.error import ConfigError, ConfigNotFoundException -from owrx.config.migration import ConfigMigratorVersion1, ConfigMigratorVersion2 - -import logging - -logger = logging.getLogger(__name__) +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 CoreConfig(object): - defaults = { - "core": { - "data_directory": "/var/lib/openwebrx", - "temporary_directory": "/tmp", - }, - "web": { - "port": 8073, - }, - "aprs": { - "symbols_path": "/usr/share/aprs-symbols/png" - } - } +class Config(PropertyStack): + sharedConfig = None 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 - - -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 _getSettingsFile(): - coreConfig = CoreConfig() - return "{data_directory}/settings.json".format(data_directory=coreConfig.get_data_directory()) - - @staticmethod - def _loadConfig(): - for file in [Config._getSettingsFile(), "/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!") + 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._migrate(Config._loadConfig()) + Config.sharedConfig = Config() return Config.sharedConfig - @staticmethod - def store(): - with open(Config._getSettingsFile(), "w") as file: - json.dump(Config.get().__dict__(), file, indent=4) + def store(self): + self.storableConfig.store() @staticmethod def validateConfig(): @@ -131,14 +34,6 @@ class Config: # just basic loading verification Config.get() - @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 + def __setitem__(self, key, value): + # in the config, all writes go to the json layer + return self.storableConfig.__setitem__(key, value) diff --git a/owrx/config/classic.py b/owrx/config/classic.py new file mode 100644 index 0000000..ba0fcbb --- /dev/null +++ b/owrx/config/classic.py @@ -0,0 +1,30 @@ +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 + + @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 diff --git a/owrx/config/core.py b/owrx/config/core.py new file mode 100644 index 0000000..e22f004 --- /dev/null +++ b/owrx/config/core.py @@ -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 diff --git a/owrx/config/defaults.py b/owrx/config/defaults.py new file mode 100644 index 0000000..a39ab67 --- /dev/null +++ b/owrx/config/defaults.py @@ -0,0 +1,323 @@ +from owrx.property import PropertyLayer + + +defaultConfig = PropertyLayer( + version=3, + max_clients=20, + receiver_name="[Callsign]", + receiver_location="Budapest, Hungary", + receiver_asl=200, + receiver_admin="example@example.com", + receiver_gps={"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_enable=True, + digimodes_fft_size=2048, + digital_voice_unvoiced_quality=1, + digital_voice_dmr_id_lookup=True, + # sdrs=... + waterfall_colors=[ + 0x30123B, + 0x311542, + 0x33184A, + 0x341B51, + 0x351E58, + 0x36215F, + 0x372466, + 0x38266C, + 0x392973, + 0x3A2C79, + 0x3B2F80, + 0x3C3286, + 0x3D358B, + 0x3E3891, + 0x3E3A97, + 0x3F3D9C, + 0x4040A2, + 0x4043A7, + 0x4146AC, + 0x4248B1, + 0x424BB6, + 0x434EBA, + 0x4351BF, + 0x4453C3, + 0x4456C7, + 0x4559CB, + 0x455BCF, + 0x455ED3, + 0x4561D7, + 0x4663DA, + 0x4666DD, + 0x4669E1, + 0x466BE4, + 0x466EE7, + 0x4671E9, + 0x4673EC, + 0x4676EE, + 0x4678F1, + 0x467BF3, + 0x467DF5, + 0x4680F7, + 0x4682F9, + 0x4685FA, + 0x4587FC, + 0x458AFD, + 0x448CFE, + 0x448FFE, + 0x4391FF, + 0x4294FF, + 0x4196FF, + 0x3F99FF, + 0x3E9BFF, + 0x3D9EFE, + 0x3BA1FD, + 0x3AA3FD, + 0x38A6FB, + 0x36A8FA, + 0x35ABF9, + 0x33ADF7, + 0x31B0F6, + 0x2FB2F4, + 0x2DB5F2, + 0x2CB7F0, + 0x2AB9EE, + 0x28BCEC, + 0x26BEEA, + 0x25C0E7, + 0x23C3E5, + 0x21C5E2, + 0x20C7E0, + 0x1FC9DD, + 0x1DCCDB, + 0x1CCED8, + 0x1BD0D5, + 0x1AD2D3, + 0x19D4D0, + 0x18D6CD, + 0x18D8CB, + 0x18DAC8, + 0x17DBC5, + 0x17DDC3, + 0x17DFC0, + 0x18E0BE, + 0x18E2BB, + 0x19E3B9, + 0x1AE5B7, + 0x1BE6B4, + 0x1DE8B2, + 0x1EE9AF, + 0x20EAAD, + 0x22ECAA, + 0x24EDA7, + 0x27EEA4, + 0x29EFA1, + 0x2CF09E, + 0x2FF19B, + 0x32F298, + 0x35F394, + 0x38F491, + 0x3CF58E, + 0x3FF68B, + 0x43F787, + 0x46F884, + 0x4AF980, + 0x4EFA7D, + 0x51FA79, + 0x55FB76, + 0x59FC73, + 0x5DFC6F, + 0x61FD6C, + 0x65FD69, + 0x69FE65, + 0x6DFE62, + 0x71FE5F, + 0x75FF5C, + 0x79FF59, + 0x7DFF56, + 0x80FF53, + 0x84FF50, + 0x88FF4E, + 0x8BFF4B, + 0x8FFF49, + 0x92FF46, + 0x96FF44, + 0x99FF42, + 0x9CFE40, + 0x9FFE3E, + 0xA2FD3D, + 0xA4FD3B, + 0xA7FC3A, + 0xAAFC39, + 0xACFB38, + 0xAFFA37, + 0xB1F936, + 0xB4F835, + 0xB7F835, + 0xB9F634, + 0xBCF534, + 0xBFF434, + 0xC1F334, + 0xC4F233, + 0xC6F033, + 0xC9EF34, + 0xCBEE34, + 0xCEEC34, + 0xD0EB34, + 0xD2E934, + 0xD5E835, + 0xD7E635, + 0xD9E435, + 0xDBE236, + 0xDDE136, + 0xE0DF37, + 0xE2DD37, + 0xE4DB38, + 0xE6D938, + 0xE7D738, + 0xE9D539, + 0xEBD339, + 0xEDD139, + 0xEECF3A, + 0xF0CD3A, + 0xF1CB3A, + 0xF3C93A, + 0xF4C73A, + 0xF5C53A, + 0xF7C33A, + 0xF8C13A, + 0xF9BF39, + 0xFABD39, + 0xFABA38, + 0xFBB838, + 0xFCB637, + 0xFCB436, + 0xFDB135, + 0xFDAF35, + 0xFEAC34, + 0xFEA933, + 0xFEA732, + 0xFEA431, + 0xFFA12F, + 0xFF9E2E, + 0xFF9C2D, + 0xFF992C, + 0xFE962B, + 0xFE932A, + 0xFE9028, + 0xFE8D27, + 0xFD8A26, + 0xFD8724, + 0xFC8423, + 0xFC8122, + 0xFB7E20, + 0xFB7B1F, + 0xFA781E, + 0xF9751C, + 0xF8721B, + 0xF86F1A, + 0xF76C19, + 0xF66917, + 0xF56616, + 0xF46315, + 0xF36014, + 0xF25D13, + 0xF05B11, + 0xEF5810, + 0xEE550F, + 0xED530E, + 0xEB500E, + 0xEA4E0D, + 0xE94B0C, + 0xE7490B, + 0xE6470A, + 0xE4450A, + 0xE34209, + 0xE14009, + 0xDF3E08, + 0xDE3C07, + 0xDC3A07, + 0xDA3806, + 0xD83606, + 0xD63405, + 0xD43205, + 0xD23105, + 0xD02F04, + 0xCE2D04, + 0xCC2B03, + 0xCA2903, + 0xC82803, + 0xC62602, + 0xC32402, + 0xC12302, + 0xBF2102, + 0xBC1F01, + 0xBA1E01, + 0xB71C01, + 0xB41B01, + 0xB21901, + 0xAF1801, + 0xAC1601, + 0xAA1501, + 0xA71401, + 0xA41201, + 0xA11101, + 0x9E1001, + 0x9B0F01, + 0x980D01, + 0x950C01, + 0x920B01, + 0x8E0A01, + 0x8B0901, + 0x880801, + 0x850701, + 0x810602, + 0x7E0502, + 0x7A0402, + ], + waterfall_min_level=-88, + waterfall_max_level=-20, + waterfall_auto_level_margin={"min": 3, "max": 10, "min_range": 50}, + frequency_display_precision=4, + squelch_auto_margin=10, + # TODO: deprecated. remove from code. + csdr_dynamic_bufsize=False, + # TODO: deprecated. remove from code. + csdr_print_bufsizes=False, + # TODO: deprecated. remove from code. + csdr_through=False, + nmux_memory=50, + 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={"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() diff --git a/owrx/config/dynamic.py b/owrx/config/dynamic.py new file mode 100644 index 0000000..72f73fd --- /dev/null +++ b/owrx/config/dynamic.py @@ -0,0 +1,25 @@ +from owrx.config.core import CoreConfig +from owrx.config.migration import Migrator +from owrx.property import PropertyLayer +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(): + self[k] = v + except FileNotFoundError: + pass + Migrator.migrate(self) + + @staticmethod + def _getSettingsFile(): + coreConfig = CoreConfig() + return "{data_directory}/settings.json".format(data_directory=coreConfig.get_data_directory()) + + def store(self): + with open(DynamicConfig._getSettingsFile(), "w") as file: + json.dump(self.__dict__(), file, indent=4) diff --git a/owrx/config/error.py b/owrx/config/error.py index 9a77bcc..19e1119 100644 --- a/owrx/config/error.py +++ b/owrx/config/error.py @@ -1,7 +1,3 @@ -class ConfigNotFoundException(Exception): - pass - - class ConfigError(Exception): def __init__(self, key, message): super().__init__("Configuration Error (key: {0}): {1}".format(key, message)) diff --git a/owrx/config/migration.py b/owrx/config/migration.py index 39ded6d..32528a6 100644 --- a/owrx/config/migration.py +++ b/owrx/config/migration.py @@ -1,5 +1,9 @@ from abc import ABC, abstractmethod +import logging + +logger = logging.getLogger(__name__) + class ConfigMigrator(ABC): @abstractmethod @@ -26,11 +30,30 @@ class ConfigMigratorVersion1(ConfigMigrator): 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 + + config["version"] = 3 + + +class Migrator(object): + currentVersion = 3 + migrators = { + 1: ConfigMigratorVersion1(), + 2: ConfigMigratorVersion2(), + } + + @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) diff --git a/owrx/controllers/assets.py b/owrx/controllers/assets.py index 87b551a..6362e79 100644 --- a/owrx/controllers/assets.py +++ b/owrx/controllers/assets.py @@ -1,5 +1,5 @@ from . import Controller -from owrx.config import CoreConfig +from owrx.config.core import CoreConfig from datetime import datetime, timezone import mimetypes import os diff --git a/owrx/controllers/imageupload.py b/owrx/controllers/imageupload.py index 30e71a5..b3f0100 100644 --- a/owrx/controllers/imageupload.py +++ b/owrx/controllers/imageupload.py @@ -1,6 +1,6 @@ from owrx.controllers.assets import AssetsController from owrx.controllers.admin import AuthorizationMixin -from owrx.config import CoreConfig +from owrx.config.core import CoreConfig import uuid import json diff --git a/owrx/controllers/settings.py b/owrx/controllers/settings.py index c9050bd..e247f14 100644 --- a/owrx/controllers/settings.py +++ b/owrx/controllers/settings.py @@ -1,6 +1,7 @@ from owrx.controllers.template import WebpageController from owrx.controllers.admin import AuthorizationMixin -from owrx.config import Config, CoreConfig +from owrx.config.core import CoreConfig +from owrx.config import Config from urllib.parse import parse_qs from owrx.form import ( TextInput, @@ -408,5 +409,5 @@ class GeneralSettingsController(AuthorizationMixin, WebpageController): del config[k] else: config[k] = v - Config.store() + config.store() self.send_redirect("/generalsettings") diff --git a/owrx/dsp.py b/owrx/dsp.py index 2b076d8..2b2bbc1 100644 --- a/owrx/dsp.py +++ b/owrx/dsp.py @@ -7,7 +7,7 @@ from owrx.source import SdrSource, SdrSourceEventClient from owrx.property import PropertyStack, PropertyLayer, PropertyValidator from owrx.property.validators import OrValidator, RegexValidator, BoolValidator from owrx.modes import Modes -from owrx.config import CoreConfig +from owrx.config.core import CoreConfig from csdr import csdr import threading import re diff --git a/owrx/feature.py b/owrx/feature.py index 0a50dc2..70b4c0a 100644 --- a/owrx/feature.py +++ b/owrx/feature.py @@ -4,7 +4,7 @@ from operator import and_ import re from distutils.version import LooseVersion import inspect -from owrx.config import CoreConfig +from owrx.config.core import CoreConfig import shlex import os from datetime import datetime, timedelta diff --git a/owrx/fft.py b/owrx/fft.py index c76d1ff..987c339 100644 --- a/owrx/fft.py +++ b/owrx/fft.py @@ -1,4 +1,5 @@ -from owrx.config import Config, CoreConfig +from owrx.config.core import CoreConfig +from owrx.config import Config from csdr import csdr import threading from owrx.source import SdrSource, SdrSourceEventClient diff --git a/owrx/property/__init__.py b/owrx/property/__init__.py index ace4d32..563c841 100644 --- a/owrx/property/__init__.py +++ b/owrx/property/__init__.py @@ -98,9 +98,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 @@ -311,7 +312,8 @@ 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()]) diff --git a/owrx/service/__init__.py b/owrx/service/__init__.py index 89b048a..3baa015 100644 --- a/owrx/service/__init__.py +++ b/owrx/service/__init__.py @@ -6,7 +6,8 @@ from csdr.csdr import dsp, output from owrx.wsjt import WsjtParser from owrx.aprs import AprsParser from owrx.js8 import Js8Parser -from owrx.config import Config, CoreConfig +from owrx.config.core import CoreConfig +from owrx.config import Config from owrx.source.resampler import Resampler from owrx.property import PropertyLayer from js8py import Js8Frame diff --git a/owrx/users.py b/owrx/users.py index 05857d1..d866585 100644 --- a/owrx/users.py +++ b/owrx/users.py @@ -1,5 +1,5 @@ from abc import ABC, abstractmethod -from owrx.config import CoreConfig +from owrx.config.core import CoreConfig from datetime import datetime, timezone import json import hashlib diff --git a/test/property/test_property_layer.py b/test/property/test_property_layer.py index 4b77e70..2d57fd9 100644 --- a/test/property/test_property_layer.py +++ b/test/property/test_property_layer.py @@ -4,6 +4,15 @@ from unittest.mock import Mock class PropertyLayerTest(TestCase): + def testCreationWithKwArgs(self): + pm = PropertyLayer(testkey="value") + self.assertEqual(pm["testkey"], "value") + + # this should be synonymous, so this is rather for illustration purposes + contents = {"testkey": "value"} + pm = PropertyLayer(**contents) + self.assertEqual(pm["testkey"], "value") + def testKeyIsset(self): pm = PropertyLayer() self.assertFalse("some_key" in pm) diff --git a/test/property/test_property_stack.py b/test/property/test_property_stack.py index 3711868..8eb5382 100644 --- a/test/property/test_property_stack.py +++ b/test/property/test_property_stack.py @@ -185,3 +185,13 @@ class PropertyStackTest(TestCase): stack.replaceLayer(0, second_layer) mock.method.assert_not_called() + + def testWritesToExpectedLayer(self): + om = PropertyStack() + low_pm = PropertyLayer() + high_pm = PropertyLayer() + low_pm["testkey"] = "low value" + om.addLayer(1, low_pm) + om.addLayer(0, high_pm) + om["testkey"] = "new value" + self.assertEqual(low_pm["testkey"], "new value") From a8c93fd8d1a26ca63aaab306d189c536268117a2 Mon Sep 17 00:00:00 2001 From: Jakob Ketterl Date: Thu, 11 Feb 2021 19:37:45 +0100 Subject: [PATCH 114/577] enable web config --- config_webrx.py | 5 ----- htdocs/include/header.include.html | 2 +- owrx/controllers/admin.py | 5 ----- owrx/controllers/template.py | 7 +------ 4 files changed, 2 insertions(+), 17 deletions(-) diff --git a/config_webrx.py b/config_webrx.py index 7ee98ee..354f2d8 100644 --- a/config_webrx.py +++ b/config_webrx.py @@ -375,8 +375,3 @@ pskreporter_callsign = "N0CALL" # in addition to these settings also make sure that receiver_gps contains your correct location wsprnet_enabled = False wsprnet_callsign = "N0CALL" - -# === Web admin settings === -# this feature is experimental at the moment. it should not be enabled on shared receivers since it allows remote -# changes to the receiver settings. enable for testing in controlled environment only. -# webadmin_enabled = False diff --git a/htdocs/include/header.include.html b/htdocs/include/header.include.html index 56aac5c..44e53a2 100644 --- a/htdocs/include/header.include.html +++ b/htdocs/include/header.include.html @@ -11,7 +11,7 @@

Log

Receiver

Map
- ${settingslink} +
Settings
diff --git a/owrx/controllers/admin.py b/owrx/controllers/admin.py index dd87a5c..2ed0093 100644 --- a/owrx/controllers/admin.py +++ b/owrx/controllers/admin.py @@ -1,5 +1,4 @@ from .session import SessionStorage -from owrx.config import Config from owrx.users import UserList from urllib import parse @@ -34,10 +33,6 @@ class AuthorizationMixin(object): 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.isAuthorized(): super().handle_request() else: diff --git a/owrx/controllers/template.py b/owrx/controllers/template.py index 6e02782..6746012 100644 --- a/owrx/controllers/template.py +++ b/owrx/controllers/template.py @@ -1,7 +1,6 @@ from . import Controller import pkg_resources from string import Template -from owrx.config import Config class TemplateController(Controller): @@ -20,11 +19,7 @@ class TemplateController(Controller): class WebpageController(TemplateController): def template_variables(self): - settingslink = "" - pm = Config.get() - if "webadmin_enabled" in pm and pm["webadmin_enabled"]: - settingslink = """
Settings
""" - header = self.render_template("include/header.include.html", settingslink=settingslink) + header = self.render_template("include/header.include.html") return {"header": header} From fb457ce9f19d74a90b226c5e615ec37346915cc6 Mon Sep 17 00:00:00 2001 From: Jakob Ketterl Date: Thu, 11 Feb 2021 19:42:23 +0100 Subject: [PATCH 115/577] comment all config keys that are now in the web config --- config_webrx.py | 102 ++++++++++++++++++++++++------------------------ 1 file changed, 51 insertions(+), 51 deletions(-) diff --git a/config_webrx.py b/config_webrx.py index 354f2d8..4d87b35 100644 --- a/config_webrx.py +++ b/config_webrx.py @@ -42,21 +42,21 @@ version = 3 max_clients = 20 # ==== Web GUI configuration ==== -receiver_name = "[Callsign]" -receiver_location = "Budapest, Hungary" -receiver_asl = 200 -receiver_admin = "example@example.com" -receiver_gps = {"lat": 47.000000, "lon": 19.000000} -photo_title = "Panorama of Budapest from Schönherz Zoltán Dormitory" +#receiver_name = "[Callsign]" +#receiver_location = "Budapest, Hungary" +#receiver_asl = 200 +#receiver_admin = "example@example.com" +#receiver_gps = {"lat": 47.000000, "lon": 19.000000} +#photo_title = "Panorama of Budapest from Schönherz Zoltán Dormitory" # photo_desc allows you to put pretty much any HTML you like into the receiver description. # The lines below should give you some examples of what's possible. -photo_desc = """ -You can add your own background photo and receiver information.
-Receiver is operated by: Receiver Operator
-Device: Receiver Device
-Antenna: Receiver Antenna
-Website: http://localhost -""" +#photo_desc = """ +#You can add your own background photo and receiver information.
+#Receiver is operated by: Receiver Operator
+#Device: Receiver Device
+#Antenna: Receiver Antenna
+#Website: http://localhost +#""" # ==== Public receiver listings ==== # You can publish your receiver on online receiver directories, like https://www.receiverbook.de @@ -64,7 +64,7 @@ Website: http://localhost # Please note that you not share your receiver keys publicly since anyone that obtains your receiver key can take over # your public listing. # Your receiver keys should be placed into this array: -receiver_keys = [] +#receiver_keys = [] # If you list your receiver on multiple sites, you can place all your keys into the array above, or you can append # keys to the arraylike this: # receiver_keys += ["my-receiver-key"] @@ -72,30 +72,30 @@ receiver_keys = [] # If you're not sure, simply copy & paste the code you received from your listing site below this line: # ==== DSP/RX settings ==== -fft_fps = 9 -fft_size = 4096 # Should be power of 2 -fft_voverlap_factor = ( - 0.3 # If fft_voverlap_factor is above 0, multiple FFTs will be used for creating a line on the diagram. -) +#fft_fps = 9 +#fft_size = 4096 # Should be power of 2 +#fft_voverlap_factor = ( +# 0.3 # If fft_voverlap_factor is above 0, multiple FFTs will be used for creating a line on the diagram. +#) -audio_compression = "adpcm" # valid values: "adpcm", "none" -fft_compression = "adpcm" # valid values: "adpcm", "none" +#audio_compression = "adpcm" # valid values: "adpcm", "none" +#fft_compression = "adpcm" # valid values: "adpcm", "none" # Tau setting for WFM (broadcast FM) deemphasis\ # Quote from wikipedia https://en.wikipedia.org/wiki/FM_broadcasting#Pre-emphasis_and_de-emphasis # "In most of the world a 50 µs time constant is used. In the Americas and South Korea, 75 µs is used" # Enable one of the following lines, depending on your location: # wfm_deemphasis_tau = 75e-6 # for US and South Korea -wfm_deemphasis_tau = 50e-6 # for the rest of the world +#wfm_deemphasis_tau = 50e-6 # for the rest of the world -digimodes_enable = True # Decoding digimodes come with higher CPU usage. -digimodes_fft_size = 2048 +#digimodes_enable = True # Decoding digimodes come with higher CPU usage. +#digimodes_fft_size = 2048 # determines the quality, and thus the cpu usage, for the ambe codec used by digital voice modes # if you're running on a Raspi (up to 3B+) you'll want to leave this on 1 -digital_voice_unvoiced_quality = 1 +#digital_voice_unvoiced_quality = 1 # enables lookup of DMR ids using the radioid api -digital_voice_dmr_id_lookup = True +#digital_voice_dmr_id_lookup = True """ Note: if you experience audio underruns while CPU usage is 100%, you can: @@ -256,8 +256,8 @@ waterfall_colors = [0x30123b, 0x311542, 0x33184a, 0x341b51, 0x351e58, 0x36215f, # waterfall_auto_level_margin = {"min": 20, "max": 30} ##For the old colors, you might also want to set [fft_voverlap_factor] to 0. -waterfall_min_level = -88 # in dB -waterfall_max_level = -20 +#waterfall_min_level = -88 # in dB +#waterfall_max_level = -20 waterfall_auto_level_margin = {"min": 3, "max": 10, "min_range": 50} # Note: When the auto waterfall level button is clicked, the following happens: @@ -270,12 +270,12 @@ waterfall_auto_level_margin = {"min": 3, "max": 10, "min_range": 50} # This setting allows you to modify the precision of the frequency displays in OpenWebRX. # Set this to the number of digits you would like to see: -frequency_display_precision = 4 +#frequency_display_precision = 4 # This setting tells the auto-squelch the offset to add to the current signal level to use as the new squelch level. # Lowering this setting will give you a more sensitive squelch, but it may also cause unwanted squelch openings when # using the auto squelch. -squelch_auto_margin = 10 # in dB +#squelch_auto_margin = 10 # in dB # === Experimental settings === # Warning! The settings below are very experimental. @@ -285,61 +285,61 @@ csdr_through = False # Setting this True will print out how much data is going nmux_memory = 50 # in megabytes. This sets the approximate size of the circular buffer used by nmux. -google_maps_api_key = "" +#google_maps_api_key = "" # how long should positions be visible on the map? # they will start fading out after half of that # in seconds; default: 2 hours -map_position_retention_time = 2 * 60 * 60 +#map_position_retention_time = 2 * 60 * 60 # decoder queue configuration # due to the nature of some operating modes (ft8, ft8, jt9, jt65, wspr and js8), the data is recorded for a given amount # of time (6 seconds up to 2 minutes) and decoded at the end. this can lead to very high peak loads. # to mitigate this, the recordings will be queued and processed in sequence. # the number of workers will limit the total amount of work (one worker will losely occupy one cpu / thread) -decoding_queue_workers = 2 +#decoding_queue_workers = 2 # the maximum queue length will cause decodes to be dumped if the workers cannot keep up # if you are running background services, make sure this number is high enough to accept the task influx during peaks # i.e. this should be higher than the number of decoding services running at the same time -decoding_queue_length = 10 +#decoding_queue_length = 10 # wsjt decoding depth will allow more results, but will also consume more cpu -wsjt_decoding_depth = 3 +#wsjt_decoding_depth = 3 # can also be set for each mode separately # jt65 seems to be somewhat prone to erroneous decodes, this setting handles that to some extent wsjt_decoding_depths = {"jt65": 1} # FST4 can be transmitted in different intervals. This setting determines which intervals will be decoded. # available values (in seconds): 15, 30, 60, 120, 300, 900, 1800 -fst4_enabled_intervals = [15, 30] +#fst4_enabled_intervals = [15, 30] # FST4W can be transmitted in different intervals. This setting determines which intervals will be decoded. # available values (in seconds): 120, 300, 900, 1800 -fst4w_enabled_intervals = [120, 300] +#fst4w_enabled_intervals = [120, 300] # Q65 allows many combinations of intervals and submodes. This setting determines which combinations will be decoded. # Please use the mode letter followed by the decode interval in seconds to specify the combinations. For example: -q65_enabled_combinations = ["A30", "E120", "C60"] +#q65_enabled_combinations = ["A30", "E120", "C60"] # JS8 comes in different speeds: normal, slow, fast, turbo. This setting controls which ones are enabled. -js8_enabled_profiles = ["normal", "slow"] +#js8_enabled_profiles = ["normal", "slow"] # JS8 decoding depth; higher value will get more results, but will also consume more cpu -js8_decoding_depth = 3 +#js8_decoding_depth = 3 # Enable background service for decoding digital data. You can find more information at: # https://github.com/jketterl/openwebrx/wiki/Background-decoding -services_enabled = False -services_decoders = ["ft8", "ft4", "wspr", "packet"] +#services_enabled = False +#services_decoders = ["ft8", "ft4", "wspr", "packet"] # === aprs igate settings === # If you want to share your APRS decodes with the aprs network, configure these settings accordingly. # Make sure that you have set services_enabled to true and customize services_decoders to your needs. -aprs_callsign = "N0CALL" -aprs_igate_enabled = False -aprs_igate_server = "euro.aprs2.net" -aprs_igate_password = "" +#aprs_callsign = "N0CALL" +#aprs_igate_enabled = False +#aprs_igate_server = "euro.aprs2.net" +#aprs_igate_password = "" # beacon uses the receiver_gps setting, so if you enable this, make sure the location is correct there -aprs_igate_beacon = False +#aprs_igate_beacon = False # Uncomment the following to customize gateway beacon details reported to the aprs network # Plese see Dire Wolf's documentation on PBEACON configuration for complete details: @@ -365,13 +365,13 @@ aprs_igate_beacon = False # === PSK Reporter settings === # enable this if you want to upload all ft8, ft4 etc spots to pskreporter.info # this also uses the receiver_gps setting from above, so make sure it contains a correct locator -pskreporter_enabled = False -pskreporter_callsign = "N0CALL" +#pskreporter_enabled = False +#pskreporter_callsign = "N0CALL" # optional antenna information, uncomment to enable #pskreporter_antenna_information = "Dipole" # === WSPRNet reporting settings # enable this if you want to upload WSPR spots to wsprnet.ort # in addition to these settings also make sure that receiver_gps contains your correct location -wsprnet_enabled = False -wsprnet_callsign = "N0CALL" +#wsprnet_enabled = False +#wsprnet_callsign = "N0CALL" From c3d459558a79ba0af0b93aab31793a5a73264d7a Mon Sep 17 00:00:00 2001 From: Jakob Ketterl Date: Thu, 11 Feb 2021 21:59:30 +0100 Subject: [PATCH 116/577] prevent accidental text selection --- htdocs/css/openwebrx.css | 1 + 1 file changed, 1 insertion(+) diff --git a/htdocs/css/openwebrx.css b/htdocs/css/openwebrx.css index ffd0419..30028b2 100644 --- a/htdocs/css/openwebrx.css +++ b/htdocs/css/openwebrx.css @@ -998,6 +998,7 @@ img.openwebrx-mirror-img .openwebrx-dmr-timeslot-panel * { cursor: pointer; + user-select: none; } .openwebrx-ysf-mode:not(:empty):before { From 690eed5d58e5559c4f268da97a48dc42a3ae51ba Mon Sep 17 00:00:00 2001 From: Jakob Ketterl Date: Thu, 11 Feb 2021 22:44:55 +0100 Subject: [PATCH 117/577] update changelog --- CHANGELOG.md | 8 ++++++++ debian/changelog | 9 +++++++++ 2 files changed, 17 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8d258e4..950947d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,14 @@ - Added support for demodulating M17 digital voice signals using m17-cxx-demod - New reporting infrastructure, allowing WSPR and FST4W spots to be sent to wsprnet.org - Add some basic filtering capabilities to the map +- New commmand-line tool `openwebrx-admin` that facilitates the administration of users +- Default bandwidth changes: + - "WFM" changed to 150kHz + - "Packet" (APRS) changed to 12.5kHz +- Configuration rework: + - System configuration separated + - Started replacing `config_webrx.py` with web configuration options + - Added upload of avatar and panorama image via web configuration - New devices supported: - HPSDR devices (Hermes Lite 2) - BBRF103 / RX666 / RX888 devices supported by libsddc diff --git a/debian/changelog b/debian/changelog index 549d7b8..97f51cb 100644 --- a/debian/changelog +++ b/debian/changelog @@ -10,6 +10,15 @@ openwebrx (0.21.0) UNRELEASED; urgency=low * New reporting infrastructure, allowing WSPR and FST4W spots to be sent to wsprnet.org * Add some basic filtering capabilities to the map + * New commmand-line tool `openwebrx-admin` that facilitates the + administration of users + * Default bandwidth changes: + - "WFM" changed to 150kHz + - "Packet" (APRS) changed to 12.5kHz + * Configuration rework: + - System configuration separated + - Started replacing `config_webrx.py` with web configuration options + - Added upload of avatar and panorama image via web configuration * New devices supported: - HPSDR devices (Hermes Lite 2) (`"type": "hpsdr"`) - BBRF103 / RX666 / RX888 devices supported by libsddc (`"type": "sddc"`) From aad757df36ee4d88a3cc80f75f5f610b3ac4299c Mon Sep 17 00:00:00 2001 From: Jakob Ketterl Date: Thu, 11 Feb 2021 22:51:50 +0100 Subject: [PATCH 118/577] remove experimental csdr settings --- config_webrx.py | 6 ------ csdr/csdr.py | 24 ++---------------------- owrx/config/defaults.py | 6 ------ owrx/dsp.py | 6 ------ owrx/fft.py | 6 ------ 5 files changed, 2 insertions(+), 46 deletions(-) diff --git a/config_webrx.py b/config_webrx.py index 4d87b35..161fedf 100644 --- a/config_webrx.py +++ b/config_webrx.py @@ -277,12 +277,6 @@ waterfall_auto_level_margin = {"min": 3, "max": 10, "min_range": 50} # using the auto squelch. #squelch_auto_margin = 10 # in dB -# === Experimental settings === -# Warning! The settings below are very experimental. -csdr_dynamic_bufsize = False # This allows you to change the buffering mode of csdr. -csdr_print_bufsizes = False # This prints the buffer sizes used for csdr processes. -csdr_through = False # Setting this True will print out how much data is going into the DSP chains. - nmux_memory = 50 # in megabytes. This sets the approximate size of the circular buffer used by nmux. #google_maps_api_key = "" diff --git a/csdr/csdr.py b/csdr/csdr.py index 7e50267..6763774 100644 --- a/csdr/csdr.py +++ b/csdr/csdr.py @@ -95,9 +95,6 @@ class dsp(object): self.decimation = None self.last_decimation = None self.nc_port = None - self.csdr_dynamic_bufsize = False - self.csdr_print_bufsizes = False - self.csdr_through = False self.squelch_level = -150 self.fft_averages = 50 self.wfm_deemphasis_tau = 50e-6 @@ -142,10 +139,6 @@ class dsp(object): def chain(self, which): chain = ["nc -v 127.0.0.1 {nc_port}"] - if self.csdr_dynamic_bufsize: - chain += ["csdr setbuf {start_bufsize}"] - if self.csdr_through: - chain += ["csdr through"] if which == "fft": chain += [ "csdr fft_cc {fft_size} {fft_block_size}", @@ -384,10 +377,6 @@ class dsp(object): ) logger.debug("secondary command (demod) = %s", secondary_command_demod) - my_env = os.environ.copy() - # if self.csdr_dynamic_bufsize: my_env["CSDR_DYNAMIC_BUFSIZE_ON"]="1"; - if self.csdr_print_bufsizes: - my_env["CSDR_PRINT_BUFSIZES"] = "1" if self.output.supports_type("secondary_fft"): secondary_command_fft = " | ".join(self.secondary_chain("fft")) secondary_command_fft = secondary_command_fft.format( @@ -400,7 +389,7 @@ class dsp(object): logger.debug("secondary command (fft) = %s", secondary_command_fft) self.secondary_process_fft = subprocess.Popen( - secondary_command_fft, stdout=subprocess.PIPE, shell=True, start_new_session=True, env=my_env + secondary_command_fft, stdout=subprocess.PIPE, shell=True, start_new_session=True ) self.output.send_output( "secondary_fft", @@ -811,14 +800,9 @@ class dsp(object): ) logger.debug("Command = %s", command) - my_env = os.environ.copy() - if self.csdr_dynamic_bufsize: - my_env["CSDR_DYNAMIC_BUFSIZE_ON"] = "1" - if self.csdr_print_bufsizes: - my_env["CSDR_PRINT_BUFSIZES"] = "1" out = subprocess.PIPE if self.output.supports_type("audio") else subprocess.DEVNULL - self.process = subprocess.Popen(command, stdout=out, shell=True, start_new_session=True, env=my_env) + self.process = subprocess.Popen(command, stdout=out, shell=True, start_new_session=True) def watch_thread(): rc = self.process.wait() @@ -862,10 +846,6 @@ class dsp(object): self.output.send_output("meta", read_meta) - if self.csdr_dynamic_bufsize: - self.process.stdout.read(8) # dummy read to skip bufsize & preamble - logger.debug("Note: CSDR_DYNAMIC_BUFSIZE_ON = 1") - def stop(self): with self.modification_lock: self.running = False diff --git a/owrx/config/defaults.py b/owrx/config/defaults.py index a39ab67..6c1a631 100644 --- a/owrx/config/defaults.py +++ b/owrx/config/defaults.py @@ -285,12 +285,6 @@ defaultConfig = PropertyLayer( waterfall_auto_level_margin={"min": 3, "max": 10, "min_range": 50}, frequency_display_precision=4, squelch_auto_margin=10, - # TODO: deprecated. remove from code. - csdr_dynamic_bufsize=False, - # TODO: deprecated. remove from code. - csdr_print_bufsizes=False, - # TODO: deprecated. remove from code. - csdr_through=False, nmux_memory=50, google_maps_api_key="", map_position_retention_time=2 * 60 * 60, diff --git a/owrx/dsp.py b/owrx/dsp.py index 2b2bbc1..9bceed4 100644 --- a/owrx/dsp.py +++ b/owrx/dsp.py @@ -65,9 +65,6 @@ 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", @@ -136,9 +133,6 @@ class DspManager(csdr.output, SdrSourceEventClient): 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"]: diff --git a/owrx/fft.py b/owrx/fft.py index 987c339..53bdc5b 100644 --- a/owrx/fft.py +++ b/owrx/fft.py @@ -24,9 +24,6 @@ class SpectrumThread(csdr.output, SdrSourceEventClient): "fft_fps", "fft_voverlap_factor", "fft_compression", - "csdr_dynamic_bufsize", - "csdr_print_bufsizes", - "csdr_through", ) self.dsp = dsp = csdr.dsp(self) @@ -55,9 +52,6 @@ class SpectrumThread(csdr.output, SdrSourceEventClient): set_fft_averages() - dsp.csdr_dynamic_bufsize = props["csdr_dynamic_bufsize"] - dsp.csdr_print_bufsizes = props["csdr_print_bufsizes"] - dsp.csdr_through = props["csdr_through"] dsp.set_temporary_directory(CoreConfig().get_temporary_directory()) logger.debug("Spectrum thread initialized successfully.") From 024a6684ceb8e7d9eb3a78e2dd6cdd85da0870cb Mon Sep 17 00:00:00 2001 From: Jakob Ketterl Date: Thu, 11 Feb 2021 23:07:45 +0100 Subject: [PATCH 119/577] fix undefined variable --- csdr/csdr.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/csdr/csdr.py b/csdr/csdr.py index 6763774..e23eeb0 100644 --- a/csdr/csdr.py +++ b/csdr/csdr.py @@ -401,7 +401,7 @@ class dsp(object): # it would block if not read. by piping it to devnull, we avoid a potential pitfall here. secondary_output = subprocess.DEVNULL if self.isPacket() else subprocess.PIPE self.secondary_process_demod = subprocess.Popen( - secondary_command_demod, stdout=secondary_output, shell=True, start_new_session=True, env=my_env + secondary_command_demod, stdout=secondary_output, shell=True, start_new_session=True ) self.secondary_processes_running = True From 5068bcd347b41661e2076ada0b1492b161fd8ea2 Mon Sep 17 00:00:00 2001 From: Jakob Ketterl Date: Thu, 11 Feb 2021 23:08:19 +0100 Subject: [PATCH 120/577] run black --- csdr/csdr.py | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/csdr/csdr.py b/csdr/csdr.py index e23eeb0..0902364 100644 --- a/csdr/csdr.py +++ b/csdr/csdr.py @@ -29,7 +29,16 @@ import math from functools import partial from owrx.kiss import KissClient, DirewolfConfig -from owrx.wsjt import Ft8Profile, WsprProfile, Jt9Profile, Jt65Profile, Ft4Profile, Fst4Profile, Fst4wProfile, Q65Profile +from owrx.wsjt import ( + Ft8Profile, + WsprProfile, + Jt9Profile, + Jt65Profile, + Ft4Profile, + Fst4Profile, + Fst4wProfile, + Q65Profile, +) from owrx.js8 import Js8Profiles from owrx.audio import AudioChopper From 7d88d83c366db56790dfe8d5d798341c521763fa Mon Sep 17 00:00:00 2001 From: Jakob Ketterl Date: Fri, 12 Feb 2021 17:00:35 +0100 Subject: [PATCH 121/577] handle empty file --- owrx/users.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/owrx/users.py b/owrx/users.py index d866585..2f2e88d 100644 --- a/owrx/users.py +++ b/owrx/users.py @@ -149,7 +149,12 @@ class UserList(object): return "{data_directory}/users.json".format(data_directory=config.get_data_directory()) def _getUsersFileModifiedTimestamp(self): - return datetime.fromtimestamp(os.path.getmtime(self._getUsersFile()), timezone.utc) + timestamp = 0 + try: + timestamp = os.path.getmtime(self._getUsersFile()) + except FileNotFoundError: + pass + return datetime.fromtimestamp(timestamp, timezone.utc) def _loadUsers(self): usersFile = self._getUsersFile() @@ -164,6 +169,7 @@ class UserList(object): self.file_modified = modified return users except FileNotFoundError: + self.file_modified = modified return {} except json.JSONDecodeError: logger.exception("error while parsing users file %s", usersFile) From dd2fda54d18161a58bd98ecbf4742c47eaeaeaba Mon Sep 17 00:00:00 2001 From: Jakob Ketterl Date: Fri, 12 Feb 2021 17:00:51 +0100 Subject: [PATCH 122/577] add logging setup for owrxadmin --- owrxadmin/__main__.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/owrxadmin/__main__.py b/owrxadmin/__main__.py index 91f0d88..f72058f 100644 --- a/owrxadmin/__main__.py +++ b/owrxadmin/__main__.py @@ -3,6 +3,9 @@ from owrxadmin.commands import NewUser, DeleteUser, ResetPassword, ListUsers, Di import argparse import sys import traceback +import logging + +logging.basicConfig(level=logging.INFO, format="%(asctime)s - %(name)s - %(levelname)s - %(message)s") def main(): From 3f3f5eacfe6338f5e120a54ca931f613ddf2921e Mon Sep 17 00:00:00 2001 From: Jakob Ketterl Date: Fri, 12 Feb 2021 17:45:10 +0100 Subject: [PATCH 123/577] no need to be verbose here --- debian/openwebrx.postinst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/debian/openwebrx.postinst b/debian/openwebrx.postinst index 3f86ea7..1201812 100755 --- a/debian/openwebrx.postinst +++ b/debian/openwebrx.postinst @@ -1,5 +1,5 @@ #!/bin/bash -set -euxo pipefail +set -euo pipefail OWRX_USER="openwebrx" OWRX_DATADIR="/var/lib/openwebrx" From ad0ca114f543b042377046c7d5ffd0f47939c196 Mon Sep 17 00:00:00 2001 From: Jakob Ketterl Date: Fri, 12 Feb 2021 18:34:28 +0100 Subject: [PATCH 124/577] switch to subparsers --- debian/openwebrx.postinst | 2 +- owrxadmin/__main__.py | 53 +++++++++++++++++++++++++-------------- owrxadmin/commands.py | 20 ++++----------- 3 files changed, 40 insertions(+), 35 deletions(-) diff --git a/debian/openwebrx.postinst b/debian/openwebrx.postinst index 1201812..585a204 100755 --- a/debian/openwebrx.postinst +++ b/debian/openwebrx.postinst @@ -14,7 +14,7 @@ case "$1" in chown "${OWRX_USER}". ${OWRX_DATADIR} # create initial openwebrx user - openwebrx-admin adduser --noninteractive --silent --user admin + openwebrx-admin --noninteractive --silent adduser admin ;; *) echo "postinst called with unknown argument '$1'" 1>&2 diff --git a/owrxadmin/__main__.py b/owrxadmin/__main__.py index f72058f..f92233c 100644 --- a/owrxadmin/__main__.py +++ b/owrxadmin/__main__.py @@ -9,34 +9,49 @@ logging.basicConfig(level=logging.INFO, format="%(asctime)s - %(name)s - %(level def main(): - print("OpenWebRX admin version {version}".format(version=openwebrx_version)) - parser = argparse.ArgumentParser() - parser.add_argument("command", help="""One of the following commands: - adduser, removeuser, listusers, resetpassword, enableuser, disableuser""") + subparsers = parser.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) + + parser.add_argument("-v", "--version", action="store_true", help="Show the software version") parser.add_argument( "--noninteractive", action="store_true", help="Don't ask for any user input (useful for automation)" ) parser.add_argument("--silent", action="store_true", help="Ignore errors (useful for automation)") - parser.add_argument("-u", "--user", help="User name to perform action upon") - parser.add_argument("-a", "--all", action="store_true", help="Show all users") args = parser.parse_args() - if args.command == "adduser": - command = NewUser() - elif args.command == "removeuser": - command = DeleteUser() - elif args.command == "resetpassword": - command = ResetPassword() - elif args.command == "listusers": - command = ListUsers() - elif args.command == "disableuser": - command = DisableUser() - elif args.command == "enableuser": - command = EnableUser() + if args.version: + print("OpenWebRX Admin CLI version {version}".format(version=openwebrx_version)) + sys.exit(0) + + if hasattr(args, "cls"): + command = args.cls() else: if not args.silent: - print("Unknown command: {command}".format(command=args.command)) + parser.print_help() sys.exit(1) sys.exit(0) diff --git a/owrxadmin/commands.py b/owrxadmin/commands.py index 46e52dc..c9590fc 100644 --- a/owrxadmin/commands.py +++ b/owrxadmin/commands.py @@ -13,16 +13,6 @@ class Command(ABC): class UserCommand(Command, metaclass=ABCMeta): - def getUser(self, args): - if args.user: - return args.user - else: - if args.noninteractive: - print("ERROR: User name not specified") - sys.exit(1) - else: - return input("Please enter the user name: ") - def getPassword(self, args, username): if args.noninteractive: print("Generating password for user {username}...".format(username=username)) @@ -47,7 +37,7 @@ class UserCommand(Command, metaclass=ABCMeta): class NewUser(UserCommand): def run(self, args): - username = self.getUser(args) + username = args.user userList = UserList() # early test to bypass the password stuff if the user already exists if username in userList: @@ -62,7 +52,7 @@ class NewUser(UserCommand): class DeleteUser(UserCommand): def run(self, args): - username = self.getUser(args) + username = args.user print("Deleting user {username}...".format(username=username)) userList = UserList() userList.deleteUser(username) @@ -70,7 +60,7 @@ class DeleteUser(UserCommand): class ResetPassword(UserCommand): def run(self, args): - username = self.getUser(args) + username = args.user password, generated = self.getPassword(args, username) userList = UserList() userList[username].setPassword(DefaultPasswordClass(password), must_change_password=generated) @@ -81,7 +71,7 @@ class ResetPassword(UserCommand): class DisableUser(UserCommand): def run(self, args): - username = self.getUser(args) + username = args.user userList = UserList() userList[username].disable() userList.store() @@ -89,7 +79,7 @@ class DisableUser(UserCommand): class EnableUser(UserCommand): def run(self, args): - username = self.getUser(args) + username = args.user userList = UserList() userList[username].enable() userList.store() From 8acfb8c1cf7db58b925cc1891032c5b1c7d6bea9 Mon Sep 17 00:00:00 2001 From: Jakob Ketterl Date: Sat, 13 Feb 2021 00:52:08 +0100 Subject: [PATCH 125/577] add configuration for max_client limit --- config_webrx.py | 2 +- owrx/controllers/settings.py | 7 +++++++ 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/config_webrx.py b/config_webrx.py index 161fedf..81df667 100644 --- a/config_webrx.py +++ b/config_webrx.py @@ -39,7 +39,7 @@ version = 3 # https://github.com/jketterl/openwebrx/wiki/Configuration-guide # ==== Server settings ==== -max_clients = 20 +#max_clients = 20 # ==== Web GUI configuration ==== #receiver_name = "[Callsign]" diff --git a/owrx/controllers/settings.py b/owrx/controllers/settings.py index e247f14..fb5795d 100644 --- a/owrx/controllers/settings.py +++ b/owrx/controllers/settings.py @@ -128,6 +128,13 @@ class GeneralSettingsController(AuthorizationMixin, WebpageController): + "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( From 5e51beac465e26803d6a57b94642401e68939a20 Mon Sep 17 00:00:00 2001 From: Jakob Ketterl Date: Sat, 13 Feb 2021 01:10:36 +0100 Subject: [PATCH 126/577] implement auto-reloading for bookmarks --- owrx/bookmarks.py | 28 +++++++++++++++++++++++++--- 1 file changed, 25 insertions(+), 3 deletions(-) diff --git a/owrx/bookmarks.py b/owrx/bookmarks.py index d5a38d8..f0fd6bd 100644 --- a/owrx/bookmarks.py +++ b/owrx/bookmarks.py @@ -1,4 +1,6 @@ +from datetime import datetime, timezone import json +import os import logging @@ -38,10 +40,29 @@ class Bookmarks(object): return Bookmarks.sharedInstance def __init__(self): - self.bookmarks = self.loadBookmarks() + self.file_modified = None + self.bookmarks = [] + self.fileList = ["/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: + timestamp = os.path.getmtime(file) + break + except FileNotFoundError: + pass + return datetime.fromtimestamp(timestamp, timezone.utc) + + def _loadBookmarks(self): + for file in self.fileList: try: f = open(file, "r") bookmarks_json = json.load(f) @@ -58,5 +79,6 @@ class Bookmarks(object): return [] def getBookmarks(self, range): + self.refresh() (lo, hi) = range return [b for b in self.bookmarks if lo <= b.getFrequency() <= hi] From ae764706120889609a9f7ea1f95be2812989e435 Mon Sep 17 00:00:00 2001 From: Jakob Ketterl Date: Sat, 13 Feb 2021 01:29:21 +0100 Subject: [PATCH 127/577] auto-reload bookmarks from file --- owrx/bands.py | 29 ++++++++++++++++++++++++++--- owrx/bookmarks.py | 4 ++-- 2 files changed, 28 insertions(+), 5 deletions(-) diff --git a/owrx/bands.py b/owrx/bands.py index 5062202..1aba72e 100644 --- a/owrx/bands.py +++ b/owrx/bands.py @@ -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)] diff --git a/owrx/bookmarks.py b/owrx/bookmarks.py index f0fd6bd..651daf7 100644 --- a/owrx/bookmarks.py +++ b/owrx/bookmarks.py @@ -44,7 +44,7 @@ class Bookmarks(object): self.bookmarks = [] self.fileList = ["/etc/openwebrx/bookmarks.json", "bookmarks.json"] - def refresh(self): + 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") @@ -79,6 +79,6 @@ class Bookmarks(object): return [] def getBookmarks(self, range): - self.refresh() + self._refresh() (lo, hi) = range return [b for b in self.bookmarks if lo <= b.getFrequency() <= hi] From cda43b5c5cf247b76d05f013b807ee1a780f341a Mon Sep 17 00:00:00 2001 From: Jakob Ketterl Date: Sat, 13 Feb 2021 16:44:14 +0100 Subject: [PATCH 128/577] re-route settings urls --- htdocs/include/header.include.html | 8 +++---- htdocs/lib/Header.js | 3 ++- htdocs/sdrsettings.html | 21 ------------------- htdocs/settings.html | 4 ++-- .../general.html} | 8 +++---- htdocs/settings/sdr.html | 21 +++++++++++++++++++ owrx/controllers/settings.py | 16 +++++++++++--- owrx/controllers/template.py | 5 ++++- owrx/form/gfx.py | 4 ++-- owrx/http.py | 6 +++--- 10 files changed, 55 insertions(+), 41 deletions(-) delete mode 100644 htdocs/sdrsettings.html rename htdocs/{generalsettings.html => settings/general.html} (53%) create mode 100644 htdocs/settings/sdr.html diff --git a/htdocs/include/header.include.html b/htdocs/include/header.include.html index 44e53a2..6e7beff 100644 --- a/htdocs/include/header.include.html +++ b/htdocs/include/header.include.html @@ -1,7 +1,7 @@
- - Receiver avatar + + Receiver avatar
@@ -10,8 +10,8 @@

Status

Log

Receiver
-
Map
-
Settings
+
Map
+
Settings
diff --git a/htdocs/lib/Header.js b/htdocs/lib/Header.js index b9ff471..ea64457 100644 --- a/htdocs/lib/Header.js +++ b/htdocs/lib/Header.js @@ -58,7 +58,8 @@ Header.prototype.toggle_rx_photo = function(ev) { Header.prototype.download_details = function() { var self = this; - $.ajax('api/receiverdetails').done(function(data){ + // TODO: make this use a relative URL again + $.ajax('/api/receiverdetails').done(function(data){ self.setDetails(data); }); }; diff --git a/htdocs/sdrsettings.html b/htdocs/sdrsettings.html deleted file mode 100644 index 74aa8b7..0000000 --- a/htdocs/sdrsettings.html +++ /dev/null @@ -1,21 +0,0 @@ - - - - OpenWebRX Settings - - - - - - - -${header} -
-
-

SDR device settings

-
-
- ${devices} -
-
- \ No newline at end of file diff --git a/htdocs/settings.html b/htdocs/settings.html index 80dce91..ef2f564 100644 --- a/htdocs/settings.html +++ b/htdocs/settings.html @@ -15,10 +15,10 @@ ${header}

Settings

Feature report diff --git a/htdocs/generalsettings.html b/htdocs/settings/general.html similarity index 53% rename from htdocs/generalsettings.html rename to htdocs/settings/general.html index 4731a02..8b628b6 100644 --- a/htdocs/generalsettings.html +++ b/htdocs/settings/general.html @@ -2,11 +2,11 @@ OpenWebRX Settings - - - + + + - + diff --git a/htdocs/settings/sdr.html b/htdocs/settings/sdr.html new file mode 100644 index 0000000..f85dfde --- /dev/null +++ b/htdocs/settings/sdr.html @@ -0,0 +1,21 @@ + + + + OpenWebRX Settings + + + + + + + +${header} +
+
+

SDR device settings

+
+
+ ${devices} +
+
+ \ No newline at end of file diff --git a/owrx/controllers/settings.py b/owrx/controllers/settings.py index fb5795d..dd1281b 100644 --- a/owrx/controllers/settings.py +++ b/owrx/controllers/settings.py @@ -64,6 +64,11 @@ class SettingsController(AuthorizationMixin, WebpageController): class SdrSettingsController(AuthorizationMixin, WebpageController): + def header_variables(self): + variables = super().header_variables() + variables["assets_prefix"] = "../" + return variables + def template_variables(self): variables = super().template_variables() variables["devices"] = self.render_devices() @@ -94,7 +99,7 @@ class SdrSettingsController(AuthorizationMixin, WebpageController): ) def indexAction(self): - self.serve_template("sdrsettings.html", **self.template_variables()) + self.serve_template("settings/sdr.html", **self.template_variables()) class GeneralSettingsController(AuthorizationMixin, WebpageController): @@ -372,7 +377,12 @@ class GeneralSettingsController(AuthorizationMixin, WebpageController): ) def indexAction(self): - self.serve_template("generalsettings.html", **self.template_variables()) + self.serve_template("settings/general.html", **self.template_variables()) + + def header_variables(self): + variables = super().header_variables() + variables["assets_prefix"] = "../" + return variables def template_variables(self): variables = super().template_variables() @@ -417,4 +427,4 @@ class GeneralSettingsController(AuthorizationMixin, WebpageController): else: config[k] = v config.store() - self.send_redirect("/generalsettings") + self.send_redirect("/settings/general") diff --git a/owrx/controllers/template.py b/owrx/controllers/template.py index 6746012..2213b83 100644 --- a/owrx/controllers/template.py +++ b/owrx/controllers/template.py @@ -18,8 +18,11 @@ class TemplateController(Controller): class WebpageController(TemplateController): + def header_variables(self): + return {"assets_prefix": ""} + def template_variables(self): - header = self.render_template("include/header.include.html") + header = self.render_template("include/header.include.html", **self.header_variables()) return {"header": header} diff --git a/owrx/form/gfx.py b/owrx/form/gfx.py index 05a01f3..99a2ca6 100644 --- a/owrx/form/gfx.py +++ b/owrx/form/gfx.py @@ -36,7 +36,7 @@ class ImageInput(Input, metaclass=ABCMeta): class AvatarInput(ImageInput): def getUrl(self) -> str: - return "static/gfx/openwebrx-avatar.png" + return "../static/gfx/openwebrx-avatar.png" def getImgClasses(self) -> list: return ["webrx-rx-avatar"] @@ -44,7 +44,7 @@ class AvatarInput(ImageInput): class TopPhotoInput(ImageInput): def getUrl(self) -> str: - return "static/gfx/openwebrx-top-photo.jpg" + return "../static/gfx/openwebrx-top-photo.jpg" def getImgClasses(self) -> list: return ["webrx-top-photo"] diff --git a/owrx/http.py b/owrx/http.py index ec791d8..d816a37 100644 --- a/owrx/http.py +++ b/owrx/http.py @@ -103,11 +103,11 @@ class Router(object): 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("/sdrsettings", SdrSettingsController), + StaticRoute("/settings/sdr", SdrSettingsController), StaticRoute("/login", SessionController, options={"action": "loginAction"}), StaticRoute("/login", SessionController, method="POST", options={"action": "processLoginAction"}), StaticRoute("/logout", SessionController, options={"action": "logoutAction"}), From 3e4ba42aabba2637223e69ada011f38335be3c7f Mon Sep 17 00:00:00 2001 From: Jakob Ketterl Date: Sat, 13 Feb 2021 17:08:56 +0100 Subject: [PATCH 129/577] style settings page; add bookmark editor page --- htdocs/css/admin.css | 11 +++++++++++ htdocs/settings.html | 25 +++++++++++++++---------- htdocs/settings/bookmarks.html | 19 +++++++++++++++++++ owrx/controllers/bookmarks.py | 12 ++++++++++++ owrx/http.py | 2 ++ 5 files changed, 59 insertions(+), 10 deletions(-) create mode 100644 htdocs/settings/bookmarks.html create mode 100644 owrx/controllers/bookmarks.py diff --git a/htdocs/css/admin.css b/htdocs/css/admin.css index 103fbf8..167159e 100644 --- a/htdocs/css/admin.css +++ b/htdocs/css/admin.css @@ -43,3 +43,14 @@ h1 { max-height: 350px; max-width: 100%; } + +.settings-grid > div { + padding: 20px; +} + +.settings-grid .btn { + width: 100%; + height: 100px; + padding: 20px; + font-size: 1.2rem; +} diff --git a/htdocs/settings.html b/htdocs/settings.html index ef2f564..f06fce5 100644 --- a/htdocs/settings.html +++ b/htdocs/settings.html @@ -11,17 +11,22 @@ ${header}
-
-

Settings

+
+

Settings

- - - \ No newline at end of file diff --git a/htdocs/settings/bookmarks.html b/htdocs/settings/bookmarks.html new file mode 100644 index 0000000..1d7970c --- /dev/null +++ b/htdocs/settings/bookmarks.html @@ -0,0 +1,19 @@ + + + + OpenWebRX Settings + + + + + + + +${header} +
+
+

Bookmarks

+
+ make me pretty! +
+ \ No newline at end of file diff --git a/owrx/controllers/bookmarks.py b/owrx/controllers/bookmarks.py new file mode 100644 index 0000000..51d45cc --- /dev/null +++ b/owrx/controllers/bookmarks.py @@ -0,0 +1,12 @@ +from owrx.controllers.template import WebpageController +from owrx.controllers.admin import AuthorizationMixin + + +class BookmarksController(AuthorizationMixin, WebpageController): + def header_variables(self): + variables = super().header_variables() + variables["assets_prefix"] = "../" + return variables + + def indexAction(self): + self.serve_template("settings/bookmarks.html", **self.template_variables()) diff --git a/owrx/http.py b/owrx/http.py index d816a37..1f816fa 100644 --- a/owrx/http.py +++ b/owrx/http.py @@ -5,6 +5,7 @@ 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.bookmarks import BookmarksController from owrx.controllers.session import SessionController from owrx.controllers.profile import ProfileController from owrx.controllers.imageupload import ImageUploadController @@ -108,6 +109,7 @@ class Router(object): "/settings/general", GeneralSettingsController, method="POST", options={"action": "processFormData"} ), StaticRoute("/settings/sdr", SdrSettingsController), + StaticRoute("/settings/bookmarks", BookmarksController), StaticRoute("/login", SessionController, options={"action": "loginAction"}), StaticRoute("/login", SessionController, method="POST", options={"action": "processLoginAction"}), StaticRoute("/logout", SessionController, options={"action": "logoutAction"}), From 3b60e0b7373f7b842bd5f95d052b686fe963c645 Mon Sep 17 00:00:00 2001 From: Jakob Ketterl Date: Sat, 13 Feb 2021 18:35:15 +0100 Subject: [PATCH 130/577] display existing bookmarks in table --- htdocs/css/admin.css | 4 ++++ htdocs/settings/bookmarks.html | 2 +- owrx/bookmarks.py | 9 ++++++--- owrx/controllers/bookmarks.py | 35 ++++++++++++++++++++++++++++++++++ 4 files changed, 46 insertions(+), 4 deletions(-) diff --git a/htdocs/css/admin.css b/htdocs/css/admin.css index 167159e..3890b80 100644 --- a/htdocs/css/admin.css +++ b/htdocs/css/admin.css @@ -54,3 +54,7 @@ h1 { padding: 20px; font-size: 1.2rem; } + +table.bookmarks .frequency { + text-align: right; +} \ No newline at end of file diff --git a/htdocs/settings/bookmarks.html b/htdocs/settings/bookmarks.html index 1d7970c..e666986 100644 --- a/htdocs/settings/bookmarks.html +++ b/htdocs/settings/bookmarks.html @@ -14,6 +14,6 @@ ${header}

Bookmarks

- make me pretty! + ${bookmarks}
\ No newline at end of file diff --git a/owrx/bookmarks.py b/owrx/bookmarks.py index 651daf7..fe74365 100644 --- a/owrx/bookmarks.py +++ b/owrx/bookmarks.py @@ -78,7 +78,10 @@ class Bookmarks(object): return [] return [] - def getBookmarks(self, range): + def getBookmarks(self, range=None): self._refresh() - (lo, hi) = range - return [b for b in self.bookmarks if lo <= b.getFrequency() <= hi] + if range is None: + return self.bookmarks + else: + (lo, hi) = range + return [b for b in self.bookmarks if lo <= b.getFrequency() <= hi] diff --git a/owrx/controllers/bookmarks.py b/owrx/controllers/bookmarks.py index 51d45cc..9720827 100644 --- a/owrx/controllers/bookmarks.py +++ b/owrx/controllers/bookmarks.py @@ -1,5 +1,6 @@ from owrx.controllers.template import WebpageController from owrx.controllers.admin import AuthorizationMixin +from owrx.bookmarks import Bookmark, Bookmarks class BookmarksController(AuthorizationMixin, WebpageController): @@ -8,5 +9,39 @@ class BookmarksController(AuthorizationMixin, WebpageController): variables["assets_prefix"] = "../" return variables + def template_variables(self): + variables = super().template_variables() + variables["bookmarks"] = self.render_table() + return variables + + def render_table(self): + bookmarks = Bookmarks.getSharedInstance() + return """ + + + + + + + {bookmarks} +
NameFrequencyModulation
+ """.format( + bookmarks="".join(self.render_bookmark(idx, b) for idx, b in enumerate(bookmarks.getBookmarks())) + ) + + def render_bookmark(self, idx: int, bookmark: Bookmark): + return """ + + {name} + {frequency} + {modulation} + + """.format( + index=idx, + name=bookmark.getName(), + frequency=bookmark.getFrequency(), + modulation=bookmark.getModulation(), + ) + def indexAction(self): self.serve_template("settings/bookmarks.html", **self.template_variables()) From 48f26d00d60eb5f8e1bc609ad5834d648fa45288 Mon Sep 17 00:00:00 2001 From: Jakob Ketterl Date: Sat, 13 Feb 2021 18:41:42 +0100 Subject: [PATCH 131/577] add action column --- owrx/controllers/bookmarks.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/owrx/controllers/bookmarks.py b/owrx/controllers/bookmarks.py index 9720827..acb284a 100644 --- a/owrx/controllers/bookmarks.py +++ b/owrx/controllers/bookmarks.py @@ -22,6 +22,7 @@ class BookmarksController(AuthorizationMixin, WebpageController): Name Frequency Modulation + Actions {bookmarks} @@ -31,13 +32,17 @@ class BookmarksController(AuthorizationMixin, WebpageController): def render_bookmark(self, idx: int, bookmark: Bookmark): return """ - + {name} {frequency} {modulation} + +
delete
+ """.format( index=idx, + id=id(bookmark), name=bookmark.getName(), frequency=bookmark.getFrequency(), modulation=bookmark.getModulation(), From 8ea4d11e9cf2950308ec4e6f0130bb5bb445688d Mon Sep 17 00:00:00 2001 From: Jakob Ketterl Date: Sat, 13 Feb 2021 23:53:16 +0100 Subject: [PATCH 132/577] make the bookmarks table editable --- htdocs/css/admin.css | 6 ++++ htdocs/lib/settings/BookmarkTable.js | 41 ++++++++++++++++++++++++++++ htdocs/settings.js | 1 + owrx/controllers/assets.py | 1 + owrx/controllers/bookmarks.py | 25 +++++++++++++++-- 5 files changed, 71 insertions(+), 3 deletions(-) create mode 100644 htdocs/lib/settings/BookmarkTable.js diff --git a/htdocs/css/admin.css b/htdocs/css/admin.css index 3890b80..ec59bd2 100644 --- a/htdocs/css/admin.css +++ b/htdocs/css/admin.css @@ -57,4 +57,10 @@ h1 { table.bookmarks .frequency { text-align: right; +} + +table.bookmarks input, table.bookmarks select { + width: initial; + text-align: inherit; + display: initial; } \ No newline at end of file diff --git a/htdocs/lib/settings/BookmarkTable.js b/htdocs/lib/settings/BookmarkTable.js new file mode 100644 index 0000000..a9e28f8 --- /dev/null +++ b/htdocs/lib/settings/BookmarkTable.js @@ -0,0 +1,41 @@ +$.fn.bookmarktable = function() { + $.each(this, function(){ + var $table = $(this); + + var inputs = $table.find('tr.inputs td').map(function(){ + var candidates = $(this).find('input, select') + return candidates.length ? candidates.first() : false; + }).toArray(); + $table.find('tr.inputs').remove(); + + $table.on('dblclick', 'td', function(e) { + var $cell = $(e.target); + var html = $cell.html(); + + var index = $cell.parent('tr').find('td').index($cell); + + var $input = inputs[index]; + if (!$input) return; + + $input.val($cell.data('value') || html); + $cell.html($input); + $input.focus(); + + var submit = function() { + var $option = $input.find('option:selected') + if ($option.length) { + $cell.html($option.html()); + } else { + $cell.html($input.val()); + } + }; + + $input.on('blur', submit).on('change', submit).on('keyup', function(e){ + if (e.keyCode == 13) return submit(); + if (e.keyCode == 27) { + $cell.html(html); + } + }); + }); + }); +}; diff --git a/htdocs/settings.js b/htdocs/settings.js index e30de75..aa2db73 100644 --- a/htdocs/settings.js +++ b/htdocs/settings.js @@ -23,4 +23,5 @@ $(function(){ $(".sdrdevice").sdrdevice(); $(".imageupload").imageUpload(); + $("table.bookmarks").bookmarktable(); }); \ No newline at end of file diff --git a/owrx/controllers/assets.py b/owrx/controllers/assets.py index 6362e79..daeaddd 100644 --- a/owrx/controllers/assets.py +++ b/owrx/controllers/assets.py @@ -148,6 +148,7 @@ class CompiledAssetsController(GzipMixin, ModificationAwareController): "lib/settings/Input.js", "lib/settings/SdrDevice.js", "lib/settings/ImageUpload.js", + "lib/settings/BookmarkTable.js", "settings.js", ], } diff --git a/owrx/controllers/bookmarks.py b/owrx/controllers/bookmarks.py index acb284a..7c7d39d 100644 --- a/owrx/controllers/bookmarks.py +++ b/owrx/controllers/bookmarks.py @@ -1,6 +1,7 @@ from owrx.controllers.template import WebpageController from owrx.controllers.admin import AuthorizationMixin from owrx.bookmarks import Bookmark, Bookmarks +from owrx.modes import Modes class BookmarksController(AuthorizationMixin, WebpageController): @@ -16,6 +17,15 @@ class BookmarksController(AuthorizationMixin, WebpageController): def render_table(self): bookmarks = Bookmarks.getSharedInstance() + + def render_mode(m): + return """ + + """.format( + mode=m.modulation, + name=m.name, + ) + return """ @@ -25,17 +35,25 @@ class BookmarksController(AuthorizationMixin, WebpageController): {bookmarks} + + + + + +
Actions
""".format( - bookmarks="".join(self.render_bookmark(idx, b) for idx, b in enumerate(bookmarks.getBookmarks())) + bookmarks="".join(self.render_bookmark(idx, b) for idx, b in enumerate(bookmarks.getBookmarks())), + options="".join(render_mode(m) for m in Modes.getAvailableModes()), ) def render_bookmark(self, idx: int, bookmark: Bookmark): + mode = Modes.findByModulation(bookmark.getModulation()) return """ {name} {frequency} - {modulation} + {modulation_name}
delete
@@ -45,7 +63,8 @@ class BookmarksController(AuthorizationMixin, WebpageController): id=id(bookmark), name=bookmark.getName(), frequency=bookmark.getFrequency(), - modulation=bookmark.getModulation(), + modulation=bookmark.getModulation() if mode is None else mode.modulation, + modulation_name=bookmark.getModulation() if mode is None else mode.name, ) def indexAction(self): From 3d97d362b58ddd5063201064f1fec56539d71363 Mon Sep 17 00:00:00 2001 From: Jakob Ketterl Date: Sun, 14 Feb 2021 00:41:03 +0100 Subject: [PATCH 133/577] implement bookmark storage --- htdocs/lib/settings/BookmarkTable.js | 9 +++++++- owrx/bookmarks.py | 13 +++++++++++- owrx/controllers/bookmarks.py | 31 ++++++++++++++++++++++++++++ owrx/http.py | 1 + 4 files changed, 52 insertions(+), 2 deletions(-) diff --git a/htdocs/lib/settings/BookmarkTable.js b/htdocs/lib/settings/BookmarkTable.js index a9e28f8..a311129 100644 --- a/htdocs/lib/settings/BookmarkTable.js +++ b/htdocs/lib/settings/BookmarkTable.js @@ -12,7 +12,8 @@ $.fn.bookmarktable = function() { var $cell = $(e.target); var html = $cell.html(); - var index = $cell.parent('tr').find('td').index($cell); + var $row = $cell.parent('tr'); + var index = $row.find('td').index($cell); var $input = inputs[index]; if (!$input) return; @@ -22,6 +23,12 @@ $.fn.bookmarktable = function() { $input.focus(); var submit = function() { + $.ajax(document.location.href + "/" + $row.data('id'), { + data: JSON.stringify(Object.fromEntries([[$input.prop('name'), $input.val()]])), + contentType: 'application/json', + method: 'POST' + }); + var $option = $input.find('option:selected') if ($option.length) { $cell.html($option.html()); diff --git a/owrx/bookmarks.py b/owrx/bookmarks.py index fe74365..211c13e 100644 --- a/owrx/bookmarks.py +++ b/owrx/bookmarks.py @@ -1,4 +1,5 @@ from datetime import datetime, timezone +from owrx.config.core import CoreConfig import json import os @@ -42,7 +43,7 @@ class Bookmarks(object): def __init__(self): self.file_modified = None self.bookmarks = [] - self.fileList = ["/etc/openwebrx/bookmarks.json", "bookmarks.json"] + self.fileList = [Bookmarks._getBookmarksFile(), "/etc/openwebrx/bookmarks.json", "bookmarks.json"] def _refresh(self): modified = self._getFileModifiedTimestamp() @@ -85,3 +86,13 @@ class Bookmarks(object): 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): + with open(Bookmarks._getBookmarksFile(), "w") as file: + json.dump([b.__dict__() for b in self.bookmarks], file, indent=4) + self.file_modified = self._getFileModifiedTimestamp() diff --git a/owrx/controllers/bookmarks.py b/owrx/controllers/bookmarks.py index 7c7d39d..5ed1608 100644 --- a/owrx/controllers/bookmarks.py +++ b/owrx/controllers/bookmarks.py @@ -2,6 +2,11 @@ from owrx.controllers.template import WebpageController from owrx.controllers.admin import AuthorizationMixin from owrx.bookmarks import Bookmark, Bookmarks from owrx.modes import Modes +import json + +import logging + +logger = logging.getLogger(__name__) class BookmarksController(AuthorizationMixin, WebpageController): @@ -67,5 +72,31 @@ class BookmarksController(AuthorizationMixin, WebpageController): 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()) + 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() + self.send_response("{}", content_type="application/json", code=200) + except json.JSONDecodeError: + self.send_response("{}", content_type="application/json", code=400) + def indexAction(self): self.serve_template("settings/bookmarks.html", **self.template_variables()) diff --git a/owrx/http.py b/owrx/http.py index 1f816fa..96f54ee 100644 --- a/owrx/http.py +++ b/owrx/http.py @@ -110,6 +110,7 @@ class Router(object): ), StaticRoute("/settings/sdr", SdrSettingsController), StaticRoute("/settings/bookmarks", BookmarksController), + RegexRoute("/settings/bookmarks/(.+)", BookmarksController, method="POST", options={"action": "update"}), StaticRoute("/login", SessionController, options={"action": "loginAction"}), StaticRoute("/login", SessionController, method="POST", options={"action": "processLoginAction"}), StaticRoute("/logout", SessionController, options={"action": "logoutAction"}), From dbf23baa451ac21ad6466a6d0410b86a05bf13a1 Mon Sep 17 00:00:00 2001 From: Jakob Ketterl Date: Sun, 14 Feb 2021 00:44:36 +0100 Subject: [PATCH 134/577] wait for successful ajax call --- htdocs/lib/settings/BookmarkTable.js | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/htdocs/lib/settings/BookmarkTable.js b/htdocs/lib/settings/BookmarkTable.js index a311129..b530513 100644 --- a/htdocs/lib/settings/BookmarkTable.js +++ b/htdocs/lib/settings/BookmarkTable.js @@ -19,22 +19,24 @@ $.fn.bookmarktable = function() { if (!$input) return; $input.val($cell.data('value') || html); + $input.prop('disabled', false); $cell.html($input); $input.focus(); var submit = function() { + $input.prop('disabled', true); $.ajax(document.location.href + "/" + $row.data('id'), { data: JSON.stringify(Object.fromEntries([[$input.prop('name'), $input.val()]])), contentType: 'application/json', method: 'POST' + }).then(function(){ + var $option = $input.find('option:selected') + if ($option.length) { + $cell.html($option.html()); + } else { + $cell.html($input.val()); + } }); - - var $option = $input.find('option:selected') - if ($option.length) { - $cell.html($option.html()); - } else { - $cell.html($input.val()); - } }; $input.on('blur', submit).on('change', submit).on('keyup', function(e){ From 9b1659d3dd8b066dbbc4af1cc285ab565e35f7b2 Mon Sep 17 00:00:00 2001 From: Jakob Ketterl Date: Sun, 14 Feb 2021 14:48:32 +0100 Subject: [PATCH 135/577] remove index (unused) --- owrx/controllers/bookmarks.py | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/owrx/controllers/bookmarks.py b/owrx/controllers/bookmarks.py index 5ed1608..f4c55ba 100644 --- a/owrx/controllers/bookmarks.py +++ b/owrx/controllers/bookmarks.py @@ -48,14 +48,14 @@ class BookmarksController(AuthorizationMixin, WebpageController): """.format( - bookmarks="".join(self.render_bookmark(idx, b) for idx, b in enumerate(bookmarks.getBookmarks())), + bookmarks="".join(self.render_bookmark(b) for b in bookmarks.getBookmarks()), options="".join(render_mode(m) for m in Modes.getAvailableModes()), ) - def render_bookmark(self, idx: int, bookmark: Bookmark): + def render_bookmark(self, bookmark: Bookmark): mode = Modes.findByModulation(bookmark.getModulation()) return """ - + {name} {frequency} {modulation_name} @@ -64,7 +64,6 @@ class BookmarksController(AuthorizationMixin, WebpageController): """.format( - index=idx, id=id(bookmark), name=bookmark.getName(), frequency=bookmark.getFrequency(), From 29a161b7b7da98c2196180aa5bc2e0b2d19ad6e5 Mon Sep 17 00:00:00 2001 From: Jakob Ketterl Date: Sun, 14 Feb 2021 16:21:09 +0100 Subject: [PATCH 136/577] add the "add bookmarks" function --- htdocs/css/admin.css | 14 ++++- htdocs/lib/settings/BookmarkTable.js | 79 +++++++++++++++++++++++++--- htdocs/settings.js | 2 +- htdocs/settings/bookmarks.html | 13 ++++- owrx/bookmarks.py | 3 ++ owrx/controllers/bookmarks.py | 16 +++++- owrx/http.py | 1 + 7 files changed, 117 insertions(+), 11 deletions(-) diff --git a/htdocs/css/admin.css b/htdocs/css/admin.css index ec59bd2..aeaf728 100644 --- a/htdocs/css/admin.css +++ b/htdocs/css/admin.css @@ -1,6 +1,10 @@ @import url("openwebrx-header.css"); @import url("openwebrx-globals.css"); +html, body { + height: unset; +} + .buttons { text-align: right; } @@ -59,8 +63,16 @@ table.bookmarks .frequency { text-align: right; } -table.bookmarks input, table.bookmarks select { +.bookmarks table input, .bookmarks table select { width: initial; text-align: inherit; display: initial; +} + +.actions { + margin: 1rem 0; +} + +.actions .btn { + width: 100%; } \ No newline at end of file diff --git a/htdocs/lib/settings/BookmarkTable.js b/htdocs/lib/settings/BookmarkTable.js index b530513..04372da 100644 --- a/htdocs/lib/settings/BookmarkTable.js +++ b/htdocs/lib/settings/BookmarkTable.js @@ -1,6 +1,6 @@ $.fn.bookmarktable = function() { $.each(this, function(){ - var $table = $(this); + var $table = $(this).find('table'); var inputs = $table.find('tr.inputs td').map(function(){ var candidates = $(this).find('input, select') @@ -8,6 +8,16 @@ $.fn.bookmarktable = function() { }).toArray(); $table.find('tr.inputs').remove(); + var transformToHtml = function($cell) { + var $input = $cell.find('input, select'); + var $option = $input.find('option:selected') + if ($option.length) { + $cell.html($option.html()); + } else { + $cell.html($input.val()); + } + }; + $table.on('dblclick', 'td', function(e) { var $cell = $(e.target); var html = $cell.html(); @@ -18,6 +28,7 @@ $.fn.bookmarktable = function() { var $input = inputs[index]; if (!$input) return; + $table.find('tr[data-id="new"]').remove(); $input.val($cell.data('value') || html); $input.prop('disabled', false); $cell.html($input); @@ -30,12 +41,7 @@ $.fn.bookmarktable = function() { contentType: 'application/json', method: 'POST' }).then(function(){ - var $option = $input.find('option:selected') - if ($option.length) { - $cell.html($option.html()); - } else { - $cell.html($input.val()); - } + transformToHtml($cell); }); }; @@ -46,5 +52,64 @@ $.fn.bookmarktable = function() { } }); }); + + $(this).find('.bookmark-add').on('click', function() { + if ($table.find('tr[data-id="new"]').length) return; + + var row = $(''); + row.append(inputs.map(function(i){ + var cell = $(''); + if (i) { + i.prop('disabled', false); + i.val(''); + cell.html(i); + } else { + cell.html( + '
' + + '' + + '' + + '
' + ); + } + return cell; + })); + + row.on('click', '.bookmark-cancel', function() { + row.remove(); + }); + + row.on('click', '.bookmark-save', function() { + var data = Object.fromEntries( + row.find('input, select').toArray().map(function(input){ + var $input = $(input); + $input.prop('disabled', true); + return [$input.prop('name'), $input.val()] + }) + ); + + $.ajax(document.location.href, { + data: JSON.stringify(data), + contentType: 'application/json', + method: 'POST' + }).then(function(data){ + if ('bookmark_id' in data) { + row.attr('data-id', data['bookmark_id']); + row.find('td').each(function(){ + var $cell = $(this); + var $group = $cell.find('.btn-group') + if ($group.length) { + $group.remove; + $cell.html('
delete
'); + } + transformToHtml($cell); + }); + } + }); + + }); + + $table.append(row); + row[0].scrollIntoView(); + }); }); }; diff --git a/htdocs/settings.js b/htdocs/settings.js index aa2db73..bddbf16 100644 --- a/htdocs/settings.js +++ b/htdocs/settings.js @@ -23,5 +23,5 @@ $(function(){ $(".sdrdevice").sdrdevice(); $(".imageupload").imageUpload(); - $("table.bookmarks").bookmarktable(); + $(".bookmarks").bookmarktable(); }); \ No newline at end of file diff --git a/htdocs/settings/bookmarks.html b/htdocs/settings/bookmarks.html index e666986..343deb6 100644 --- a/htdocs/settings/bookmarks.html +++ b/htdocs/settings/bookmarks.html @@ -13,7 +13,18 @@ ${header}

Bookmarks

+
+ Double-click the values in the table to edit them. +
+
+
+
+ +
+ ${bookmarks} +
+ +
- ${bookmarks}
\ No newline at end of file diff --git a/owrx/bookmarks.py b/owrx/bookmarks.py index 211c13e..31ec647 100644 --- a/owrx/bookmarks.py +++ b/owrx/bookmarks.py @@ -96,3 +96,6 @@ class Bookmarks(object): with open(Bookmarks._getBookmarksFile(), "w") as file: json.dump([b.__dict__() for b in self.bookmarks], file, indent=4) self.file_modified = self._getFileModifiedTimestamp() + + def addBookmark(self, bookmark: Bookmark): + self.bookmarks.append(bookmark) diff --git a/owrx/controllers/bookmarks.py b/owrx/controllers/bookmarks.py index f4c55ba..8171ad7 100644 --- a/owrx/controllers/bookmarks.py +++ b/owrx/controllers/bookmarks.py @@ -32,7 +32,7 @@ class BookmarksController(AuthorizationMixin, WebpageController): ) return """ - +
@@ -97,5 +97,19 @@ class BookmarksController(AuthorizationMixin, WebpageController): except json.JSONDecodeError: self.send_response("{}", content_type="application/json", code=400) + def new(self): + bookmarks = Bookmarks.getSharedInstance() + try: + data = json.loads(self.get_body()) + # sanitize + data = {k: data[k] for k in ["name", "frequency", "modulation"]} + bookmark = Bookmark(data) + + bookmarks.addBookmark(bookmark) + bookmarks.store() + self.send_response(json.dumps({"bookmark_id": id(bookmark)}), content_type="application/json", code=200) + except json.JSONDecodeError: + self.send_response("{}", content_type="application/json", code=400) + def indexAction(self): self.serve_template("settings/bookmarks.html", **self.template_variables()) diff --git a/owrx/http.py b/owrx/http.py index 96f54ee..008168b 100644 --- a/owrx/http.py +++ b/owrx/http.py @@ -110,6 +110,7 @@ class Router(object): ), StaticRoute("/settings/sdr", SdrSettingsController), StaticRoute("/settings/bookmarks", BookmarksController), + StaticRoute("/settings/bookmarks", BookmarksController, method="POST", options={"action": "new"}), RegexRoute("/settings/bookmarks/(.+)", BookmarksController, method="POST", options={"action": "update"}), StaticRoute("/login", SessionController, options={"action": "loginAction"}), StaticRoute("/login", SessionController, method="POST", options={"action": "processLoginAction"}), From 48c594fdae34ac73827fbed071d372df1fdaed96 Mon Sep 17 00:00:00 2001 From: Jakob Ketterl Date: Sun, 14 Feb 2021 16:51:16 +0100 Subject: [PATCH 137/577] implement bookmark deletion --- htdocs/lib/settings/BookmarkTable.js | 17 +++++++++++++++-- owrx/bookmarks.py | 5 +++++ owrx/controllers/bookmarks.py | 13 ++++++++++++- owrx/http.py | 4 ++++ 4 files changed, 36 insertions(+), 3 deletions(-) diff --git a/htdocs/lib/settings/BookmarkTable.js b/htdocs/lib/settings/BookmarkTable.js index 04372da..5594fc6 100644 --- a/htdocs/lib/settings/BookmarkTable.js +++ b/htdocs/lib/settings/BookmarkTable.js @@ -22,7 +22,7 @@ $.fn.bookmarktable = function() { var $cell = $(e.target); var html = $cell.html(); - var $row = $cell.parent('tr'); + var $row = $cell.parents('tr'); var index = $row.find('td').index($cell); var $input = inputs[index]; @@ -53,7 +53,20 @@ $.fn.bookmarktable = function() { }); }); - $(this).find('.bookmark-add').on('click', function() { + $table.on('click', '.bookmark-delete', function(e) { + var $button = $(e.target); + $button.prop('disabled', true); + var $row = $button.parents('tr'); + $.ajax(document.location.href + "/" + $row.data('id'), { + data: "{}", + contentType: 'application/json', + method: 'DELETE' + }).then(function(){ + $row.remove(); + }); + }); + + $(this).on('click', '.bookmark-add', function() { if ($table.find('tr[data-id="new"]').length) return; var row = $(''); diff --git a/owrx/bookmarks.py b/owrx/bookmarks.py index 31ec647..d8a9a2b 100644 --- a/owrx/bookmarks.py +++ b/owrx/bookmarks.py @@ -99,3 +99,8 @@ class Bookmarks(object): def addBookmark(self, bookmark: Bookmark): self.bookmarks.append(bookmark) + + def removeBookmark(self, bookmark: Bookmark): + if bookmark not in self.bookmarks: + return + self.bookmarks.remove(bookmark) diff --git a/owrx/controllers/bookmarks.py b/owrx/controllers/bookmarks.py index 8171ad7..4ece8dd 100644 --- a/owrx/controllers/bookmarks.py +++ b/owrx/controllers/bookmarks.py @@ -60,7 +60,7 @@ class BookmarksController(AuthorizationMixin, WebpageController): """.format( @@ -111,5 +111,16 @@ class BookmarksController(AuthorizationMixin, WebpageController): 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()) diff --git a/owrx/http.py b/owrx/http.py index 008168b..90d4a72 100644 --- a/owrx/http.py +++ b/owrx/http.py @@ -35,6 +35,9 @@ class RequestHandler(BaseHTTPRequestHandler): def do_POST(self): self.router.route(self, self.get_request("POST")) + def do_DELETE(self): + self.router.route(self, self.get_request("DELETE")) + def get_request(self, method): url = urlparse(self.path) return Request(url, method, self.headers) @@ -112,6 +115,7 @@ class Router(object): 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("/login", SessionController, options={"action": "loginAction"}), StaticRoute("/login", SessionController, method="POST", options={"action": "processLoginAction"}), StaticRoute("/logout", SessionController, options={"action": "logoutAction"}), From 830d7ae656b0ccd281b03e44ba184b7dd71a6b3a Mon Sep 17 00:00:00 2001 From: Jakob Ketterl Date: Mon, 15 Feb 2021 00:04:43 +0100 Subject: [PATCH 138/577] fix ios 14.2 bug --- htdocs/css/openwebrx.css | 2 ++ 1 file changed, 2 insertions(+) diff --git a/htdocs/css/openwebrx.css b/htdocs/css/openwebrx.css index 30028b2..64e4a31 100644 --- a/htdocs/css/openwebrx.css +++ b/htdocs/css/openwebrx.css @@ -912,6 +912,8 @@ img.openwebrx-mirror-img display: flex; flex-direction: row; gap: 10px; + /* compatibility with iOS 14.2 */ + flex: 0 0 auto; } .openwebrx-meta-slot { From 391069653ab0f160e8f447227c531e5a9c604e19 Mon Sep 17 00:00:00 2001 From: Jakob Ketterl Date: Mon, 15 Feb 2021 15:29:02 +0100 Subject: [PATCH 139/577] split settings controller module (preparation to split general settings) --- owrx/controllers/settings/__init__.py | 33 ++++++++ .../{settings.py => settings/general.py} | 75 +------------------ owrx/controllers/settings/sdr.py | 44 +++++++++++ owrx/http.py | 4 +- setup.py | 1 + 5 files changed, 84 insertions(+), 73 deletions(-) create mode 100644 owrx/controllers/settings/__init__.py rename owrx/controllers/{settings.py => settings/general.py} (86%) create mode 100644 owrx/controllers/settings/sdr.py diff --git a/owrx/controllers/settings/__init__.py b/owrx/controllers/settings/__init__.py new file mode 100644 index 0000000..0970b3d --- /dev/null +++ b/owrx/controllers/settings/__init__.py @@ -0,0 +1,33 @@ +from owrx.config import Config +from owrx.controllers.admin import AuthorizationMixin +from owrx.controllers.template import WebpageController + + +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 """ +
+

+ {title} +

+ {inputs} +
+ """.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(AuthorizationMixin, WebpageController): + def indexAction(self): + self.serve_template("settings.html", **self.template_variables()) diff --git a/owrx/controllers/settings.py b/owrx/controllers/settings/general.py similarity index 86% rename from owrx/controllers/settings.py rename to owrx/controllers/settings/general.py index dd1281b..c19a16a 100644 --- a/owrx/controllers/settings.py +++ b/owrx/controllers/settings/general.py @@ -1,3 +1,4 @@ +from owrx.controllers.settings import Section from owrx.controllers.template import WebpageController from owrx.controllers.admin import AuthorizationMixin from owrx.config.core import CoreConfig @@ -22,86 +23,16 @@ from owrx.form.aprs import AprsBeaconSymbols, AprsAntennaDirections from owrx.form.wfm import WfmTauValues from owrx.form.wsjt import Q65ModeMatrix from owrx.form.gfx import AvatarInput, TopPhotoInput -from urllib.parse import quote from owrx.wsjt import Fst4Profile, Fst4wProfile -import json -import logging import shutil import os from glob import glob +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 """ -
-

- {title} -

- {inputs} -
- """.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(AuthorizationMixin, WebpageController): - def indexAction(self): - self.serve_template("settings.html", **self.template_variables()) - - -class SdrSettingsController(AuthorizationMixin, WebpageController): - def header_variables(self): - variables = super().header_variables() - variables["assets_prefix"] = "../" - return variables - - 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 """ -
-
- {device_name} -
-
- {form} -
-
- """.format( - device_name=config["name"], form=self.render_form(device_id, config) - ) - - def render_form(self, device_id, config): - return """ -
- """.format( - device_id=device_id, formdata=quote(json.dumps(config)) - ) - - def indexAction(self): - self.serve_template("settings/sdr.html", **self.template_variables()) - - class GeneralSettingsController(AuthorizationMixin, WebpageController): sections = [ Section( diff --git a/owrx/controllers/settings/sdr.py b/owrx/controllers/settings/sdr.py new file mode 100644 index 0000000..ec5b601 --- /dev/null +++ b/owrx/controllers/settings/sdr.py @@ -0,0 +1,44 @@ +from owrx.controllers.admin import AuthorizationMixin +from owrx.controllers.template import WebpageController +from owrx.config import Config +from urllib.parse import quote +import json + + +class SdrSettingsController(AuthorizationMixin, WebpageController): + def header_variables(self): + variables = super().header_variables() + variables["assets_prefix"] = "../" + return variables + + 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 """ +
+
+ {device_name} +
+
+ {form} +
+
+ """.format( + device_name=config["name"], form=self.render_form(device_id, config) + ) + + def render_form(self, device_id, config): + return """ +
+ """.format( + device_id=device_id, formdata=quote(json.dumps(config)) + ) + + def indexAction(self): + self.serve_template("settings/sdr.html", **self.template_variables()) diff --git a/owrx/http.py b/owrx/http.py index 90d4a72..1ef8339 100644 --- a/owrx/http.py +++ b/owrx/http.py @@ -4,7 +4,9 @@ from owrx.controllers.assets import OwrxAssetsController, AprsSymbolsController, 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 SdrSettingsController from owrx.controllers.bookmarks import BookmarksController from owrx.controllers.session import SessionController from owrx.controllers.profile import ProfileController diff --git a/setup.py b/setup.py index f15522a..bcafa1b 100644 --- a/setup.py +++ b/setup.py @@ -18,6 +18,7 @@ setup( "owrx.source", "owrx.service", "owrx.controllers", + "owrx.controllers.settings", "owrx.property", "owrx.form", "owrx.config", From 49640b5e33dacad49afbcad59c7063ab0da0eda9 Mon Sep 17 00:00:00 2001 From: Jakob Ketterl Date: Mon, 15 Feb 2021 15:40:37 +0100 Subject: [PATCH 140/577] generalize settings controller --- htdocs/settings/general.html | 2 +- owrx/controllers/settings/__init__.py | 57 +++ owrx/controllers/settings/general.py | 568 ++++++++++++-------------- 3 files changed, 324 insertions(+), 303 deletions(-) diff --git a/htdocs/settings/general.html b/htdocs/settings/general.html index 8b628b6..de0353e 100644 --- a/htdocs/settings/general.html +++ b/htdocs/settings/general.html @@ -13,7 +13,7 @@ ${header}
-

General settings

+

${title}

${sections}
diff --git a/owrx/controllers/settings/__init__.py b/owrx/controllers/settings/__init__.py index 0970b3d..907ceda 100644 --- a/owrx/controllers/settings/__init__.py +++ b/owrx/controllers/settings/__init__.py @@ -1,6 +1,8 @@ from owrx.config import Config from owrx.controllers.admin import AuthorizationMixin from owrx.controllers.template import WebpageController +from abc import ABCMeta, abstractmethod +from urllib.parse import parse_qs class Section(object): @@ -31,3 +33,58 @@ class Section(object): class SettingsController(AuthorizationMixin, WebpageController): def indexAction(self): self.serve_template("settings.html", **self.template_variables()) + + +class SettingsFormController(AuthorizationMixin, WebpageController, metaclass=ABCMeta): + @abstractmethod + def getSections(self): + pass + + @abstractmethod + def getTitle(self): + pass + + def render_sections(self): + sections = "".join(section.render() for section in self.getSections()) + return """ +
+ {sections} +
+ +
+ + """.format( + sections=sections + ) + + def indexAction(self): + self.serve_template("settings/general.html", **self.template_variables()) + + def header_variables(self): + variables = super().header_variables() + variables["assets_prefix"] = "../" + return variables + + def template_variables(self): + variables = super().template_variables() + variables["sections"] = self.render_sections() + variables["title"] = self.getTitle() + return variables + + def parseFormData(self): + data = parse_qs(self.get_body().decode("utf-8"), keep_blank_values=True) + return {k: v for i in self.getSections() for k, v in i.parse(data).items()} + + def processFormData(self): + self.processData(self.parseFormData()) + + def processData(self, data): + config = Config.get() + for k, v in data.items(): + if v is None: + if k in config: + del config[k] + else: + config[k] = v + config.store() + self.send_redirect(self.request.path) diff --git a/owrx/controllers/settings/general.py b/owrx/controllers/settings/general.py index c19a16a..2f1ff72 100644 --- a/owrx/controllers/settings/general.py +++ b/owrx/controllers/settings/general.py @@ -1,9 +1,5 @@ -from owrx.controllers.settings import Section -from owrx.controllers.template import WebpageController -from owrx.controllers.admin import AuthorizationMixin +from owrx.controllers.settings import Section, SettingsFormController from owrx.config.core import CoreConfig -from owrx.config import Config -from urllib.parse import parse_qs from owrx.form import ( TextInput, NumberInput, @@ -33,292 +29,270 @@ import logging logger = logging.getLogger(__name__) -class GeneralSettingsController(AuthorizationMixin, WebpageController): - sections = [ - 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. " - + 'Receiverbook) here, one per line', - ), - ), - Section( - "Waterfall settings", - 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.", - ), - NumberInput("waterfall_min_level", "Lowest waterfall level", append="dBFS"), - NumberInput("waterfall_max_level", "Highest waterfall level", append="dBFS"), - ), - 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", append="bins"), - ), - 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 ' - + "this Wikipedia article for more information", - ), - ), - Section( - "Display settings", - NumberInput( - "frequency_display_precision", - "Frequency display precision", - infotext="Number of decimal digits to show on the frequency display", - ), - ), - 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.
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( - "Map settings", - TextInput( - "google_maps_api_key", - "Google Maps API key", - infotext="Google Maps requires an API key, check out " - + '' - + "their documentation 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", - ), - ), - 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"), - 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"), - ), - 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="Send received APRS data to APRS-IS", - ), - 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 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", - "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", - ), - 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", - "Reporting", - checkboxText="Enable sending spots to wsprnet.org", - ), - TextInput( - "wsprnet_callsign", - "wsprnet callsign", - infotext="This callsign will be used to send spots to wsprnet.org", - ), - ), - ] +class GeneralSettingsController(SettingsFormController): + def getTitle(self): + return "General Settings" - def render_sections(self): - sections = "".join(section.render() for section in GeneralSettingsController.sections) - return """ -
- {sections} -
- -
- - """.format( - sections=sections - ) - - def indexAction(self): - self.serve_template("settings/general.html", **self.template_variables()) - - def header_variables(self): - variables = super().header_variables() - variables["assets_prefix"] = "../" - return variables - - def template_variables(self): - variables = super().template_variables() - variables["sections"] = self.render_sections() - return variables + 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. " + + 'Receiverbook) here, one per line', + ), + ), + Section( + "Waterfall settings", + 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.", + ), + NumberInput("waterfall_min_level", "Lowest waterfall level", append="dBFS"), + NumberInput("waterfall_max_level", "Highest waterfall level", append="dBFS"), + ), + 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", append="bins"), + ), + 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 ' + + "this Wikipedia article for more information", + ), + ), + Section( + "Display settings", + NumberInput( + "frequency_display_precision", + "Frequency display precision", + infotext="Number of decimal digits to show on the frequency display", + ), + ), + 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.
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( + "Map settings", + TextInput( + "google_maps_api_key", + "Google Maps API key", + infotext="Google Maps requires an API key, check out " + + '' + + "their documentation 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", + ), + ), + 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"), + 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"), + ), + 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="Send received APRS data to APRS-IS", + ), + 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 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", + "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", + ), + 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", + "Reporting", + checkboxText="Enable sending spots to wsprnet.org", + ), + TextInput( + "wsprnet_callsign", + "wsprnet callsign", + infotext="This callsign will be used to send spots to wsprnet.org", + ), + ), + ] def handle_image(self, data, image_id): if image_id in data: @@ -344,18 +318,8 @@ class GeneralSettingsController(AuthorizationMixin, WebpageController): for file in glob("{}/{}*".format(config.get_temporary_directory(), image_id)): os.unlink(file) - def processFormData(self): - data = parse_qs(self.get_body().decode("utf-8"), keep_blank_values=True) - data = {k: v for i in GeneralSettingsController.sections for k, v in i.parse(data).items()} + def processData(self, data): # Image handling for img in ["receiver_avatar", "receiver_top_photo"]: self.handle_image(data, img) - config = Config.get() - for k, v in data.items(): - if v is None: - if k in config: - del config[k] - else: - config[k] = v - config.store() - self.send_redirect("/settings/general") + super().processData(data) From d998ab5c61806b65c6e214869f9876b2b7b590cc Mon Sep 17 00:00:00 2001 From: Jakob Ketterl Date: Mon, 15 Feb 2021 15:49:44 +0100 Subject: [PATCH 141/577] break out reporting into its own settings page --- htdocs/settings.html | 3 + owrx/controllers/settings/general.py | 80 ---------------------- owrx/controllers/settings/reporting.py | 91 ++++++++++++++++++++++++++ owrx/http.py | 5 ++ 4 files changed, 99 insertions(+), 80 deletions(-) create mode 100644 owrx/controllers/settings/reporting.py diff --git a/htdocs/settings.html b/htdocs/settings.html index f06fce5..91eaa4b 100644 --- a/htdocs/settings.html +++ b/htdocs/settings.html @@ -21,6 +21,9 @@ ${header} + diff --git a/owrx/controllers/settings/general.py b/owrx/controllers/settings/general.py index 2f1ff72..88db5e4 100644 --- a/owrx/controllers/settings/general.py +++ b/owrx/controllers/settings/general.py @@ -13,9 +13,7 @@ from owrx.form import ( Js8ProfileCheckboxInput, MultiCheckboxInput, ) -from owrx.form.converter import OptionalConverter from owrx.form.receiverid import ReceiverKeysConverter -from owrx.form.aprs import AprsBeaconSymbols, AprsAntennaDirections from owrx.form.wfm import WfmTauValues from owrx.form.wsjt import Q65ModeMatrix from owrx.form.gfx import AvatarInput, TopPhotoInput @@ -214,84 +212,6 @@ class GeneralSettingsController(SettingsFormController): ), 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="Send received APRS data to APRS-IS", - ), - 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 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", - "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", - ), - 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", - "Reporting", - checkboxText="Enable sending spots to wsprnet.org", - ), - TextInput( - "wsprnet_callsign", - "wsprnet callsign", - infotext="This callsign will be used to send spots to wsprnet.org", - ), - ), ] def handle_image(self, data, image_id): diff --git a/owrx/controllers/settings/reporting.py b/owrx/controllers/settings/reporting.py new file mode 100644 index 0000000..dfd8c3c --- /dev/null +++ b/owrx/controllers/settings/reporting.py @@ -0,0 +1,91 @@ +from owrx.controllers.settings import SettingsFormController, Section +from owrx.form.converter import OptionalConverter +from owrx.form.aprs import AprsBeaconSymbols, AprsAntennaDirections +from owrx.form import TextInput, CheckboxInput, DropdownInput, NumberInput + + +class ReportingController(SettingsFormController): + def getTitle(self): + return "Spotting and Reporting" + + def getSections(self): + return [ + Section( + "APRS-IS reporting", + CheckboxInput( + "aprs_igate_enabled", + "APRS I-Gate", + checkboxText="Send received APRS data to APRS-IS", + ), + 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", + "APRS beacon", + checkboxText="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", + "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", + ), + 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", + "Reporting", + checkboxText="Enable sending spots to wsprnet.org", + ), + TextInput( + "wsprnet_callsign", + "wsprnet callsign", + infotext="This callsign will be used to send spots to wsprnet.org", + ), + ), + ] diff --git a/owrx/http.py b/owrx/http.py index 1ef8339..24af405 100644 --- a/owrx/http.py +++ b/owrx/http.py @@ -7,6 +7,7 @@ from owrx.controllers.metrics import MetricsController from owrx.controllers.settings import SettingsController from owrx.controllers.settings.general import GeneralSettingsController from owrx.controllers.settings.sdr import SdrSettingsController +from owrx.controllers.settings.reporting import ReportingController from owrx.controllers.bookmarks import BookmarksController from owrx.controllers.session import SessionController from owrx.controllers.profile import ProfileController @@ -118,6 +119,10 @@ class Router(object): 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("/login", SessionController, options={"action": "loginAction"}), StaticRoute("/login", SessionController, method="POST", options={"action": "processLoginAction"}), StaticRoute("/logout", SessionController, options={"action": "logoutAction"}), From e61dde7d0e56b99438e355e05ff27a814226ef8a Mon Sep 17 00:00:00 2001 From: Jakob Ketterl Date: Mon, 15 Feb 2021 15:56:17 +0100 Subject: [PATCH 142/577] separate background decoding --- htdocs/settings.html | 7 +++++-- .../settings/backgrounddecoding.py | 20 +++++++++++++++++++ owrx/controllers/settings/general.py | 9 --------- owrx/http.py | 8 ++++++++ 4 files changed, 33 insertions(+), 11 deletions(-) create mode 100644 owrx/controllers/settings/backgrounddecoding.py diff --git a/htdocs/settings.html b/htdocs/settings.html index 91eaa4b..5e4120c 100644 --- a/htdocs/settings.html +++ b/htdocs/settings.html @@ -22,10 +22,13 @@ ${header} SDR device settings +
Feature report diff --git a/owrx/controllers/settings/backgrounddecoding.py b/owrx/controllers/settings/backgrounddecoding.py new file mode 100644 index 0000000..9ee9596 --- /dev/null +++ b/owrx/controllers/settings/backgrounddecoding.py @@ -0,0 +1,20 @@ +from owrx.controllers.settings import SettingsFormController, Section +from owrx.form import CheckboxInput, ServicesCheckboxInput + + +class BackgroundDecodingController(SettingsFormController): + def getTitle(self): + return "Background decoding" + + def getSections(self): + return [ + Section( + "Background decoding", + CheckboxInput( + "services_enabled", + "Service", + checkboxText="Enable background decoding services", + ), + ServicesCheckboxInput("services_decoders", "Enabled services"), + ), + ] diff --git a/owrx/controllers/settings/general.py b/owrx/controllers/settings/general.py index 88db5e4..827bc4d 100644 --- a/owrx/controllers/settings/general.py +++ b/owrx/controllers/settings/general.py @@ -203,15 +203,6 @@ class GeneralSettingsController(SettingsFormController): ), Q65ModeMatrix("q65_enabled_combinations", "Enabled Q65 Mode combinations"), ), - Section( - "Background decoding", - CheckboxInput( - "services_enabled", - "Service", - checkboxText="Enable background decoding services", - ), - ServicesCheckboxInput("services_decoders", "Enabled services"), - ), ] def handle_image(self, data, image_id): diff --git a/owrx/http.py b/owrx/http.py index 24af405..685b988 100644 --- a/owrx/http.py +++ b/owrx/http.py @@ -8,6 +8,7 @@ from owrx.controllers.settings import SettingsController from owrx.controllers.settings.general import GeneralSettingsController from owrx.controllers.settings.sdr import SdrSettingsController from owrx.controllers.settings.reporting import ReportingController +from owrx.controllers.settings.backgrounddecoding import BackgroundDecodingController from owrx.controllers.bookmarks import BookmarksController from owrx.controllers.session import SessionController from owrx.controllers.profile import ProfileController @@ -123,6 +124,13 @@ class Router(object): StaticRoute( "/settings/reporting", ReportingController, method="POST", options={"action": "processFormData"} ), + StaticRoute("/settings/backgrounddecoding", BackgroundDecodingController), + StaticRoute( + "/settings/backgrounddecoding", + BackgroundDecodingController, + method="POST", + options={"action": "processFormData"}, + ), StaticRoute("/login", SessionController, options={"action": "loginAction"}), StaticRoute("/login", SessionController, method="POST", options={"action": "processLoginAction"}), StaticRoute("/logout", SessionController, options={"action": "logoutAction"}), From 7f9c0539bbf554f48cb148b196529ffe4ba5d109 Mon Sep 17 00:00:00 2001 From: Jakob Ketterl Date: Mon, 15 Feb 2021 16:06:14 +0100 Subject: [PATCH 143/577] break out demodulation and decoding settings --- htdocs/settings.html | 3 ++ owrx/controllers/settings/decoding.py | 76 +++++++++++++++++++++++++++ owrx/controllers/settings/general.py | 69 ------------------------ owrx/http.py | 5 ++ 4 files changed, 84 insertions(+), 69 deletions(-) create mode 100644 owrx/controllers/settings/decoding.py diff --git a/htdocs/settings.html b/htdocs/settings.html index 5e4120c..18dcdc3 100644 --- a/htdocs/settings.html +++ b/htdocs/settings.html @@ -24,6 +24,9 @@ ${header} + diff --git a/owrx/controllers/settings/decoding.py b/owrx/controllers/settings/decoding.py new file mode 100644 index 0000000..a40edd9 --- /dev/null +++ b/owrx/controllers/settings/decoding.py @@ -0,0 +1,76 @@ +from owrx.controllers.settings import SettingsFormController, Section +from owrx.form import CheckboxInput, NumberInput, DropdownInput, Js8ProfileCheckboxInput, MultiCheckboxInput, Option +from owrx.form.wfm import WfmTauValues +from owrx.form.wsjt import Q65ModeMatrix +from owrx.wsjt import Fst4Profile, Fst4wProfile + + +class DecodingSettingsController(SettingsFormController): + def getTitle(self): + return "Demodulation and 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 ' + + "this Wikipedia article 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.
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( + "Digimodes", + CheckboxInput("digimodes_enable", "", checkboxText="Enable 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", + ), + 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"), + ), + ] diff --git a/owrx/controllers/settings/general.py b/owrx/controllers/settings/general.py index 827bc4d..86f0340 100644 --- a/owrx/controllers/settings/general.py +++ b/owrx/controllers/settings/general.py @@ -6,18 +6,11 @@ from owrx.form import ( FloatInput, LocationInput, TextAreaInput, - CheckboxInput, DropdownInput, Option, - ServicesCheckboxInput, - Js8ProfileCheckboxInput, - MultiCheckboxInput, ) from owrx.form.receiverid import ReceiverKeysConverter -from owrx.form.wfm import WfmTauValues -from owrx.form.wsjt import Q65ModeMatrix from owrx.form.gfx import AvatarInput, TopPhotoInput -from owrx.wsjt import Fst4Profile, Fst4wProfile import shutil import os from glob import glob @@ -117,27 +110,6 @@ class GeneralSettingsController(SettingsFormController): ], ), ), - Section( - "Digimodes", - CheckboxInput("digimodes_enable", "", checkboxText="Enable Digimodes"), - NumberInput("digimodes_fft_size", "Digimodes FFT size", append="bins"), - ), - 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 ' - + "this Wikipedia article for more information", - ), - ), Section( "Display settings", NumberInput( @@ -146,20 +118,6 @@ class GeneralSettingsController(SettingsFormController): infotext="Number of decimal digits to show on the frequency display", ), ), - 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.
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( "Map settings", TextInput( @@ -176,33 +134,6 @@ class GeneralSettingsController(SettingsFormController): append="s", ), ), - 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"), - 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"), - ), ] def handle_image(self, data, image_id): diff --git a/owrx/http.py b/owrx/http.py index 685b988..7e95c8e 100644 --- a/owrx/http.py +++ b/owrx/http.py @@ -9,6 +9,7 @@ from owrx.controllers.settings.general import GeneralSettingsController from owrx.controllers.settings.sdr import SdrSettingsController from owrx.controllers.settings.reporting import ReportingController from owrx.controllers.settings.backgrounddecoding import BackgroundDecodingController +from owrx.controllers.settings.decoding import DecodingSettingsController from owrx.controllers.bookmarks import BookmarksController from owrx.controllers.session import SessionController from owrx.controllers.profile import ProfileController @@ -131,6 +132,10 @@ class Router(object): method="POST", options={"action": "processFormData"}, ), + StaticRoute("/settings/decoding", DecodingSettingsController), + StaticRoute( + "/settings/decoding", DecodingSettingsController, method="POST", options={"action": "processFormData"} + ), StaticRoute("/login", SessionController, options={"action": "loginAction"}), StaticRoute("/login", SessionController, method="POST", options={"action": "processLoginAction"}), StaticRoute("/logout", SessionController, options={"action": "logoutAction"}), From 48a9c76c1818867b5cfd44e6d62a145be56c66e5 Mon Sep 17 00:00:00 2001 From: Jakob Ketterl Date: Mon, 15 Feb 2021 17:12:17 +0100 Subject: [PATCH 144/577] inline header variables --- htdocs/include/header.include.html | 8 ++++---- htdocs/lib/Header.js | 9 --------- owrx/controllers/api.py | 6 ------ owrx/controllers/template.py | 9 ++++++--- owrx/http.py | 1 - 5 files changed, 10 insertions(+), 23 deletions(-) diff --git a/htdocs/include/header.include.html b/htdocs/include/header.include.html index 6e7beff..10541ce 100644 --- a/htdocs/include/header.include.html +++ b/htdocs/include/header.include.html @@ -3,8 +3,8 @@ Receiver avatar
-
-
+
${receiver_name}
+
${receiver_location} | Loc: ${locator}, ASL: ${receiver_asl} m

Status
@@ -15,8 +15,8 @@
-
-
+
${photo_title}
+
${photo_desc}
diff --git a/htdocs/lib/Header.js b/htdocs/lib/Header.js index ea64457..be0cc9c 100644 --- a/htdocs/lib/Header.js +++ b/htdocs/lib/Header.js @@ -11,7 +11,6 @@ function Header(el) { }); this.init_rx_photo(); - this.download_details(); }; Header.prototype.setDetails = function(details) { @@ -56,14 +55,6 @@ Header.prototype.toggle_rx_photo = function(ev) { } }; -Header.prototype.download_details = function() { - var self = this; - // TODO: make this use a relative URL again - $.ajax('/api/receiverdetails').done(function(data){ - self.setDetails(data); - }); -}; - $.fn.header = function() { if (!this.data('header')) { this.data('header', new Header(this)); diff --git a/owrx/controllers/api.py b/owrx/controllers/api.py index 4dcde14..4e7a966 100644 --- a/owrx/controllers/api.py +++ b/owrx/controllers/api.py @@ -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") diff --git a/owrx/controllers/template.py b/owrx/controllers/template.py index 2213b83..253e7b7 100644 --- a/owrx/controllers/template.py +++ b/owrx/controllers/template.py @@ -1,6 +1,7 @@ -from . import Controller -import pkg_resources +from owrx.controllers import Controller +from owrx.details import ReceiverDetails from string import Template +import pkg_resources class TemplateController(Controller): @@ -19,7 +20,9 @@ class TemplateController(Controller): class WebpageController(TemplateController): def header_variables(self): - return {"assets_prefix": ""} + variables = {"assets_prefix": ""} + variables.update(ReceiverDetails().__dict__()) + return variables def template_variables(self): header = self.render_template("include/header.include.html", **self.header_variables()) diff --git a/owrx/http.py b/owrx/http.py index 7e95c8e..4d712d4 100644 --- a/owrx/http.py +++ b/owrx/http.py @@ -108,7 +108,6 @@ class Router(object): StaticRoute("/map", MapController), StaticRoute("/features", FeatureController), StaticRoute("/api/features", ApiController), - StaticRoute("/api/receiverdetails", ApiController, options={"action": "receiverDetails"}), StaticRoute("/metrics", MetricsController, options={"action": "prometheusAction"}), StaticRoute("/metrics.json", MetricsController), StaticRoute("/settings", SettingsController), From 2d37f63f2cd2d6ca7f1e064da2e76b2d80b28105 Mon Sep 17 00:00:00 2001 From: Jakob Ketterl Date: Mon, 15 Feb 2021 17:16:55 +0100 Subject: [PATCH 145/577] title should be a header for SEO --- htdocs/css/openwebrx-header.css | 3 ++- htdocs/include/header.include.html | 2 +- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/htdocs/css/openwebrx-header.css b/htdocs/css/openwebrx-header.css index f9a765e..35cd0e7 100644 --- a/htdocs/css/openwebrx-header.css +++ b/htdocs/css/openwebrx-header.css @@ -81,12 +81,13 @@ margin: auto 0; } -.webrx-rx-texts div { +.webrx-rx-texts div, .webrx-rx-texts h1 { margin: 0 10px; padding: 3px; white-space:nowrap; overflow: hidden; color: #909090; + text-align: left; } .webrx-rx-title { diff --git a/htdocs/include/header.include.html b/htdocs/include/header.include.html index 10541ce..84084bb 100644 --- a/htdocs/include/header.include.html +++ b/htdocs/include/header.include.html @@ -3,7 +3,7 @@ Receiver avatar
-
${receiver_name}
+

${receiver_name}

${receiver_location} | Loc: ${locator}, ASL: ${receiver_asl} m
From a72a11d3c7f90a3d14dd2710cb2145b3ee5644c9 Mon Sep 17 00:00:00 2001 From: Jakob Ketterl Date: Mon, 15 Feb 2021 17:25:46 +0100 Subject: [PATCH 146/577] fix old unsubscription todo --- owrx/connection.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/owrx/connection.py b/owrx/connection.py index 102b227..b9b2192 100644 --- a/owrx/connection.py +++ b/owrx/connection.py @@ -99,13 +99,16 @@ 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 = [ From 28b1abfa405851f47239fe2e6a98b6e9cbd5a61a Mon Sep 17 00:00:00 2001 From: Jakob Ketterl Date: Mon, 15 Feb 2021 17:33:47 +0100 Subject: [PATCH 147/577] fix missing unit --- htdocs/css/openwebrx.css | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/htdocs/css/openwebrx.css b/htdocs/css/openwebrx.css index 64e4a31..584762c 100644 --- a/htdocs/css/openwebrx.css +++ b/htdocs/css/openwebrx.css @@ -573,7 +573,7 @@ img.openwebrx-mirror-img .openwebrx-progressbar-text { position: absolute; - left:50; + left:50%; top:50%; transform: translate(-50%, -50%); white-space: nowrap; From b2d4046d8ab36046ded6a5645fedec310fe614d1 Mon Sep 17 00:00:00 2001 From: Jakob Ketterl Date: Mon, 15 Feb 2021 18:00:46 +0100 Subject: [PATCH 148/577] apply z-index layering to status bars to make them render correctly --- htdocs/css/openwebrx.css | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/htdocs/css/openwebrx.css b/htdocs/css/openwebrx.css index 584762c..2fa5933 100644 --- a/htdocs/css/openwebrx.css +++ b/htdocs/css/openwebrx.css @@ -552,6 +552,7 @@ img.openwebrx-mirror-img -moz-user-select: none; -ms-user-select: none; overflow: hidden; + z-index: 1 } .openwebrx-progressbar-bar { @@ -564,6 +565,7 @@ img.openwebrx-mirror-img transition-timing-function: ease-in-out; transform: translate(-100%) translateZ(0); will-change: transform, background-color; + z-index: 0; } .openwebrx-progressbar--over .openwebrx-progressbar-bar { @@ -577,7 +579,7 @@ img.openwebrx-mirror-img top:50%; transform: translate(-50%, -50%); white-space: nowrap; - z-index: 1; + z-index: 2; } #openwebrx-panel-status From 819790cbc8b9849ee7c567b5e751f5c9bf02a8ec Mon Sep 17 00:00:00 2001 From: Jakob Ketterl Date: Mon, 15 Feb 2021 18:03:16 +0100 Subject: [PATCH 149/577] prevent an endless loop when client has problematic audio --- csdr/csdr.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/csdr/csdr.py b/csdr/csdr.py index 0902364..80ff9e3 100644 --- a/csdr/csdr.py +++ b/csdr/csdr.py @@ -530,6 +530,8 @@ class dsp(object): (self.decimation, self.last_decimation) = self.get_decimation(self.samp_rate, self.get_audio_rate()) def get_decimation(self, input_rate, output_rate): + if output_rate <= 0: + raise ValueError("invalid output rate: {rate}".format(rate=output_rate)) decimation = 1 target_rate = output_rate # wideband fm has a much higher frequency deviation (75kHz). From c0193e677c823354b3bc8992dbb0039ee6e401b7 Mon Sep 17 00:00:00 2001 From: Jakob Ketterl Date: Mon, 15 Feb 2021 20:19:43 +0100 Subject: [PATCH 150/577] add an input for wsjt_decoding_depths --- htdocs/css/admin.css | 4 +++ htdocs/lib/settings/MapInput.js | 23 ++++++++++++++ .../lib/settings/WsjtDecodingDepthsInput.js | 16 ++++++++++ htdocs/settings.js | 30 ++++--------------- owrx/controllers/assets.py | 2 ++ owrx/controllers/settings/decoding.py | 6 +++- owrx/form/wsjt.py | 17 +++++++++++ 7 files changed, 72 insertions(+), 26 deletions(-) create mode 100644 htdocs/lib/settings/MapInput.js create mode 100644 htdocs/lib/settings/WsjtDecodingDepthsInput.js diff --git a/htdocs/css/admin.css b/htdocs/css/admin.css index aeaf728..dc0f952 100644 --- a/htdocs/css/admin.css +++ b/htdocs/css/admin.css @@ -75,4 +75,8 @@ table.bookmarks .frequency { .actions .btn { width: 100%; +} + +.wsjt-decoding-depths-table { + width: auto; } \ No newline at end of file diff --git a/htdocs/lib/settings/MapInput.js b/htdocs/lib/settings/MapInput.js new file mode 100644 index 0000000..a35b8a9 --- /dev/null +++ b/htdocs/lib/settings/MapInput.js @@ -0,0 +1,23 @@ +$.fn.mapInput = function() { + this.each(function(el) { + var $el = $(this); + var field_id = $el.attr("for"); + var $lat = $('#' + field_id + '-lat'); + var $lon = $('#' + field_id + '-lon'); + $.getScript('https://maps.googleapis.com/maps/api/js?key=' + $el.data('key')).done(function(){ + $el.css('height', '200px'); + var lp = new locationPicker($el.get(0), { + lat: parseFloat($lat.val()), + lng: parseFloat($lon.val()) + }, { + zoom: 7 + }); + + google.maps.event.addListener(lp.map, 'idle', function(event){ + var pos = lp.getMarkerPosition(); + $lat.val(pos.lat); + $lon.val(pos.lng); + }); + }); + }); +}; \ No newline at end of file diff --git a/htdocs/lib/settings/WsjtDecodingDepthsInput.js b/htdocs/lib/settings/WsjtDecodingDepthsInput.js new file mode 100644 index 0000000..f49aa04 --- /dev/null +++ b/htdocs/lib/settings/WsjtDecodingDepthsInput.js @@ -0,0 +1,16 @@ +$.fn.wsjtDecodingDepthsInput = function() { + var renderTable = function(data) { + var $table = $('
Name Frequency
{frequency} {modulation_name} -
delete
+
'); + $table.append($.map(data, function(value, mode){ + return $(''); + })); + return $table; + } + + this.each(function(){ + var $input = $(this); + var $el = $input.parent(); + var $table = renderTable(JSON.parse($input.val())); + $el.append($table); + }); +}; \ No newline at end of file diff --git a/htdocs/settings.js b/htdocs/settings.js index bddbf16..072ae79 100644 --- a/htdocs/settings.js +++ b/htdocs/settings.js @@ -1,27 +1,7 @@ $(function(){ - $(".map-input").each(function(el) { - var $el = $(this); - var field_id = $el.attr("for"); - var $lat = $('#' + field_id + '-lat'); - var $lon = $('#' + field_id + '-lon'); - $.getScript("https://maps.googleapis.com/maps/api/js?key=" + $el.data("key")).done(function(){ - $el.css("height", "200px"); - var lp = new locationPicker($el.get(0), { - lat: parseFloat($lat.val()), - lng: parseFloat($lon.val()) - }, { - zoom: 7 - }); - - google.maps.event.addListener(lp.map, 'idle', function(event){ - var pos = lp.getMarkerPosition(); - $lat.val(pos.lat); - $lon.val(pos.lng); - }); - }); - }); - - $(".sdrdevice").sdrdevice(); - $(".imageupload").imageUpload(); - $(".bookmarks").bookmarktable(); + $('.map-input').mapInput(); + $('.sdrdevice').sdrdevice(); + $('.imageupload').imageUpload(); + $('.bookmarks').bookmarktable(); + $('.wsjt-decoding-depths').wsjtDecodingDepthsInput(); }); \ No newline at end of file diff --git a/owrx/controllers/assets.py b/owrx/controllers/assets.py index daeaddd..61483b7 100644 --- a/owrx/controllers/assets.py +++ b/owrx/controllers/assets.py @@ -147,8 +147,10 @@ class CompiledAssetsController(GzipMixin, ModificationAwareController): "lib/Header.js", "lib/settings/Input.js", "lib/settings/SdrDevice.js", + "lib/settings/MapInput.js", "lib/settings/ImageUpload.js", "lib/settings/BookmarkTable.js", + "lib/settings/WsjtDecodingDepthsInput.js", "settings.js", ], } diff --git a/owrx/controllers/settings/decoding.py b/owrx/controllers/settings/decoding.py index a40edd9..4ef5428 100644 --- a/owrx/controllers/settings/decoding.py +++ b/owrx/controllers/settings/decoding.py @@ -1,7 +1,7 @@ from owrx.controllers.settings import SettingsFormController, Section from owrx.form import CheckboxInput, NumberInput, DropdownInput, Js8ProfileCheckboxInput, MultiCheckboxInput, Option from owrx.form.wfm import WfmTauValues -from owrx.form.wsjt import Q65ModeMatrix +from owrx.form.wsjt import Q65ModeMatrix, WsjtDecodingDepthsInput from owrx.wsjt import Fst4Profile, Fst4wProfile @@ -55,6 +55,10 @@ class DecodingSettingsController(SettingsFormController): "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", diff --git a/owrx/form/wsjt.py b/owrx/form/wsjt.py index 1c77a3c..dd3827c 100644 --- a/owrx/form/wsjt.py +++ b/owrx/form/wsjt.py @@ -1,5 +1,8 @@ from owrx.form import Input from owrx.wsjt import Q65Mode, Q65Interval +from owrx.modes import Modes +import json +import html class Q65ModeMatrix(Input): @@ -50,3 +53,17 @@ class Q65ModeMatrix(Input): if in_response(mode, interval) ], } + + +class WsjtDecodingDepthsInput(Input): + def render_input(self, value): + return """ + + """.format( + id=self.id, + classes=self.input_classes(), + value=html.escape(json.dumps(value)), + ) + + def input_classes(self): + return super().input_classes() + " wsjt-decoding-depths" From a664770881ebacbac5582ccd8299c5a46b8911cb Mon Sep 17 00:00:00 2001 From: Jakob Ketterl Date: Mon, 15 Feb 2021 20:20:32 +0100 Subject: [PATCH 151/577] change link targets to _blank --- owrx/controllers/settings/decoding.py | 7 ++++--- owrx/controllers/settings/general.py | 2 +- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/owrx/controllers/settings/decoding.py b/owrx/controllers/settings/decoding.py index 4ef5428..3ee4260 100644 --- a/owrx/controllers/settings/decoding.py +++ b/owrx/controllers/settings/decoding.py @@ -23,8 +23,8 @@ class DecodingSettingsController(SettingsFormController): "wfm_deemphasis_tau", "Tau setting for WFM (broadcast FM) deemphasis", WfmTauValues, - infotext='See ' - + "this Wikipedia article for more information", + infotext='See this Wikipedia article for more information', ), ), Section( @@ -38,7 +38,8 @@ class DecodingSettingsController(SettingsFormController): CheckboxInput( "digital_voice_dmr_id_lookup", "DMR id lookup", - checkboxText="Enable lookup of DMR ids in the radioid database to show callsigns and names", + checkboxText='Enable lookup of DMR ids in the ' + + "radioid database to show callsigns and names", ), ), Section( diff --git a/owrx/controllers/settings/general.py b/owrx/controllers/settings/general.py index 86f0340..b9adb39 100644 --- a/owrx/controllers/settings/general.py +++ b/owrx/controllers/settings/general.py @@ -69,7 +69,7 @@ class GeneralSettingsController(SettingsFormController): "Receiver keys", converter=ReceiverKeysConverter(), infotext="Put the keys you receive on listing sites (e.g. " - + 'Receiverbook) here, one per line', + + 'Receiverbook) here, one per line', ), ), Section( From 578f165bdcd940d9f9b947c7559df4c2fd61c0e8 Mon Sep 17 00:00:00 2001 From: Jakob Ketterl Date: Mon, 15 Feb 2021 20:20:53 +0100 Subject: [PATCH 152/577] wording change --- owrx/controllers/settings/decoding.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/owrx/controllers/settings/decoding.py b/owrx/controllers/settings/decoding.py index 3ee4260..92427b8 100644 --- a/owrx/controllers/settings/decoding.py +++ b/owrx/controllers/settings/decoding.py @@ -33,7 +33,7 @@ class DecodingSettingsController(SettingsFormController): "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.
If you're running on a Raspi (up to 3B+) you should leave this set at 1", + + " modes.
If you're running on a Raspberry Pi (up to 3B+) you should leave this set at 1", ), CheckboxInput( "digital_voice_dmr_id_lookup", From 1112334ea84299744514c9e1c3f9dbd9689da359 Mon Sep 17 00:00:00 2001 From: Jakob Ketterl Date: Mon, 15 Feb 2021 22:14:56 +0100 Subject: [PATCH 153/577] render inputs, mode dropdown --- htdocs/css/admin.css | 5 ++ .../lib/settings/WsjtDecodingDepthsInput.js | 28 ++++++++--- owrx/form/wsjt.py | 15 +++++- owrx/modes.py | 46 +++++++------------ 4 files changed, 58 insertions(+), 36 deletions(-) diff --git a/htdocs/css/admin.css b/htdocs/css/admin.css index dc0f952..a632da6 100644 --- a/htdocs/css/admin.css +++ b/htdocs/css/admin.css @@ -79,4 +79,9 @@ table.bookmarks .frequency { .wsjt-decoding-depths-table { width: auto; + margin: 0; +} + +.wsjt-decoding-depths-table td:first-child { + padding-left: 0; } \ No newline at end of file diff --git a/htdocs/lib/settings/WsjtDecodingDepthsInput.js b/htdocs/lib/settings/WsjtDecodingDepthsInput.js index f49aa04..0383630 100644 --- a/htdocs/lib/settings/WsjtDecodingDepthsInput.js +++ b/htdocs/lib/settings/WsjtDecodingDepthsInput.js @@ -1,16 +1,32 @@ $.fn.wsjtDecodingDepthsInput = function() { - var renderTable = function(data) { - var $table = $('
' + mode + '' + value + '
'); - $table.append($.map(data, function(value, mode){ - return $(''); + function WsjtDecodingDepthRow(inputs, mode, value) { + this.el = $(''); + this.modeInput = $(inputs.get(0)).clone(); + this.modeInput.val(mode); + this.valueInput = $(inputs.get(1)).clone(); + this.valueInput.val(value); + this.el.append([this.modeInput, this.valueInput].map(function(i) { + return $(' - - - + + + @@ -61,7 +64,9 @@ class BookmarksController(AuthorizationMixin, WebpageController): """.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, ) From 65443eb0ba373f7987531772f562e2191eaf031d Mon Sep 17 00:00:00 2001 From: Jakob Ketterl Date: Sat, 27 Mar 2021 23:40:10 +0100 Subject: [PATCH 348/577] improve event handling --- owrx/connection.py | 20 +++++++++++++------- 1 file changed, 13 insertions(+), 7 deletions(-) diff --git a/owrx/connection.py b/owrx/connection.py index a06e538..a3c542f 100644 --- a/owrx/connection.py +++ b/owrx/connection.py @@ -194,18 +194,23 @@ class OpenWebRxReceiverClient(OpenWebRxClient, SdrSourceEventClient): 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() - cf = configProps["center_freq"] - srh = configProps["samp_rate"] / 2 - frequencyRange = (cf - srh, cf + srh) - self.bookmarkSub = Bookmarks.getSharedInstance().subscribe(frequencyRange, sendBookmarks) + 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)) @@ -344,6 +349,7 @@ class OpenWebRxReceiverClient(OpenWebRxClient, SdrSourceEventClient): self.configSubs.pop().cancel() if self.bookmarkSub is not None: self.bookmarkSub.cancel() + self.bookmarkSub = None super().close() def stopDsp(self): From df72147b936da396480c4e662d0d9139cc5d665a Mon Sep 17 00:00:00 2001 From: Jakob Ketterl Date: Sat, 27 Mar 2021 23:40:30 +0100 Subject: [PATCH 349/577] handle only successful results --- htdocs/lib/settings/BookmarkTable.js | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/htdocs/lib/settings/BookmarkTable.js b/htdocs/lib/settings/BookmarkTable.js index ca1e71d..e98c0c5 100644 --- a/htdocs/lib/settings/BookmarkTable.js +++ b/htdocs/lib/settings/BookmarkTable.js @@ -204,7 +204,7 @@ $.fn.bookmarktable = function() { data: JSON.stringify(Object.fromEntries([[name, editor.getValue()]])), contentType: 'application/json', method: 'POST' - }).then(function(){ + }).done(function(){ $cell.data('value', editor.getValue()); $cell.html(editor.getHtml()); }); @@ -223,7 +223,7 @@ $.fn.bookmarktable = function() { data: "{}", contentType: 'application/json', method: 'DELETE' - }).then(function(){ + }).done(function(){ $row.remove(); }); }); @@ -270,7 +270,7 @@ $.fn.bookmarktable = function() { data: JSON.stringify(data), contentType: 'application/json', method: 'POST' - }).then(function(data){ + }).done(function(data){ if ('bookmark_id' in data) { row.attr('data-id', data['bookmark_id']); var tds = row.find('td'); From 6796699e35cf32194ef46f546c1648c178adcc3b Mon Sep 17 00:00:00 2001 From: Jakob Ketterl Date: Sat, 27 Mar 2021 23:45:21 +0100 Subject: [PATCH 350/577] don't redirect XHR calls to the login page, 403 instead --- owrx/controllers/admin.py | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/owrx/controllers/admin.py b/owrx/controllers/admin.py index 2ed0093..a6cf3ac 100644 --- a/owrx/controllers/admin.py +++ b/owrx/controllers/admin.py @@ -36,5 +36,11 @@ class AuthorizationMixin(object): if self.isAuthorized(): super().handle_request() else: - target = "/login?{0}".format(parse.urlencode({"ref": self.request.path})) - self.send_redirect(target) + if ( + "x-requested-with" in self.request.headers + and self.request.headers["x-requested-with"] == "XMLHttpRequest" + ): + self.send_response("{}", code=403) + else: + target = "/login?{0}".format(parse.urlencode({"ref": self.request.path})) + self.send_redirect(target) From a86a2f31cdd422e994ec5a410780ee331c6f57bb Mon Sep 17 00:00:00 2001 From: Jakob Ketterl Date: Sat, 27 Mar 2021 23:50:39 +0100 Subject: [PATCH 351/577] styling --- htdocs/settings/bookmarks.html | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/htdocs/settings/bookmarks.html b/htdocs/settings/bookmarks.html index 185d664..27ad28c 100644 --- a/htdocs/settings/bookmarks.html +++ b/htdocs/settings/bookmarks.html @@ -13,11 +13,11 @@ ${header}

Bookmarks

-
- Double-click the values in the table to edit them. -
-
+
+
Double-click the values in the table to edit them.
+
+
${bookmarks}
From af211739fb51aa5f10807c1c1964df5d95eb2e23 Mon Sep 17 00:00:00 2001 From: Jakob Ketterl Date: Sun, 28 Mar 2021 16:51:34 +0200 Subject: [PATCH 352/577] confirmation modal before deleting bookmarks --- htdocs/css/admin.css | 2 +- htdocs/lib/settings/BookmarkTable.js | 26 ++++++++++++++++++++++---- htdocs/settings/bookmarks.html | 19 +++++++++++++++++++ owrx/controllers/settings/bookmarks.py | 2 +- 4 files changed, 43 insertions(+), 6 deletions(-) diff --git a/htdocs/css/admin.css b/htdocs/css/admin.css index 0369d72..fc2bfe6 100644 --- a/htdocs/css/admin.css +++ b/htdocs/css/admin.css @@ -80,7 +80,7 @@ h1 { padding-right: 15px; } -table.bookmarks .frequency { +.bookmarks table .frequency { text-align: right; } diff --git a/htdocs/lib/settings/BookmarkTable.js b/htdocs/lib/settings/BookmarkTable.js index e98c0c5..c01f3dc 100644 --- a/htdocs/lib/settings/BookmarkTable.js +++ b/htdocs/lib/settings/BookmarkTable.js @@ -215,19 +215,37 @@ $.fn.bookmarktable = function() { }; }); - $table.on('click', '.bookmark-delete', function(e) { - var $button = $(e.target); - $button.prop('disabled', true); - var $row = $button.parents('tr'); + var $modal = $('#deleteModal').modal({show:false}); + + $modal.on('hidden.bs.modal', function() { + var $row = $modal.data('row'); + if (!$row) return; + $row.find('.bookmark-delete').prop('disabled', false); + $modal.removeData('row'); + }); + + $modal.on('click', '.confirm', function() { + var $row = $modal.data('row'); + if (!$row) return; $.ajax(document.location.href + "/" + $row.data('id'), { data: "{}", contentType: 'application/json', method: 'DELETE' }).done(function(){ $row.remove(); + $modal.modal('hide'); }); }); + $table.on('click', '.bookmark-delete', function(e) { + var $button = $(e.target); + $button.prop('disabled', true); + + var $row = $button.parents('tr'); + $modal.data('row', $row); + $modal.modal('show'); + }); + $(this).on('click', '.bookmark-add', function() { if ($table.find('tr[data-id="new"]').length) return; diff --git a/htdocs/settings/bookmarks.html b/htdocs/settings/bookmarks.html index 27ad28c..1a25d23 100644 --- a/htdocs/settings/bookmarks.html +++ b/htdocs/settings/bookmarks.html @@ -24,4 +24,23 @@ ${header}
+ \ No newline at end of file diff --git a/owrx/controllers/settings/bookmarks.py b/owrx/controllers/settings/bookmarks.py index 2a4fb3a..38de38a 100644 --- a/owrx/controllers/settings/bookmarks.py +++ b/owrx/controllers/settings/bookmarks.py @@ -20,7 +20,7 @@ class BookmarksController(AuthorizationMixin, WebpageController): bookmarks = Bookmarks.getSharedInstance() return """ -
' + mode + '' + value + '
').append(i); })); - return $table; + } + + WsjtDecodingDepthRow.prototype.getEl = function() { + return this.el; } this.each(function(){ var $input = $(this); var $el = $input.parent(); - var $table = renderTable(JSON.parse($input.val())); + var $inputs = $el.find('.inputs') + var inputs = $inputs.find('input, select'); + $inputs.remove(); + var rows = $.map(JSON.parse($input.val()), function(value, mode) { + return new WsjtDecodingDepthRow(inputs, mode, value); + }); + var $table = $(''); + $table.append(rows.map(function(r) { + return r.getEl(); + })); $el.append($table); }); }; \ No newline at end of file diff --git a/owrx/form/wsjt.py b/owrx/form/wsjt.py index dd3827c..ec79654 100644 --- a/owrx/form/wsjt.py +++ b/owrx/form/wsjt.py @@ -1,6 +1,6 @@ from owrx.form import Input from owrx.wsjt import Q65Mode, Q65Interval -from owrx.modes import Modes +from owrx.modes import Modes, WsjtMode import json import html @@ -57,12 +57,25 @@ class Q65ModeMatrix(Input): class WsjtDecodingDepthsInput(Input): def render_input(self, value): + def render_mode(m): + return """ + + """.format( + mode=m.modulation, + name=m.name, + ) + return """ + """.format( id=self.id, classes=self.input_classes(), value=html.escape(json.dumps(value)), + options="".join(render_mode(m) for m in Modes.getAvailableModes() if isinstance(m, WsjtMode)), ) def input_classes(self): diff --git a/owrx/modes.py b/owrx/modes.py index 0df4747..869bf49 100644 --- a/owrx/modes.py +++ b/owrx/modes.py @@ -51,6 +51,15 @@ class DigitalMode(Mode): return Modes.findByModulation(self.underlying[0]).get_modulation() +class WsjtMode(DigitalMode): + def __init__(self, modulation, name, bandpass=None, requirements=None): + if bandpass is None: + bandpass = Bandpass(0, 3000) + if requirements is None: + requirements = ["wsjt-x"] + super().__init__(modulation, name, ["usb"], bandpass=bandpass, requirements=requirements, service=True) + + class Modes(object): mappings = [ AnalogMode("nfm", "FM", bandpass=Bandpass(-4000, 4000)), @@ -72,35 +81,14 @@ 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( - "q65", "Q65", underlying=["usb"], bandpass=Bandpass(0, 3000), requirements=["wsjt-x-2-4"], 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"]), DigitalMode( "js8", "JS8Call", underlying=["usb"], bandpass=Bandpass(0, 3000), requirements=["js8call"], service=True ), From c2617fcfaf2bc375028f7cf2878ee18689e43fc1 Mon Sep 17 00:00:00 2001 From: Jakob Ketterl Date: Mon, 15 Feb 2021 22:22:07 +0100 Subject: [PATCH 154/577] use a converter -> parsing done --- owrx/form/converter.py | 7 +++++++ owrx/form/wsjt.py | 6 +++++- 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/owrx/form/converter.py b/owrx/form/converter.py index 7739591..7997cd9 100644 --- a/owrx/form/converter.py +++ b/owrx/form/converter.py @@ -1,4 +1,5 @@ from abc import ABC, abstractmethod +import json class Converter(ABC): @@ -59,3 +60,9 @@ class EnumConverter(Converter): return self.enumCls[value].value +class JsonConverter(Converter): + def convert_to_form(self, value): + return json.dumps(value) + + def convert_from_form(self, value): + return json.loads(value) diff --git a/owrx/form/wsjt.py b/owrx/form/wsjt.py index ec79654..52bd50f 100644 --- a/owrx/form/wsjt.py +++ b/owrx/form/wsjt.py @@ -1,4 +1,5 @@ from owrx.form import Input +from owrx.form.converter import JsonConverter from owrx.wsjt import Q65Mode, Q65Interval from owrx.modes import Modes, WsjtMode import json @@ -56,6 +57,9 @@ class Q65ModeMatrix(Input): class WsjtDecodingDepthsInput(Input): + def defaultConverter(self): + return JsonConverter() + def render_input(self, value): def render_mode(m): return """ @@ -74,7 +78,7 @@ class WsjtDecodingDepthsInput(Input): """.format( id=self.id, classes=self.input_classes(), - value=html.escape(json.dumps(value)), + value=html.escape(value), options="".join(render_mode(m) for m in Modes.getAvailableModes() if isinstance(m, WsjtMode)), ) From 8267aa8d9dc84f7e1b4958ac9a2e80369159972e Mon Sep 17 00:00:00 2001 From: Jakob Ketterl Date: Mon, 15 Feb 2021 22:57:21 +0100 Subject: [PATCH 155/577] implement removal --- .../lib/settings/WsjtDecodingDepthsInput.js | 38 ++++++++++++++++++- 1 file changed, 37 insertions(+), 1 deletion(-) diff --git a/htdocs/lib/settings/WsjtDecodingDepthsInput.js b/htdocs/lib/settings/WsjtDecodingDepthsInput.js index 0383630..5a2ddf4 100644 --- a/htdocs/lib/settings/WsjtDecodingDepthsInput.js +++ b/htdocs/lib/settings/WsjtDecodingDepthsInput.js @@ -5,7 +5,9 @@ $.fn.wsjtDecodingDepthsInput = function() { this.modeInput.val(mode); this.valueInput = $(inputs.get(1)).clone(); this.valueInput.val(value); - this.el.append([this.modeInput, this.valueInput].map(function(i) { + this.removeButton = $(''); + this.removeButton.data('row', this); + this.el.append([this.modeInput, this.valueInput, this.removeButton].map(function(i) { return $(' """.format( diff --git a/owrx/form/__init__.py b/owrx/form/__init__.py index 3e828fe..f53cc00 100644 --- a/owrx/form/__init__.py +++ b/owrx/form/__init__.py @@ -42,7 +42,7 @@ class Input(ABC): input=input, infotext=infotext, removable="removable" if self.removable else "", - removebutton='' + removebutton='' if self.removable else "", ) diff --git a/owrx/form/device.py b/owrx/form/device.py index e4b5165..767a010 100644 --- a/owrx/form/device.py +++ b/owrx/form/device.py @@ -227,7 +227,7 @@ class SchedulerInput(Input):
{time_inputs} {select} - +
""".format( time_inputs=render_time_inputs(slot), @@ -241,10 +241,10 @@ class SchedulerInput(Input):
- +
""".format( rows=rows, diff --git a/owrx/form/gfx.py b/owrx/form/gfx.py index 99a2ca6..c4d766d 100644 --- a/owrx/form/gfx.py +++ b/owrx/form/gfx.py @@ -11,8 +11,8 @@ class ImageInput(Input, metaclass=ABCMeta):
{label}
- - + + """.format( id=self.id, label=self.label, url=self.cachebuster(self.getUrl()), classes=" ".join(self.getImgClasses()) diff --git a/owrx/source/__init__.py b/owrx/source/__init__.py index ca052e0..e752f09 100644 --- a/owrx/source/__init__.py +++ b/owrx/source/__init__.py @@ -415,7 +415,7 @@ class OptionalSection(Section): {options}
- +
From 60df3afe26c97ae0647a8e047f0ea19f8ba88087 Mon Sep 17 00:00:00 2001 From: Jakob Ketterl Date: Thu, 4 Mar 2021 22:14:10 +0100 Subject: [PATCH 298/577] add tab navigation to profile and device pages --- owrx/controllers/settings/sdr.py | 54 +++++++++++++++++++++++++++++--- 1 file changed, 49 insertions(+), 5 deletions(-) diff --git a/owrx/controllers/settings/sdr.py b/owrx/controllers/settings/sdr.py index db40bc8..704075f 100644 --- a/owrx/controllers/settings/sdr.py +++ b/owrx/controllers/settings/sdr.py @@ -104,6 +104,50 @@ class SdrFormController(SettingsFormController, metaclass=ABCMeta): 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 self.render_tabs() + super().render_sections() + + def render_tabs(self): + return """ + + """.format( + device_link="{}settings/sdr/{}".format(self.get_document_root(), quote(self.device_id)), + device_name=self.device["name"], + device_active="active" if self.isDeviceActive() else "", + new_profile_link="{}settings/sdr/{}/newprofile".format(self.get_document_root(), quote(self.device_id)), + profile_tabs="".join( + """ + + """.format( + profile_link="{}settings/sdr/{}/profile/{}".format( + self.get_document_root(), quote(self.device_id), quote(profile_id) + ), + profile_name=profile["name"], + 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 store(self): # need to overwrite the existing key in the config since the layering won't capture the changes otherwise config = Config.get() @@ -176,8 +220,8 @@ class SdrDeviceController(SdrFormControllerWithModal): + super().render_buttons() ) - def getTitle(self): - return self.device["name"] + def isDeviceActive(self) -> bool: + return True def indexAction(self): if self.device is None: @@ -262,6 +306,9 @@ class SdrProfileController(SdrFormControllerWithModal): 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"]) @@ -270,9 +317,6 @@ class SdrProfileController(SdrFormControllerWithModal): # TODO provide a generic interface that allows to switch the type return [] - def getTitle(self): - return self.profile["name"] - def indexAction(self): if self.profile is None: self.send_response("profile not found", code=404) From 190c90ccdf0fce17468a740fb98792f4b34fe680 Mon Sep 17 00:00:00 2001 From: Jakob Ketterl Date: Fri, 5 Mar 2021 17:43:15 +0100 Subject: [PATCH 299/577] tab styling --- htdocs/css/admin.css | 17 +++++++++++++---- owrx/controllers/settings/sdr.py | 10 +++++++++- 2 files changed, 22 insertions(+), 5 deletions(-) diff --git a/htdocs/css/admin.css b/htdocs/css/admin.css index 019be64..7597188 100644 --- a/htdocs/css/admin.css +++ b/htdocs/css/admin.css @@ -29,11 +29,8 @@ hr { margin: 15px 15px 0; } -.settings-section { - margin-top: 3em; -} - .settings-section h3 { + margin-top: 1em; margin-bottom: 1em; } @@ -71,6 +68,18 @@ h1 { font-size: 1.2rem; } +.tab-body { + overflow: auto; + border: 1px solid #444; + border-top: none; + border-bottom-left-radius: 0.25rem; + border-bottom-right-radius: 0.25rem; +} + +.tab-body .form-group { + padding-right: 15px; +} + table.bookmarks .frequency { text-align: right; } diff --git a/owrx/controllers/settings/sdr.py b/owrx/controllers/settings/sdr.py index 704075f..4966c41 100644 --- a/owrx/controllers/settings/sdr.py +++ b/owrx/controllers/settings/sdr.py @@ -108,7 +108,15 @@ class SdrFormController(SettingsFormController, metaclass=ABCMeta): return self.device["name"] def render_sections(self): - return self.render_tabs() + super().render_sections() + return """ + {tabs} +
+ {sections} +
+ """.format( + tabs=self.render_tabs(), + sections=super().render_sections(), + ) def render_tabs(self): return """ From 45e9bd12a57465053e5460ede743f5c7604dae55 Mon Sep 17 00:00:00 2001 From: Jakob Ketterl Date: Fri, 5 Mar 2021 17:51:19 +0100 Subject: [PATCH 300/577] hightlight "new profile" link --- owrx/controllers/settings/sdr.py | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/owrx/controllers/settings/sdr.py b/owrx/controllers/settings/sdr.py index 4966c41..a61a1e8 100644 --- a/owrx/controllers/settings/sdr.py +++ b/owrx/controllers/settings/sdr.py @@ -126,13 +126,14 @@ class SdrFormController(SettingsFormController, metaclass=ABCMeta): {profile_tabs} """.format( device_link="{}settings/sdr/{}".format(self.get_document_root(), quote(self.device_id)), device_name=self.device["name"], 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( """ @@ -156,6 +157,9 @@ class SdrFormController(SettingsFormController, metaclass=ABCMeta): 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() @@ -383,6 +387,9 @@ class NewProfileController(SdrFormController): def getTitle(self): return "New profile" + def isNewProfileActive(self) -> bool: + return True + def store(self): if self.stack["id"] in self.device["profiles"]: raise ValueError("Profile {} already exists!".format(self.stack["id"])) From a14f247859207c4fe3966bcb037a60934494cca1 Mon Sep 17 00:00:00 2001 From: Jakob Ketterl Date: Fri, 5 Mar 2021 18:07:19 +0100 Subject: [PATCH 301/577] make the add button look more like the remove button --- htdocs/css/admin.css | 6 +++--- owrx/source/__init__.py | 12 ++++++------ 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/htdocs/css/admin.css b/htdocs/css/admin.css index 7597188..0369d72 100644 --- a/htdocs/css/admin.css +++ b/htdocs/css/admin.css @@ -117,17 +117,17 @@ table.bookmarks .frequency { overflow-y: auto; } -.removable-group.removable { +.removable-group.removable, .add-group { display: flex; flex-direction: row; } -.removable-group.removable .removable-item { +.removable-group.removable .removable-item, .add-group .add-group-select { flex: 1 0 auto; margin-right: .25rem; } -.removable-group.removable .option-remove-button { +.removable-group.removable .option-remove-button, .add-group .option-add-button { flex: 0 0 70px; } diff --git a/owrx/source/__init__.py b/owrx/source/__init__.py index e752f09..706b576 100644 --- a/owrx/source/__init__.py +++ b/owrx/source/__init__.py @@ -410,13 +410,13 @@ class OptionalSection(Section): -
- -
- +
+
+
+
""".format( From a3cfde02c4012e403a5e94ade42241629f190b8d Mon Sep 17 00:00:00 2001 From: Jakob Ketterl Date: Fri, 5 Mar 2021 18:32:16 +0100 Subject: [PATCH 302/577] re-wire profile add & delete --- owrx/controllers/settings/sdr.py | 50 ++++++++++++++++---------------- 1 file changed, 25 insertions(+), 25 deletions(-) diff --git a/owrx/controllers/settings/sdr.py b/owrx/controllers/settings/sdr.py index a61a1e8..57502ac 100644 --- a/owrx/controllers/settings/sdr.py +++ b/owrx/controllers/settings/sdr.py @@ -177,6 +177,12 @@ class SdrFormController(SettingsFormController, metaclass=ABCMeta): 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 """
-
    - {profiles} -
- Add new profile... + {additional_info}
@@ -78,9 +63,7 @@ class SdrDeviceListController(AuthorizationMixin, WebpageController): device_name=config["name"], device_link="{}/{}".format(self.request.path, quote(device_id)), state="Unknown" if source is None else source.getState(), - num_profiles=len(config["profiles"]), additional_info=additional_info, - profiles="".join(render_profile(device_id, p_id, p) for p_id, p in config["profiles"].items()), newprofile_link="{}settings/sdr/{}/newprofile".format(self.get_document_root(), quote(device_id)), ) From ff9f771e1b2e46946e40778036b4569ddecf4293 Mon Sep 17 00:00:00 2001 From: Jakob Ketterl Date: Fri, 5 Mar 2021 19:44:45 +0100 Subject: [PATCH 307/577] handle the resampler --- owrx/source/__init__.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/owrx/source/__init__.py b/owrx/source/__init__.py index 8681c05..1fc0650 100644 --- a/owrx/source/__init__.py +++ b/owrx/source/__init__.py @@ -64,6 +64,9 @@ class SdrSourceEventClient(ABC): 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 From b4460f4f700757fdbfbc07a3f1c6cfc4960bf2a1 Mon Sep 17 00:00:00 2001 From: Jakob Ketterl Date: Fri, 5 Mar 2021 20:20:22 +0100 Subject: [PATCH 308/577] fix receiver appearance in firefox --- htdocs/css/openwebrx.css | 25 +++++++++++++++++-------- 1 file changed, 17 insertions(+), 8 deletions(-) diff --git a/htdocs/css/openwebrx.css b/htdocs/css/openwebrx.css index e6c477d..d0a240b 100644 --- a/htdocs/css/openwebrx.css +++ b/htdocs/css/openwebrx.css @@ -36,15 +36,14 @@ input vertical-align:middle; } -input[type=range] -{ +input[type=range] { -webkit-appearance: none; margin: 0 0; - background: transparent; + background: transparent !important; --track-background: #B6B6B6; } -input[type=range]:focus -{ + +input[type=range]:focus { outline: none; } @@ -826,11 +825,21 @@ img.openwebrx-mirror-img font-weight: normal; font-size: 13pt; margin-right: 1px; - background:-webkit-gradient( linear, left top, left bottom, color-stop(0.0 , #373737), color-stop(1, #4F4F4F) ); - background:-moz-linear-gradient( center top, #373737 0%, #4F4F4F 100% ); + background:linear-gradient(#373737, #4F4F4F); border-color: transparent; border-width: 0px; - -moz-appearance: none; +} + +@supports(-moz-appearance: none) { + .openwebrx-panel select, + .openwebrx-dialog select { + -moz-appearance: none; + background-image: url('data:image/svg+xml;charset=US-ASCII,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20width%3D%22292.4%22%20height%3D%22292.4%22%20%20xmlns%3Av%3D%22https%3A%2F%2Fvecta.io%2Fnano%22%3E%3Cpath%20d%3D%22M287%2069.4a17.6%2017.6%200%200%200-13-5.4H18.4c-5%200-9.3%201.8-12.9%205.4A17.6%2017.6%200%200%200%200%2082.2c0%205%201.8%209.3%205.4%2012.9l128%20127.9c3.6%203.6%207.8%205.4%2012.8%205.4s9.2-1.8%2012.8-5.4L287%2095c3.5-3.5%205.4-7.8%205.4-12.8s-1.9-9.2-5.5-12.8z%22%20fill%3D%22%23fff%22%2F%3E%3C%2Fsvg%3E'), + linear-gradient(#373737, #4F4F4F); + background-repeat: no-repeat, repeat; + background-position: right .7em top 50%, 0 0; + background-size: .65em auto, 100%; + } } .openwebrx-panel select option, From 6af0ad0262d70ec9ee32e013d625e50b6bd6f108 Mon Sep 17 00:00:00 2001 From: Jakob Ketterl Date: Fri, 5 Mar 2021 20:31:23 +0100 Subject: [PATCH 309/577] fix frequency unit dropdown for firefox --- htdocs/css/openwebrx.css | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/htdocs/css/openwebrx.css b/htdocs/css/openwebrx.css index d0a240b..9e282e8 100644 --- a/htdocs/css/openwebrx.css +++ b/htdocs/css/openwebrx.css @@ -833,13 +833,18 @@ img.openwebrx-mirror-img @supports(-moz-appearance: none) { .openwebrx-panel select, .openwebrx-dialog select { - -moz-appearance: none; + -moz-appearance: none; background-image: url('data:image/svg+xml;charset=US-ASCII,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20width%3D%22292.4%22%20height%3D%22292.4%22%20%20xmlns%3Av%3D%22https%3A%2F%2Fvecta.io%2Fnano%22%3E%3Cpath%20d%3D%22M287%2069.4a17.6%2017.6%200%200%200-13-5.4H18.4c-5%200-9.3%201.8-12.9%205.4A17.6%2017.6%200%200%200%200%2082.2c0%205%201.8%209.3%205.4%2012.9l128%20127.9c3.6%203.6%207.8%205.4%2012.8%205.4s9.2-1.8%2012.8-5.4L287%2095c3.5-3.5%205.4-7.8%205.4-12.8s-1.9-9.2-5.5-12.8z%22%20fill%3D%22%23fff%22%2F%3E%3C%2Fsvg%3E'), linear-gradient(#373737, #4F4F4F); background-repeat: no-repeat, repeat; - background-position: right .7em top 50%, 0 0; + background-position: right .3em top 50%, 0 0; background-size: .65em auto, 100%; - } + } + + .openwebrx-panel .input-group select, + .openwebrx-dialog .input-group select { + padding-right: 1em; + } } .openwebrx-panel select option, From 3d20e3ed801fa2e29b205ef50db263b9a69cd858 Mon Sep 17 00:00:00 2001 From: Jakob Ketterl Date: Sat, 6 Mar 2021 22:20:47 +0100 Subject: [PATCH 310/577] simplify api by abstracting layer changes --- owrx/property/__init__.py | 10 ++++++---- owrx/source/__init__.py | 2 +- test/property/test_property_carousel.py | 18 ++++++++++++------ 3 files changed, 19 insertions(+), 11 deletions(-) diff --git a/owrx/property/__init__.py b/owrx/property/__init__.py index 9586cb7..80bd364 100644 --- a/owrx/property/__init__.py +++ b/owrx/property/__init__.py @@ -353,10 +353,12 @@ class PropertyCarousel(PropertyDelegator): return self.emptyLayer def addLayer(self, key, value): - self.layers[key] = value - - def hasLayer(self, key): - return key in self.layers + 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: diff --git a/owrx/source/__init__.py b/owrx/source/__init__.py index 1fc0650..8c210db 100644 --- a/owrx/source/__init__.py +++ b/owrx/source/__init__.py @@ -84,7 +84,7 @@ class SdrProfileCarousel(PropertyCarousel): for profile_id, profile in changes.items(): if profile is PropertyDeleted: self.removeLayer(profile_id) - elif not self.hasLayer(profile_id): + else: self.addLayer(profile_id, profile) def _getDefaultLayer(self): diff --git a/test/property/test_property_carousel.py b/test/property/test_property_carousel.py index 29f79ee..bc0c837 100644 --- a/test/property/test_property_carousel.py +++ b/test/property/test_property_carousel.py @@ -77,12 +77,6 @@ class PropertyCarouselTest(TestCase): with self.assertRaises(KeyError): pc.switch("doesntmatter") - def testHasLayer(self): - pc = PropertyCarousel() - pc.addLayer("testkey", PropertyLayer()) - self.assertTrue(pc.hasLayer("testkey")) - self.assertFalse(pc.hasLayer("otherkey")) - def testRemoveLayer(self): pc = PropertyCarousel() pl = PropertyLayer(testkey="testvalue") @@ -117,3 +111,15 @@ class PropertyCarouselTest(TestCase): pc = PropertyCarousel() with self.assertRaises(PropertyWriteError): pc["testkey"] = "testvalue" + + def testSendsChangesIfActiveLayerIsReplaced(self): + pc = PropertyCarousel() + pl = PropertyLayer(testkey="testvalue") + pc.addLayer("x", pl) + pc.switch("x") + self.assertEqual(pc["testkey"], "testvalue") + mock = Mock() + pc.wire(mock.method) + pl = PropertyLayer(testkey="othervalue") + pc.addLayer("x", pl) + mock.method.assert_called_once_with({"testkey": "othervalue"}) From e0985c38028153af04658a434675ca3744f783b8 Mon Sep 17 00:00:00 2001 From: Jakob Ketterl Date: Sat, 6 Mar 2021 23:34:27 +0100 Subject: [PATCH 311/577] fix status page --- owrx/controllers/status.py | 3 ++- owrx/property/__init__.py | 31 ++++++++++++++++++++++++++++++- 2 files changed, 32 insertions(+), 2 deletions(-) diff --git a/owrx/controllers/status.py b/owrx/controllers/status.py index 9100b34..05cacd1 100644 --- a/owrx/controllers/status.py +++ b/owrx/controllers/status.py @@ -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 @@ -40,4 +41,4 @@ class StatusController(ReceiverIdController): "version": openwebrx_version, "sdrs": [self.getReceiverStats(r) for r in SdrService.getSources().values()], } - self.send_response(json.dumps(status), content_type="application/json") + self.send_response(json.dumps(status, cls=Encoder), content_type="application/json") diff --git a/owrx/property/__init__.py b/owrx/property/__init__.py index 80bd364..2e3b573 100644 --- a/owrx/property/__init__.py +++ b/owrx/property/__init__.py @@ -63,8 +63,13 @@ class PropertyManager(ABC): def keys(self): pass + @abstractmethod + def values(self): + pass + + @abstractmethod def items(self): - return self.__dict__().items() + pass def __len__(self): return self.__dict__().__len__() @@ -141,6 +146,12 @@ class PropertyLayer(PropertyManager): 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, filter: Filter): @@ -179,6 +190,12 @@ class PropertyFilter(PropertyManager): def keys(self): 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): @@ -204,6 +221,12 @@ class PropertyDelegator(PropertyManager): 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): @@ -341,6 +364,12 @@ class PropertyStack(PropertyManager): 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): From 161408dbf4c62bc74140116eee33d287a301d8ac Mon Sep 17 00:00:00 2001 From: Jakob Ketterl Date: Sat, 6 Mar 2021 23:48:31 +0100 Subject: [PATCH 312/577] handle deletions correctly --- owrx/property/__init__.py | 8 +++++--- test/property/test_property_stack.py | 11 +++++++++++ 2 files changed, 16 insertions(+), 3 deletions(-) diff --git a/owrx/property/__init__.py b/owrx/property/__init__.py index 2e3b573..f04b115 100644 --- a/owrx/property/__init__.py +++ b/owrx/property/__init__.py @@ -322,11 +322,13 @@ class PropertyStack(PropertyManager): def receiveEvent(self, layer, changes): changesToForward = {name: value for name, value in changes.items() if layer == self._getTopLayer(name)} - # deletions need to be handled separately: only send them if deleted in all layers + # 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: value + name: PropertyDeleted if self._getTopLayer(name, False) is None else self[name] for name, value in changes.items() - if value is PropertyDeleted and self._getTopLayer(name, False) is None + if value is PropertyDeleted } self._fireCallbacks({**changesToForward, **deletionsToForward}) diff --git a/test/property/test_property_stack.py b/test/property/test_property_stack.py index 0efeac4..f3c3a12 100644 --- a/test/property/test_property_stack.py +++ b/test/property/test_property_stack.py @@ -215,3 +215,14 @@ class PropertyStackTest(TestCase): ps.wire(mock.method) del low_pm["testkey"] mock.method.assert_called_once_with({"testkey": PropertyDeleted}) + + def testChangeEventWhenKeyDeleted(self): + ps = PropertyStack() + low_pm = PropertyLayer(testkey="lowvalue") + high_pm = PropertyLayer(testkey="highvalue") + ps.addLayer(0, high_pm) + ps.addLayer(1, low_pm) + mock = Mock() + ps.wire(mock.method) + del high_pm["testkey"] + mock.method.assert_called_once_with({"testkey": "lowvalue"}) From 620771eaf29e6431463dade1c7ea7ed1cc79f33a Mon Sep 17 00:00:00 2001 From: Jakob Ketterl Date: Thu, 18 Mar 2021 18:58:29 +0100 Subject: [PATCH 313/577] use a property layer right from the start --- owrx/controllers/settings/sdr.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/owrx/controllers/settings/sdr.py b/owrx/controllers/settings/sdr.py index 733518c..a280a34 100644 --- a/owrx/controllers/settings/sdr.py +++ b/owrx/controllers/settings/sdr.py @@ -253,7 +253,7 @@ class NewSdrDeviceController(SettingsFormController): def __init__(self, handler, request, options): super().__init__(handler, request, options) id_layer = PropertyLayer(id="") - self.data_layer = PropertyLayer(name="", type="", profiles={}) + self.data_layer = PropertyLayer(name="", type="", profiles=PropertyLayer()) self.stack = PropertyStack() self.stack.addLayer(0, id_layer) self.stack.addLayer(1, self.data_layer) From 916f19ac608231ad38536a31df9a1c245bc9305f Mon Sep 17 00:00:00 2001 From: Jakob Ketterl Date: Thu, 18 Mar 2021 18:59:38 +0100 Subject: [PATCH 314/577] mapping sdr device layer --- owrx/sdr.py | 122 ++++++++++++++++++++++++++++++++++------------------ 1 file changed, 80 insertions(+), 42 deletions(-) diff --git a/owrx/sdr.py b/owrx/sdr.py index c326301..9def8e3 100644 --- a/owrx/sdr.py +++ b/owrx/sdr.py @@ -1,49 +1,94 @@ from owrx.config import Config -from owrx.property import PropertyLayer +from owrx.property import PropertyManager, PropertyDeleted, PropertyDelegator, PropertyLayer from owrx.feature import FeatureDetector, UnknownFeatureException from owrx.source import SdrSourceState +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: + del self[key] + else: + self._addSource(key, value) - 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 handleDeviceUpdate(self, key, value, changes): + if self.isDeviceValid(value) and key not in self: + self._addSource(key, value) + elif not self.isDeviceValid(value) and key in self: + self._removeSource(key) + + def _addSource(self, key, value): + if self.isDeviceValid(value): + self[key] = self.buildNewSource(key, value) + updateMethod = partial(self.handleDeviceUpdate, key, value) + self.subscriptions[key] = [ + value.filter("type", "profiles").wire(updateMethod), + value["profiles"].wire(updateMethod) + ] + + def _removeSource(self, key): + if key in self: + self[key].shutdown() + for sub in self.subscriptions[key]: + sub.cancel() + del self.subscriptions[key] + + def isDeviceValid(self, device): + return self._hasProfiles(device) and self._sdrTypeAvailable(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 requirements.'.format( + value["type"] ) - return False - - # transform all dictionary items into PropertyManager object, filtering out unavailable ones - SdrService.sdrProps = { - name: value for (name, value) in pm["sdrs"].items() if sdrTypeAvailable(value) - } - logger.info( - "SDR sources loaded. Available SDRs: {0}".format( - ", ".join(x["name"] for x in 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 __setitem__(self, key, value): + if key in self: + self._removeSource(key) + super().__setitem__(key, value) + + def __delitem__(self, key): + if key in self: + self._removeSource(key) + super().__delitem__(key) + + +class SdrService(object): + sources = None @staticmethod def getFirstSource(): @@ -58,21 +103,14 @@ class SdrService(object): sources = SdrService.getSources() 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 id not 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) + if SdrService.sources is None: + SdrService.sources = MappedSdrSources(Config.get()["sdrs"]) return { key: s for key, s in SdrService.sources.items() From b25a67382923163ff387f30a6b009310278a6168 Mon Sep 17 00:00:00 2001 From: Jakob Ketterl Date: Thu, 18 Mar 2021 19:34:53 +0100 Subject: [PATCH 315/577] refactor state handling: uncouple failed and enabled flags --- owrx/connection.py | 10 +++---- owrx/dsp.py | 10 +++---- owrx/fft.py | 8 +++--- owrx/sdr.py | 3 +- owrx/service/__init__.py | 10 +++---- owrx/service/schedule.py | 7 +++-- owrx/source/__init__.py | 61 +++++++++++++++++++++++++++++----------- 7 files changed, 66 insertions(+), 43 deletions(-) diff --git a/owrx/connection.py b/owrx/connection.py index 2acfbd9..b691a51 100644 --- a/owrx/connection.py +++ b/owrx/connection.py @@ -2,7 +2,7 @@ from owrx.details import ReceiverDetails from owrx.dsp import DspManager from owrx.cpu import CpuUsageThread from owrx.sdr import SdrService -from owrx.source import SdrSourceState, SdrBusyState, SdrClientClass, 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 @@ -219,17 +219,15 @@ class OpenWebRxReceiverClient(OpenWebRxClient, SdrSourceEventClient): def onStateChange(self, state: SdrSourceState): if state is SdrSourceState.RUNNING: self.handleSdrAvailable() - elif state is SdrSourceState.FAILED: - self.handleSdrFailed() + + def onFail(self): + self.handleSdrFailed() def handleSdrFailed(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: SdrBusyState): - pass - def getClientClass(self) -> SdrClientClass: return SdrClientClass.USER diff --git a/owrx/dsp.py b/owrx/dsp.py index 0f34998..84bc22e 100644 --- a/owrx/dsp.py +++ b/owrx/dsp.py @@ -3,7 +3,7 @@ from owrx.wsjt import WsjtParser from owrx.js8 import Js8Parser from owrx.aprs import AprsParser from owrx.pocsag import PocsagParser -from owrx.source import SdrSourceEventClient, SdrSourceState, SdrBusyState, SdrClientClass +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 @@ -210,9 +210,7 @@ class DspManager(csdr.output, SdrSourceEventClient): elif state is SdrSourceState.STOPPING: logger.debug("received STATE_STOPPING, shutting down DspSource") self.dsp.stop() - elif state is SdrSourceState.FAILED: - logger.debug("received STATE_FAILED, shutting down DspSource") - self.dsp.stop() - def onBusyStateChange(self, state: SdrBusyState): - pass + def onFail(self): + logger.debug("received onFail(), shutting down DspSource") + self.dsp.stop() diff --git a/owrx/fft.py b/owrx/fft.py index f210313..cfef176 100644 --- a/owrx/fft.py +++ b/owrx/fft.py @@ -2,7 +2,7 @@ from owrx.config.core import CoreConfig from owrx.config import Config from csdr import csdr import threading -from owrx.source import SdrSourceEventClient, SdrSourceState, SdrBusyState, SdrClientClass +from owrx.source import SdrSourceEventClient, SdrSourceState, SdrClientClass from owrx.property import PropertyStack import logging @@ -77,10 +77,10 @@ class SpectrumThread(csdr.output, SdrSourceEventClient): return SdrClientClass.USER def onStateChange(self, state: SdrSourceState): - if state in [SdrSourceState.STOPPING, SdrSourceState.FAILED]: + if state is SdrSourceState.STOPPING: self.dsp.stop() elif state is SdrSourceState.RUNNING: self.dsp.start() - def onBusyStateChange(self, state: SdrBusyState): - pass + def onFail(self): + self.dsp.stop() diff --git a/owrx/sdr.py b/owrx/sdr.py index 9def8e3..f0bbe07 100644 --- a/owrx/sdr.py +++ b/owrx/sdr.py @@ -1,7 +1,6 @@ from owrx.config import Config from owrx.property import PropertyManager, PropertyDeleted, PropertyDelegator, PropertyLayer from owrx.feature import FeatureDetector, UnknownFeatureException -from owrx.source import SdrSourceState from functools import partial import logging @@ -114,5 +113,5 @@ class SdrService(object): return { key: s for key, s in SdrService.sources.items() - if s.getState() not in [SdrSourceState.FAILED, SdrSourceState.DISABLED] + if not s.isFailed() and s.isEnabled() } diff --git a/owrx/service/__init__.py b/owrx/service/__init__.py index fe3b49d..fd75aec 100644 --- a/owrx/service/__init__.py +++ b/owrx/service/__init__.py @@ -1,5 +1,5 @@ import threading -from owrx.source import SdrSourceEventClient, SdrSourceState, SdrBusyState, SdrClientClass +from owrx.source import SdrSourceEventClient, SdrSourceState, SdrClientClass from owrx.sdr import SdrService from owrx.bands import Bandplan from csdr.csdr import dsp, output @@ -117,12 +117,10 @@ class ServiceHandler(SdrSourceEventClient): elif state is SdrSourceState.STOPPING: logger.debug("sdr source becoming unavailable; stopping services.") self.stopServices() - elif state is SdrSourceState.FAILED: - logger.debug("sdr source failed; stopping services.") - self.stopServices() - def onBusyStateChange(self, state: SdrBusyState): - pass + def onFail(self): + logger.debug("sdr source failed; stopping services.") + self.stopServices() def isSupported(self, mode): configured = Config.get()["services_decoders"] diff --git a/owrx/service/schedule.py b/owrx/service/schedule.py index 0e9ad83..5b58627 100644 --- a/owrx/service/schedule.py +++ b/owrx/service/schedule.py @@ -231,7 +231,7 @@ class ServiceScheduler(SdrSourceEventClient): self.source.removeClient(self) def scheduleSelection(self, time=None): - if self.source.getState() is SdrSourceState.FAILED: + if self.source.isFailed(): return seconds = 10 if time is not None: @@ -254,8 +254,9 @@ class ServiceScheduler(SdrSourceEventClient): def onStateChange(self, state: SdrSourceState): if state is SdrSourceState.STOPPING: self.scheduleSelection() - elif state is SdrSourceState.FAILED: - self.shutdown() + + def onFail(self): + self.shutdown() def onBusyStateChange(self, state: SdrBusyState): if state is SdrBusyState.IDLE: diff --git a/owrx/source/__init__.py b/owrx/source/__init__.py index 8c210db..0c9b5f6 100644 --- a/owrx/source/__init__.py +++ b/owrx/source/__init__.py @@ -30,8 +30,6 @@ class SdrSourceState(Enum): RUNNING = "Running" STOPPING = "Stopping" TUNING = "Tuning" - FAILED = "Failed" - DISABLED = "Disabled" def __str__(self): return self.value @@ -48,15 +46,22 @@ class SdrClientClass(Enum): USER = auto() -class SdrSourceEventClient(ABC): - @abstractmethod +class SdrSourceEventClient(object): def onStateChange(self, state: SdrSourceState): pass - @abstractmethod def onBusyStateChange(self, state: SdrBusyState): pass + def onFail(self): + pass + + def onDisable(self): + pass + + def onEnable(self): + pass + def getClientClass(self) -> SdrClientClass: return SdrClientClass.INACTIVE @@ -129,14 +134,39 @@ class SdrSource(ABC): self.spectrumLock = threading.Lock() self.process = None self.modificationLock = threading.Lock() - self.state = SdrSourceState.STOPPED if "enabled" not in props or props["enabled"] else SdrSourceState.DISABLED + self.state = SdrSourceState.STOPPED + self.enabled = "enabled" not in props or props["enabled"] + props.filter("enabled").wire(self._handleEnableChanged) + self.failed = False self.busyState = SdrBusyState.IDLE self.validateProfiles() - if self.isAlwaysOn() and self.state is not SdrSourceState.DISABLED: + 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 + for c in self.clients: + 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: + c.onFail() + def validateProfiles(self): props = PropertyStack() props.addLayer(1, self.props) @@ -220,7 +250,7 @@ class SdrSource(ABC): if self.monitor: return - if self.getState() is SdrSourceState.FAILED: + if self.isFailed(): return try: @@ -254,9 +284,8 @@ class SdrSource(ABC): self.monitor = None if self.getState() is SdrSourceState.RUNNING: failed = True - self.setState(SdrSourceState.FAILED) - else: - self.setState(SdrSourceState.STOPPED) + self.fail() + self.setState(SdrSourceState.STOPPED) self.monitor = threading.Thread(target=wait_for_process_to_end, name="source_monitor") self.monitor.start() @@ -284,7 +313,10 @@ class SdrSource(ABC): logger.exception("Exception during postStart()") failed = True - self.setState(SdrSourceState.FAILED if failed else SdrSourceState.RUNNING) + if failed: + self.fail() + else: + self.setState(SdrSourceState.RUNNING) def preStart(self): """ @@ -302,10 +334,7 @@ class SdrSource(ABC): return self.monitor is not None def stop(self): - # don't overwrite failed flag - # TODO introduce a better solution? - if self.getState() is not SdrSourceState.FAILED: - self.setState(SdrSourceState.STOPPING) + self.setState(SdrSourceState.STOPPING) with self.modificationLock: From 37e7331627d93beaa221565dd4fdc7cd48c6f3cd Mon Sep 17 00:00:00 2001 From: Jakob Ketterl Date: Thu, 18 Mar 2021 19:47:11 +0100 Subject: [PATCH 316/577] fix device failover (concurrent modification problem) --- owrx/source/__init__.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/owrx/source/__init__.py b/owrx/source/__init__.py index 0c9b5f6..95812f0 100644 --- a/owrx/source/__init__.py +++ b/owrx/source/__init__.py @@ -153,7 +153,7 @@ class SdrSource(ABC): self.enabled = changes["enabled"] else: self.enabled = True - for c in self.clients: + for c in self.clients.copy(): if self.isEnabled(): c.onEnable() else: @@ -164,7 +164,7 @@ class SdrSource(ABC): def fail(self): self.failed = True - for c in self.clients: + for c in self.clients.copy(): c.onFail() def validateProfiles(self): @@ -420,14 +420,14 @@ class SdrSource(ABC): 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: SdrBusyState): if state == self.busyState: return self.busyState = state - for c in self.clients: + for c in self.clients.copy(): c.onBusyStateChange(state) From d573561c67f84120328290598c8e58eef8685bc6 Mon Sep 17 00:00:00 2001 From: Jakob Ketterl Date: Thu, 18 Mar 2021 19:59:10 +0100 Subject: [PATCH 317/577] activate enable / disable cycle --- owrx/connection.py | 8 +++++--- owrx/service/__init__.py | 3 +++ owrx/service/schedule.py | 6 ++++++ owrx/source/__init__.py | 2 ++ 4 files changed, 16 insertions(+), 3 deletions(-) diff --git a/owrx/connection.py b/owrx/connection.py index b691a51..5cf291a 100644 --- a/owrx/connection.py +++ b/owrx/connection.py @@ -221,13 +221,15 @@ class OpenWebRxReceiverClient(OpenWebRxClient, SdrSourceEventClient): self.handleSdrAvailable() def onFail(self): - self.handleSdrFailed() - - def handleSdrFailed(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 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) -> SdrClientClass: return SdrClientClass.USER diff --git a/owrx/service/__init__.py b/owrx/service/__init__.py index fd75aec..fe696cb 100644 --- a/owrx/service/__init__.py +++ b/owrx/service/__init__.py @@ -122,6 +122,9 @@ class ServiceHandler(SdrSourceEventClient): logger.debug("sdr source failed; stopping services.") self.stopServices() + def onEnable(self): + self._scheduleServiceStartup() + def isSupported(self, mode): configured = Config.get()["services_decoders"] available = [m.modulation for m in Modes.getAvailableServices()] diff --git a/owrx/service/schedule.py b/owrx/service/schedule.py index 5b58627..394561c 100644 --- a/owrx/service/schedule.py +++ b/owrx/service/schedule.py @@ -258,6 +258,12 @@ class ServiceScheduler(SdrSourceEventClient): def onFail(self): self.shutdown() + def onDisable(self): + self.shutdown() + + def onEnable(self): + self.scheduleSelection() + def onBusyStateChange(self, state: SdrBusyState): if state is SdrBusyState.IDLE: self.scheduleSelection() diff --git a/owrx/source/__init__.py b/owrx/source/__init__.py index 95812f0..6ac69d4 100644 --- a/owrx/source/__init__.py +++ b/owrx/source/__init__.py @@ -153,6 +153,8 @@ class SdrSource(ABC): 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() From 9dcf342b13d83db4deb607087c322b37f8bbe449 Mon Sep 17 00:00:00 2001 From: Jakob Ketterl Date: Thu, 18 Mar 2021 21:17:23 +0100 Subject: [PATCH 318/577] fix scheduler behavior on enable / disable --- owrx/service/schedule.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/owrx/service/schedule.py b/owrx/service/schedule.py index 394561c..4d136b3 100644 --- a/owrx/service/schedule.py +++ b/owrx/service/schedule.py @@ -231,7 +231,7 @@ class ServiceScheduler(SdrSourceEventClient): self.source.removeClient(self) def scheduleSelection(self, time=None): - if self.source.isFailed(): + if not self.source.isEnabled() or self.source.isFailed(): return seconds = 10 if time is not None: @@ -259,7 +259,7 @@ class ServiceScheduler(SdrSourceEventClient): self.shutdown() def onDisable(self): - self.shutdown() + self.cancelTimer() def onEnable(self): self.scheduleSelection() From 364c7eb5050492514edb325ae85cf5c966190303 Mon Sep 17 00:00:00 2001 From: Jakob Ketterl Date: Thu, 18 Mar 2021 21:53:59 +0100 Subject: [PATCH 319/577] show more information on the sdr settings page --- owrx/__main__.py | 2 +- owrx/connection.py | 2 +- owrx/controllers/settings/sdr.py | 16 +++++++++++++--- owrx/controllers/status.py | 2 +- owrx/sdr.py | 12 ++++++++---- owrx/service/__init__.py | 4 ++-- 6 files changed, 26 insertions(+), 12 deletions(-) diff --git a/owrx/__main__.py b/owrx/__main__.py index 1bc62c6..c6598bb 100644 --- a/owrx/__main__.py +++ b/owrx/__main__.py @@ -64,7 +64,7 @@ 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() diff --git a/owrx/connection.py b/owrx/connection.py index 5cf291a..6ed86c5 100644 --- a/owrx/connection.py +++ b/owrx/connection.py @@ -236,7 +236,7 @@ class OpenWebRxReceiverClient(OpenWebRxClient, SdrSourceEventClient): def __sendProfiles(self): profiles = [ {"name": s.getName() + " " + p["name"], "id": sid + "|" + pid} - for (sid, s) in SdrService.getSources().items() + for (sid, s) in SdrService.getActiveSources().items() for (pid, p) in s.getProfiles().items() ] self.write_profiles(profiles) diff --git a/owrx/controllers/settings/sdr.py b/owrx/controllers/settings/sdr.py index a280a34..0243850 100644 --- a/owrx/controllers/settings/sdr.py +++ b/owrx/controllers/settings/sdr.py @@ -22,10 +22,11 @@ class SdrDeviceListController(AuthorizationMixin, WebpageController): def render_devices(self): def render_device(device_id, config): - # TODO: this only returns non-failed sources... - source = SdrService.getSource(device_id) + 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() @@ -45,6 +46,15 @@ class SdrDeviceListController(AuthorizationMixin, WebpageController): 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 """
  • @@ -62,7 +72,7 @@ class SdrDeviceListController(AuthorizationMixin, WebpageController): """.format( device_name=config["name"], device_link="{}/{}".format(self.request.path, quote(device_id)), - state="Unknown" if source is None else source.getState(), + state=state_info, additional_info=additional_info, newprofile_link="{}settings/sdr/{}/newprofile".format(self.get_document_root(), quote(device_id)), ) diff --git a/owrx/controllers/status.py b/owrx/controllers/status.py index 05cacd1..b45292a 100644 --- a/owrx/controllers/status.py +++ b/owrx/controllers/status.py @@ -39,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, cls=Encoder), content_type="application/json") diff --git a/owrx/sdr.py b/owrx/sdr.py index f0bbe07..73c1ee7 100644 --- a/owrx/sdr.py +++ b/owrx/sdr.py @@ -91,7 +91,7 @@ class SdrService(object): @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. @@ -99,7 +99,7 @@ class SdrService(object): @staticmethod def getSource(id): - sources = SdrService.getSources() + sources = SdrService.getActiveSources() if not sources: return None if id not in sources: @@ -107,11 +107,15 @@ class SdrService(object): return sources[id] @staticmethod - def getSources(): + def getAllSources(): if SdrService.sources is None: SdrService.sources = MappedSdrSources(Config.get()["sdrs"]) + return SdrService.sources + + @staticmethod + def getActiveSources(): return { key: s - for key, s in SdrService.sources.items() + for key, s in SdrService.getAllSources().items() if not s.isFailed() and s.isEnabled() } diff --git a/owrx/service/__init__.py b/owrx/service/__init__.py index fe696cb..5072fd5 100644 --- a/owrx/service/__init__.py +++ b/owrx/service/__init__.py @@ -322,13 +322,13 @@ class Services(object): def start(): config = Config.get() config.wireProperty("services_enabled", Services._receiveEvent) - for source in SdrService.getSources().values(): + for source in SdrService.getActiveSources().values(): Services.schedulers.append(ServiceScheduler(source)) @staticmethod def _receiveEvent(state): if state: - for source in SdrService.getSources().values(): + for source in SdrService.getActiveSources().values(): Services.handlers.append(ServiceHandler(source)) else: while Services.handlers: From f1619b81fe4083668a259ae4e408dcbe8dcc0572 Mon Sep 17 00:00:00 2001 From: Jakob Ketterl Date: Thu, 18 Mar 2021 22:24:53 +0100 Subject: [PATCH 320/577] use the right method --- owrx/sdr.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/owrx/sdr.py b/owrx/sdr.py index 73c1ee7..ceed661 100644 --- a/owrx/sdr.py +++ b/owrx/sdr.py @@ -40,7 +40,7 @@ class MappedSdrSources(PropertyDelegator): def _removeSource(self, key): if key in self: - self[key].shutdown() + self[key].stop() for sub in self.subscriptions[key]: sub.cancel() del self.subscriptions[key] From c50473fea50382103ab06da398ba6d4ef123f454 Mon Sep 17 00:00:00 2001 From: Jakob Ketterl Date: Thu, 18 Mar 2021 22:59:46 +0100 Subject: [PATCH 321/577] implement device shutdown on deletion or lack of profiles --- owrx/connection.py | 5 +++++ owrx/dsp.py | 3 +++ owrx/fft.py | 3 +++ owrx/sdr.py | 25 +++++++++++++------------ owrx/service/__init__.py | 4 ++++ owrx/service/schedule.py | 3 +++ owrx/source/__init__.py | 8 ++++++++ 7 files changed, 39 insertions(+), 12 deletions(-) diff --git a/owrx/connection.py b/owrx/connection.py index 6ed86c5..fb2a148 100644 --- a/owrx/connection.py +++ b/owrx/connection.py @@ -230,6 +230,11 @@ class OpenWebRxReceiverClient(OpenWebRxClient, SdrSourceEventClient): self.write_log_message('SDR device "{0}" was disabled, selecting new device'.format(self.sdr.getName())) self.setSdr() + 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 getClientClass(self) -> SdrClientClass: return SdrClientClass.USER diff --git a/owrx/dsp.py b/owrx/dsp.py index 84bc22e..647d28c 100644 --- a/owrx/dsp.py +++ b/owrx/dsp.py @@ -214,3 +214,6 @@ class DspManager(csdr.output, SdrSourceEventClient): def onFail(self): logger.debug("received onFail(), shutting down DspSource") self.dsp.stop() + + def onShutdown(self): + self.dsp.stop() diff --git a/owrx/fft.py b/owrx/fft.py index cfef176..2c8ba15 100644 --- a/owrx/fft.py +++ b/owrx/fft.py @@ -84,3 +84,6 @@ class SpectrumThread(csdr.output, SdrSourceEventClient): def onFail(self): self.dsp.stop() + + def onShutdown(self): + self.dsp.stop() diff --git a/owrx/sdr.py b/owrx/sdr.py index ceed661..9933dda 100644 --- a/owrx/sdr.py +++ b/owrx/sdr.py @@ -27,7 +27,7 @@ class MappedSdrSources(PropertyDelegator): if self.isDeviceValid(value) and key not in self: self._addSource(key, value) elif not self.isDeviceValid(value) and key in self: - self._removeSource(key) + del self[key] def _addSource(self, key, value): if self.isDeviceValid(value): @@ -38,13 +38,6 @@ class MappedSdrSources(PropertyDelegator): value["profiles"].wire(updateMethod) ] - def _removeSource(self, key): - if key in self: - self[key].stop() - for sub in self.subscriptions[key]: - sub.cancel() - del self.subscriptions[key] - def isDeviceValid(self, device): return self._hasProfiles(device) and self._sdrTypeAvailable(device) @@ -75,15 +68,23 @@ class MappedSdrSources(PropertyDelegator): 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): - if key in self: - self._removeSource(key) + source = self[key] if key in self else None super().__setitem__(key, value) + if source is not None: + self._removeSource(key, source) def __delitem__(self, key): - if key in self: - self._removeSource(key) + source = self[key] if key in self else None super().__delitem__(key) + if source is not None: + self._removeSource(key, source) class SdrService(object): diff --git a/owrx/service/__init__.py b/owrx/service/__init__.py index 5072fd5..cf76e38 100644 --- a/owrx/service/__init__.py +++ b/owrx/service/__init__.py @@ -122,6 +122,10 @@ class ServiceHandler(SdrSourceEventClient): 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() diff --git a/owrx/service/schedule.py b/owrx/service/schedule.py index 4d136b3..bd6ec23 100644 --- a/owrx/service/schedule.py +++ b/owrx/service/schedule.py @@ -258,6 +258,9 @@ class ServiceScheduler(SdrSourceEventClient): def onFail(self): self.shutdown() + def onShutdown(self): + self.shutdown() + def onDisable(self): self.cancelTimer() diff --git a/owrx/source/__init__.py b/owrx/source/__init__.py index 6ac69d4..af96f27 100644 --- a/owrx/source/__init__.py +++ b/owrx/source/__init__.py @@ -56,6 +56,9 @@ class SdrSourceEventClient(object): def onFail(self): pass + def onShutdown(self): + pass + def onDisable(self): pass @@ -349,6 +352,11 @@ class SdrSource(ABC): if self.monitor: self.monitor.join() + def shutdown(self): + self.stop() + for c in self.clients.copy(): + c.onShutdown() + def getClients(self, *args): if not args: return self.clients From c58ebfa657f91aa7d922555bc04dcd9081deea4c Mon Sep 17 00:00:00 2001 From: Jakob Ketterl Date: Sat, 20 Mar 2021 00:54:45 +0100 Subject: [PATCH 322/577] readonly also prevents deletion --- owrx/property/__init__.py | 3 +++ test/property/test_property_readonly.py | 8 ++++++++ 2 files changed, 11 insertions(+) diff --git a/owrx/property/__init__.py b/owrx/property/__init__.py index f04b115..88d80f4 100644 --- a/owrx/property/__init__.py +++ b/owrx/property/__init__.py @@ -264,6 +264,9 @@ class PropertyReadOnly(PropertyDelegator): def __setitem__(self, key, value): raise PropertyWriteError(key) + def __delitem__(self, key): + raise PropertyWriteError(key) + class PropertyStack(PropertyManager): def __init__(self): diff --git a/test/property/test_property_readonly.py b/test/property/test_property_readonly.py index a6e74f7..09d57ee 100644 --- a/test/property/test_property_readonly.py +++ b/test/property/test_property_readonly.py @@ -13,3 +13,11 @@ class PropertyReadOnlyTest(TestCase): ro["otherkey"] = "testvalue" self.assertEqual(ro["testkey"], "initial value") self.assertNotIn("otherkey", ro) + + def testPreventsDeletes(self): + layer = PropertyLayer(testkey="some value") + ro = PropertyReadOnly(layer) + with self.assertRaises(PropertyWriteError): + del ro["testkey"] + self.assertEqual(ro["testkey"], "some value") + self.assertEqual(layer["testkey"], "some value") From 792f76f83152846baf6e317808f9bed8dcaedafb Mon Sep 17 00:00:00 2001 From: Jakob Ketterl Date: Sat, 20 Mar 2021 01:10:18 +0100 Subject: [PATCH 323/577] turn the dict of active sources into a living PropertyManager --- owrx/sdr.py | 68 ++++++++++++++++++++++++++++++++++++++++++++++++----- 1 file changed, 62 insertions(+), 6 deletions(-) diff --git a/owrx/sdr.py b/owrx/sdr.py index 9933dda..cad207e 100644 --- a/owrx/sdr.py +++ b/owrx/sdr.py @@ -1,6 +1,7 @@ from owrx.config import Config -from owrx.property import PropertyManager, PropertyDeleted, PropertyDelegator, 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 @@ -76,6 +77,8 @@ class MappedSdrSources(PropertyDelegator): 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) @@ -87,8 +90,63 @@ class MappedSdrSources(PropertyDelegator): 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 SdrService(object): sources = None + activeSources = None @staticmethod def getFirstSource(): @@ -115,8 +173,6 @@ class SdrService(object): @staticmethod def getActiveSources(): - return { - key: s - for key, s in SdrService.getAllSources().items() - if not s.isFailed() and s.isEnabled() - } + if SdrService.activeSources is None: + SdrService.activeSources = ActiveSdrSources(SdrService.getAllSources()) + return SdrService.activeSources From cfeab98620b5d83429d394c2e817c8ed184cc445 Mon Sep 17 00:00:00 2001 From: Jakob Ketterl Date: Sat, 20 Mar 2021 01:56:07 +0100 Subject: [PATCH 324/577] hook up service handling to new device events --- owrx/service/__init__.py | 50 ++++++++++++++++++++++++++++------------ 1 file changed, 35 insertions(+), 15 deletions(-) diff --git a/owrx/service/__init__.py b/owrx/service/__init__.py index cf76e38..5ea16b3 100644 --- a/owrx/service/__init__.py +++ b/owrx/service/__init__.py @@ -12,7 +12,7 @@ from owrx.source.resampler import Resampler 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 @@ -319,28 +319,48 @@ class Js8Handler(object): class Services(object): - handlers = [] - schedulers = [] + handlers = {} + schedulers = {} @staticmethod def start(): config = Config.get() - config.wireProperty("services_enabled", Services._receiveEvent) - for source in SdrService.getActiveSources().values(): - Services.schedulers.append(ServiceScheduler(source)) + 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 _receiveEvent(state): + def _receiveEnabledEvent(state): if state: - for source in SdrService.getActiveSources().values(): - Services.handlers.append(ServiceHandler(source)) + for key, source in SdrService.getActiveSources().__dict__().items(): + Services.handlers[key] = ServiceHandler(source) else: - while Services.handlers: - Services.handlers.pop().shutdown() + for handler in 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(): - while Services.handlers: - Services.handlers.pop().shutdown() - while Services.schedulers: - Services.schedulers.pop().shutdown() + for handler in Services.handlers.values(): + handler.shutdown() + Services.handlers = {} + for scheduler in Services.schedulers.values(): + scheduler.shutdown() + Services.schedulers = {} From 3b9763eee56b007b30b6d396beae0182513b6968 Mon Sep 17 00:00:00 2001 From: Jakob Ketterl Date: Sat, 20 Mar 2021 02:16:08 +0100 Subject: [PATCH 325/577] fix device deletion --- owrx/sdr.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/owrx/sdr.py b/owrx/sdr.py index cad207e..9a177d4 100644 --- a/owrx/sdr.py +++ b/owrx/sdr.py @@ -20,7 +20,8 @@ class MappedSdrSources(PropertyDelegator): def handleSdrDeviceChange(self, changes): for key, value in changes.items(): if value is PropertyDeleted: - del self[key] + if key in self: + del self[key] else: self._addSource(key, value) From d872152cc8b89df8366d112fa55ef34d88681cb1 Mon Sep 17 00:00:00 2001 From: Jakob Ketterl Date: Sat, 20 Mar 2021 17:23:35 +0100 Subject: [PATCH 326/577] restore python 3.5 compatibility --- owrx/source/__init__.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/owrx/source/__init__.py b/owrx/source/__init__.py index af96f27..ef9f4e4 100644 --- a/owrx/source/__init__.py +++ b/owrx/source/__init__.py @@ -17,7 +17,7 @@ from owrx.form.converter import OptionalConverter from owrx.form.device import GainInput, SchedulerInput, WaterfallLevelsInput from owrx.controllers.settings import Section from typing import List -from enum import Enum, auto +from enum import Enum import logging @@ -36,14 +36,14 @@ class SdrSourceState(Enum): class SdrBusyState(Enum): - IDLE = auto() - BUSY = auto() + IDLE = 1 + BUSY = 2 class SdrClientClass(Enum): - INACTIVE = auto() - BACKGROUND = auto() - USER = auto() + INACTIVE = 1 + BACKGROUND = 2 + USER = 3 class SdrSourceEventClient(object): From 341e254640101f97c1bd6b9e1988ba926ffc09a3 Mon Sep 17 00:00:00 2001 From: Jakob Ketterl Date: Sat, 20 Mar 2021 17:24:00 +0100 Subject: [PATCH 327/577] fix shutdown iteration --- owrx/service/__init__.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/owrx/service/__init__.py b/owrx/service/__init__.py index 5ea16b3..799863b 100644 --- a/owrx/service/__init__.py +++ b/owrx/service/__init__.py @@ -337,7 +337,7 @@ class Services(object): for key, source in SdrService.getActiveSources().__dict__().items(): Services.handlers[key] = ServiceHandler(source) else: - for handler in Services.handlers.values(): + for handler in list(Services.handlers.values()): handler.shutdown() Services.handlers = {} @@ -358,9 +358,9 @@ class Services(object): @staticmethod def stop(): - for handler in Services.handlers.values(): + for handler in list(Services.handlers.values()): handler.shutdown() Services.handlers = {} - for scheduler in Services.schedulers.values(): + for scheduler in list(Services.schedulers.values()): scheduler.shutdown() Services.schedulers = {} From 2a82f4e452eb2a2195a533b333322fa002fc083a Mon Sep 17 00:00:00 2001 From: Jakob Ketterl Date: Sat, 20 Mar 2021 23:14:29 +0100 Subject: [PATCH 328/577] wire profile transmission into active sdr device hash --- owrx/connection.py | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/owrx/connection.py b/owrx/connection.py index fb2a148..827269c 100644 --- a/owrx/connection.py +++ b/owrx/connection.py @@ -161,7 +161,8 @@ class OpenWebRxReceiverClient(OpenWebRxClient, SdrSourceEventClient): modes = Modes.getModes() self.write_modes(modes) - self.__sendProfiles() + self._sendProfiles() + SdrService.getActiveSources().wire(self._sendProfiles) CpuUsageThread.getSharedInstance().add_client(self) @@ -238,7 +239,7 @@ class OpenWebRxReceiverClient(OpenWebRxClient, SdrSourceEventClient): def getClientClass(self) -> SdrClientClass: return SdrClientClass.USER - def __sendProfiles(self): + def _sendProfiles(self, *args): profiles = [ {"name": s.getName() + " " + p["name"], "id": sid + "|" + pid} for (sid, s) in SdrService.getActiveSources().items() @@ -314,8 +315,6 @@ class OpenWebRxReceiverClient(OpenWebRxClient, SdrSourceEventClient): self.getDsp().setProperties(self.connectionProperties) self.stack.replaceLayer(0, self.sdr.getProps()) - self.__sendProfiles() - self.sdr.addSpectrumClient(self) def handleNoSdrsAvailable(self): From 8fa17960379fa689b2dc618160a4697975c6d997 Mon Sep 17 00:00:00 2001 From: Jakob Ketterl Date: Sat, 20 Mar 2021 23:30:09 +0100 Subject: [PATCH 329/577] re-start connection sdr if no sdr was available before --- owrx/connection.py | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/owrx/connection.py b/owrx/connection.py index 827269c..c90e41b 100644 --- a/owrx/connection.py +++ b/owrx/connection.py @@ -162,7 +162,7 @@ class OpenWebRxReceiverClient(OpenWebRxClient, SdrSourceEventClient): self.write_modes(modes) self._sendProfiles() - SdrService.getActiveSources().wire(self._sendProfiles) + SdrService.getActiveSources().wire(self._onSdrDeviceChanges) CpuUsageThread.getSharedInstance().add_client(self) @@ -239,7 +239,13 @@ class OpenWebRxReceiverClient(OpenWebRxClient, SdrSourceEventClient): def getClientClass(self) -> SdrClientClass: return SdrClientClass.USER - def _sendProfiles(self, *args): + def _onSdrDeviceChanges(self, changes): + self._sendProfiles() + # 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): profiles = [ {"name": s.getName() + " " + p["name"], "id": sid + "|" + pid} for (sid, s) in SdrService.getActiveSources().items() @@ -302,13 +308,14 @@ class OpenWebRxReceiverClient(OpenWebRxClient, SdrSourceEventClient): 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): From acee318daedd2ae781397d407dd2187f83ace01e Mon Sep 17 00:00:00 2001 From: Jakob Ketterl Date: Sun, 21 Mar 2021 00:14:18 +0100 Subject: [PATCH 330/577] make the frontend resume when an sdr device becomes present --- htdocs/openwebrx.js | 7 +++++++ owrx/connection.py | 1 + owrx/property/__init__.py | 5 +++++ test/property/test_property_stack.py | 12 ++++++++++++ 4 files changed, 25 insertions(+) diff --git a/htdocs/openwebrx.js b/htdocs/openwebrx.js index b6343fd..0c535ba 100644 --- a/htdocs/openwebrx.js +++ b/htdocs/openwebrx.js @@ -800,6 +800,12 @@ function on_ws_recv(evt) { return '"; }).join("")); $('#openwebrx-sdr-profiles-listbox').val(currentprofile.toString()); + // this is a bit hacky since it only makes sense if the error is actually "no sdr devices" + // the only other error condition for which the overlay is used right now is "too many users" + // so there shouldn't be a problem here + if (json['value'].keys()) { + $('#openwebrx-error-overlay').hide(); + } break; case "features": Modes.setFeatures(json['value']); @@ -836,6 +842,7 @@ function on_ws_recv(evt) { var $overlay = $('#openwebrx-error-overlay'); $overlay.find('.errormessage').text(json['value']); $overlay.show(); + $("#openwebrx-panel-receiver").demodulatorPanel().stopDemodulator(); break; case 'secondary_demod': secondary_demod_push_data(json['value']); diff --git a/owrx/connection.py b/owrx/connection.py index c90e41b..b09a505 100644 --- a/owrx/connection.py +++ b/owrx/connection.py @@ -325,6 +325,7 @@ class OpenWebRxReceiverClient(OpenWebRxClient, SdrSourceEventClient): self.sdr.addSpectrumClient(self) def handleNoSdrsAvailable(self): + self.stack.removeLayerByPriority(0) self.write_sdr_error("No SDR Devices available") def startDsp(self): diff --git a/owrx/property/__init__.py b/owrx/property/__init__.py index 88d80f4..a38c592 100644 --- a/owrx/property/__init__.py +++ b/owrx/property/__init__.py @@ -294,6 +294,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: diff --git a/test/property/test_property_stack.py b/test/property/test_property_stack.py index f3c3a12..860f13f 100644 --- a/test/property/test_property_stack.py +++ b/test/property/test_property_stack.py @@ -42,6 +42,18 @@ class PropertyStackTest(TestCase): om.removeLayer(high_pm) self.assertEqual(om["testkey"], "low value") + def testLayerRemovalByPriority(self): + om = PropertyStack() + low_pm = PropertyLayer() + high_pm = PropertyLayer() + low_pm["testkey"] = "low value" + high_pm["testkey"] = "high value" + om.addLayer(1, low_pm) + om.addLayer(0, high_pm) + self.assertEqual(om["testkey"], "high value") + om.removeLayerByPriority(0) + self.assertEqual(om["testkey"], "low value") + def testPropertyChange(self): layer = PropertyLayer() stack = PropertyStack() From 5fc8672dd6ac9368a86c91a6142c7fc6002470a3 Mon Sep 17 00:00:00 2001 From: Jakob Ketterl Date: Sun, 21 Mar 2021 00:18:35 +0100 Subject: [PATCH 331/577] fix profile detection --- htdocs/openwebrx.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/htdocs/openwebrx.js b/htdocs/openwebrx.js index 0c535ba..83616a6 100644 --- a/htdocs/openwebrx.js +++ b/htdocs/openwebrx.js @@ -803,7 +803,7 @@ function on_ws_recv(evt) { // this is a bit hacky since it only makes sense if the error is actually "no sdr devices" // the only other error condition for which the overlay is used right now is "too many users" // so there shouldn't be a problem here - if (json['value'].keys()) { + if (Object.keys(json['value']).length) { $('#openwebrx-error-overlay').hide(); } break; From c9d303c43e55b3742cf62ea04748a27743cab175 Mon Sep 17 00:00:00 2001 From: Jakob Ketterl Date: Sun, 21 Mar 2021 15:19:40 +0100 Subject: [PATCH 332/577] remove "configurable_keys" hack --- owrx/connection.py | 18 ------------------ 1 file changed, 18 deletions(-) diff --git a/owrx/connection.py b/owrx/connection.py index b09a505..ce7b795 100644 --- a/owrx/connection.py +++ b/owrx/connection.py @@ -269,9 +269,6 @@ class OpenWebRxReceiverClient(OpenWebRxClient, SdrSourceEventClient): 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"]) @@ -348,21 +345,6 @@ class OpenWebRxReceiverClient(OpenWebRxClient, SdrSourceEventClient): 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) From 62e67afc9cd0a9d2f436a29e267c40c97ce7e7b2 Mon Sep 17 00:00:00 2001 From: Jakob Ketterl Date: Sun, 21 Mar 2021 15:23:26 +0100 Subject: [PATCH 333/577] update config to version 6 --- config_webrx.py | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/config_webrx.py b/config_webrx.py index 636f65f..929d5b4 100644 --- a/config_webrx.py +++ b/config_webrx.py @@ -33,7 +33,7 @@ config_webrx: configuration options for OpenWebRX """ # configuration version. please only modify if you're able to perform the associated migration steps. -version = 5 +version = 6 # NOTE: you can find additional information about configuring OpenWebRX in the Wiki: # https://github.com/jketterl/openwebrx/wiki/Configuration-guide @@ -270,8 +270,12 @@ waterfall_auto_level_margin = {"min": 3, "max": 10, "min_range": 50} # current_max_power_level __| # This setting allows you to modify the precision of the frequency displays in OpenWebRX. -# Set this to the number of digits you would like to see: -#frequency_display_precision = 4 +# Set this to exponent of 10 to select the most precise digit in Hz you'd like to see +# examples: +# a value of 2 selects 10^2 = 100Hz tuning precision (default): +#tuning_precision = 2 +# a value of 1 selects 10^1 = 10Hz tuning precision: +#tuning_precision = 1 # This setting tells the auto-squelch the offset to add to the current signal level to use as the new squelch level. # Lowering this setting will give you a more sensitive squelch, but it may also cause unwanted squelch openings when From deeaccba12d22b2ec9b95630cd00f853b568cd98 Mon Sep 17 00:00:00 2001 From: Jakob Ketterl Date: Wed, 24 Mar 2021 15:57:25 +0100 Subject: [PATCH 334/577] profile as properties, live sync additions and removals with the client --- owrx/connection.py | 12 ++++-------- owrx/sdr.py | 44 ++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 48 insertions(+), 8 deletions(-) diff --git a/owrx/connection.py b/owrx/connection.py index ce7b795..fe5ac17 100644 --- a/owrx/connection.py +++ b/owrx/connection.py @@ -161,8 +161,9 @@ class OpenWebRxReceiverClient(OpenWebRxClient, SdrSourceEventClient): modes = Modes.getModes() self.write_modes(modes) + self.configSubs.append(SdrService.getActiveSources().wire(self._onSdrDeviceChanges)) + self.configSubs.append(SdrService.getAvailableProfiles().wire(self._sendProfiles)) self._sendProfiles() - SdrService.getActiveSources().wire(self._onSdrDeviceChanges) CpuUsageThread.getSharedInstance().add_client(self) @@ -240,17 +241,12 @@ class OpenWebRxReceiverClient(OpenWebRxClient, SdrSourceEventClient): return SdrClientClass.USER def _onSdrDeviceChanges(self, changes): - self._sendProfiles() # 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): - profiles = [ - {"name": s.getName() + " " + p["name"], "id": sid + "|" + pid} - for (sid, s) in SdrService.getActiveSources().items() - for (pid, p) in s.getProfiles().items() - ] + 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): diff --git a/owrx/sdr.py b/owrx/sdr.py index 9a177d4..e17856d 100644 --- a/owrx/sdr.py +++ b/owrx/sdr.py @@ -145,9 +145,47 @@ class ActiveSdrSources(PropertyReadOnly): del self._layer[key] +class AvailableProfiles(PropertyReadOnly): + def __init__(self, pm: PropertyManager): + self.subscriptions = {} + 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 handleProfileChange(self, source_id, source: SdrSource, changes): + for key, value in changes.items(): + if value is PropertyDeleted: + del self._layer["{}|{}".format(source_id, key)] + else: + self._layer["{}|{}".format(source_id, key)] = "{} {}".format(source.getName(), value["name"]) + + def _addSource(self, key, source: SdrSource): + for pid, p in source.getProfiles().items(): + self._layer["{}|{}".format(key, pid)] = "{} {}".format(source.getName(), p["name"]) + self.subscriptions[key] = source.getProfiles().wire(partial(self.handleProfileChange, key, source)) + + 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: + self.subscriptions[key].cancel() + del self.subscriptions[key] + + class SdrService(object): sources = None activeSources = None + availableProfiles = None @staticmethod def getFirstSource(): @@ -177,3 +215,9 @@ class SdrService(object): 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 From d50d08ad2c87650d6c6a2612d2d8459b0f80a3d3 Mon Sep 17 00:00:00 2001 From: Jakob Ketterl Date: Wed, 24 Mar 2021 16:08:13 +0100 Subject: [PATCH 335/577] add a robots.txt to exclude certain routes for search engines --- owrx/controllers/robots.py | 16 ++++++++++++++++ owrx/http.py | 2 ++ 2 files changed, 18 insertions(+) create mode 100644 owrx/controllers/robots.py diff --git a/owrx/controllers/robots.py b/owrx/controllers/robots.py new file mode 100644 index 0000000..3d00a1e --- /dev/null +++ b/owrx/controllers/robots.py @@ -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", + ) diff --git a/owrx/http.py b/owrx/http.py index 51a6837..9247dea 100644 --- a/owrx/http.py +++ b/owrx/http.py @@ -20,6 +20,7 @@ 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 @@ -105,6 +106,7 @@ class Router(object): def __init__(self): self.routes = [ StaticRoute("/", IndexController), + StaticRoute("/robots.txt", RobotsController), StaticRoute("/status.json", StatusController), RegexRoute("^/static/(.+)$", OwrxAssetsController), RegexRoute("^/compiled/(.+)$", CompiledAssetsController), From a90f77e545053bcec49ee8a6c2a1f82ea2cc6b99 Mon Sep 17 00:00:00 2001 From: Jakob Ketterl Date: Wed, 24 Mar 2021 16:53:01 +0100 Subject: [PATCH 336/577] retain the redirect url on login failure --- owrx/controllers/session.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/owrx/controllers/session.py b/owrx/controllers/session.py index 009ef9f..44f1e14 100644 --- a/owrx/controllers/session.py +++ b/owrx/controllers/session.py @@ -54,7 +54,8 @@ class SessionController(WebpageController): target = "/pwchange?{0}".format(urlencode({"ref": target})) self.send_redirect(target, cookies=cookie) 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") From 5f7daba3b2ed5fe9724fae770d1f5aa75d6c009e Mon Sep 17 00:00:00 2001 From: Jakob Ketterl Date: Wed, 24 Mar 2021 17:19:49 +0100 Subject: [PATCH 337/577] move the default sdrs to the new defaults file --- config_webrx.py | 238 ++++++++++++++++++++-------------------- owrx/config/defaults.py | 120 +++++++++++++++++++- 2 files changed, 238 insertions(+), 120 deletions(-) diff --git a/config_webrx.py b/config_webrx.py index 929d5b4..1ce7c06 100644 --- a/config_webrx.py +++ b/config_webrx.py @@ -121,125 +121,125 @@ Note: if you experience audio underruns while CPU usage is 100%, you can: # For more details on specific types, please checkout the wiki: # https://github.com/jketterl/openwebrx/wiki/Supported-Hardware#sdr-devices -sdrs = { - "rtlsdr": { - "name": "RTL-SDR USB Stick", - "type": "rtl_sdr", - "ppm": 0, - # you can change this if you use an upconverter. formula is: - # center_freq + lfo_offset = actual frequency on the sdr - # "lfo_offset": 0, - "profiles": { - "70cm": { - "name": "70cm Relais", - "center_freq": 438800000, - "rf_gain": 29, - "samp_rate": 2400000, - "start_freq": 439275000, - "start_mod": "nfm", - }, - "2m": { - "name": "2m komplett", - "center_freq": 145000000, - "rf_gain": 29, - "samp_rate": 2048000, - "start_freq": 145725000, - "start_mod": "nfm", - }, - }, - }, - "airspy": { - "name": "Airspy HF+", - "type": "airspyhf", - "ppm": 0, - "rf_gain": "auto", - "profiles": { - "20m": { - "name": "20m", - "center_freq": 14150000, - "samp_rate": 384000, - "start_freq": 14070000, - "start_mod": "usb", - }, - "30m": { - "name": "30m", - "center_freq": 10125000, - "samp_rate": 192000, - "start_freq": 10142000, - "start_mod": "usb", - }, - "40m": { - "name": "40m", - "center_freq": 7100000, - "samp_rate": 256000, - "start_freq": 7070000, - "start_mod": "lsb", - }, - "80m": { - "name": "80m", - "center_freq": 3650000, - "samp_rate": 384000, - "start_freq": 3570000, - "start_mod": "lsb", - }, - "49m": { - "name": "49m Broadcast", - "center_freq": 6050000, - "samp_rate": 384000, - "start_freq": 6070000, - "start_mod": "am", - }, - }, - }, - "sdrplay": { - "name": "SDRPlay RSP2", - "type": "sdrplay", - "ppm": 0, - "antenna": "Antenna A", - "profiles": { - "20m": { - "name": "20m", - "center_freq": 14150000, - "rf_gain": 0, - "samp_rate": 500000, - "start_freq": 14070000, - "start_mod": "usb", - }, - "30m": { - "name": "30m", - "center_freq": 10125000, - "rf_gain": 0, - "samp_rate": 250000, - "start_freq": 10142000, - "start_mod": "usb", - }, - "40m": { - "name": "40m", - "center_freq": 7100000, - "rf_gain": 0, - "samp_rate": 500000, - "start_freq": 7070000, - "start_mod": "lsb", - }, - "80m": { - "name": "80m", - "center_freq": 3650000, - "rf_gain": 0, - "samp_rate": 500000, - "start_freq": 3570000, - "start_mod": "lsb", - }, - "49m": { - "name": "49m Broadcast", - "center_freq": 6000000, - "rf_gain": 0, - "samp_rate": 500000, - "start_freq": 6070000, - "start_mod": "am", - }, - }, - }, -} +#sdrs = { +# "rtlsdr": { +# "name": "RTL-SDR USB Stick", +# "type": "rtl_sdr", +# "ppm": 0, +# # you can change this if you use an upconverter. formula is: +# # center_freq + lfo_offset = actual frequency on the sdr +# # "lfo_offset": 0, +# "profiles": { +# "70cm": { +# "name": "70cm Relais", +# "center_freq": 438800000, +# "rf_gain": 29, +# "samp_rate": 2400000, +# "start_freq": 439275000, +# "start_mod": "nfm", +# }, +# "2m": { +# "name": "2m komplett", +# "center_freq": 145000000, +# "rf_gain": 29, +# "samp_rate": 2048000, +# "start_freq": 145725000, +# "start_mod": "nfm", +# }, +# }, +# }, +# "airspy": { +# "name": "Airspy HF+", +# "type": "airspyhf", +# "ppm": 0, +# "rf_gain": "auto", +# "profiles": { +# "20m": { +# "name": "20m", +# "center_freq": 14150000, +# "samp_rate": 384000, +# "start_freq": 14070000, +# "start_mod": "usb", +# }, +# "30m": { +# "name": "30m", +# "center_freq": 10125000, +# "samp_rate": 192000, +# "start_freq": 10142000, +# "start_mod": "usb", +# }, +# "40m": { +# "name": "40m", +# "center_freq": 7100000, +# "samp_rate": 256000, +# "start_freq": 7070000, +# "start_mod": "lsb", +# }, +# "80m": { +# "name": "80m", +# "center_freq": 3650000, +# "samp_rate": 384000, +# "start_freq": 3570000, +# "start_mod": "lsb", +# }, +# "49m": { +# "name": "49m Broadcast", +# "center_freq": 6050000, +# "samp_rate": 384000, +# "start_freq": 6070000, +# "start_mod": "am", +# }, +# }, +# }, +# "sdrplay": { +# "name": "SDRPlay RSP2", +# "type": "sdrplay", +# "ppm": 0, +# "antenna": "Antenna A", +# "profiles": { +# "20m": { +# "name": "20m", +# "center_freq": 14150000, +# "rf_gain": 0, +# "samp_rate": 500000, +# "start_freq": 14070000, +# "start_mod": "usb", +# }, +# "30m": { +# "name": "30m", +# "center_freq": 10125000, +# "rf_gain": 0, +# "samp_rate": 250000, +# "start_freq": 10142000, +# "start_mod": "usb", +# }, +# "40m": { +# "name": "40m", +# "center_freq": 7100000, +# "rf_gain": 0, +# "samp_rate": 500000, +# "start_freq": 7070000, +# "start_mod": "lsb", +# }, +# "80m": { +# "name": "80m", +# "center_freq": 3650000, +# "rf_gain": 0, +# "samp_rate": 500000, +# "start_freq": 3570000, +# "start_mod": "lsb", +# }, +# "49m": { +# "name": "49m Broadcast", +# "center_freq": 6000000, +# "rf_gain": 0, +# "samp_rate": 500000, +# "start_freq": 6070000, +# "start_mod": "am", +# }, +# }, +# }, +#} # ==== Color themes ==== diff --git a/owrx/config/defaults.py b/owrx/config/defaults.py index db0441d..2ea3286 100644 --- a/owrx/config/defaults.py +++ b/owrx/config/defaults.py @@ -21,7 +21,125 @@ defaultConfig = PropertyLayer( digimodes_fft_size=2048, digital_voice_unvoiced_quality=1, digital_voice_dmr_id_lookup=True, - # sdrs=... + sdrs=PropertyLayer( + rtlsdr=PropertyLayer( + name="RTL-SDR USB Stick", + type="rtl_sdr", + profiles=PropertyLayer( + **{ + "70cm": PropertyLayer( + name="70cm Relais", + center_freq=438800000, + rf_gain=29, + samp_rate=2400000, + start_freq=439275000, + start_mod="nfm", + ), + "2m": PropertyLayer( + name="2m komplett", + 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_level_margin=PropertyLayer(min=3, max=10, min_range=50), From b01792c3d271cab36819864ca0b28d81eaa20f05 Mon Sep 17 00:00:00 2001 From: Jakob Ketterl Date: Wed, 24 Mar 2021 17:25:59 +0100 Subject: [PATCH 338/577] fix deletion of sdrs when there's no changes --- owrx/controllers/settings/sdr.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/owrx/controllers/settings/sdr.py b/owrx/controllers/settings/sdr.py index 0243850..a655a6d 100644 --- a/owrx/controllers/settings/sdr.py +++ b/owrx/controllers/settings/sdr.py @@ -255,6 +255,8 @@ class SdrDeviceController(SdrFormControllerWithModal): 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())) From 4cbce9c84075d9578ab368bbafb7fef3bb9d4330 Mon Sep 17 00:00:00 2001 From: Jakob Ketterl Date: Wed, 24 Mar 2021 20:47:04 +0100 Subject: [PATCH 339/577] always remove device props on switch, fixes device failover --- owrx/connection.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/owrx/connection.py b/owrx/connection.py index fe5ac17..f55f7d8 100644 --- a/owrx/connection.py +++ b/owrx/connection.py @@ -297,6 +297,7 @@ class OpenWebRxReceiverClient(OpenWebRxClient, SdrSourceEventClient): return self.stopDsp() + self.stack.removeLayerByPriority(0) if self.sdr is not None: self.sdr.removeClient(self) @@ -318,7 +319,6 @@ class OpenWebRxReceiverClient(OpenWebRxClient, SdrSourceEventClient): self.sdr.addSpectrumClient(self) def handleNoSdrsAvailable(self): - self.stack.removeLayerByPriority(0) self.write_sdr_error("No SDR Devices available") def startDsp(self): From 6ddced4689ebb0dde4b8893c40d6d9b039e85677 Mon Sep 17 00:00:00 2001 From: Jakob Ketterl Date: Wed, 24 Mar 2021 22:46:51 +0100 Subject: [PATCH 340/577] implement basic error handling and validation for forms --- owrx/controllers/settings/__init__.py | 60 ++++++++++--- owrx/controllers/settings/sdr.py | 9 +- owrx/form/__init__.py | 116 ++++++++++++++++---------- owrx/form/device.py | 44 +++++----- owrx/form/error.py | 15 ++++ owrx/form/gfx.py | 3 +- owrx/form/validator.py | 14 ++++ owrx/form/wsjt.py | 23 ++--- owrx/source/__init__.py | 21 +++-- 9 files changed, 206 insertions(+), 99 deletions(-) create mode 100644 owrx/form/error.py create mode 100644 owrx/form/validator.py diff --git a/owrx/controllers/settings/__init__.py b/owrx/controllers/settings/__init__.py index c96645c..85bd2a9 100644 --- a/owrx/controllers/settings/__init__.py +++ b/owrx/controllers/settings/__init__.py @@ -1,6 +1,7 @@ from owrx.config import Config from owrx.controllers.admin import AuthorizationMixin from owrx.controllers.template import WebpageController +from owrx.form.error import FormError from abc import ABCMeta, abstractmethod from urllib.parse import parse_qs @@ -10,16 +11,16 @@ class Section(object): self.title = title self.inputs = inputs - def render_input(self, input, data): - return input.render(data) + def render_input(self, input, data, errors): + return input.render(data, errors) - def render_inputs(self, data): - return "".join([self.render_input(i, data) for i in self.inputs]) + 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): + def render(self, data, errors): return """

    @@ -28,11 +29,20 @@ class Section(object): {inputs}

    """.format( - classes=" ".join(self.classes()), title=self.title, inputs=self.render_inputs(data) + classes=" ".join(self.classes()), title=self.title, inputs=self.render_inputs(data, errors) ) def parse(self, data): - return {k: v for i in self.inputs for k, v in i.parse(data).items()} + 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 SettingsController(AuthorizationMixin, WebpageController): @@ -41,6 +51,10 @@ class SettingsController(AuthorizationMixin, WebpageController): class SettingsFormController(AuthorizationMixin, WebpageController, metaclass=ABCMeta): + def __init__(self, handler, request, options): + super().__init__(handler, request, options) + self.errors = {} + @abstractmethod def getSections(self): pass @@ -52,8 +66,11 @@ class SettingsFormController(AuthorizationMixin, WebpageController, metaclass=AB def getData(self): return Config.get() + def getErrors(self): + return self.errors + def render_sections(self): - sections = "".join(section.render(self.getData()) for section in self.getSections()) + sections = "".join(section.render(self.getData(), self.getErrors()) for section in self.getSections()) buttons = self.render_buttons() return """ @@ -84,15 +101,34 @@ class SettingsFormController(AuthorizationMixin, WebpageController, metaclass=AB def parseFormData(self): data = parse_qs(self.get_body().decode("utf-8"), keep_blank_values=True) - return {k: v for i in self.getSections() for k, v in i.parse(data).items()} + 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.request.path + 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): - self.processData(self.parseFormData()) - self.store() - self.send_redirect(self.getSuccessfulRedirect()) + data, errors = self.parseFormData() + if errors: + self.errors = self._mergeErrors(errors) + self.indexAction() + else: + self.processData(data) + self.store() + self.send_redirect(self.getSuccessfulRedirect()) def processData(self, data): config = self.getData() diff --git a/owrx/controllers/settings/sdr.py b/owrx/controllers/settings/sdr.py index a655a6d..524f1b1 100644 --- a/owrx/controllers/settings/sdr.py +++ b/owrx/controllers/settings/sdr.py @@ -8,6 +8,7 @@ from owrx.controllers.settings import Section from urllib.parse import quote, unquote from owrx.sdr import SdrService from owrx.form import TextInput, DropdownInput, Option +from owrx.form.validator import RequiredValidator from owrx.property import PropertyLayer, PropertyStack from abc import ABCMeta, abstractmethod @@ -235,7 +236,7 @@ class SdrDeviceController(SdrFormControllerWithModal): if self.device is None: self.send_response("device not found", code=404) return - self.serve_template("settings/general.html", **self.template_variables()) + super().indexAction() def processFormData(self): if self.device is None: @@ -276,7 +277,7 @@ class NewSdrDeviceController(SettingsFormController): "New device settings", TextInput("name", "Device name"), DropdownInput("type", "Device type", [Option(name, name) for name in SdrDeviceDescription.getTypes()]), - TextInput("id", "Device ID"), + TextInput("id", "Device ID", validator=RequiredValidator()), ) ] @@ -331,7 +332,7 @@ class SdrProfileController(SdrFormControllerWithModal): if self.profile is None: self.send_response("profile not found", code=404) return - self.serve_template("settings/general.html", **self.template_variables()) + super().indexAction() def processFormData(self): if self.profile is None: @@ -377,7 +378,7 @@ class NewProfileController(SdrProfileController): return [ Section( "New profile settings", - TextInput("id", "Profile ID"), + TextInput("id", "Profile ID", validator=RequiredValidator()), ) ] + super().getSections() diff --git a/owrx/form/__init__.py b/owrx/form/__init__.py index f53cc00..4968c1e 100644 --- a/owrx/form/__init__.py +++ b/owrx/form/__init__.py @@ -1,16 +1,18 @@ -from abc import ABC, abstractmethod +from abc import ABC from owrx.modes import Modes from owrx.config import Config +from owrx.form.validator import Validator from owrx.form.converter import Converter, NullConverter, IntConverter, FloatConverter, EnumConverter from enum import Enum class Input(ABC): - def __init__(self, id, label, infotext=None, converter: Converter = None, disabled=False, removable=False): + 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 @@ -24,7 +26,6 @@ class Input(ABC): return NullConverter() def bootstrap_decorate(self, input): - infotext = "{text}".format(text=self.infotext) if self.infotext else "" return """
    @@ -40,19 +41,22 @@ class Input(ABC): id=self.id, label=self.label, input=input, - infotext=infotext, + infotext="{text}".format(text=self.infotext) if self.infotext else "", removable="removable" if self.removable else "", removebutton='' if self.removable else "", ) - def input_classes(self): - return " ".join(["form-control", "form-control-sm"]) + def input_classes(self, error): + classes = ["form-control", "form-control-sm"] + if error: + classes.append("is-invalid") + return " ".join(classes) - def input_properties(self, value): + def input_properties(self, value, error): props = { - "class": self.input_classes(), + "class": self.input_classes(error), "id": self.id, "name": self.id, "placeholder": self.label, @@ -62,26 +66,35 @@ class Input(ABC): props["disabled"] = "disabled" return props - def render_input_properties(self, value): - return " ".join('{}="{}"'.format(prop, value) for prop, value in self.input_properties(value).items()) + def render_input_properties(self, value, error): + return " ".join('{}="{}"'.format(prop, value) for prop, value in self.input_properties(value, error).items()) - def render_input(self, value): - return "".format(properties=self.render_input_properties(value)) + def render_errors(self, errors): + return "".join("""
    {msg}
    """.format(msg=e) for e in errors) - def render(self, config): + def render_input(self, value, errors): + return "{errors}".format( + properties=self.render_input_properties(value, errors), errors=self.render_errors(errors) + ) + + def render(self, config, errors): value = config[self.id] if self.id in config else None - return self.bootstrap_decorate(self.render_input(self.converter.convert_to_form(value))) + error = errors[self.id] if self.id in errors else [] + return self.bootstrap_decorate(self.render_input(self.converter.convert_to_form(value), error)) def parse(self, data): - return {self.id: self.converter.convert_from_form(data[self.id][0])} if self.id in data else {} + 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} if self.id in data else {} def getLabel(self): return self.label class TextInput(Input): - def input_properties(self, value): - props = super().input_properties(value) + def input_properties(self, value, errors): + props = super().input_properties(value, errors) props["type"] = "text" return props @@ -95,14 +108,14 @@ class NumberInput(Input): def defaultConverter(self): return IntConverter() - def input_properties(self, value): - props = super().input_properties(value) + 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(self, value): + def render_input(self, value, errors): if self.append: append = """
    @@ -120,7 +133,7 @@ class NumberInput(Input): {append}
    """.format( - input=super().render_input(value), + input=super().render_input(value, errors), append=append, ) @@ -135,7 +148,8 @@ class FloatInput(NumberInput): class LocationInput(Input): - def render_input(self, value): + def render_input(self, value, errors): + # TODO display errors return """
    {inputs} @@ -145,11 +159,11 @@ class LocationInput(Input):
    """.format( id=self.id, - inputs="".join(self.render_sub_input(value, id) for id in ["lat", "lon"]), + inputs="".join(self.render_sub_input(value, id, errors) for id in ["lat", "lon"]), key=Config.get()["google_maps_api_key"], ) - def render_sub_input(self, value, id): + def render_sub_input(self, value, id, errors): return """
    {value} + {errors} """.format( - id=self.id, classes=self.input_classes(), value=value, disabled="disabled" if self.disabled else "" + id=self.id, + classes=self.input_classes(errors), + value=value, + disabled="disabled" if self.disabled else "", + errors=self.render_errors(errors), ) @@ -181,7 +200,7 @@ class CheckboxInput(Input): super().__init__(id, "", infotext=infotext, converter=converter) self.checkboxText = checkboxText - def render_input(self, value): + def render_input(self, value, errors): return """
    @@ -189,17 +208,22 @@ class CheckboxInput(Input): + {errors}
    """.format( id=self.id, - classes=self.input_classes(), + classes=self.input_classes(errors), checked="checked" if value else "", disabled="disabled" if self.disabled else "", checkboxText=self.checkboxText, + errors=self.render_errors(errors) ) - def input_classes(self): - return " ".join(["form-check", "form-control-sm"]) + 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: @@ -222,13 +246,14 @@ class MultiCheckboxInput(Input): 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 render_input(self, value, errors): + # TODO display 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): + def render_checkbox(self, option, value, errors): return """
    @@ -238,7 +263,7 @@ class MultiCheckboxInput(Input):
    """.format( id=self.checkbox_id(option), - classes=self.input_classes(), + classes=self.input_classes(errors), checked="checked" if option.value in value else "", checkboxText=option.text, disabled="disabled" if self.disabled else "", @@ -251,8 +276,11 @@ class MultiCheckboxInput(Input): 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"]) + def input_classes(self, error): + classes = ["form-check", "form-control-sm"] + if error: + classes.append("is-invalid") + return " ".join(classes) class ServicesCheckboxInput(MultiCheckboxInput): @@ -286,14 +314,16 @@ class DropdownInput(Input): self.options = options super().__init__(id, label, infotext=infotext, converter=converter) - def render_input(self, value): + def render_input(self, value, errors): return """ + {errors} """.format( - classes=self.input_classes(), + classes=self.input_classes(errors), id=self.id, options=self.render_options(value), disabled="disabled" if self.disabled else "", + errors=self.render_errors(errors), ) def render_options(self, value): @@ -329,13 +359,13 @@ class ExponentialInput(Input): def defaultConverter(self): return IntConverter() - def input_properties(self, value): - props = super().input_properties(value) + def input_properties(self, value, errors): + props = super().input_properties(value, errors) props["type"] = "number" props["step"] = "any" return props - def render_input(self, value): + def render_input(self, value, errors): append = """
    """.format( id="{}-{}".format(self.id, stage), - classes=self.input_classes(), + classes=self.input_classes(errors), extra_classes=extra_classes, disabled="disabled" if self.disabled else "", options="".join( @@ -203,7 +204,7 @@ class SchedulerInput(Input): ), ) - def render_static_entires(self, value): + 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 '
    -
    '.join( @@ -211,7 +212,7 @@ class SchedulerInput(Input): """.format( id="{}-{}-{}".format(self.id, "time", "start" if i == 0 else "end"), - classes=self.input_classes(), + classes=self.input_classes(errors), disabled="disabled" if self.disabled else "", value=v, ) @@ -231,7 +232,7 @@ class SchedulerInput(Input):
    """.format( time_inputs=render_time_inputs(slot), - select=self.render_profiles_select(value, slot, "profile"), + select=self.render_profiles_select(value, errors, slot, "profile"), ) for slot, entry in schedule.items() ) @@ -249,10 +250,10 @@ class SchedulerInput(Input): """.format( rows=rows, time_inputs=render_time_inputs("0000-0000"), - select=self.render_profiles_select("", "0000-0000", "profile"), + select=self.render_profiles_select("", errors, "0000-0000", "profile"), ) - def render_daylight_entries(self, value): + def render_daylight_entries(self, value, errors): return "".join( """
    @@ -261,12 +262,12 @@ class SchedulerInput(Input):
    """.format( name=name, - select=self.render_profiles_select(value, stage, stage, extra_classes="col-9"), + select=self.render_profiles_select(value, errors, stage, stage, extra_classes="col-9"), ) for stage, name in [("day", "Day"), ("night", "Night"), ("greyline", "Greyline")] ) - def render_input(self, value): + def render_input(self, value, errors): return """
    diff --git a/owrx/form/validator.py b/owrx/form/validator.py new file mode 100644 index 0000000..165471f --- /dev/null +++ b/owrx/form/validator.py @@ -0,0 +1,14 @@ +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") diff --git a/owrx/form/wsjt.py b/owrx/form/wsjt.py index 215b1b7..6fa7302 100644 --- a/owrx/form/wsjt.py +++ b/owrx/form/wsjt.py @@ -9,7 +9,7 @@ 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): + def render_checkbox(self, mode: Q65Mode, interval: Q65Interval, value, errors): return """
    @@ -18,16 +18,16 @@ class Q65ModeMatrix(Input):
    """.format( - classes=self.input_classes(), + 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(self, value): + def render_input(self, value, errors): checkboxes = "".join( - self.render_checkbox(mode, interval, value) for interval in Q65Interval for mode in Q65Mode + self.render_checkbox(mode, interval, value, errors) for interval in Q65Interval for mode in Q65Mode ) return """
    @@ -37,8 +37,11 @@ class Q65ModeMatrix(Input): checkboxes=checkboxes ) - def input_classes(self): - return " ".join(["form-check", "form-control-sm"]) + 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): @@ -59,7 +62,7 @@ class WsjtDecodingDepthsInput(Input): def defaultConverter(self): return JsonConverter() - def render_input(self, value): + def render_input(self, value, errors): def render_mode(m): return """ @@ -76,11 +79,11 @@ class WsjtDecodingDepthsInput(Input):
    """.format( id=self.id, - classes=self.input_classes(), + 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): - return super().input_classes() + " wsjt-decoding-depths" + def input_classes(self, error): + return super().input_classes(error) + " wsjt-decoding-depths" diff --git a/owrx/source/__init__.py b/owrx/source/__init__.py index ef9f4e4..a5fb7db 100644 --- a/owrx/source/__init__.py +++ b/owrx/source/__init__.py @@ -488,19 +488,23 @@ class OptionalSection(Section): ) ) - def render_optional_inputs(self, data): + def render_optional_inputs(self, data, errors): return """ """.format( - inputs="".join(self.render_input(input, data) for input in self.optional_inputs) + inputs="".join(self.render_input(input, data, errors) for input in self.optional_inputs) ) - def render_inputs(self, data): - return super().render_inputs(data) + self.render_optional_select() + self.render_optional_inputs(data) + 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): + 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]) optional_keys = set(k for k in self.optional if k not in data) @@ -512,15 +516,15 @@ class OptionalSection(Section): for input in self.optional_inputs: input.setRemovable() input.setDisabled() - return super().render(data) + return super().render(data, errors) def parse(self, data): - data = super().parse(data) + data, errors = super().parse(data) # remove optional keys if they have been removed from the form for k in self.optional: if k not in data: data[k] = None - return data + return data, errors class SdrDeviceDescription(object): @@ -542,6 +546,7 @@ class SdrDeviceDescription(object): return True except SdrDeviceDescriptionMissing: return False + return [module_name for _, module_name, _ in pkgutil.walk_packages(__path__) if has_description(module_name)] def getDeviceInputs(self) -> List[Input]: From 19496d46a39b80be35b0f9afa5ae8460ff0c2502 Mon Sep 17 00:00:00 2001 From: Jakob Ketterl Date: Wed, 24 Mar 2021 23:17:50 +0100 Subject: [PATCH 341/577] fix form evaluation for optional fields --- owrx/controllers/settings/sdr.py | 2 +- owrx/source/__init__.py | 21 +++++++++++++++------ owrx/source/airspy.py | 4 ++-- owrx/source/connector.py | 4 ++-- owrx/source/hackrf.py | 4 ++-- owrx/source/perseussdr.py | 4 ++-- owrx/source/rtl_sdr.py | 4 ++-- owrx/source/rtl_sdr_soapy.py | 4 ++-- owrx/source/rtl_tcp.py | 4 ++-- owrx/source/runds.py | 8 ++++---- owrx/source/sdrplay.py | 4 ++-- owrx/source/soapy.py | 4 ++-- owrx/source/soapy_remote.py | 4 ++-- 13 files changed, 40 insertions(+), 31 deletions(-) diff --git a/owrx/controllers/settings/sdr.py b/owrx/controllers/settings/sdr.py index 524f1b1..6dfb68c 100644 --- a/owrx/controllers/settings/sdr.py +++ b/owrx/controllers/settings/sdr.py @@ -275,7 +275,7 @@ class NewSdrDeviceController(SettingsFormController): return [ Section( "New device settings", - TextInput("name", "Device name"), + TextInput("name", "Device name", validator=RequiredValidator()), DropdownInput("type", "Device type", [Option(name, name) for name in SdrDeviceDescription.getTypes()]), TextInput("id", "Device ID", validator=RequiredValidator()), ) diff --git a/owrx/source/__init__.py b/owrx/source/__init__.py index a5fb7db..dcdf338 100644 --- a/owrx/source/__init__.py +++ b/owrx/source/__init__.py @@ -15,6 +15,7 @@ from owrx.property.filter import ByLambda from owrx.form import Input, TextInput, NumberInput, CheckboxInput, ModesInput, ExponentialInput from owrx.form.converter import OptionalConverter from owrx.form.device import GainInput, SchedulerInput, WaterfallLevelsInput +from owrx.form.validator import RequiredValidator from owrx.controllers.settings import Section from typing import List from enum import Enum @@ -520,7 +521,9 @@ class OptionalSection(Section): def parse(self, data): data, errors = super().parse(data) - # remove optional keys if they have been removed from the form + # filter out errors for optional fields + errors = [e for e in errors if e.getKey() not in self.optional or e.getKey() in 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 @@ -550,10 +553,16 @@ class SdrDeviceDescription(object): return [module_name for _, module_name, _ in pkgutil.walk_packages(__path__) if has_description(module_name)] def getDeviceInputs(self) -> List[Input]: - return [TextInput("name", "Device name")] + self.getInputs() + 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]: - return [TextInput("name", "Profile name")] + self.getInputs() + 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 [ @@ -593,10 +602,10 @@ class SdrDeviceDescription(object): # default is True since most devices have agc. override in subclasses if agc is not available return True - def getMandatoryKeys(self): + def getDeviceMandatoryKeys(self): return ["name", "enabled"] - def getOptionalKeys(self): + def getDeviceOptionalKeys(self): return [ "ppm", "always-on", @@ -615,7 +624,7 @@ class SdrDeviceDescription(object): def getDeviceSection(self): return OptionalSection( - "Device settings", self.getDeviceInputs(), self.getMandatoryKeys(), self.getOptionalKeys() + "Device settings", self.getDeviceInputs(), self.getDeviceMandatoryKeys(), self.getDeviceOptionalKeys() ) def getProfileSection(self): diff --git a/owrx/source/airspy.py b/owrx/source/airspy.py index a6b64ff..ab201dd 100644 --- a/owrx/source/airspy.py +++ b/owrx/source/airspy.py @@ -31,8 +31,8 @@ class AirspyDeviceDescription(SoapyConnectorDeviceDescription): ), ] - def getOptionalKeys(self): - return super().getOptionalKeys() + ["bias_tee", "bitpack"] + def getDeviceOptionalKeys(self): + return super().getDeviceOptionalKeys() + ["bias_tee", "bitpack"] def getProfileOptionalKeys(self): return super().getProfileOptionalKeys() + ["bias_tee"] diff --git a/owrx/source/connector.py b/owrx/source/connector.py index 6789e3f..67b91df 100644 --- a/owrx/source/connector.py +++ b/owrx/source/connector.py @@ -90,8 +90,8 @@ class ConnectorDeviceDescription(SdrDeviceDescription): ), ] - def getOptionalKeys(self): - return super().getOptionalKeys() + ["rtltcp_compat", "iqswap"] + def getDeviceOptionalKeys(self): + return super().getDeviceOptionalKeys() + ["rtltcp_compat", "iqswap"] def getProfileOptionalKeys(self): return super().getProfileOptionalKeys() + ["iqswap"] diff --git a/owrx/source/hackrf.py b/owrx/source/hackrf.py index fbbab7d..db67d0b 100644 --- a/owrx/source/hackrf.py +++ b/owrx/source/hackrf.py @@ -18,8 +18,8 @@ class HackrfDeviceDescription(SoapyConnectorDeviceDescription): def getInputs(self) -> List[Input]: return super().getInputs() + [BiasTeeInput()] - def getOptionalKeys(self): - return super().getOptionalKeys() + ["bias_tee"] + def getDeviceOptionalKeys(self): + return super().getDeviceOptionalKeys() + ["bias_tee"] def getProfileOptionalKeys(self): return super().getProfileOptionalKeys() + ["bias_tee"] diff --git a/owrx/source/perseussdr.py b/owrx/source/perseussdr.py index 5d0dacc..3a8d4d4 100644 --- a/owrx/source/perseussdr.py +++ b/owrx/source/perseussdr.py @@ -58,9 +58,9 @@ class PerseussdrDeviceDescription(DirectSourceDeviceDescription): CheckboxInput("wideband", "Disable analog filters"), ] - def getOptionalKeys(self): + def getDeviceOptionalKeys(self): # no rf_gain - return [key for key in super().getOptionalKeys() if key != "rf_gain"] + [ + return [key for key in super().getDeviceOptionalKeys() if key != "rf_gain"] + [ "attenuator", "adc_preamp", "adc_dither", diff --git a/owrx/source/rtl_sdr.py b/owrx/source/rtl_sdr.py index f725e3d..1c84ab0 100644 --- a/owrx/source/rtl_sdr.py +++ b/owrx/source/rtl_sdr.py @@ -27,8 +27,8 @@ class RtlSdrDeviceDescription(ConnectorDeviceDescription): DirectSamplingInput(), ] - def getOptionalKeys(self): - return super().getOptionalKeys() + ["device", "bias_tee", "direct_sampling"] + def getDeviceOptionalKeys(self): + return super().getDeviceOptionalKeys() + ["device", "bias_tee", "direct_sampling"] def getProfileOptionalKeys(self): return super().getProfileOptionalKeys() + ["bias_tee", "direct_sampling"] diff --git a/owrx/source/rtl_sdr_soapy.py b/owrx/source/rtl_sdr_soapy.py index e437b80..8016e7c 100644 --- a/owrx/source/rtl_sdr_soapy.py +++ b/owrx/source/rtl_sdr_soapy.py @@ -18,8 +18,8 @@ class RtlSdrSoapyDeviceDescription(SoapyConnectorDeviceDescription): def getInputs(self) -> List[Input]: return super().getInputs() + [BiasTeeInput(), DirectSamplingInput()] - def getOptionalKeys(self): - return super().getOptionalKeys() + ["bias_tee", "direct_sampling"] + def getDeviceOptionalKeys(self): + return super().getDeviceOptionalKeys() + ["bias_tee", "direct_sampling"] def getProfileOptionalKeys(self): return super().getProfileOptionalKeys() + ["bias_tee", "direct_sampling"] diff --git a/owrx/source/rtl_tcp.py b/owrx/source/rtl_tcp.py index 5bf2712..3daec3f 100644 --- a/owrx/source/rtl_tcp.py +++ b/owrx/source/rtl_tcp.py @@ -25,5 +25,5 @@ class RtlTcpDeviceDescription(ConnectorDeviceDescription): def getInputs(self) -> List[Input]: return super().getInputs() + [RemoteInput()] - def getMandatoryKeys(self): - return super().getMandatoryKeys() + ["device"] + def getDeviceMandatoryKeys(self): + return super().getDeviceMandatoryKeys() + ["device"] diff --git a/owrx/source/runds.py b/owrx/source/runds.py index 224d65d..028c49d 100644 --- a/owrx/source/runds.py +++ b/owrx/source/runds.py @@ -44,8 +44,8 @@ class RundsDeviceDescription(ConnectorDeviceDescription): CheckboxInput("long", "Use 32-bit sample size (LONG)"), ] - def getMandatoryKeys(self): - return super().getMandatoryKeys() + ["device"] + def getDeviceMandatoryKeys(self): + return super().getDeviceMandatoryKeys() + ["device"] - def getOptionalKeys(self): - return super().getOptionalKeys() + ["protocol", "long"] + def getDeviceOptionalKeys(self): + return super().getDeviceOptionalKeys() + ["protocol", "long"] diff --git a/owrx/source/sdrplay.py b/owrx/source/sdrplay.py index 3bfb385..b3a7741 100644 --- a/owrx/source/sdrplay.py +++ b/owrx/source/sdrplay.py @@ -54,8 +54,8 @@ class SdrplayDeviceDescription(SoapyConnectorDeviceDescription): ), ] - def getOptionalKeys(self): - return super().getOptionalKeys() + ["bias_tee", "rf_notch", "dab_notch", "if_mode"] + 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"] diff --git a/owrx/source/soapy.py b/owrx/source/soapy.py index 2597dcf..df3ce8a 100644 --- a/owrx/source/soapy.py +++ b/owrx/source/soapy.py @@ -98,8 +98,8 @@ class SoapyConnectorDeviceDescription(ConnectorDeviceDescription): TextInput("antenna", "Antenna"), ] - def getOptionalKeys(self): - return super().getOptionalKeys() + ["device", "rf_gain", "antenna"] + def getDeviceOptionalKeys(self): + return super().getDeviceOptionalKeys() + ["device", "rf_gain", "antenna"] def getProfileOptionalKeys(self): return super().getProfileOptionalKeys() + ["antenna"] diff --git a/owrx/source/soapy_remote.py b/owrx/source/soapy_remote.py index 19be5a5..c3ed174 100644 --- a/owrx/source/soapy_remote.py +++ b/owrx/source/soapy_remote.py @@ -29,5 +29,5 @@ class SoapyRemoteDeviceDescription(SoapyConnectorDeviceDescription): ), ] - def getOptionalKeys(self): - return super().getOptionalKeys() + ["remote_driver"] + def getDeviceOptionalKeys(self): + return super().getDeviceOptionalKeys() + ["remote_driver"] From 383c08ed48da2f1fcca34bae0378f3e619922661 Mon Sep 17 00:00:00 2001 From: Jakob Ketterl Date: Wed, 24 Mar 2021 23:43:19 +0100 Subject: [PATCH 342/577] implement tuning precision dropdown --- owrx/controllers/settings/general.py | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/owrx/controllers/settings/general.py b/owrx/controllers/settings/general.py index b12ab4e..cae76c0 100644 --- a/owrx/controllers/settings/general.py +++ b/owrx/controllers/settings/general.py @@ -9,7 +9,7 @@ from owrx.form import ( DropdownInput, Option, ) -from owrx.form.converter import WaterfallColorsConverter +from owrx.form.converter import WaterfallColorsConverter, IntConverter from owrx.form.receiverid import ReceiverKeysConverter from owrx.form.gfx import AvatarInput, TopPhotoInput from owrx.form.device import WaterfallLevelsInput @@ -126,12 +126,11 @@ class GeneralSettingsController(SettingsFormController): ), Section( "Display settings", - # TODO: custom input for this? - NumberInput( + DropdownInput( "tuning_precision", "Tuning precision", - infotext="Specifies the precision of the frequency tuning display as an exponent of 10, in Hertz. " - + "Setting this to 1 means 10Hz precision, 2 means 100Hz precision, etc.", + options=[Option(str(i), "{} Hz".format(10 ** i)) for i in range(0, 6)], + converter=IntConverter() ), ), Section( From 69237c0bb4fe37e56fc0ced788139086edd06b54 Mon Sep 17 00:00:00 2001 From: Jakob Ketterl Date: Thu, 25 Mar 2021 14:48:09 +0100 Subject: [PATCH 343/577] make more inputs display errors --- owrx/form/__init__.py | 55 +++++++++++++++++------------- owrx/form/device.py | 78 +++++++++++++++++++++++++------------------ 2 files changed, 78 insertions(+), 55 deletions(-) diff --git a/owrx/form/__init__.py b/owrx/form/__init__.py index 4968c1e..f1f7113 100644 --- a/owrx/form/__init__.py +++ b/owrx/form/__init__.py @@ -48,15 +48,15 @@ class Input(ABC): else "", ) - def input_classes(self, error): + def input_classes(self, errors): classes = ["form-control", "form-control-sm"] - if error: + if errors: classes.append("is-invalid") return " ".join(classes) - def input_properties(self, value, error): + def input_properties(self, value, errors): props = { - "class": self.input_classes(error), + "class": self.input_classes(errors), "id": self.id, "name": self.id, "placeholder": self.label, @@ -72,15 +72,22 @@ class Input(ABC): def render_errors(self, errors): return "".join("""
    {msg}
    """.format(msg=e) for e in errors) - def render_input(self, value, errors): - return "{errors}".format( - properties=self.render_input_properties(value, errors), errors=self.render_errors(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 "".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(self.converter.convert_to_form(value), error)) + return self.bootstrap_decorate(self.render_input_group(self.converter.convert_to_form(value), error)) def parse(self, data): value = self.converter.convert_from_form(data[self.id][0]) @@ -115,7 +122,7 @@ class NumberInput(Input): props["step"] = self.step return props - def render_input(self, value, errors): + def render_input_group(self, value, errors): if self.append: append = """
    @@ -131,10 +138,12 @@ class NumberInput(Input):
    {input} {append} + {errors}
    """.format( - input=super().render_input(value, errors), + input=self.render_input(value, errors), append=append, + errors=self.render_errors(errors) ) @@ -148,21 +157,26 @@ class FloatInput(NumberInput): class LocationInput(Input): - def render_input(self, value, errors): - # TODO display errors + def render_input_group(self, value, errors): return """ -
    +
    {inputs}
    + {errors}
    """.format( id=self.id, - inputs="".join(self.render_sub_input(value, id, errors) for id in ["lat", "lon"]), + 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 """
    @@ -185,13 +199,11 @@ class TextAreaInput(Input): def render_input(self, value, errors): return """ - {errors} """.format( id=self.id, classes=self.input_classes(errors), value=value, disabled="disabled" if self.disabled else "", - errors=self.render_errors(errors), ) @@ -208,7 +220,6 @@ class CheckboxInput(Input): - {errors}
    """.format( id=self.id, @@ -216,7 +227,6 @@ class CheckboxInput(Input): checked="checked" if value else "", disabled="disabled" if self.disabled else "", checkboxText=self.checkboxText, - errors=self.render_errors(errors) ) def input_classes(self, error): @@ -247,7 +257,6 @@ class MultiCheckboxInput(Input): self.options = options def render_input(self, value, errors): - # TODO display errors return "".join(self.render_checkbox(o, value, errors) for o in self.options) def checkbox_id(self, option): @@ -317,13 +326,11 @@ class DropdownInput(Input): def render_input(self, value, errors): return """ - {errors} """.format( classes=self.input_classes(errors), id=self.id, options=self.render_options(value), disabled="disabled" if self.disabled else "", - errors=self.render_errors(errors), ) def render_options(self, value): @@ -365,7 +372,7 @@ class ExponentialInput(Input): props["step"] = "any" return props - def render_input(self, value, errors): + def render_input_group(self, value, errors): append = """
    - {options} - - - {stageoption} + + + {stageoption} """.format( id=self.id, classes=self.input_classes(errors), @@ -38,6 +35,18 @@ class GainInput(Input): disabled="disabled" if self.disabled else "", ) + def render_input_group(self, value, errors): + return """ +
    + {input} + {errors} +
    + """.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: @@ -342,35 +351,40 @@ class SchedulerInput(Input): class WaterfallLevelsInput(Input): - def render_input(self, value, errors): - # TODO display errors + def render_input_group(self, value, errors): return """ -
    - {inputs} +
    + {input}
    + {errors} """.format( + rowclass="is-invalid" if errors else "", id=self.id, - inputs="".join( - """ -
    - -
    - -
    - dBFS -
    + input=self.render_input(value, errors), + errors=self.render_errors(errors), + ) + + def render_input(self, value, errors): + return "".join( + """ +
    + +
    + +
    + dBFS
    - """.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 "", - ) - for name, label in [("min", "Minimum"), ("max", "Maximum")] +
    + """.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 "", ) + for name, label in [("min", "Minimum"), ("max", "Maximum")] ) def parse(self, data): From 20cd3f6efeb0ca10e2b03c2bd1634bd058599f73 Mon Sep 17 00:00:00 2001 From: Jakob Ketterl Date: Thu, 25 Mar 2021 15:02:59 +0100 Subject: [PATCH 344/577] more inputs that can display errors --- htdocs/lib/settings/WsjtDecodingDepthsInput.js | 4 ++-- owrx/form/wsjt.py | 14 +++++++++----- 2 files changed, 11 insertions(+), 7 deletions(-) diff --git a/htdocs/lib/settings/WsjtDecodingDepthsInput.js b/htdocs/lib/settings/WsjtDecodingDepthsInput.js index f02b27f..f06f59c 100644 --- a/htdocs/lib/settings/WsjtDecodingDepthsInput.js +++ b/htdocs/lib/settings/WsjtDecodingDepthsInput.js @@ -45,7 +45,6 @@ $.fn.wsjtDecodingDepthsInput = function() { }; $table.on('change', updateValue); - $el.append($table); var $addButton = $(''); $addButton.on('click', function() { @@ -63,6 +62,7 @@ $.fn.wsjtDecodingDepthsInput = function() { updateValue(); return false; }); - $el.append($addButton); + + $input.after($table, $addButton); }); }; \ No newline at end of file diff --git a/owrx/form/wsjt.py b/owrx/form/wsjt.py index 6fa7302..3dcaf91 100644 --- a/owrx/form/wsjt.py +++ b/owrx/form/wsjt.py @@ -25,16 +25,20 @@ class Q65ModeMatrix(Input): disabled="" if interval.is_available(mode) and not self.disabled else "disabled", ) - def render_input(self, value, errors): - checkboxes = "".join( - self.render_checkbox(mode, interval, value, errors) for interval in Q65Interval for mode in Q65Mode - ) + def render_input_group(self, value, errors): return """
    {checkboxes} + {errors}
    """.format( - checkboxes=checkboxes + 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): From 287a04be94904347e39403e20b1901e8cde30aae Mon Sep 17 00:00:00 2001 From: Jakob Ketterl Date: Thu, 25 Mar 2021 15:25:15 +0100 Subject: [PATCH 345/577] send updated bookmarks to clients on the fly --- owrx/bookmarks.py | 36 ++++++++++++++++++++++++++ owrx/connection.py | 16 ++++++++++-- owrx/controllers/settings/bookmarks.py | 8 +++++- 3 files changed, 57 insertions(+), 3 deletions(-) diff --git a/owrx/bookmarks.py b/owrx/bookmarks.py index e2a56cb..39b3a3d 100644 --- a/owrx/bookmarks.py +++ b/owrx/bookmarks.py @@ -31,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 @@ -43,6 +60,7 @@ class Bookmarks(object): def __init__(self): self.file_modified = None self.bookmarks = [] + self.subscriptions = [] self.fileList = [Bookmarks._getBookmarksFile(), "/etc/openwebrx/bookmarks.json", "bookmarks.json"] def _refresh(self): @@ -101,8 +119,26 @@ class Bookmarks(object): 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) diff --git a/owrx/connection.py b/owrx/connection.py index f55f7d8..a06e538 100644 --- a/owrx/connection.py +++ b/owrx/connection.py @@ -141,6 +141,7 @@ class OpenWebRxReceiverClient(OpenWebRxClient, SdrSourceEventClient): self.dsp = None self.sdr = None self.configSubs = [] + self.bookmarkSub = None self.connectionProperties = {} try: @@ -190,7 +191,7 @@ 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) @@ -198,8 +199,17 @@ class OpenWebRxReceiverClient(OpenWebRxClient, SdrSourceEventClient): bookmarks = [b.__dict__() for b in Bookmarks.getSharedInstance().getBookmarks(frequencyRange)] self.write_bookmarks(bookmarks) + def updateBookmarkSubscription(*args): + if self.bookmarkSub is not None: + self.bookmarkSub.cancel() + 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() @@ -332,6 +342,8 @@ class OpenWebRxReceiverClient(OpenWebRxClient, SdrSourceEventClient): ClientRegistry.getSharedInstance().removeClient(self) while self.configSubs: self.configSubs.pop().cancel() + if self.bookmarkSub is not None: + self.bookmarkSub.cancel() super().close() def stopDsp(self): diff --git a/owrx/controllers/settings/bookmarks.py b/owrx/controllers/settings/bookmarks.py index 4e802e1..2d4dd82 100644 --- a/owrx/controllers/settings/bookmarks.py +++ b/owrx/controllers/settings/bookmarks.py @@ -88,6 +88,8 @@ class BookmarksController(AuthorizationMixin, WebpageController): 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) @@ -97,7 +99,11 @@ class BookmarksController(AuthorizationMixin, WebpageController): try: data = json.loads(self.get_body()) # sanitize - data = {k: data[k] for k in ["name", "frequency", "modulation"]} + data = { + "name": data["name"], + "frequency": int(data["frequency"]), + "modulation": data["modulation"], + } bookmark = Bookmark(data) bookmarks.addBookmark(bookmark) From e1dd9d32f49183f2f27dc16fefd01fa6be409c42 Mon Sep 17 00:00:00 2001 From: Jakob Ketterl Date: Thu, 25 Mar 2021 16:08:02 +0100 Subject: [PATCH 346/577] prevent javascript errors if frequency is NaN --- htdocs/lib/FrequencyDisplay.js | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/htdocs/lib/FrequencyDisplay.js b/htdocs/lib/FrequencyDisplay.js index 3d20f8c..86c7632 100644 --- a/htdocs/lib/FrequencyDisplay.js +++ b/htdocs/lib/FrequencyDisplay.js @@ -29,7 +29,11 @@ FrequencyDisplay.prototype.getPrefix = function() { FrequencyDisplay.prototype.setFrequency = function(freq) { this.frequency = freq; - this.exponent = Math.floor(Math.log10(this.frequency) / 3) * 3; + if (Number.isNaN(this.frequency)) { + this.exponent = 0 + } else { + this.exponent = Math.floor(Math.log10(this.frequency) / 3) * 3; + } var digits = Math.max(0, this.exponent - this.precision); var formatted = (freq / 10 ** this.exponent).toLocaleString( From 29c0f7148a6bf0c0946b0c12de242658b15991c3 Mon Sep 17 00:00:00 2001 From: Jakob Ketterl Date: Sat, 27 Mar 2021 23:08:43 +0100 Subject: [PATCH 347/577] re-work the bookmarks table to incorporate the improved frequency input --- htdocs/lib/settings/BookmarkTable.js | 293 ++++++++++++++++++++----- owrx/controllers/settings/bookmarks.py | 43 ++-- 2 files changed, 256 insertions(+), 80 deletions(-) diff --git a/htdocs/lib/settings/BookmarkTable.js b/htdocs/lib/settings/BookmarkTable.js index 66a8975..ca1e71d 100644 --- a/htdocs/lib/settings/BookmarkTable.js +++ b/htdocs/lib/settings/BookmarkTable.js @@ -1,56 +1,218 @@ +function Editor(table) { + this.table = table; +} + +Editor.prototype.getInputHtml = function() { + return ''; +} + +Editor.prototype.render = function(el) { + this.input = $(this.getInputHtml()); + el.append(this.input); + this.setupEvents(); +}; + +Editor.prototype.setupEvents = function() { + var me = this; + this.input.on('blur', function() { me.submit(); }).on('change', function() { me.submit(); }).on('keydown', function(e){ + if (e.keyCode == 13) return me.submit(); + if (e.keyCode == 27) return me.cancel(); + }); +}; + +Editor.prototype.submit = function() { + if (!this.onSubmit) return; + var submit = this.onSubmit; + delete this.onSubmit; + submit(); +}; + +Editor.prototype.cancel = function() { + if (!this.onCancel) return; + var cancel = this.onCancel; + delete this.onCancel; + cancel(); +}; + +Editor.prototype.focus = function() { + this.input.focus(); +}; + +Editor.prototype.disable = function(flag) { + this.input.prop('disabled', flag); +}; + +Editor.prototype.setValue = function(value) { + this.input.val(value); +}; + +Editor.prototype.getValue = function() { + return this.input.val(); +}; + +Editor.prototype.getHtml = function() { + return this.getValue(); +}; + +function NameEditor(table) { + Editor.call(this, table); +} + +NameEditor.prototype = new Editor(); + +NameEditor.prototype.getInputHtml = function() { + return ''; +} + +function FrequencyEditor(table) { + Editor.call(this, table); + this.suffixes = { + 'K': 3, + 'M': 6, + 'G': 9, + 'T': 12 + }; +} + +FrequencyEditor.prototype = new Editor(); + +FrequencyEditor.prototype.getInputHtml = function() { + return '
    ' + + '' + + '
    ' + + '' + + '
    ' + + '
    '; +}; + +FrequencyEditor.prototype.render = function(el) { + this.input = $(this.getInputHtml()); + el.append(this.input); + this.freqInput = el.find('input'); + this.expInput = el.find('select'); + this.setupEvents(); +}; + +FrequencyEditor.prototype.setupEvents = function() { + var me = this; + var inputs = [this.freqInput, this.expInput].map(function(i) { return i[0]; }); + inputs.forEach(function(input) { + $(input).on('blur', function(e){ + if (inputs.indexOf(e.relatedTarget) >= 0) { + return; + } + me.submit(); + }); + }); + + var me = this; + this.freqInput.on('keydown', function(e){ + if (e.keyCode == 13) return me.submit(); + if (e.keyCode == 27) return me.cancel(); + var c = String.fromCharCode(e.which); + if (c in me.suffixes) { + me.expInput.val(me.suffixes[c]); + } + }); +} + +FrequencyEditor.prototype.getValue = function() { + var frequency = parseFloat(this.freqInput.val()); + var exp = parseInt(this.expInput.val()); + return Math.floor(frequency * 10 ** exp); +}; + +FrequencyEditor.prototype.setValue = function(value) { + var value = parseFloat(value); + var exp = 0; + if (!Number.isNaN(value)) { + exp = Math.floor(Math.log10(value) / 3) * 3; + } + this.freqInput.val(value / 10 ** exp); + this.expInput.val(exp); +}; + +FrequencyEditor.prototype.focus = function() { + this.freqInput.focus(); +}; + +FrequencyEditor.prototype.getHtml = function() { + var value = this.getValue(); + var exp = 0; + if (!Number.isNaN(value)) { + exp = Math.floor(Math.log10(value) / 3) * 3; + } + var frequency = value / 10 ** exp; + var expString = this.expInput.find('option[value=' + exp + ']').html(); + return frequency + ' ' + expString; +}; + +function ModulationEditor(table) { + Editor.call(this, table); + this.modes = table.data('modes'); +} + +ModulationEditor.prototype = new Editor(); + +ModulationEditor.prototype.getInputHtml = function() { + return ''; +}; + +ModulationEditor.prototype.getHtml = function() { + var $option = this.input.find('option:selected') + return $option.html(); +}; + $.fn.bookmarktable = function() { + var editors = { + name: NameEditor, + frequency: FrequencyEditor, + modulation: ModulationEditor + }; + $.each(this, function(){ var $table = $(this).find('table'); - var inputs = $table.find('tr.inputs td').map(function(){ - var candidates = $(this).find('input, select') - return candidates.length ? candidates.first() : false; - }).toArray(); - $table.find('tr.inputs').remove(); - - var transformToHtml = function($cell) { - var $input = $cell.find('input, select'); - var $option = $input.find('option:selected') - if ($option.length) { - $cell.html($option.html()); - } else { - $cell.html($input.val()); - } - }; - $table.on('dblclick', 'td', function(e) { var $cell = $(e.target); var html = $cell.html(); var $row = $cell.parents('tr'); - var index = $row.find('td').index($cell); + var name = $cell.data('editor'); + var EditorCls = editors[name]; + if (!EditorCls) return; - var $input = inputs[index]; - if (!$input) return; + var editor = new EditorCls($table); + editor.render($cell.html('')); + editor.setValue($cell.data('value')); + editor.focus(); - $table.find('tr[data-id="new"]').remove(); - $input.val($cell.data('value') || html); - $input.prop('disabled', false); - $cell.html($input); - $input.focus(); - - var submit = function() { - $input.prop('disabled', true); + editor.onSubmit = function() { + editor.disable(true); $.ajax(document.location.href + "/" + $row.data('id'), { - data: JSON.stringify(Object.fromEntries([[$input.prop('name'), $input.val()]])), + data: JSON.stringify(Object.fromEntries([[name, editor.getValue()]])), contentType: 'application/json', method: 'POST' }).then(function(){ - transformToHtml($cell); + $cell.data('value', editor.getValue()); + $cell.html(editor.getHtml()); }); }; - $input.on('blur', submit).on('change', submit).on('keyup', function(e){ - if (e.keyCode == 13) return submit(); - if (e.keyCode == 27) { - $cell.html(html); - } - }); + editor.onCancel = function() { + $cell.html(html); + }; }); $table.on('click', '.bookmark-delete', function(e) { @@ -70,22 +232,26 @@ $.fn.bookmarktable = function() { if ($table.find('tr[data-id="new"]').length) return; var row = $('
  • '); - row.append(inputs.map(function(i){ - var cell = $('' + )); row.on('click', '.bookmark-cancel', function() { row.remove(); @@ -93,10 +259,10 @@ $.fn.bookmarktable = function() { row.on('click', '.bookmark-save', function() { var data = Object.fromEntries( - row.find('input, select').toArray().map(function(input){ - var $input = $(input); - $input.prop('disabled', true); - return [$input.prop('name'), $input.val()] + $.map(inputs, function(input, name){ + input.disable(true); + // double wrapped because jQuery.map() flattens the result + return [[name, input.getValue()]]; }) ); @@ -107,15 +273,20 @@ $.fn.bookmarktable = function() { }).then(function(data){ if ('bookmark_id' in data) { row.attr('data-id', data['bookmark_id']); - row.find('td').each(function(){ - var $cell = $(this); - var $group = $cell.find('.btn-group') - if ($group.length) { - $group.remove; - $cell.html('
    delete
    '); - } - transformToHtml($cell); + var tds = row.find('td'); + + Object.values(inputs).forEach(function(input, index) { + var td = $(tds[index]); + td.data('value', input.getValue()); + td.html(input.getHtml()); }); + + var $cell = row.find('td').last(); + var $group = $cell.find('.btn-group'); + if ($group.length) { + $group.remove; + $cell.html('
    delete
    '); + } } }); diff --git a/owrx/controllers/settings/bookmarks.py b/owrx/controllers/settings/bookmarks.py index 2d4dd82..2a4fb3a 100644 --- a/owrx/controllers/settings/bookmarks.py +++ b/owrx/controllers/settings/bookmarks.py @@ -3,6 +3,7 @@ from owrx.controllers.admin import AuthorizationMixin from owrx.bookmarks import Bookmark, Bookmarks from owrx.modes import Modes import json +import math import logging @@ -18,16 +19,8 @@ class BookmarksController(AuthorizationMixin, WebpageController): def render_table(self): bookmarks = Bookmarks.getSharedInstance() - def render_mode(m): - return """ - - """.format( - mode=m.modulation, - name=m.name, - ) - return """ -
    ').append(i); })); } @@ -14,6 +16,14 @@ $.fn.wsjtDecodingDepthsInput = function() { return this.el; } + WsjtDecodingDepthRow.prototype.getValue = function() { + var value = parseInt(this.valueInput.val()) + if (Number.isNaN(value)) { + return {}; + } + return Object.fromEntries([[this.modeInput.val(), value]]); + } + this.each(function(){ var $input = $(this); var $el = $input.parent(); @@ -27,6 +37,32 @@ $.fn.wsjtDecodingDepthsInput = function() { $table.append(rows.map(function(r) { return r.getEl(); })); + + var updateValue = function(){ + $input.val(JSON.stringify($.extend.apply({}, rows.map(function(r) { + return r.getValue(); + })))); + }; + + $table.on('change', updateValue); $el.append($table); + var $addButton = $(''); + + $addButton.on('click', function() { + var row = new WsjtDecodingDepthRow(inputs) + rows.push(row); + $table.append(row.getEl()); + return false; + }); + $el.on('click', '.btn.remove', function(e){ + var row = $(e.target).data('row'); + var index = rows.indexOf(row); + if (index < 0) return false; + rows.splice(index, 1); + row.getEl().remove(); + updateValue(); + return false; + }); + $el.append($addButton); }); }; \ No newline at end of file From d3ba8668008b2eb12e4ba78eb4a445712f4ae8e6 Mon Sep 17 00:00:00 2001 From: Jakob Ketterl Date: Mon, 15 Feb 2021 22:58:02 +0100 Subject: [PATCH 156/577] comment config since it is now supported in the web config --- config_webrx.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/config_webrx.py b/config_webrx.py index 81df667..5068fdc 100644 --- a/config_webrx.py +++ b/config_webrx.py @@ -301,7 +301,7 @@ nmux_memory = 50 # in megabytes. This sets the approximate size of the circular #wsjt_decoding_depth = 3 # can also be set for each mode separately # jt65 seems to be somewhat prone to erroneous decodes, this setting handles that to some extent -wsjt_decoding_depths = {"jt65": 1} +#wsjt_decoding_depths = {"jt65": 1} # FST4 can be transmitted in different intervals. This setting determines which intervals will be decoded. # available values (in seconds): 15, 30, 60, 120, 300, 900, 1800 From c8496a2547690090bbd99930966f5a6b3495a018 Mon Sep 17 00:00:00 2001 From: Jakob Ketterl Date: Tue, 16 Feb 2021 15:59:31 +0100 Subject: [PATCH 157/577] remove unused import --- owrx/form/wsjt.py | 1 - owrx/waterfall.py | 0 2 files changed, 1 deletion(-) create mode 100644 owrx/waterfall.py diff --git a/owrx/form/wsjt.py b/owrx/form/wsjt.py index 52bd50f..a5e1af0 100644 --- a/owrx/form/wsjt.py +++ b/owrx/form/wsjt.py @@ -2,7 +2,6 @@ from owrx.form import Input from owrx.form.converter import JsonConverter from owrx.wsjt import Q65Mode, Q65Interval from owrx.modes import Modes, WsjtMode -import json import html diff --git a/owrx/waterfall.py b/owrx/waterfall.py new file mode 100644 index 0000000..e69de29 From 496e771e17984b17c1f95d2d2d893649b95976d3 Mon Sep 17 00:00:00 2001 From: Jakob Ketterl Date: Tue, 16 Feb 2021 17:12:57 +0100 Subject: [PATCH 158/577] implement new waterfall color selection --- owrx/config/defaults.py | 261 +--------------------- owrx/config/migration.py | 24 +- owrx/controllers/settings/general.py | 6 + owrx/waterfall.py | 320 +++++++++++++++++++++++++++ 4 files changed, 351 insertions(+), 260 deletions(-) diff --git a/owrx/config/defaults.py b/owrx/config/defaults.py index 6c1a631..20d6516 100644 --- a/owrx/config/defaults.py +++ b/owrx/config/defaults.py @@ -2,7 +2,7 @@ from owrx.property import PropertyLayer defaultConfig = PropertyLayer( - version=3, + version=4, max_clients=20, receiver_name="[Callsign]", receiver_location="Budapest, Hungary", @@ -22,264 +22,7 @@ defaultConfig = PropertyLayer( digital_voice_unvoiced_quality=1, digital_voice_dmr_id_lookup=True, # sdrs=... - waterfall_colors=[ - 0x30123B, - 0x311542, - 0x33184A, - 0x341B51, - 0x351E58, - 0x36215F, - 0x372466, - 0x38266C, - 0x392973, - 0x3A2C79, - 0x3B2F80, - 0x3C3286, - 0x3D358B, - 0x3E3891, - 0x3E3A97, - 0x3F3D9C, - 0x4040A2, - 0x4043A7, - 0x4146AC, - 0x4248B1, - 0x424BB6, - 0x434EBA, - 0x4351BF, - 0x4453C3, - 0x4456C7, - 0x4559CB, - 0x455BCF, - 0x455ED3, - 0x4561D7, - 0x4663DA, - 0x4666DD, - 0x4669E1, - 0x466BE4, - 0x466EE7, - 0x4671E9, - 0x4673EC, - 0x4676EE, - 0x4678F1, - 0x467BF3, - 0x467DF5, - 0x4680F7, - 0x4682F9, - 0x4685FA, - 0x4587FC, - 0x458AFD, - 0x448CFE, - 0x448FFE, - 0x4391FF, - 0x4294FF, - 0x4196FF, - 0x3F99FF, - 0x3E9BFF, - 0x3D9EFE, - 0x3BA1FD, - 0x3AA3FD, - 0x38A6FB, - 0x36A8FA, - 0x35ABF9, - 0x33ADF7, - 0x31B0F6, - 0x2FB2F4, - 0x2DB5F2, - 0x2CB7F0, - 0x2AB9EE, - 0x28BCEC, - 0x26BEEA, - 0x25C0E7, - 0x23C3E5, - 0x21C5E2, - 0x20C7E0, - 0x1FC9DD, - 0x1DCCDB, - 0x1CCED8, - 0x1BD0D5, - 0x1AD2D3, - 0x19D4D0, - 0x18D6CD, - 0x18D8CB, - 0x18DAC8, - 0x17DBC5, - 0x17DDC3, - 0x17DFC0, - 0x18E0BE, - 0x18E2BB, - 0x19E3B9, - 0x1AE5B7, - 0x1BE6B4, - 0x1DE8B2, - 0x1EE9AF, - 0x20EAAD, - 0x22ECAA, - 0x24EDA7, - 0x27EEA4, - 0x29EFA1, - 0x2CF09E, - 0x2FF19B, - 0x32F298, - 0x35F394, - 0x38F491, - 0x3CF58E, - 0x3FF68B, - 0x43F787, - 0x46F884, - 0x4AF980, - 0x4EFA7D, - 0x51FA79, - 0x55FB76, - 0x59FC73, - 0x5DFC6F, - 0x61FD6C, - 0x65FD69, - 0x69FE65, - 0x6DFE62, - 0x71FE5F, - 0x75FF5C, - 0x79FF59, - 0x7DFF56, - 0x80FF53, - 0x84FF50, - 0x88FF4E, - 0x8BFF4B, - 0x8FFF49, - 0x92FF46, - 0x96FF44, - 0x99FF42, - 0x9CFE40, - 0x9FFE3E, - 0xA2FD3D, - 0xA4FD3B, - 0xA7FC3A, - 0xAAFC39, - 0xACFB38, - 0xAFFA37, - 0xB1F936, - 0xB4F835, - 0xB7F835, - 0xB9F634, - 0xBCF534, - 0xBFF434, - 0xC1F334, - 0xC4F233, - 0xC6F033, - 0xC9EF34, - 0xCBEE34, - 0xCEEC34, - 0xD0EB34, - 0xD2E934, - 0xD5E835, - 0xD7E635, - 0xD9E435, - 0xDBE236, - 0xDDE136, - 0xE0DF37, - 0xE2DD37, - 0xE4DB38, - 0xE6D938, - 0xE7D738, - 0xE9D539, - 0xEBD339, - 0xEDD139, - 0xEECF3A, - 0xF0CD3A, - 0xF1CB3A, - 0xF3C93A, - 0xF4C73A, - 0xF5C53A, - 0xF7C33A, - 0xF8C13A, - 0xF9BF39, - 0xFABD39, - 0xFABA38, - 0xFBB838, - 0xFCB637, - 0xFCB436, - 0xFDB135, - 0xFDAF35, - 0xFEAC34, - 0xFEA933, - 0xFEA732, - 0xFEA431, - 0xFFA12F, - 0xFF9E2E, - 0xFF9C2D, - 0xFF992C, - 0xFE962B, - 0xFE932A, - 0xFE9028, - 0xFE8D27, - 0xFD8A26, - 0xFD8724, - 0xFC8423, - 0xFC8122, - 0xFB7E20, - 0xFB7B1F, - 0xFA781E, - 0xF9751C, - 0xF8721B, - 0xF86F1A, - 0xF76C19, - 0xF66917, - 0xF56616, - 0xF46315, - 0xF36014, - 0xF25D13, - 0xF05B11, - 0xEF5810, - 0xEE550F, - 0xED530E, - 0xEB500E, - 0xEA4E0D, - 0xE94B0C, - 0xE7490B, - 0xE6470A, - 0xE4450A, - 0xE34209, - 0xE14009, - 0xDF3E08, - 0xDE3C07, - 0xDC3A07, - 0xDA3806, - 0xD83606, - 0xD63405, - 0xD43205, - 0xD23105, - 0xD02F04, - 0xCE2D04, - 0xCC2B03, - 0xCA2903, - 0xC82803, - 0xC62602, - 0xC32402, - 0xC12302, - 0xBF2102, - 0xBC1F01, - 0xBA1E01, - 0xB71C01, - 0xB41B01, - 0xB21901, - 0xAF1801, - 0xAC1601, - 0xAA1501, - 0xA71401, - 0xA41201, - 0xA11101, - 0x9E1001, - 0x9B0F01, - 0x980D01, - 0x950C01, - 0x920B01, - 0x8E0A01, - 0x8B0901, - 0x880801, - 0x850701, - 0x810602, - 0x7E0502, - 0x7A0402, - ], + waterfall_scheme="GoogleTurboWaterfall", waterfall_min_level=-88, waterfall_max_level=-20, waterfall_auto_level_margin={"min": 3, "max": 10, "min_range": 50}, diff --git a/owrx/config/migration.py b/owrx/config/migration.py index 32528a6..21e9836 100644 --- a/owrx/config/migration.py +++ b/owrx/config/migration.py @@ -40,11 +40,33 @@ class ConfigMigratorVersion2(ConfigMigrator): 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 Migrator(object): - currentVersion = 3 + currentVersion = 4 migrators = { 1: ConfigMigratorVersion1(), 2: ConfigMigratorVersion2(), + 3: ConfigMigratorVersion3(), } @staticmethod diff --git a/owrx/controllers/settings/general.py b/owrx/controllers/settings/general.py index b9adb39..083d89b 100644 --- a/owrx/controllers/settings/general.py +++ b/owrx/controllers/settings/general.py @@ -11,6 +11,7 @@ from owrx.form import ( ) from owrx.form.receiverid import ReceiverKeysConverter from owrx.form.gfx import AvatarInput, TopPhotoInput +from owrx.waterfall import WaterfallOptions import shutil import os from glob import glob @@ -74,6 +75,11 @@ class GeneralSettingsController(SettingsFormController): ), Section( "Waterfall settings", + DropdownInput( + "waterfall_scheme", + "Waterfall color scheme", + options=WaterfallOptions, + ), NumberInput( "fft_fps", "FFT speed", diff --git a/owrx/waterfall.py b/owrx/waterfall.py index e69de29..a8811bb 100644 --- a/owrx/waterfall.py +++ b/owrx/waterfall.py @@ -0,0 +1,320 @@ +from owrx.form import DropdownEnum + + +class Waterfall(object): + def __init__(self, colors): + self.colors = colors + + def getColors(self): + return self.colors + + +class GoogleTurboWaterfall(Waterfall): + def __init__(self): + super().__init__( + [ + 0x30123B, + 0x311542, + 0x33184A, + 0x341B51, + 0x351E58, + 0x36215F, + 0x372466, + 0x38266C, + 0x392973, + 0x3A2C79, + 0x3B2F80, + 0x3C3286, + 0x3D358B, + 0x3E3891, + 0x3E3A97, + 0x3F3D9C, + 0x4040A2, + 0x4043A7, + 0x4146AC, + 0x4248B1, + 0x424BB6, + 0x434EBA, + 0x4351BF, + 0x4453C3, + 0x4456C7, + 0x4559CB, + 0x455BCF, + 0x455ED3, + 0x4561D7, + 0x4663DA, + 0x4666DD, + 0x4669E1, + 0x466BE4, + 0x466EE7, + 0x4671E9, + 0x4673EC, + 0x4676EE, + 0x4678F1, + 0x467BF3, + 0x467DF5, + 0x4680F7, + 0x4682F9, + 0x4685FA, + 0x4587FC, + 0x458AFD, + 0x448CFE, + 0x448FFE, + 0x4391FF, + 0x4294FF, + 0x4196FF, + 0x3F99FF, + 0x3E9BFF, + 0x3D9EFE, + 0x3BA1FD, + 0x3AA3FD, + 0x38A6FB, + 0x36A8FA, + 0x35ABF9, + 0x33ADF7, + 0x31B0F6, + 0x2FB2F4, + 0x2DB5F2, + 0x2CB7F0, + 0x2AB9EE, + 0x28BCEC, + 0x26BEEA, + 0x25C0E7, + 0x23C3E5, + 0x21C5E2, + 0x20C7E0, + 0x1FC9DD, + 0x1DCCDB, + 0x1CCED8, + 0x1BD0D5, + 0x1AD2D3, + 0x19D4D0, + 0x18D6CD, + 0x18D8CB, + 0x18DAC8, + 0x17DBC5, + 0x17DDC3, + 0x17DFC0, + 0x18E0BE, + 0x18E2BB, + 0x19E3B9, + 0x1AE5B7, + 0x1BE6B4, + 0x1DE8B2, + 0x1EE9AF, + 0x20EAAD, + 0x22ECAA, + 0x24EDA7, + 0x27EEA4, + 0x29EFA1, + 0x2CF09E, + 0x2FF19B, + 0x32F298, + 0x35F394, + 0x38F491, + 0x3CF58E, + 0x3FF68B, + 0x43F787, + 0x46F884, + 0x4AF980, + 0x4EFA7D, + 0x51FA79, + 0x55FB76, + 0x59FC73, + 0x5DFC6F, + 0x61FD6C, + 0x65FD69, + 0x69FE65, + 0x6DFE62, + 0x71FE5F, + 0x75FF5C, + 0x79FF59, + 0x7DFF56, + 0x80FF53, + 0x84FF50, + 0x88FF4E, + 0x8BFF4B, + 0x8FFF49, + 0x92FF46, + 0x96FF44, + 0x99FF42, + 0x9CFE40, + 0x9FFE3E, + 0xA2FD3D, + 0xA4FD3B, + 0xA7FC3A, + 0xAAFC39, + 0xACFB38, + 0xAFFA37, + 0xB1F936, + 0xB4F835, + 0xB7F835, + 0xB9F634, + 0xBCF534, + 0xBFF434, + 0xC1F334, + 0xC4F233, + 0xC6F033, + 0xC9EF34, + 0xCBEE34, + 0xCEEC34, + 0xD0EB34, + 0xD2E934, + 0xD5E835, + 0xD7E635, + 0xD9E435, + 0xDBE236, + 0xDDE136, + 0xE0DF37, + 0xE2DD37, + 0xE4DB38, + 0xE6D938, + 0xE7D738, + 0xE9D539, + 0xEBD339, + 0xEDD139, + 0xEECF3A, + 0xF0CD3A, + 0xF1CB3A, + 0xF3C93A, + 0xF4C73A, + 0xF5C53A, + 0xF7C33A, + 0xF8C13A, + 0xF9BF39, + 0xFABD39, + 0xFABA38, + 0xFBB838, + 0xFCB637, + 0xFCB436, + 0xFDB135, + 0xFDAF35, + 0xFEAC34, + 0xFEA933, + 0xFEA732, + 0xFEA431, + 0xFFA12F, + 0xFF9E2E, + 0xFF9C2D, + 0xFF992C, + 0xFE962B, + 0xFE932A, + 0xFE9028, + 0xFE8D27, + 0xFD8A26, + 0xFD8724, + 0xFC8423, + 0xFC8122, + 0xFB7E20, + 0xFB7B1F, + 0xFA781E, + 0xF9751C, + 0xF8721B, + 0xF86F1A, + 0xF76C19, + 0xF66917, + 0xF56616, + 0xF46315, + 0xF36014, + 0xF25D13, + 0xF05B11, + 0xEF5810, + 0xEE550F, + 0xED530E, + 0xEB500E, + 0xEA4E0D, + 0xE94B0C, + 0xE7490B, + 0xE6470A, + 0xE4450A, + 0xE34209, + 0xE14009, + 0xDF3E08, + 0xDE3C07, + 0xDC3A07, + 0xDA3806, + 0xD83606, + 0xD63405, + 0xD43205, + 0xD23105, + 0xD02F04, + 0xCE2D04, + 0xCC2B03, + 0xCA2903, + 0xC82803, + 0xC62602, + 0xC32402, + 0xC12302, + 0xBF2102, + 0xBC1F01, + 0xBA1E01, + 0xB71C01, + 0xB41B01, + 0xB21901, + 0xAF1801, + 0xAC1601, + 0xAA1501, + 0xA71401, + 0xA41201, + 0xA11101, + 0x9E1001, + 0x9B0F01, + 0x980D01, + 0x950C01, + 0x920B01, + 0x8E0A01, + 0x8B0901, + 0x880801, + 0x850701, + 0x810602, + 0x7E0502, + 0x7A0402, + ] + ) + + +class TeejeezWaterfall(Waterfall): + def __init__(self): + super().__init__([0x000000, 0x0000FF, 0x00FFFF, 0x00FF00, 0xFFFF00, 0xFF0000, 0xFF00FF, 0xFFFFFF]) + + +class Ha7ilmWaterfall(Waterfall): + def __init__(self): + super().__init__([0x000000, 0x2E6893, 0x69A5D0, 0x214B69, 0x9DC4E0, 0xFFF775, 0xFF8A8A, 0xB20000]) + + +class CustomWaterfall(Waterfall): + def __init__(self): + # TODO: read waterfall_colors from config + super().__init__([0x00000]) + + +class WaterfallOptions(DropdownEnum): + DEFAULT = ("Google Turbo (OpenWebRX default)", GoogleTurboWaterfall) + TEEJEEZ = ("Original colorscheme by teejeez (default in OpenWebRX < 0.20)", TeejeezWaterfall) + HA7ILM = ("Old theme by HA7ILM", Ha7ilmWaterfall) + CUSTOM = ("Custom", CustomWaterfall) + + def __new__(cls, *args, **kwargs): + description, waterfallClass = args + obj = object.__new__(cls) + obj._value_ = waterfallClass.__name__ + obj.waterfallClass = waterfallClass + obj.description = description + return obj + + def __str__(self): + return self.description + + def instantiate(self): + return self.waterfallClass() + + @staticmethod + def findByColors(colors): + for o in WaterfallOptions: + if o is WaterfallOptions.CUSTOM: + continue + waterfall = o.instantiate() + if waterfall.getColors() == colors: + return o + return WaterfallOptions.CUSTOM From 3c0a26eaa814d2b82c18ea13a76973bc97cdada0 Mon Sep 17 00:00:00 2001 From: Jakob Ketterl Date: Tue, 16 Feb 2021 17:17:09 +0100 Subject: [PATCH 159/577] prevent file corruption during json.dump --- owrx/bookmarks.py | 4 +++- owrx/config/dynamic.py | 4 +++- owrx/users.py | 4 +++- 3 files changed, 9 insertions(+), 3 deletions(-) diff --git a/owrx/bookmarks.py b/owrx/bookmarks.py index d8a9a2b..e2a56cb 100644 --- a/owrx/bookmarks.py +++ b/owrx/bookmarks.py @@ -93,8 +93,10 @@ class Bookmarks(object): 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: - json.dump([b.__dict__() for b in self.bookmarks], file, indent=4) + file.write(jsonContent) self.file_modified = self._getFileModifiedTimestamp() def addBookmark(self, bookmark: Bookmark): diff --git a/owrx/config/dynamic.py b/owrx/config/dynamic.py index 72f73fd..30deae8 100644 --- a/owrx/config/dynamic.py +++ b/owrx/config/dynamic.py @@ -21,5 +21,7 @@ class DynamicConfig(PropertyLayer): 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) with open(DynamicConfig._getSettingsFile(), "w") as file: - json.dump(self.__dict__(), file, indent=4) + file.write(jsonContent) diff --git a/owrx/users.py b/owrx/users.py index 2f2e88d..0fc5c0c 100644 --- a/owrx/users.py +++ b/owrx/users.py @@ -185,8 +185,10 @@ class UserList(object): usersFile = self._getUsersFile() users = [u.toJson() for u in self.values()] try: + # don't write directly to file to avoid corruption on exceptions + jsonContent = json.dumps(users, indent=4) with open(usersFile, "w") as f: - json.dump(users, f, indent=4) + f.write(jsonContent) except Exception: logger.exception("error while writing users file %s", usersFile) self.refresh() From 917562983890ab24d09f8e38fb843934eb2c3ac2 Mon Sep 17 00:00:00 2001 From: Jakob Ketterl Date: Tue, 16 Feb 2021 17:34:04 +0100 Subject: [PATCH 160/577] send waterfall colors to the client --- owrx/connection.py | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/owrx/connection.py b/owrx/connection.py index b9b2192..7a74073 100644 --- a/owrx/connection.py +++ b/owrx/connection.py @@ -12,6 +12,7 @@ from owrx.map import Map from owrx.property import PropertyStack from owrx.modes import Modes, DigitalMode from owrx.config import Config +from owrx.waterfall import WaterfallOptions from queue import Queue, Full, Empty from js8py import Js8Frame from abc import ABC, ABCMeta, abstractmethod @@ -125,6 +126,7 @@ class OpenWebRxReceiverClient(OpenWebRxClient, SdrSourceEventClient): ] global_config_keys = [ + "waterfall_scheme", "waterfall_colors", "waterfall_auto_level_margin", "fft_size", @@ -202,9 +204,17 @@ class OpenWebRxReceiverClient(OpenWebRxClient, SdrSourceEventClient): 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: + scheme = WaterfallOptions(changes["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: From 409370aba2044f201329e974a04d172ea7878644 Mon Sep 17 00:00:00 2001 From: Jakob Ketterl Date: Tue, 16 Feb 2021 17:48:12 +0100 Subject: [PATCH 161/577] implement custom waterfall option --- config_webrx.py | 16 +++++++++------- owrx/waterfall.py | 5 +++-- 2 files changed, 12 insertions(+), 9 deletions(-) diff --git a/config_webrx.py b/config_webrx.py index 5068fdc..e75bce8 100644 --- a/config_webrx.py +++ b/config_webrx.py @@ -33,7 +33,7 @@ config_webrx: configuration options for OpenWebRX """ # configuration version. please only modify if you're able to perform the associated migration steps. -version = 3 +version = 4 # NOTE: you can find additional information about configuring OpenWebRX in the Wiki: # https://github.com/jketterl/openwebrx/wiki/Configuration-guide @@ -244,18 +244,20 @@ sdrs = { # ==== Color themes ==== ### google turbo colormap (see: https://ai.googleblog.com/2019/08/turbo-improved-rainbow-colormap-for.html) -waterfall_colors = [0x30123b, 0x311542, 0x33184a, 0x341b51, 0x351e58, 0x36215f, 0x372466, 0x38266c, 0x392973, 0x3a2c79, 0x3b2f80, 0x3c3286, 0x3d358b, 0x3e3891, 0x3e3a97, 0x3f3d9c, 0x4040a2, 0x4043a7, 0x4146ac, 0x4248b1, 0x424bb6, 0x434eba, 0x4351bf, 0x4453c3, 0x4456c7, 0x4559cb, 0x455bcf, 0x455ed3, 0x4561d7, 0x4663da, 0x4666dd, 0x4669e1, 0x466be4, 0x466ee7, 0x4671e9, 0x4673ec, 0x4676ee, 0x4678f1, 0x467bf3, 0x467df5, 0x4680f7, 0x4682f9, 0x4685fa, 0x4587fc, 0x458afd, 0x448cfe, 0x448ffe, 0x4391ff, 0x4294ff, 0x4196ff, 0x3f99ff, 0x3e9bff, 0x3d9efe, 0x3ba1fd, 0x3aa3fd, 0x38a6fb, 0x36a8fa, 0x35abf9, 0x33adf7, 0x31b0f6, 0x2fb2f4, 0x2db5f2, 0x2cb7f0, 0x2ab9ee, 0x28bcec, 0x26beea, 0x25c0e7, 0x23c3e5, 0x21c5e2, 0x20c7e0, 0x1fc9dd, 0x1dccdb, 0x1cced8, 0x1bd0d5, 0x1ad2d3, 0x19d4d0, 0x18d6cd, 0x18d8cb, 0x18dac8, 0x17dbc5, 0x17ddc3, 0x17dfc0, 0x18e0be, 0x18e2bb, 0x19e3b9, 0x1ae5b7, 0x1be6b4, 0x1de8b2, 0x1ee9af, 0x20eaad, 0x22ecaa, 0x24eda7, 0x27eea4, 0x29efa1, 0x2cf09e, 0x2ff19b, 0x32f298, 0x35f394, 0x38f491, 0x3cf58e, 0x3ff68b, 0x43f787, 0x46f884, 0x4af980, 0x4efa7d, 0x51fa79, 0x55fb76, 0x59fc73, 0x5dfc6f, 0x61fd6c, 0x65fd69, 0x69fe65, 0x6dfe62, 0x71fe5f, 0x75ff5c, 0x79ff59, 0x7dff56, 0x80ff53, 0x84ff50, 0x88ff4e, 0x8bff4b, 0x8fff49, 0x92ff46, 0x96ff44, 0x99ff42, 0x9cfe40, 0x9ffe3e, 0xa2fd3d, 0xa4fd3b, 0xa7fc3a, 0xaafc39, 0xacfb38, 0xaffa37, 0xb1f936, 0xb4f835, 0xb7f835, 0xb9f634, 0xbcf534, 0xbff434, 0xc1f334, 0xc4f233, 0xc6f033, 0xc9ef34, 0xcbee34, 0xceec34, 0xd0eb34, 0xd2e934, 0xd5e835, 0xd7e635, 0xd9e435, 0xdbe236, 0xdde136, 0xe0df37, 0xe2dd37, 0xe4db38, 0xe6d938, 0xe7d738, 0xe9d539, 0xebd339, 0xedd139, 0xeecf3a, 0xf0cd3a, 0xf1cb3a, 0xf3c93a, 0xf4c73a, 0xf5c53a, 0xf7c33a, 0xf8c13a, 0xf9bf39, 0xfabd39, 0xfaba38, 0xfbb838, 0xfcb637, 0xfcb436, 0xfdb135, 0xfdaf35, 0xfeac34, 0xfea933, 0xfea732, 0xfea431, 0xffa12f, 0xff9e2e, 0xff9c2d, 0xff992c, 0xfe962b, 0xfe932a, 0xfe9028, 0xfe8d27, 0xfd8a26, 0xfd8724, 0xfc8423, 0xfc8122, 0xfb7e20, 0xfb7b1f, 0xfa781e, 0xf9751c, 0xf8721b, 0xf86f1a, 0xf76c19, 0xf66917, 0xf56616, 0xf46315, 0xf36014, 0xf25d13, 0xf05b11, 0xef5810, 0xee550f, 0xed530e, 0xeb500e, 0xea4e0d, 0xe94b0c, 0xe7490b, 0xe6470a, 0xe4450a, 0xe34209, 0xe14009, 0xdf3e08, 0xde3c07, 0xdc3a07, 0xda3806, 0xd83606, 0xd63405, 0xd43205, 0xd23105, 0xd02f04, 0xce2d04, 0xcc2b03, 0xca2903, 0xc82803, 0xc62602, 0xc32402, 0xc12302, 0xbf2102, 0xbc1f01, 0xba1e01, 0xb71c01, 0xb41b01, 0xb21901, 0xaf1801, 0xac1601, 0xaa1501, 0xa71401, 0xa41201, 0xa11101, 0x9e1001, 0x9b0f01, 0x980d01, 0x950c01, 0x920b01, 0x8e0a01, 0x8b0901, 0x880801, 0x850701, 0x810602, 0x7e0502, 0x7a0402] +#waterfall_scheme = "GoogleTurboWaterfall" ### original theme by teejez: -#waterfall_colors = [0x000000, 0x0000FF, 0x00FFFF, 0x00FF00, 0xFFFF00, 0xFF0000, 0xFF00FF, 0xFFFFFF] +#waterfall_scheme = "TeejeezWaterfall" ### old theme by HA7ILM: -#waterfall_colors = [0x000000, 0x2e6893, 0x69a5d0, 0x214b69, 0x9dc4e0, 0xfff775, 0xff8a8a, 0xb20000] -# waterfall_min_level = -115 #in dB -# waterfall_max_level = 0 -# waterfall_auto_level_margin = {"min": 20, "max": 30} +#waterfall_scheme = "Ha7ilmWaterfall" ##For the old colors, you might also want to set [fft_voverlap_factor] to 0. +### custom waterfall schemes can be configured like this: +#waterfall_scheme = "CustomWaterfall" +#waterfall_colors = [0x0000FF, 0x00FF00, 0xFF0000] + +### Waterfall calibration #waterfall_min_level = -88 # in dB #waterfall_max_level = -20 waterfall_auto_level_margin = {"min": 3, "max": 10, "min_range": 50} diff --git a/owrx/waterfall.py b/owrx/waterfall.py index a8811bb..68d5fdf 100644 --- a/owrx/waterfall.py +++ b/owrx/waterfall.py @@ -1,4 +1,5 @@ from owrx.form import DropdownEnum +from owrx.config import Config class Waterfall(object): @@ -285,8 +286,8 @@ class Ha7ilmWaterfall(Waterfall): class CustomWaterfall(Waterfall): def __init__(self): - # TODO: read waterfall_colors from config - super().__init__([0x00000]) + config = Config.get() + super().__init__(config["waterfall_colors"]) class WaterfallOptions(DropdownEnum): From 8d2763930bd601eb35f5615d1af06c09fb6d7a69 Mon Sep 17 00:00:00 2001 From: Jakob Ketterl Date: Tue, 16 Feb 2021 18:07:13 +0100 Subject: [PATCH 162/577] implement input for custom waterfall colors --- owrx/controllers/settings/general.py | 7 +++++++ owrx/form/converter.py | 17 +++++++++++++++++ 2 files changed, 24 insertions(+) diff --git a/owrx/controllers/settings/general.py b/owrx/controllers/settings/general.py index 083d89b..fb2b231 100644 --- a/owrx/controllers/settings/general.py +++ b/owrx/controllers/settings/general.py @@ -9,6 +9,7 @@ from owrx.form import ( DropdownInput, Option, ) +from owrx.form.converter import WaterfallColorsConverter from owrx.form.receiverid import ReceiverKeysConverter from owrx.form.gfx import AvatarInput, TopPhotoInput from owrx.waterfall import WaterfallOptions @@ -80,6 +81,12 @@ class GeneralSettingsController(SettingsFormController): "Waterfall color scheme", options=WaterfallOptions, ), + TextAreaInput( + "waterfall_colors", + "Custom waterfall colors", + infotext="TODO: describe", + converter=WaterfallColorsConverter() + ), NumberInput( "fft_fps", "FFT speed", diff --git a/owrx/form/converter.py b/owrx/form/converter.py index 7997cd9..6c8dbff 100644 --- a/owrx/form/converter.py +++ b/owrx/form/converter.py @@ -66,3 +66,20 @@ class JsonConverter(Converter): 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): + 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) + + # \r\n or \n? this should work with both. + return [parseString(v.strip("\r ")) for v in value.split("\n")] From 9aebeb51f8af17553482b78fd59f3b71271919b1 Mon Sep 17 00:00:00 2001 From: Jakob Ketterl Date: Tue, 16 Feb 2021 18:12:10 +0100 Subject: [PATCH 163/577] remove waterfall_colors unless scheme is custom --- owrx/controllers/settings/general.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/owrx/controllers/settings/general.py b/owrx/controllers/settings/general.py index fb2b231..a9be1d4 100644 --- a/owrx/controllers/settings/general.py +++ b/owrx/controllers/settings/general.py @@ -177,4 +177,9 @@ class GeneralSettingsController(SettingsFormController): # 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) From 691d88f841a42da927b96806ebe84fab3dcfbacd Mon Sep 17 00:00:00 2001 From: Jakob Ketterl Date: Tue, 16 Feb 2021 18:35:18 +0100 Subject: [PATCH 164/577] waterfall config fine-adjustments * hide the waterfall colors input when pre-defined color scheme is selected * skip unparseable lines on custom color input * fallback to black and white if custom color config is unusable * always use the waterfall classes when sending changes to the client --- htdocs/lib/settings/WaterfallDropdown.js | 11 +++++++++++ htdocs/settings.js | 1 + owrx/connection.py | 4 ++-- owrx/controllers/assets.py | 1 + owrx/form/converter.py | 14 +++++++++----- owrx/waterfall.py | 7 ++++++- 6 files changed, 30 insertions(+), 8 deletions(-) create mode 100644 htdocs/lib/settings/WaterfallDropdown.js diff --git a/htdocs/lib/settings/WaterfallDropdown.js b/htdocs/lib/settings/WaterfallDropdown.js new file mode 100644 index 0000000..ea05992 --- /dev/null +++ b/htdocs/lib/settings/WaterfallDropdown.js @@ -0,0 +1,11 @@ +$.fn.waterfallDropdown = function(){ + this.each(function(){ + var $select = $(this); + var setVisibility = function() { + var show = $select.val() === 'CUSTOM'; + $('#waterfall_colors').parents('.form-group')[show ? 'show' : 'hide'](); + } + $select.on('change', setVisibility); + setVisibility(); + }) +} \ No newline at end of file diff --git a/htdocs/settings.js b/htdocs/settings.js index 072ae79..1a76dbe 100644 --- a/htdocs/settings.js +++ b/htdocs/settings.js @@ -4,4 +4,5 @@ $(function(){ $('.imageupload').imageUpload(); $('.bookmarks').bookmarktable(); $('.wsjt-decoding-depths').wsjtDecodingDepthsInput(); + $('#waterfall_scheme').waterfallDropdown(); }); \ No newline at end of file diff --git a/owrx/connection.py b/owrx/connection.py index 7a74073..bf0e38d 100644 --- a/owrx/connection.py +++ b/owrx/connection.py @@ -207,8 +207,8 @@ class OpenWebRxReceiverClient(OpenWebRxClient, SdrSourceEventClient): 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: - scheme = WaterfallOptions(changes["waterfall_scheme"]).instantiate() + 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) diff --git a/owrx/controllers/assets.py b/owrx/controllers/assets.py index 61483b7..6565bb5 100644 --- a/owrx/controllers/assets.py +++ b/owrx/controllers/assets.py @@ -151,6 +151,7 @@ class CompiledAssetsController(GzipMixin, ModificationAwareController): "lib/settings/ImageUpload.js", "lib/settings/BookmarkTable.js", "lib/settings/WsjtDecodingDepthsInput.js", + "lib/settings/WaterfallDropdown.js", "settings.js", ], } diff --git a/owrx/form/converter.py b/owrx/form/converter.py index 6c8dbff..580b368 100644 --- a/owrx/form/converter.py +++ b/owrx/form/converter.py @@ -76,10 +76,14 @@ class WaterfallColorsConverter(Converter): def convert_from_form(self, value): def parseString(s): - 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) + 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. - return [parseString(v.strip("\r ")) for v in value.split("\n")] + values = [parseString(v.strip("\r ")) for v in value.split("\n")] + return [v for v in values if v is not None] diff --git a/owrx/waterfall.py b/owrx/waterfall.py index 68d5fdf..ad1a31e 100644 --- a/owrx/waterfall.py +++ b/owrx/waterfall.py @@ -287,7 +287,12 @@ class Ha7ilmWaterfall(Waterfall): class CustomWaterfall(Waterfall): def __init__(self): config = Config.get() - super().__init__(config["waterfall_colors"]) + if "waterfall_colors" in config and config["waterfall_colors"]: + colors = config["waterfall_colors"] + else: + # fallback: black and white + colors = [0x000000, 0xffffff] + super().__init__(colors) class WaterfallOptions(DropdownEnum): From b7688c3c971e741d6d5fe25bbed29e52ca5458e9 Mon Sep 17 00:00:00 2001 From: Jakob Ketterl Date: Tue, 16 Feb 2021 18:39:42 +0100 Subject: [PATCH 165/577] add infotext for custom html colors --- owrx/controllers/settings/general.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/owrx/controllers/settings/general.py b/owrx/controllers/settings/general.py index a9be1d4..07f9a7e 100644 --- a/owrx/controllers/settings/general.py +++ b/owrx/controllers/settings/general.py @@ -84,8 +84,9 @@ class GeneralSettingsController(SettingsFormController): TextAreaInput( "waterfall_colors", "Custom waterfall colors", - infotext="TODO: describe", - converter=WaterfallColorsConverter() + infotext="Please provide 6-digit hexadecimal RGB colors in HTML notation (#RRGGBB)" + + " or HEX notation (0xRRGGBB), one per line", + converter=WaterfallColorsConverter(), ), NumberInput( "fft_fps", From 06361754b3b0100d4d79871f9652c2e4ab64fe81 Mon Sep 17 00:00:00 2001 From: Jakob Ketterl Date: Wed, 17 Feb 2021 23:39:16 +0100 Subject: [PATCH 166/577] add config script --- debian/openwebrx.config | 6 ++++++ debian/openwebrx.template | 6 ++++++ 2 files changed, 12 insertions(+) create mode 100755 debian/openwebrx.config create mode 100644 debian/openwebrx.template diff --git a/debian/openwebrx.config b/debian/openwebrx.config new file mode 100755 index 0000000..08364d9 --- /dev/null +++ b/debian/openwebrx.config @@ -0,0 +1,6 @@ +#!/bin/sh -e + +. /usr/share/debconf/confmodule + +db_input medium openwebrx/admin_user_password +db_go diff --git a/debian/openwebrx.template b/debian/openwebrx.template new file mode 100644 index 0000000..d3a7c04 --- /dev/null +++ b/debian/openwebrx.template @@ -0,0 +1,6 @@ +Template: openwebrx/admin_user_password +Type: password +Description: OpenWebRX "admin" user password + The system will create a user named "admin" for the OpenWebRX web + configuration interface. You will be able to log into the "settings" area + of OpenWebRX with this user to configure your receiver. From f488a01c789b7f37f9fa1c0f0fdbc7779543521d Mon Sep 17 00:00:00 2001 From: Jakob Ketterl Date: Wed, 17 Feb 2021 23:45:22 +0100 Subject: [PATCH 167/577] linitian also finds spelling errors?!? --- CHANGELOG.md | 2 +- debian/changelog | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 950947d..58b6785 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,7 +6,7 @@ - Added support for demodulating M17 digital voice signals using m17-cxx-demod - New reporting infrastructure, allowing WSPR and FST4W spots to be sent to wsprnet.org - Add some basic filtering capabilities to the map -- New commmand-line tool `openwebrx-admin` that facilitates the administration of users +- New command-line tool `openwebrx-admin` that facilitates the administration of users - Default bandwidth changes: - "WFM" changed to 150kHz - "Packet" (APRS) changed to 12.5kHz diff --git a/debian/changelog b/debian/changelog index 97f51cb..9976157 100644 --- a/debian/changelog +++ b/debian/changelog @@ -10,7 +10,7 @@ openwebrx (0.21.0) UNRELEASED; urgency=low * New reporting infrastructure, allowing WSPR and FST4W spots to be sent to wsprnet.org * Add some basic filtering capabilities to the map - * New commmand-line tool `openwebrx-admin` that facilitates the + * New command-line tool `openwebrx-admin` that facilitates the administration of users * Default bandwidth changes: - "WFM" changed to 150kHz From 8fcfa689ae829ba5af9153e07d6d1863ddb068dc Mon Sep 17 00:00:00 2001 From: Jakob Ketterl Date: Thu, 18 Feb 2021 00:13:58 +0100 Subject: [PATCH 168/577] add postinst/postrm integration --- debian/openwebrx.postinst | 9 ++++++++- debian/openwebrx.postrm | 6 ++++++ 2 files changed, 14 insertions(+), 1 deletion(-) create mode 100644 debian/openwebrx.postrm diff --git a/debian/openwebrx.postinst b/debian/openwebrx.postinst index 585a204..21f7363 100755 --- a/debian/openwebrx.postinst +++ b/debian/openwebrx.postinst @@ -1,6 +1,8 @@ #!/bin/bash set -euo pipefail +. /usr/share/debconf/confmodule + OWRX_USER="openwebrx" OWRX_DATADIR="/var/lib/openwebrx" @@ -14,7 +16,12 @@ case "$1" in chown "${OWRX_USER}". ${OWRX_DATADIR} # create initial openwebrx user - openwebrx-admin --noninteractive --silent adduser admin + db_get openwebrx/admin_user_password + if [ "${RET}" != "__DONE__" ]; then + OWRX_PASSWORD="${RET}" openwebrx-admin --noninteractive --silent adduser admin + fi + # remove actual password from debconf database, but leave a marker + db_set openwebrx/admin_user_password "__DONE__" ;; *) echo "postinst called with unknown argument '$1'" 1>&2 diff --git a/debian/openwebrx.postrm b/debian/openwebrx.postrm new file mode 100644 index 0000000..ed7b4e9 --- /dev/null +++ b/debian/openwebrx.postrm @@ -0,0 +1,6 @@ +#!/bin/sh -e + +if [ "$1" = purge ] && [ -e /usr/share/debconf/confmodule ]; then + . /usr/share/debconf/confmodule + db_purge +fi \ No newline at end of file From 404f995e393260e2d02d7868446458a6b06abf65 Mon Sep 17 00:00:00 2001 From: Jakob Ketterl Date: Thu, 18 Feb 2021 00:22:37 +0100 Subject: [PATCH 169/577] confmodule doesn't work with our bash parameters --- debian/openwebrx.postinst | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/debian/openwebrx.postinst b/debian/openwebrx.postinst index 21f7363..a2b1e9e 100755 --- a/debian/openwebrx.postinst +++ b/debian/openwebrx.postinst @@ -1,8 +1,8 @@ #!/bin/bash -set -euo pipefail - . /usr/share/debconf/confmodule +set -euo pipefail + OWRX_USER="openwebrx" OWRX_DATADIR="/var/lib/openwebrx" From 8271eddefbbb9324428601b5598055487b0a99cf Mon Sep 17 00:00:00 2001 From: Jakob Ketterl Date: Thu, 18 Feb 2021 00:26:52 +0100 Subject: [PATCH 170/577] rename templates file --- debian/{openwebrx.template => openwebrx.templates} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename debian/{openwebrx.template => openwebrx.templates} (100%) diff --git a/debian/openwebrx.template b/debian/openwebrx.templates similarity index 100% rename from debian/openwebrx.template rename to debian/openwebrx.templates From 518588885cb643aeade75c0ea83515821f6dc945 Mon Sep 17 00:00:00 2001 From: Jakob Ketterl Date: Thu, 18 Feb 2021 01:00:47 +0100 Subject: [PATCH 171/577] make postrm executable --- debian/openwebrx.postrm | 0 1 file changed, 0 insertions(+), 0 deletions(-) mode change 100644 => 100755 debian/openwebrx.postrm diff --git a/debian/openwebrx.postrm b/debian/openwebrx.postrm old mode 100644 new mode 100755 From 3122077603c846aa131650b99c30b79c59a6fc6a Mon Sep 17 00:00:00 2001 From: Jakob Ketterl Date: Thu, 18 Feb 2021 01:12:26 +0100 Subject: [PATCH 172/577] fix debconf password questions --- debian/openwebrx.config | 2 +- debian/openwebrx.postinst | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/debian/openwebrx.config b/debian/openwebrx.config index 08364d9..7599342 100755 --- a/debian/openwebrx.config +++ b/debian/openwebrx.config @@ -2,5 +2,5 @@ . /usr/share/debconf/confmodule -db_input medium openwebrx/admin_user_password +db_input medium openwebrx/admin_user_password || true db_go diff --git a/debian/openwebrx.postinst b/debian/openwebrx.postinst index a2b1e9e..78ba051 100755 --- a/debian/openwebrx.postinst +++ b/debian/openwebrx.postinst @@ -17,11 +17,11 @@ case "$1" in # create initial openwebrx user db_get openwebrx/admin_user_password - if [ "${RET}" != "__DONE__" ]; then + if [ ! -z "${RET}" ] && [ "${RET}" != "__DONE__" ]; then OWRX_PASSWORD="${RET}" openwebrx-admin --noninteractive --silent adduser admin + # remove actual password from debconf database, but leave a marker + db_set openwebrx/admin_user_password "__DONE__" fi - # remove actual password from debconf database, but leave a marker - db_set openwebrx/admin_user_password "__DONE__" ;; *) echo "postinst called with unknown argument '$1'" 1>&2 From 2eec29db053ce4c713a039981fc7068aa83d6bfa Mon Sep 17 00:00:00 2001 From: Jakob Ketterl Date: Thu, 18 Feb 2021 01:28:40 +0100 Subject: [PATCH 173/577] change debconf priority to high --- debian/openwebrx.config | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/debian/openwebrx.config b/debian/openwebrx.config index 7599342..a91068f 100755 --- a/debian/openwebrx.config +++ b/debian/openwebrx.config @@ -2,5 +2,5 @@ . /usr/share/debconf/confmodule -db_input medium openwebrx/admin_user_password || true +db_input high openwebrx/admin_user_password || true db_go From 0714ce5703b6ed7e192a09b14521bcbe97807f61 Mon Sep 17 00:00:00 2001 From: Jakob Ketterl Date: Thu, 18 Feb 2021 01:32:27 +0100 Subject: [PATCH 174/577] parse password from env if available --- owrxadmin/commands.py | 17 +++++++++++------ 1 file changed, 11 insertions(+), 6 deletions(-) diff --git a/owrxadmin/commands.py b/owrxadmin/commands.py index c9590fc..69aad1d 100644 --- a/owrxadmin/commands.py +++ b/owrxadmin/commands.py @@ -4,6 +4,7 @@ from owrx.users import UserList, User, DefaultPasswordClass import sys import random import string +import os class Command(ABC): @@ -15,12 +16,16 @@ class Command(ABC): class UserCommand(Command, metaclass=ABCMeta): def getPassword(self, args, username): if args.noninteractive: - 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.') + 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: ") From ad5166cf9ed4e47480e6c78ead67d563a925c406 Mon Sep 17 00:00:00 2001 From: Jakob Ketterl Date: Thu, 18 Feb 2021 01:36:04 +0100 Subject: [PATCH 175/577] allow reconfigure in postinst --- debian/openwebrx.postinst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/debian/openwebrx.postinst b/debian/openwebrx.postinst index 78ba051..540f06e 100755 --- a/debian/openwebrx.postinst +++ b/debian/openwebrx.postinst @@ -7,7 +7,7 @@ OWRX_USER="openwebrx" OWRX_DATADIR="/var/lib/openwebrx" case "$1" in - configure) + configure|reconfigure) adduser --system --group --no-create-home --home /nonexistent --quiet "${OWRX_USER}" usermod -aG plugdev openwebrx From 9492bbebbb4bc041d205292973e9d26f2fc768c6 Mon Sep 17 00:00:00 2001 From: Jakob Ketterl Date: Thu, 18 Feb 2021 01:42:06 +0100 Subject: [PATCH 176/577] un-silence --- debian/openwebrx.postinst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/debian/openwebrx.postinst b/debian/openwebrx.postinst index 540f06e..a3e3344 100755 --- a/debian/openwebrx.postinst +++ b/debian/openwebrx.postinst @@ -18,7 +18,7 @@ case "$1" in # create initial openwebrx user db_get openwebrx/admin_user_password if [ ! -z "${RET}" ] && [ "${RET}" != "__DONE__" ]; then - OWRX_PASSWORD="${RET}" openwebrx-admin --noninteractive --silent adduser admin + OWRX_PASSWORD="${RET}" openwebrx-admin --noninteractive adduser admin # remove actual password from debconf database, but leave a marker db_set openwebrx/admin_user_password "__DONE__" fi From 06d4b24b09874c51e4c708eebe7209e169bd4460 Mon Sep 17 00:00:00 2001 From: Jakob Ketterl Date: Thu, 18 Feb 2021 15:27:05 +0100 Subject: [PATCH 177/577] handle config key not set --- owrx/form/receiverid.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/owrx/form/receiverid.py b/owrx/form/receiverid.py index 139bd82..28b7266 100644 --- a/owrx/form/receiverid.py +++ b/owrx/form/receiverid.py @@ -3,7 +3,7 @@ from owrx.form.converter import Converter class ReceiverKeysConverter(Converter): def convert_to_form(self, value): - return "\n".join(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. From c09f17579c613b9cd533490337da1bacb897013b Mon Sep 17 00:00:00 2001 From: Jakob Ketterl Date: Thu, 18 Feb 2021 15:42:12 +0100 Subject: [PATCH 178/577] implement a command to test for a user's existence --- owrxadmin/__main__.py | 6 +++++- owrxadmin/commands.py | 16 ++++++++++++++++ 2 files changed, 21 insertions(+), 1 deletion(-) diff --git a/owrxadmin/__main__.py b/owrxadmin/__main__.py index f92233c..36f7b6e 100644 --- a/owrxadmin/__main__.py +++ b/owrxadmin/__main__.py @@ -1,5 +1,5 @@ from owrx.version import openwebrx_version -from owrxadmin.commands import NewUser, DeleteUser, ResetPassword, ListUsers, DisableUser, EnableUser +from owrxadmin.commands import NewUser, DeleteUser, ResetPassword, ListUsers, DisableUser, EnableUser, HasUser import argparse import sys import traceback @@ -36,6 +36,10 @@ def main(): 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) + parser.add_argument("-v", "--version", action="store_true", help="Show the software version") parser.add_argument( "--noninteractive", action="store_true", help="Don't ask for any user input (useful for automation)" diff --git a/owrxadmin/commands.py b/owrxadmin/commands.py index 69aad1d..8eca85e 100644 --- a/owrxadmin/commands.py +++ b/owrxadmin/commands.py @@ -97,3 +97,19 @@ class ListUsers(Command): 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) From 5e37b75cfb0c7fa3d600838a2fdaf76151674fa7 Mon Sep 17 00:00:00 2001 From: Jakob Ketterl Date: Thu, 18 Feb 2021 15:55:55 +0100 Subject: [PATCH 179/577] test for existence of admin user before asking questions --- debian/openwebrx.config | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/debian/openwebrx.config b/debian/openwebrx.config index a91068f..afb8242 100755 --- a/debian/openwebrx.config +++ b/debian/openwebrx.config @@ -2,5 +2,7 @@ . /usr/share/debconf/confmodule -db_input high openwebrx/admin_user_password || true -db_go +if [ ! -e /usr/bin/openwebrx-admin ] || [ ! /usr/bin/openwebrx-admin hasuser admin ]; then + db_input high openwebrx/admin_user_password || true + db_go +fi From 8f49337b81ff88925c259f00a27ce61723684ec5 Mon Sep 17 00:00:00 2001 From: Jakob Ketterl Date: Thu, 18 Feb 2021 16:01:13 +0100 Subject: [PATCH 180/577] don't use expansion to test --- debian/openwebrx.config | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/debian/openwebrx.config b/debian/openwebrx.config index afb8242..8fdb2ff 100755 --- a/debian/openwebrx.config +++ b/debian/openwebrx.config @@ -2,7 +2,7 @@ . /usr/share/debconf/confmodule -if [ ! -e /usr/bin/openwebrx-admin ] || [ ! /usr/bin/openwebrx-admin hasuser admin ]; then +if [ ! -e /usr/bin/openwebrx-admin ] || ! /usr/bin/openwebrx-admin hasuser admin; then db_input high openwebrx/admin_user_password || true db_go fi From 1956907d6ddba81fd754fef3dfd059cecd0cd8d7 Mon Sep 17 00:00:00 2001 From: Jakob Ketterl Date: Thu, 18 Feb 2021 16:04:56 +0100 Subject: [PATCH 181/577] suppress errors during check --- debian/openwebrx.config | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/debian/openwebrx.config b/debian/openwebrx.config index 8fdb2ff..2306287 100755 --- a/debian/openwebrx.config +++ b/debian/openwebrx.config @@ -2,7 +2,7 @@ . /usr/share/debconf/confmodule -if [ ! -e /usr/bin/openwebrx-admin ] || ! /usr/bin/openwebrx-admin hasuser admin; then +if [ ! -e /usr/bin/openwebrx-admin ] || ! /usr/bin/openwebrx-admin --silent hasuser admin; then db_input high openwebrx/admin_user_password || true db_go fi From eb8b8c4a5a14b57f0d3ac0ffe7c545fdbeae3272 Mon Sep 17 00:00:00 2001 From: Jakob Ketterl Date: Thu, 18 Feb 2021 16:08:22 +0100 Subject: [PATCH 182/577] include confmodule only when needed, avoiding potential warnings --- debian/openwebrx.config | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/debian/openwebrx.config b/debian/openwebrx.config index 2306287..a89e355 100755 --- a/debian/openwebrx.config +++ b/debian/openwebrx.config @@ -1,8 +1,8 @@ #!/bin/sh -e -. /usr/share/debconf/confmodule - if [ ! -e /usr/bin/openwebrx-admin ] || ! /usr/bin/openwebrx-admin --silent hasuser admin; then + . /usr/share/debconf/confmodule + db_input high openwebrx/admin_user_password || true db_go fi From a75072645982f54a602155dfc631fa969393b914 Mon Sep 17 00:00:00 2001 From: Jakob Ketterl Date: Thu, 18 Feb 2021 16:14:15 +0100 Subject: [PATCH 183/577] new mechanism doesn't require any dummy values in the db --- debian/openwebrx.postinst | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/debian/openwebrx.postinst b/debian/openwebrx.postinst index a3e3344..cdc1ad7 100755 --- a/debian/openwebrx.postinst +++ b/debian/openwebrx.postinst @@ -17,10 +17,10 @@ case "$1" in # create initial openwebrx user db_get openwebrx/admin_user_password - if [ ! -z "${RET}" ] && [ "${RET}" != "__DONE__" ]; then + if [ ! -z "${RET}" ]; then OWRX_PASSWORD="${RET}" openwebrx-admin --noninteractive adduser admin - # remove actual password from debconf database, but leave a marker - db_set openwebrx/admin_user_password "__DONE__" + # remove actual password from debconf database + db_unregister openwebrx/admin_user_password fi ;; *) From 74aea63b9bfbeec56abb66903759d1b8e92521ad Mon Sep 17 00:00:00 2001 From: Jakob Ketterl Date: Thu, 18 Feb 2021 16:14:45 +0100 Subject: [PATCH 184/577] always remove password, no matter what the value --- debian/openwebrx.postinst | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/debian/openwebrx.postinst b/debian/openwebrx.postinst index cdc1ad7..4ac385a 100755 --- a/debian/openwebrx.postinst +++ b/debian/openwebrx.postinst @@ -19,9 +19,9 @@ case "$1" in db_get openwebrx/admin_user_password if [ ! -z "${RET}" ]; then OWRX_PASSWORD="${RET}" openwebrx-admin --noninteractive adduser admin - # remove actual password from debconf database - db_unregister openwebrx/admin_user_password fi + # remove password from debconf database + db_unregister openwebrx/admin_user_password ;; *) echo "postinst called with unknown argument '$1'" 1>&2 From e8ad4588cedfbd1ee53eb0a2f2e4f0cac0349461 Mon Sep 17 00:00:00 2001 From: Jakob Ketterl Date: Thu, 18 Feb 2021 17:02:14 +0100 Subject: [PATCH 185/577] add debhelper token to postrm script (lintian) --- debian/openwebrx.postrm | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/debian/openwebrx.postrm b/debian/openwebrx.postrm index ed7b4e9..9260b8e 100755 --- a/debian/openwebrx.postrm +++ b/debian/openwebrx.postrm @@ -3,4 +3,6 @@ if [ "$1" = purge ] && [ -e /usr/share/debconf/confmodule ]; then . /usr/share/debconf/confmodule db_purge -fi \ No newline at end of file +fi + +#DEBHELPER# From fc5d560345cbff6c6dcb9e776eac641cfef3012d Mon Sep 17 00:00:00 2001 From: Jakob Ketterl Date: Thu, 18 Feb 2021 17:04:45 +0100 Subject: [PATCH 186/577] don't need to check for command, if it's not there the result will be the same --- debian/openwebrx.config | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/debian/openwebrx.config b/debian/openwebrx.config index a89e355..35b47bc 100755 --- a/debian/openwebrx.config +++ b/debian/openwebrx.config @@ -1,6 +1,6 @@ #!/bin/sh -e -if [ ! -e /usr/bin/openwebrx-admin ] || ! /usr/bin/openwebrx-admin --silent hasuser admin; then +if ! /usr/bin/openwebrx-admin --silent hasuser admin; then . /usr/share/debconf/confmodule db_input high openwebrx/admin_user_password || true From 34b369b200a39ca752a12b0292397db4e8ac4cb1 Mon Sep 17 00:00:00 2001 From: Jakob Ketterl Date: Thu, 18 Feb 2021 17:09:08 +0100 Subject: [PATCH 187/577] restore unconditional confmodule --- debian/openwebrx.config | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/debian/openwebrx.config b/debian/openwebrx.config index 35b47bc..9aed84e 100755 --- a/debian/openwebrx.config +++ b/debian/openwebrx.config @@ -1,8 +1,7 @@ #!/bin/sh -e +. /usr/share/debconf/confmodule if ! /usr/bin/openwebrx-admin --silent hasuser admin; then - . /usr/share/debconf/confmodule - db_input high openwebrx/admin_user_password || true db_go fi From e70ff075cae8c670c0f0abb1a1e45c9c567bec78 Mon Sep 17 00:00:00 2001 From: Jakob Ketterl Date: Thu, 18 Feb 2021 17:25:33 +0100 Subject: [PATCH 188/577] fix pasword prompt (lintian) --- debian/openwebrx.templates | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/debian/openwebrx.templates b/debian/openwebrx.templates index d3a7c04..038d8bc 100644 --- a/debian/openwebrx.templates +++ b/debian/openwebrx.templates @@ -1,6 +1,6 @@ Template: openwebrx/admin_user_password Type: password -Description: OpenWebRX "admin" user password +Description: OpenWebRX "admin" user password: The system will create a user named "admin" for the OpenWebRX web configuration interface. You will be able to log into the "settings" area of OpenWebRX with this user to configure your receiver. From 50e19085b000dba3d8513ccc22efd7947c5fe63f Mon Sep 17 00:00:00 2001 From: Jakob Ketterl Date: Thu, 18 Feb 2021 17:28:00 +0100 Subject: [PATCH 189/577] don't use full path (lintian) --- debian/openwebrx.config | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/debian/openwebrx.config b/debian/openwebrx.config index 9aed84e..9e9a6dc 100755 --- a/debian/openwebrx.config +++ b/debian/openwebrx.config @@ -1,7 +1,7 @@ #!/bin/sh -e . /usr/share/debconf/confmodule -if ! /usr/bin/openwebrx-admin --silent hasuser admin; then +if ! openwebrx-admin --silent hasuser admin; then db_input high openwebrx/admin_user_password || true db_go fi From 1a6f738c97013f154f2f3080039a59168f7e1230 Mon Sep 17 00:00:00 2001 From: Jakob Ketterl Date: Thu, 18 Feb 2021 18:28:12 +0100 Subject: [PATCH 190/577] fix permission problems on initial install --- debian/openwebrx.postinst | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/debian/openwebrx.postinst b/debian/openwebrx.postinst index 4ac385a..3a96d66 100755 --- a/debian/openwebrx.postinst +++ b/debian/openwebrx.postinst @@ -5,6 +5,7 @@ set -euo pipefail OWRX_USER="openwebrx" OWRX_DATADIR="/var/lib/openwebrx" +OWRX_USERS_FILE="${OWRX_DATADIR}/users.json" case "$1" in configure|reconfigure) @@ -15,9 +16,15 @@ case "$1" in if [ ! -d "${OWRX_DATADIR}" ] && [ ! -L "${OWRX_DATADIR}" ]; then mkdir "${OWRX_DATADIR}"; fi chown "${OWRX_USER}". ${OWRX_DATADIR} - # create initial openwebrx user + if [ ! -e "${OWRX_USERS_FILE}" ]; then + # create an empty users file now to avoid permission problems later + echo "[]" > "${OWRX_USERS_FILE}" + chown "${OWRX_USER}". ${$OWRX_USERS_FILE} + fi + db_get openwebrx/admin_user_password if [ ! -z "${RET}" ]; then + # create initial openwebrx user OWRX_PASSWORD="${RET}" openwebrx-admin --noninteractive adduser admin fi # remove password from debconf database From a29d72d67fa4de9b1b89c9756d1bc7bf7012441e Mon Sep 17 00:00:00 2001 From: Jakob Ketterl Date: Thu, 18 Feb 2021 18:38:37 +0100 Subject: [PATCH 191/577] more details in the password dialog --- debian/openwebrx.templates | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/debian/openwebrx.templates b/debian/openwebrx.templates index 038d8bc..484e143 100644 --- a/debian/openwebrx.templates +++ b/debian/openwebrx.templates @@ -1,6 +1,16 @@ Template: openwebrx/admin_user_password Type: password Description: OpenWebRX "admin" user password: - The system will create a user named "admin" for the OpenWebRX web - configuration interface. You will be able to log into the "settings" area - of OpenWebRX with this user to configure your receiver. + The system can create a user for the OpenWebRX web configuration interface for + you. Using this user, you will be able to log into the "settings" area of + OpenWebRX to configure your receiver conveniently through your browser. + . + The name of the created user will be "admin". + . + If you do not wish to create a web admin user right now, you can leave this + empty for now. You can return to this prompt at a later time by running the + command "sudo dpkg-reconfigure openwebrx". + . + You can also use the "openwebrx-admin" command to create, delete or manage + existing users. More information is available in by running the command + "openwebrx-admin --help". \ No newline at end of file From b06a629ffb0729f3e3248036f9e772804aae5e85 Mon Sep 17 00:00:00 2001 From: Jakob Ketterl Date: Thu, 18 Feb 2021 18:41:39 +0100 Subject: [PATCH 192/577] fix variable substitution --- debian/openwebrx.postinst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/debian/openwebrx.postinst b/debian/openwebrx.postinst index 3a96d66..ca04fd0 100755 --- a/debian/openwebrx.postinst +++ b/debian/openwebrx.postinst @@ -19,7 +19,7 @@ case "$1" in if [ ! -e "${OWRX_USERS_FILE}" ]; then # create an empty users file now to avoid permission problems later echo "[]" > "${OWRX_USERS_FILE}" - chown "${OWRX_USER}". ${$OWRX_USERS_FILE} + chown "${OWRX_USER}". ${OWRX_USERS_FILE} fi db_get openwebrx/admin_user_password From 0d77aaff262c86b0dfdce15232857babcd6d6bf2 Mon Sep 17 00:00:00 2001 From: Jakob Ketterl Date: Thu, 18 Feb 2021 20:57:41 +0100 Subject: [PATCH 193/577] restrict access to openwebrx users file --- debian/openwebrx.postinst | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/debian/openwebrx.postinst b/debian/openwebrx.postinst index ca04fd0..8b98999 100755 --- a/debian/openwebrx.postinst +++ b/debian/openwebrx.postinst @@ -19,7 +19,8 @@ case "$1" in if [ ! -e "${OWRX_USERS_FILE}" ]; then # create an empty users file now to avoid permission problems later echo "[]" > "${OWRX_USERS_FILE}" - chown "${OWRX_USER}". ${OWRX_USERS_FILE} + chown "${OWRX_USER}". "${OWRX_USERS_FILE}" + chmod 0600 "${OWRX_USERS_FILE}" fi db_get openwebrx/admin_user_password From d612792593ed75067f6ca0eb5a1ff3472d9ceef8 Mon Sep 17 00:00:00 2001 From: Jakob Ketterl Date: Thu, 18 Feb 2021 21:07:45 +0100 Subject: [PATCH 194/577] update permissions on write --- owrx/users.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/owrx/users.py b/owrx/users.py index 0fc5c0c..be103b1 100644 --- a/owrx/users.py +++ b/owrx/users.py @@ -4,6 +4,7 @@ from datetime import datetime, timezone import json import hashlib import os +import stat import logging @@ -189,6 +190,8 @@ class UserList(object): jsonContent = json.dumps(users, indent=4) with open(usersFile, "w") as f: f.write(jsonContent) + # file should be readable by us only + os.chmod(usersFile, stat.S_IWUSR + stat.S_IRUSR) except Exception: logger.exception("error while writing users file %s", usersFile) self.refresh() From 54fde2c1c00c4570cc1242e8c3e8623bb52e9f91 Mon Sep 17 00:00:00 2001 From: Jakob Ketterl Date: Thu, 18 Feb 2021 22:12:13 +0100 Subject: [PATCH 195/577] reuse existing template --- htdocs/settings/sdr.html | 21 --------------------- owrx/controllers/settings/sdr.py | 13 ++++++++++--- 2 files changed, 10 insertions(+), 24 deletions(-) delete mode 100644 htdocs/settings/sdr.html diff --git a/htdocs/settings/sdr.html b/htdocs/settings/sdr.html deleted file mode 100644 index f85dfde..0000000 --- a/htdocs/settings/sdr.html +++ /dev/null @@ -1,21 +0,0 @@ - - - - OpenWebRX Settings - - - - - - - -${header} -
    -
    -

    SDR device settings

    -
    -
    - ${devices} -
    -
    - \ No newline at end of file diff --git a/owrx/controllers/settings/sdr.py b/owrx/controllers/settings/sdr.py index ec5b601..708a24f 100644 --- a/owrx/controllers/settings/sdr.py +++ b/owrx/controllers/settings/sdr.py @@ -13,11 +13,18 @@ class SdrSettingsController(AuthorizationMixin, WebpageController): def template_variables(self): variables = super().template_variables() - variables["devices"] = self.render_devices() + variables["sections"] = self.render_devices() + variables["title"] = "SDR device settings" return variables def render_devices(self): - return "".join(self.render_device(key, value) for key, value in Config.get()["sdrs"].items()) + return """ +
    + {devices} +
    + """.format( + devices="".join(self.render_device(key, value) for key, value in Config.get()["sdrs"].items()) + ) def render_device(self, device_id, config): return """ @@ -41,4 +48,4 @@ class SdrSettingsController(AuthorizationMixin, WebpageController): ) def indexAction(self): - self.serve_template("settings/sdr.html", **self.template_variables()) + self.serve_template("settings/general.html", **self.template_variables()) From c5585e290aacd25128588649c516ee30c9dfea5a Mon Sep 17 00:00:00 2001 From: Jakob Ketterl Date: Thu, 18 Feb 2021 22:24:31 +0100 Subject: [PATCH 196/577] undo javascript device configuration --- htdocs/lib/settings/Input.js | 138 ----------------- htdocs/lib/settings/SdrDevice.js | 252 ------------------------------- owrx/controllers/assets.py | 2 - owrx/controllers/settings/sdr.py | 11 +- 4 files changed, 2 insertions(+), 401 deletions(-) delete mode 100644 htdocs/lib/settings/Input.js delete mode 100644 htdocs/lib/settings/SdrDevice.js diff --git a/htdocs/lib/settings/Input.js b/htdocs/lib/settings/Input.js deleted file mode 100644 index f638257..0000000 --- a/htdocs/lib/settings/Input.js +++ /dev/null @@ -1,138 +0,0 @@ -function Input(name, value, options) { - this.name = name; - this.value = value; - this.options = options; - this.label = options && options.label || name; -}; - -Input.prototype.getClasses = function() { - return ['form-control', 'form-control-sm']; -} - -Input.prototype.bootstrapify = function(input) { - this.getClasses().forEach(input.addClass.bind(input)); - return [ - '
    ', - '', - '
    ', - $.map(input, function(el) { - return el.outerHTML; - }).join(''), - '
    ', - '
    ' - ].join(''); -}; - -function TextInput() { - Input.apply(this, arguments); -}; - -TextInput.prototype = new Input(); - -TextInput.prototype.render = function() { - return this.bootstrapify($('')); -} - -function NumberInput() { - Input.apply(this, arguments); -}; - -NumberInput.prototype = new Input(); - -NumberInput.prototype.render = function() { - return this.bootstrapify($('')); -}; - -function SoapyGainInput() { - Input.apply(this, arguments); -} - -SoapyGainInput.prototype = new Input(); - -SoapyGainInput.prototype.getClasses = function() { - return []; -}; - -SoapyGainInput.prototype.render = function(){ - var markup = $( - '
    ' + - '
    Gain mode
    ' + - '
    ' + - '' + - '
    ' + - '
    ' + - '
    ' + - '
    Gain
    ' + - '
    ' + - '' + - '
    ' + - '
    ' + - this.options.gains.map(function(g){ - return '
    ' + - '
    ' + g + '
    ' + - '
    ' + - '' + - '
    ' + - '
    '; - }).join('') - ); - var el = $(this.bootstrapify(markup)) - var setMode = function(mode){ - el.find('select').val(mode); - el.find('.option').hide(); - el.find('.gain-mode-' + mode).show(); - }; - el.on('change', 'select', function(){ - var mode = $(this).val(); - setMode(mode); - }); - if (typeof(this.value) === 'number') { - setMode('single'); - el.find('.gain-mode-single input').val(this.value); - } else if (typeof(this.value) === 'string') { - if (this.value === 'auto') { - setMode('auto'); - } else { - setMode('separate'); - values = $.extend.apply($, this.value.split(',').map(function(seg){ - var split = seg.split('='); - if (split.length < 2) return; - var res = {}; - res[split[0]] = parseInt(split[1]); - return res; - })); - el.find('.gain-mode-separate input').each(function(){ - var $input = $(this); - var g = $input.data('gain'); - $input.val(g in values ? values[g] : 0); - }); - } - } else { - setMode('auto'); - } - return el; -}; - -function ProfileInput() { - Input.apply(this, arguments); -}; - -ProfileInput.prototype = new Input(); - -ProfileInput.prototype.render = function() { - return $('

    Profiles

    '); -}; - -function SchedulerInput() { - Input.apply(this, arguments); -}; - -SchedulerInput.prototype = new Input(); - -SchedulerInput.prototype.render = function() { - return $('

    Scheduler

    '); -}; diff --git a/htdocs/lib/settings/SdrDevice.js b/htdocs/lib/settings/SdrDevice.js deleted file mode 100644 index 25f85c9..0000000 --- a/htdocs/lib/settings/SdrDevice.js +++ /dev/null @@ -1,252 +0,0 @@ -function SdrDevice(el, data) { - this.el = el; - this.data = data; - this.inputs = {}; - this.render(); - - var self = this; - el.on('click', '.fieldselector .btn', function() { - var key = el.find('.fieldselector select').val(); - self.data[key] = self.getInitialValue(key); - self.render(); - }); -}; - -SdrDevice.create = function(el) { - var data = JSON.parse(decodeURIComponent(el.data('config'))); - var type = data.type; - var constructor = SdrDevice.types[type] || SdrDevice; - return new constructor(el, data); -}; - -SdrDevice.prototype.getData = function() { - return $.extend(new Object(), this.getDefaults(), this.data); -}; - -SdrDevice.prototype.getDefaults = function() { - var defaults = {} - $.each(this.getMappings(), function(k, v) { - if (!v.includeInDefault) return; - defaults[k] = 'initialValue' in v ? v['initialValue'] : false; - }); - return defaults; -}; - -SdrDevice.prototype.getMappings = function() { - return { - "name": { - constructor: TextInput, - inputOptions: { - label: "Name" - }, - initialValue: "", - includeInDefault: true - }, - "type": { - constructor: TextInput, - inputOptions: { - label: "Type" - }, - initialValue: '', - includeInDefault: true - }, - "ppm": { - constructor: NumberInput, - inputOptions: { - label: "PPM" - }, - initialValue: 0 - }, - "profiles": { - constructor: ProfileInput, - inputOptions: { - label: "Profiles" - }, - initialValue: [], - includeInDefault: true, - position: 100 - }, - "scheduler": { - constructor: SchedulerInput, - inputOptions: { - label: "Scheduler", - }, - initialValue: {}, - position: 101 - }, - "rf_gain": { - constructor: TextInput, - inputOptions: { - label: "Gain", - }, - initialValue: 0 - } - }; -}; - -SdrDevice.prototype.getMapping = function(key) { - var mappings = this.getMappings(); - return mappings[key]; -}; - -SdrDevice.prototype.getInputClass = function(key) { - var mapping = this.getMapping(key); - return mapping && mapping.constructor || TextInput; -}; - -SdrDevice.prototype.getInitialValue = function(key) { - var mapping = this.getMapping(key); - return mapping && ('initialValue' in mapping) ? mapping['initialValue'] : false; -}; - -SdrDevice.prototype.getPosition = function(key) { - var mapping = this.getMapping(key); - return mapping && mapping.position || 10; -}; - -SdrDevice.prototype.getInputOptions = function(key) { - var mapping = this.getMapping(key); - return mapping && mapping.inputOptions || {}; -}; - -SdrDevice.prototype.getLabel = function(key) { - var options = this.getInputOptions(key); - return options && options.label || key; -}; - -SdrDevice.prototype.render = function() { - var self = this; - self.el.empty(); - var data = this.getData(); - Object.keys(data).sort(function(a, b){ - return self.getPosition(a) - self.getPosition(b); - }).forEach(function(key){ - var value = data[key]; - var inputClass = self.getInputClass(key); - var input = new inputClass(key, value, self.getInputOptions(key)); - self.inputs[key] = input; - self.el.append(input.render()); - }); - self.el.append(this.renderFieldSelector()); -}; - -SdrDevice.prototype.renderFieldSelector = function() { - var self = this; - return '
    ' + - '

    Add new configuration options

    ' + - '
    ' + - '
    ' + - '
    ' + - '
    Add to config
    ' + - '
    ' + - '
    ' + - '

    '; -}; - -RtlSdrDevice = function() { - SdrDevice.apply(this, arguments); -}; - -RtlSdrDevice.prototype = Object.create(SdrDevice.prototype); -RtlSdrDevice.prototype.constructor = RtlSdrDevice; - -RtlSdrDevice.prototype.getMappings = function() { - var mappings = SdrDevice.prototype.getMappings.apply(this, arguments); - return $.extend(new Object(), mappings, { - "device": { - constructor: TextInput, - inputOptions:{ - label: "Serial number" - }, - initialValue: "" - } - }); -}; - -SoapySdrDevice = function() { - SdrDevice.apply(this, arguments); -}; - -SoapySdrDevice.prototype = Object.create(SdrDevice.prototype); -SoapySdrDevice.prototype.constructor = SoapySdrDevice; - -SoapySdrDevice.prototype.getMappings = function() { - var mappings = SdrDevice.prototype.getMappings.apply(this, arguments); - return $.extend(new Object(), mappings, { - "device": { - constructor: TextInput, - inputOptions:{ - label: "Soapy device selector" - }, - initialValue: "" - }, - "rf_gain": { - constructor: SoapyGainInput, - initialValue: 0, - inputOptions: { - label: "Gain", - gains: this.getGains() - } - } - }); -}; - -SoapySdrDevice.prototype.getGains = function() { - return []; -}; - -SdrplaySdrDevice = function() { - SoapySdrDevice.apply(this, arguments); -}; - -SdrplaySdrDevice.prototype = Object.create(SoapySdrDevice.prototype); -SdrplaySdrDevice.prototype.constructor = SdrplaySdrDevice; - -SdrplaySdrDevice.prototype.getGains = function() { - return ['RFGR', 'IFGR']; -}; - -AirspyHfSdrDevice = function() { - SoapySdrDevice.apply(this, arguments); -}; - -AirspyHfSdrDevice.prototype = Object.create(SoapySdrDevice.prototype); -AirspyHfSdrDevice.prototype.constructor = AirspyHfSdrDevice; - -AirspyHfSdrDevice.prototype.getGains = function() { - return ['RF', 'VGA']; -}; - -HackRfSdrDevice = function() { - SoapySdrDevice.apply(this, arguments); -}; - -HackRfSdrDevice.prototype = Object.create(SoapySdrDevice.prototype); -HackRfSdrDevice.prototype.constructor = HackRfSdrDevice; - -HackRfSdrDevice.prototype.getGains = function() { - return ['LNA', 'VGA', 'AMP']; -}; - -SdrDevice.types = { - 'rtl_sdr': RtlSdrDevice, - 'sdrplay': SdrplaySdrDevice, - 'airspyhf': AirspyHfSdrDevice, - 'hackrf': HackRfSdrDevice -}; - -$.fn.sdrdevice = function() { - return this.map(function(){ - var el = $(this); - if (!el.data('sdrdevice')) { - el.data('sdrdevice', SdrDevice.create(el)); - } - return el.data('sdrdevice'); - }); -}; diff --git a/owrx/controllers/assets.py b/owrx/controllers/assets.py index 6565bb5..51343aa 100644 --- a/owrx/controllers/assets.py +++ b/owrx/controllers/assets.py @@ -145,8 +145,6 @@ class CompiledAssetsController(GzipMixin, ModificationAwareController): "settings.js": [ "lib/jquery-3.2.1.min.js", "lib/Header.js", - "lib/settings/Input.js", - "lib/settings/SdrDevice.js", "lib/settings/MapInput.js", "lib/settings/ImageUpload.js", "lib/settings/BookmarkTable.js", diff --git a/owrx/controllers/settings/sdr.py b/owrx/controllers/settings/sdr.py index 708a24f..e8cd6e7 100644 --- a/owrx/controllers/settings/sdr.py +++ b/owrx/controllers/settings/sdr.py @@ -33,18 +33,11 @@ class SdrSettingsController(AuthorizationMixin, WebpageController): {device_name}
    - {form} + sdr detail goes here
    """.format( - device_name=config["name"], form=self.render_form(device_id, config) - ) - - def render_form(self, device_id, config): - return """ -
    - """.format( - device_id=device_id, formdata=quote(json.dumps(config)) + device_name=config["name"] ) def indexAction(self): From d65743f2eacfc3a1420a11693e4741dfdd949fba Mon Sep 17 00:00:00 2001 From: Jakob Ketterl Date: Thu, 18 Feb 2021 23:05:43 +0100 Subject: [PATCH 197/577] rename template variable --- htdocs/settings/general.html | 6 +++--- owrx/controllers/settings/__init__.py | 2 +- owrx/controllers/settings/sdr.py | 26 ++++++++++++-------------- 3 files changed, 16 insertions(+), 18 deletions(-) diff --git a/htdocs/settings/general.html b/htdocs/settings/general.html index de0353e..e9297b1 100644 --- a/htdocs/settings/general.html +++ b/htdocs/settings/general.html @@ -12,9 +12,9 @@ ${header}
    -
    -

    ${title}

    +
    +

    ${title}s

    - ${sections} + ${content}
    \ No newline at end of file diff --git a/owrx/controllers/settings/__init__.py b/owrx/controllers/settings/__init__.py index 907ceda..6ddcb54 100644 --- a/owrx/controllers/settings/__init__.py +++ b/owrx/controllers/settings/__init__.py @@ -67,7 +67,7 @@ class SettingsFormController(AuthorizationMixin, WebpageController, metaclass=AB def template_variables(self): variables = super().template_variables() - variables["sections"] = self.render_sections() + variables["content"] = self.render_sections() variables["title"] = self.getTitle() return variables diff --git a/owrx/controllers/settings/sdr.py b/owrx/controllers/settings/sdr.py index e8cd6e7..24d39e0 100644 --- a/owrx/controllers/settings/sdr.py +++ b/owrx/controllers/settings/sdr.py @@ -1,8 +1,6 @@ from owrx.controllers.admin import AuthorizationMixin from owrx.controllers.template import WebpageController from owrx.config import Config -from urllib.parse import quote -import json class SdrSettingsController(AuthorizationMixin, WebpageController): @@ -13,21 +11,13 @@ class SdrSettingsController(AuthorizationMixin, WebpageController): def template_variables(self): variables = super().template_variables() - variables["sections"] = self.render_devices() + variables["content"] = self.render_devices() variables["title"] = "SDR device settings" return variables def render_devices(self): - return """ -
    - {devices} -
    - """.format( - devices="".join(self.render_device(key, value) for key, value in Config.get()["sdrs"].items()) - ) - - def render_device(self, device_id, config): - return """ + def render_device(device_id, config): + return """
    {device_name} @@ -37,7 +27,15 @@ class SdrSettingsController(AuthorizationMixin, WebpageController):
    """.format( - device_name=config["name"] + device_name=config["name"] + ) + + return """ +
    + {devices} +
    + """.format( + devices="".join(render_device(key, value) for key, value in Config.get()["sdrs"].items()) ) def indexAction(self): From 872c7a4bfda1ef49f364ba8c5b0b7e418e364bde Mon Sep 17 00:00:00 2001 From: Jakob Ketterl Date: Fri, 19 Feb 2021 00:03:25 +0100 Subject: [PATCH 198/577] setup device list and routing for device pages --- htdocs/css/admin.css | 8 ++--- htdocs/settings/general.html | 10 +++--- owrx/controllers/settings/__init__.py | 1 + owrx/controllers/settings/sdr.py | 52 +++++++++++++++++++++------ owrx/http.py | 5 +-- 5 files changed, 54 insertions(+), 22 deletions(-) diff --git a/htdocs/css/admin.css b/htdocs/css/admin.css index a632da6..5c94caa 100644 --- a/htdocs/css/admin.css +++ b/htdocs/css/admin.css @@ -13,10 +13,6 @@ html, body { margin: 15px 15px 0; } -.device { - margin-top: 20px; -} - .settings-section { margin-top: 3em; } @@ -84,4 +80,8 @@ table.bookmarks .frequency { .wsjt-decoding-depths-table td:first-child { padding-left: 0; +} + +.sdr-device-list .list-group-item { + background: initial; } \ No newline at end of file diff --git a/htdocs/settings/general.html b/htdocs/settings/general.html index e9297b1..1e6724d 100644 --- a/htdocs/settings/general.html +++ b/htdocs/settings/general.html @@ -2,18 +2,18 @@ OpenWebRX Settings - - - + + + - + ${header}
    -

    ${title}s

    +

    ${title}

    ${content}
    diff --git a/owrx/controllers/settings/__init__.py b/owrx/controllers/settings/__init__.py index 6ddcb54..9800801 100644 --- a/owrx/controllers/settings/__init__.py +++ b/owrx/controllers/settings/__init__.py @@ -69,6 +69,7 @@ class SettingsFormController(AuthorizationMixin, WebpageController, metaclass=AB variables = super().template_variables() variables["content"] = self.render_sections() variables["title"] = self.getTitle() + variables["assets_prefix"] = "../" return variables def parseFormData(self): diff --git a/owrx/controllers/settings/sdr.py b/owrx/controllers/settings/sdr.py index 24d39e0..88cf10f 100644 --- a/owrx/controllers/settings/sdr.py +++ b/owrx/controllers/settings/sdr.py @@ -1,9 +1,10 @@ from owrx.controllers.admin import AuthorizationMixin from owrx.controllers.template import WebpageController from owrx.config import Config +from urllib.parse import quote, unquote -class SdrSettingsController(AuthorizationMixin, WebpageController): +class SdrDeviceListController(AuthorizationMixin, WebpageController): def header_variables(self): variables = super().header_variables() variables["assets_prefix"] = "../" @@ -13,30 +14,59 @@ class SdrSettingsController(AuthorizationMixin, WebpageController): variables = super().template_variables() variables["content"] = self.render_devices() variables["title"] = "SDR device settings" + variables["assets_prefix"] = "../" return variables def render_devices(self): def render_device(device_id, config): return """ -
    -
    - {device_name} +
  • + +

    {device_name}

    +
    +
    + some more device info here
    -
    - sdr detail goes here -
    -
  • + """.format( - device_name=config["name"] + device_name=config["name"], + device_link="{}/{}".format(self.request.path, quote(device_id)), ) return """ -
    +
      {devices} -
    + """.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 SdrDeviceController(AuthorizationMixin, WebpageController): + def get_device(self): + device_id = unquote(self.request.matches.group(1)) + if device_id not in Config.get()["sdrs"]: + return None + return Config.get()["sdrs"][device_id] + + def header_variables(self): + variables = super().header_variables() + variables["assets_prefix"] = "../../" + return variables + + def template_variables(self, device): + variables = super().template_variables() + variables["title"] = device["name"] + variables["content"] = "TODO" + variables["assets_prefix"] = "../../" + return variables + + def indexAction(self): + device = self.get_device() + if device is None: + self.send_response("device not found", code=404) + return + self.serve_template("settings/general.html", **self.template_variables(device)) diff --git a/owrx/http.py b/owrx/http.py index 4d712d4..9360fb6 100644 --- a/owrx/http.py +++ b/owrx/http.py @@ -6,7 +6,7 @@ from owrx.controllers.api import ApiController from owrx.controllers.metrics import MetricsController from owrx.controllers.settings import SettingsController from owrx.controllers.settings.general import GeneralSettingsController -from owrx.controllers.settings.sdr import SdrSettingsController +from owrx.controllers.settings.sdr import SdrDeviceListController, SdrDeviceController from owrx.controllers.settings.reporting import ReportingController from owrx.controllers.settings.backgrounddecoding import BackgroundDecodingController from owrx.controllers.settings.decoding import DecodingSettingsController @@ -115,7 +115,8 @@ class Router(object): StaticRoute( "/settings/general", GeneralSettingsController, method="POST", options={"action": "processFormData"} ), - StaticRoute("/settings/sdr", SdrSettingsController), + StaticRoute("/settings/sdr", SdrDeviceListController), + RegexRoute("/settings/sdr/(.+)", SdrDeviceController), StaticRoute("/settings/bookmarks", BookmarksController), StaticRoute("/settings/bookmarks", BookmarksController, method="POST", options={"action": "new"}), RegexRoute("/settings/bookmarks/(.+)", BookmarksController, method="POST", options={"action": "update"}), From 012952f6f340154a2b0900afc3ea0dc261fafd5f Mon Sep 17 00:00:00 2001 From: Jakob Ketterl Date: Fri, 19 Feb 2021 00:46:52 +0100 Subject: [PATCH 199/577] implement some basic infrastructure to present device forms --- owrx/controllers/settings/devices/__init__.py | 23 +++++++++++++++ owrx/controllers/settings/devices/rtl_sdr.py | 13 +++++++++ owrx/controllers/settings/sdr.py | 29 ++++++++++++++----- owrx/sdr.py | 2 +- 4 files changed, 58 insertions(+), 9 deletions(-) create mode 100644 owrx/controllers/settings/devices/__init__.py create mode 100644 owrx/controllers/settings/devices/rtl_sdr.py diff --git a/owrx/controllers/settings/devices/__init__.py b/owrx/controllers/settings/devices/__init__.py new file mode 100644 index 0000000..30f1882 --- /dev/null +++ b/owrx/controllers/settings/devices/__init__.py @@ -0,0 +1,23 @@ +from owrx.form import Input +from owrx.controllers.settings import Section +from abc import ABC, abstractmethod +from typing import List + + +class SdrDeviceType(ABC): + @staticmethod + def getByType(sdr_type: str) -> "SdrDeviceType": + try: + className = "".join(x for x in sdr_type.title() if x.isalnum()) + "DeviceType" + module = __import__("owrx.controllers.settings.devices.{0}".format(sdr_type), fromlist=[className]) + cls = getattr(module, className) + return cls() + except ModuleNotFoundError: + return None + + @abstractmethod + def getInputs(self) -> List[Input]: + pass + + def getSection(self): + return Section("Device settings", *self.getInputs()) diff --git a/owrx/controllers/settings/devices/rtl_sdr.py b/owrx/controllers/settings/devices/rtl_sdr.py new file mode 100644 index 0000000..af6673e --- /dev/null +++ b/owrx/controllers/settings/devices/rtl_sdr.py @@ -0,0 +1,13 @@ +from typing import List +from owrx.controllers.settings.devices import SdrDeviceType +from owrx.form import Input, TextInput + + +class RtlSdrDeviceType(SdrDeviceType): + def getInputs(self) -> List[Input]: + return [ + TextInput( + "test", + "This is a drill" + ), + ] diff --git a/owrx/controllers/settings/sdr.py b/owrx/controllers/settings/sdr.py index 88cf10f..6c2661e 100644 --- a/owrx/controllers/settings/sdr.py +++ b/owrx/controllers/settings/sdr.py @@ -1,5 +1,7 @@ from owrx.controllers.admin import AuthorizationMixin from owrx.controllers.template import WebpageController +from owrx.controllers.settings import SettingsFormController +from owrx.controllers.settings.devices import SdrDeviceType from owrx.config import Config from urllib.parse import quote, unquote @@ -45,8 +47,22 @@ class SdrDeviceListController(AuthorizationMixin, WebpageController): self.serve_template("settings/general.html", **self.template_variables()) -class SdrDeviceController(AuthorizationMixin, WebpageController): - def get_device(self): +class SdrDeviceController(SettingsFormController): + def __init__(self, handler, request, options): + super().__init__(handler, request, options) + self.device = self._get_device() + + def getSections(self): + device_type = SdrDeviceType.getByType(self.device["type"]) + if device_type is None: + # TODO provide a generic interface that allows to switch the type + return [] + return [device_type.getSection()] + + def getTitle(self): + return self.device["name"] + + def _get_device(self): device_id = unquote(self.request.matches.group(1)) if device_id not in Config.get()["sdrs"]: return None @@ -57,16 +73,13 @@ class SdrDeviceController(AuthorizationMixin, WebpageController): variables["assets_prefix"] = "../../" return variables - def template_variables(self, device): + def template_variables(self): variables = super().template_variables() - variables["title"] = device["name"] - variables["content"] = "TODO" variables["assets_prefix"] = "../../" return variables def indexAction(self): - device = self.get_device() - if device is None: + if self.device is None: self.send_response("device not found", code=404) return - self.serve_template("settings/general.html", **self.template_variables(device)) + self.serve_template("settings/general.html", **self.template_variables()) diff --git a/owrx/sdr.py b/owrx/sdr.py index 039e914..87cc9bd 100644 --- a/owrx/sdr.py +++ b/owrx/sdr.py @@ -71,7 +71,7 @@ class SdrService(object): def getSources(): SdrService._loadProps() for id in SdrService.sdrProps.keys(): - if not id in SdrService.sources: + if id not in SdrService.sources: props = SdrService.sdrProps[id] sdrType = props["type"] className = "".join(x for x in sdrType.title() if x.isalnum()) + "Source" From bec61465c9f69f4bcf531e92fda4933faa5c93fe Mon Sep 17 00:00:00 2001 From: Jakob Ketterl Date: Fri, 19 Feb 2021 14:44:16 +0100 Subject: [PATCH 200/577] move device descriptions to owrx.source --- .../settings/{devices/__init__.py => device.py} | 16 ++++++++++------ owrx/controllers/settings/devices/rtl_sdr.py | 13 ------------- owrx/controllers/settings/sdr.py | 9 +++++---- owrx/source/rtl_sdr.py | 13 +++++++++++++ 4 files changed, 28 insertions(+), 23 deletions(-) rename owrx/controllers/settings/{devices/__init__.py => device.py} (51%) delete mode 100644 owrx/controllers/settings/devices/rtl_sdr.py diff --git a/owrx/controllers/settings/devices/__init__.py b/owrx/controllers/settings/device.py similarity index 51% rename from owrx/controllers/settings/devices/__init__.py rename to owrx/controllers/settings/device.py index 30f1882..803644a 100644 --- a/owrx/controllers/settings/devices/__init__.py +++ b/owrx/controllers/settings/device.py @@ -4,16 +4,20 @@ from abc import ABC, abstractmethod from typing import List -class SdrDeviceType(ABC): +class SdrDeviceDescriptionMissing(Exception): + pass + + +class SdrDeviceDescription(ABC): @staticmethod - def getByType(sdr_type: str) -> "SdrDeviceType": + def getByType(sdr_type: str) -> "SdrDeviceDescription": try: - className = "".join(x for x in sdr_type.title() if x.isalnum()) + "DeviceType" - module = __import__("owrx.controllers.settings.devices.{0}".format(sdr_type), fromlist=[className]) + 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: - return None + except (ModuleNotFoundError, AttributeError): + raise SdrDeviceDescriptionMissing("Device description for type {} not available".format(sdr_type)) @abstractmethod def getInputs(self) -> List[Input]: diff --git a/owrx/controllers/settings/devices/rtl_sdr.py b/owrx/controllers/settings/devices/rtl_sdr.py deleted file mode 100644 index af6673e..0000000 --- a/owrx/controllers/settings/devices/rtl_sdr.py +++ /dev/null @@ -1,13 +0,0 @@ -from typing import List -from owrx.controllers.settings.devices import SdrDeviceType -from owrx.form import Input, TextInput - - -class RtlSdrDeviceType(SdrDeviceType): - def getInputs(self) -> List[Input]: - return [ - TextInput( - "test", - "This is a drill" - ), - ] diff --git a/owrx/controllers/settings/sdr.py b/owrx/controllers/settings/sdr.py index 6c2661e..4a45b2e 100644 --- a/owrx/controllers/settings/sdr.py +++ b/owrx/controllers/settings/sdr.py @@ -1,7 +1,7 @@ from owrx.controllers.admin import AuthorizationMixin from owrx.controllers.template import WebpageController from owrx.controllers.settings import SettingsFormController -from owrx.controllers.settings.devices import SdrDeviceType +from owrx.controllers.settings.device import SdrDeviceDescription, SdrDeviceDescriptionMissing from owrx.config import Config from urllib.parse import quote, unquote @@ -53,11 +53,12 @@ class SdrDeviceController(SettingsFormController): self.device = self._get_device() def getSections(self): - device_type = SdrDeviceType.getByType(self.device["type"]) - if device_type is None: + try: + description = SdrDeviceDescription.getByType(self.device["type"]) + return [description.getSection()] + except SdrDeviceDescriptionMissing: # TODO provide a generic interface that allows to switch the type return [] - return [device_type.getSection()] def getTitle(self): return self.device["name"] diff --git a/owrx/source/rtl_sdr.py b/owrx/source/rtl_sdr.py index a6ecbc9..ff1a0df 100644 --- a/owrx/source/rtl_sdr.py +++ b/owrx/source/rtl_sdr.py @@ -1,5 +1,8 @@ from .connector import ConnectorSource from owrx.command import Flag, Option +from owrx.controllers.settings.device import SdrDeviceDescription +from typing import List +from owrx.form import Input, TextInput class RtlSdrSource(ConnectorSource): @@ -10,3 +13,13 @@ class RtlSdrSource(ConnectorSource): .setBase("rtl_connector") .setMappings({"bias_tee": Flag("-b"), "direct_sampling": Option("-e")}) ) + + +class RtlSdrDeviceDescription(SdrDeviceDescription): + def getInputs(self) -> List[Input]: + return [ + TextInput( + "test", + "This is a drill" + ), + ] From 4316832b9576eeb150e700edeac60eaa044db3b4 Mon Sep 17 00:00:00 2001 From: Jakob Ketterl Date: Fri, 19 Feb 2021 14:53:30 +0100 Subject: [PATCH 201/577] input merging mechanism --- owrx/controllers/settings/device.py | 11 +++++++---- owrx/source/rtl_sdr.py | 12 ++++++------ 2 files changed, 13 insertions(+), 10 deletions(-) diff --git a/owrx/controllers/settings/device.py b/owrx/controllers/settings/device.py index 803644a..edf6348 100644 --- a/owrx/controllers/settings/device.py +++ b/owrx/controllers/settings/device.py @@ -1,6 +1,5 @@ from owrx.form import Input from owrx.controllers.settings import Section -from abc import ABC, abstractmethod from typing import List @@ -8,7 +7,7 @@ class SdrDeviceDescriptionMissing(Exception): pass -class SdrDeviceDescription(ABC): +class SdrDeviceDescription(object): @staticmethod def getByType(sdr_type: str) -> "SdrDeviceDescription": try: @@ -19,9 +18,13 @@ class SdrDeviceDescription(ABC): except (ModuleNotFoundError, AttributeError): raise SdrDeviceDescriptionMissing("Device description for type {} not available".format(sdr_type)) - @abstractmethod def getInputs(self) -> List[Input]: - pass + return [] + + def mergeInputs(self, *args): + # build a dictionary indexed by the input id to make sure every id only exists once + inputs = {input.id: input for input_list in args for input in input_list} + return inputs.values() def getSection(self): return Section("Device settings", *self.getInputs()) diff --git a/owrx/source/rtl_sdr.py b/owrx/source/rtl_sdr.py index ff1a0df..7fe899b 100644 --- a/owrx/source/rtl_sdr.py +++ b/owrx/source/rtl_sdr.py @@ -17,9 +17,9 @@ class RtlSdrSource(ConnectorSource): class RtlSdrDeviceDescription(SdrDeviceDescription): def getInputs(self) -> List[Input]: - return [ - TextInput( - "test", - "This is a drill" - ), - ] + return self.mergeInputs( + super().getInputs(), + [ + TextInput("test", "This is a drill"), + ], + ) From 3aa238727e2f130ed0674a7c719d5c3c20854b71 Mon Sep 17 00:00:00 2001 From: Jakob Ketterl Date: Fri, 19 Feb 2021 15:29:17 +0100 Subject: [PATCH 202/577] start building device forms --- owrx/controllers/settings/device.py | 30 -------------------------- owrx/controllers/settings/sdr.py | 2 +- owrx/source/__init__.py | 33 +++++++++++++++++++++++++++++ owrx/source/connector.py | 8 +++++-- owrx/source/rtl_sdr.py | 7 +++--- owrx/source/sdrplay.py | 6 +++++- owrx/source/soapy.py | 18 +++++++++++++++- 7 files changed, 65 insertions(+), 39 deletions(-) delete mode 100644 owrx/controllers/settings/device.py diff --git a/owrx/controllers/settings/device.py b/owrx/controllers/settings/device.py deleted file mode 100644 index edf6348..0000000 --- a/owrx/controllers/settings/device.py +++ /dev/null @@ -1,30 +0,0 @@ -from owrx.form import Input -from owrx.controllers.settings import Section -from typing import List - - -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)) - - def getInputs(self) -> List[Input]: - return [] - - def mergeInputs(self, *args): - # build a dictionary indexed by the input id to make sure every id only exists once - inputs = {input.id: input for input_list in args for input in input_list} - return inputs.values() - - def getSection(self): - return Section("Device settings", *self.getInputs()) diff --git a/owrx/controllers/settings/sdr.py b/owrx/controllers/settings/sdr.py index 4a45b2e..f490ef8 100644 --- a/owrx/controllers/settings/sdr.py +++ b/owrx/controllers/settings/sdr.py @@ -1,7 +1,7 @@ from owrx.controllers.admin import AuthorizationMixin from owrx.controllers.template import WebpageController from owrx.controllers.settings import SettingsFormController -from owrx.controllers.settings.device import SdrDeviceDescription, SdrDeviceDescriptionMissing +from owrx.source import SdrDeviceDescription, SdrDeviceDescriptionMissing from owrx.config import Config from urllib.parse import quote, unquote diff --git a/owrx/source/__init__.py b/owrx/source/__init__.py index 540b7f4..9b84b67 100644 --- a/owrx/source/__init__.py +++ b/owrx/source/__init__.py @@ -10,6 +10,9 @@ from abc import ABC, abstractmethod from owrx.command import CommandMapper from owrx.socket import getAvailablePort from owrx.property import PropertyStack, PropertyLayer +from owrx.form import Input, TextInput, NumberInput +from owrx.controllers.settings import Section +from typing import List import logging @@ -352,3 +355,33 @@ class SdrSource(ABC): self.busyState = state for c in self.clients: 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)) + + def getInputs(self) -> List[Input]: + return [ + TextInput("name", "Device name"), + NumberInput("ppm", "Frequency correction", append="ppm"), + ] + + def mergeInputs(self, *args): + # build a dictionary indexed by the input id to make sure every id only exists once + inputs = {input.id: input for input_list in args for input in input_list} + return inputs.values() + + def getSection(self): + return Section("Device settings", *self.getInputs()) diff --git a/owrx/source/connector.py b/owrx/source/connector.py index 8789cbe..dd055c2 100644 --- a/owrx/source/connector.py +++ b/owrx/source/connector.py @@ -1,7 +1,7 @@ -from . import SdrSource +from owrx.source import SdrSource, SdrDeviceDescription from owrx.socket import getAvailablePort import socket -from owrx.command import CommandMapper, Flag, Option +from owrx.command import Flag, Option import logging @@ -69,3 +69,7 @@ class ConnectorSource(SdrSource): values["port"] = self.getPort() values["controlPort"] = self.getControlPort() return values + + +class ConnectorDeviceDescription(SdrDeviceDescription): + pass diff --git a/owrx/source/rtl_sdr.py b/owrx/source/rtl_sdr.py index 7fe899b..6a65daa 100644 --- a/owrx/source/rtl_sdr.py +++ b/owrx/source/rtl_sdr.py @@ -1,6 +1,5 @@ -from .connector import ConnectorSource +from owrx.source.connector import ConnectorSource, ConnectorDeviceDescription from owrx.command import Flag, Option -from owrx.controllers.settings.device import SdrDeviceDescription from typing import List from owrx.form import Input, TextInput @@ -15,11 +14,11 @@ class RtlSdrSource(ConnectorSource): ) -class RtlSdrDeviceDescription(SdrDeviceDescription): +class RtlSdrDeviceDescription(ConnectorDeviceDescription): def getInputs(self) -> List[Input]: return self.mergeInputs( super().getInputs(), [ - TextInput("test", "This is a drill"), + TextInput("device", "Device identifier", infotext="Device serial number or index"), ], ) diff --git a/owrx/source/sdrplay.py b/owrx/source/sdrplay.py index 5fb2f4c..5b3ef23 100644 --- a/owrx/source/sdrplay.py +++ b/owrx/source/sdrplay.py @@ -1,4 +1,4 @@ -from .soapy import SoapyConnectorSource +from owrx.source.soapy import SoapyConnectorSource, SoapyConnectorDeviceDescription class SdrplaySource(SoapyConnectorSource): @@ -17,3 +17,7 @@ class SdrplaySource(SoapyConnectorSource): def getDriver(self): return "sdrplay" + + +class SdrplayDeviceDescription(SoapyConnectorDeviceDescription): + pass diff --git a/owrx/source/soapy.py b/owrx/source/soapy.py index 64c0713..d9c4b2e 100644 --- a/owrx/source/soapy.py +++ b/owrx/source/soapy.py @@ -1,6 +1,8 @@ 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 import Input, TextInput class SoapyConnectorSource(ConnectorSource, metaclass=ABCMeta): @@ -94,3 +96,17 @@ 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 self.mergeInputs( + super().getInputs(), + [ + TextInput( + "device", + "Device Identifier", + infotext='SoapySDR device identifier string (example: "serial=123456789")', + ), + ], + ) From 27c16c372071c29020bec2edd8f18aff258475a2 Mon Sep 17 00:00:00 2001 From: Jakob Ketterl Date: Fri, 19 Feb 2021 16:29:30 +0100 Subject: [PATCH 203/577] add more inputs --- owrx/source/__init__.py | 9 ++++++++- owrx/source/connector.py | 16 +++++++++++++++- 2 files changed, 23 insertions(+), 2 deletions(-) diff --git a/owrx/source/__init__.py b/owrx/source/__init__.py index 9b84b67..f03e752 100644 --- a/owrx/source/__init__.py +++ b/owrx/source/__init__.py @@ -10,7 +10,7 @@ from abc import ABC, abstractmethod from owrx.command import CommandMapper from owrx.socket import getAvailablePort from owrx.property import PropertyStack, PropertyLayer -from owrx.form import Input, TextInput, NumberInput +from owrx.form import Input, TextInput, NumberInput, CheckboxInput from owrx.controllers.settings import Section from typing import List @@ -376,6 +376,13 @@ class SdrDeviceDescription(object): return [ TextInput("name", "Device name"), NumberInput("ppm", "Frequency correction", append="ppm"), + CheckboxInput( + "always-on", + "", + checkboxText="Keep device running at all times", + infotext="Prevents shutdown of the device when idle. Useful for devices with unreliable startup." + ), + CheckboxInput("services", "", "Run services on this device"), ] def mergeInputs(self, *args): diff --git a/owrx/source/connector.py b/owrx/source/connector.py index dd055c2..60ce212 100644 --- a/owrx/source/connector.py +++ b/owrx/source/connector.py @@ -2,6 +2,8 @@ from owrx.source import SdrSource, SdrDeviceDescription from owrx.socket import getAvailablePort import socket from owrx.command import Flag, Option +from typing import List +from owrx.form import Input, NumberInput import logging @@ -72,4 +74,16 @@ class ConnectorSource(SdrSource): class ConnectorDeviceDescription(SdrDeviceDescription): - pass + def getInputs(self) -> List[Input]: + return self.mergeInputs( + super().getInputs(), + [ + NumberInput( + "rtltcp_compat", + "Port for rtl_tcp compatible data", + infotext="Activate an rtl_tcp compatible interface on the port number specified.
    " + + "Note: Port is only available on the local machine, not on the network.
    " + + "Note: IQ data may be degraded by the downsampling process to 8 bits.", + ) + ], + ) From 039b57d28bd161161da666d31e5aed0d1c6760cc Mon Sep 17 00:00:00 2001 From: Jakob Ketterl Date: Fri, 19 Feb 2021 18:18:25 +0100 Subject: [PATCH 204/577] add more inputs, bind to actual data --- owrx/controllers/settings/__init__.py | 16 +++++++++------- owrx/controllers/settings/sdr.py | 11 +++++++++++ owrx/form/__init__.py | 6 +++--- owrx/form/converter.py | 14 ++++++++++---- owrx/form/soapy.py | 13 +++++++++++++ owrx/http.py | 1 + owrx/source/__init__.py | 20 ++++++++++++++++---- owrx/source/connector.py | 2 ++ owrx/source/sdrplay.py | 3 ++- owrx/source/soapy.py | 9 +++++++++ 10 files changed, 76 insertions(+), 19 deletions(-) create mode 100644 owrx/form/soapy.py diff --git a/owrx/controllers/settings/__init__.py b/owrx/controllers/settings/__init__.py index 9800801..030e9fc 100644 --- a/owrx/controllers/settings/__init__.py +++ b/owrx/controllers/settings/__init__.py @@ -10,11 +10,10 @@ class Section(object): 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_inputs(self, data): + return "".join([i.render(data) for i in self.inputs]) - def render(self): + def render(self, data): return """

    @@ -23,7 +22,7 @@ class Section(object): {inputs}

    """.format( - title=self.title, inputs=self.render_inputs() + title=self.title, inputs=self.render_inputs(data) ) def parse(self, data): @@ -44,8 +43,11 @@ class SettingsFormController(AuthorizationMixin, WebpageController, metaclass=AB def getTitle(self): pass + def getData(self): + return Config.get() + def render_sections(self): - sections = "".join(section.render() for section in self.getSections()) + sections = "".join(section.render(self.getData()) for section in self.getSections()) return """
    {sections} @@ -78,6 +80,7 @@ class SettingsFormController(AuthorizationMixin, WebpageController, metaclass=AB def processFormData(self): self.processData(self.parseFormData()) + self.send_redirect(self.request.path) def processData(self, data): config = Config.get() @@ -88,4 +91,3 @@ class SettingsFormController(AuthorizationMixin, WebpageController, metaclass=AB else: config[k] = v config.store() - self.send_redirect(self.request.path) diff --git a/owrx/controllers/settings/sdr.py b/owrx/controllers/settings/sdr.py index f490ef8..0b9f940 100644 --- a/owrx/controllers/settings/sdr.py +++ b/owrx/controllers/settings/sdr.py @@ -5,6 +5,10 @@ from owrx.source import SdrDeviceDescription, SdrDeviceDescriptionMissing from owrx.config import Config from urllib.parse import quote, unquote +import logging + +logger = logging.getLogger(__name__) + class SdrDeviceListController(AuthorizationMixin, WebpageController): def header_variables(self): @@ -52,6 +56,13 @@ class SdrDeviceController(SettingsFormController): super().__init__(handler, request, options) self.device = self._get_device() + def getData(self): + return self.device + + def processData(self, data): + # TODO implement storing of data here + logger.debug(data) + def getSections(self): try: description = SdrDeviceDescription.getByType(self.device["type"]) diff --git a/owrx/form/__init__.py b/owrx/form/__init__.py index 5323cc0..27ad9ed 100644 --- a/owrx/form/__init__.py +++ b/owrx/form/__init__.py @@ -139,8 +139,8 @@ class TextAreaInput(Input): class CheckboxInput(Input): - def __init__(self, id, label, checkboxText, infotext=None): - super().__init__(id, label, infotext=infotext) + def __init__(self, id, label, checkboxText, infotext=None, converter: Converter = None): + super().__init__(id, label, infotext=infotext, converter=converter) self.checkboxText = checkboxText def render_input(self, value): @@ -162,7 +162,7 @@ class CheckboxInput(Input): return " ".join(["form-check", "form-control-sm"]) def parse(self, data): - return {self.id: self.id in data and data[self.id][0] == "on"} + return {self.id: self.converter.convert_from_form(self.id in data and data[self.id][0] == "on")} class Option(object): diff --git a/owrx/form/converter.py b/owrx/form/converter.py index 580b368..d825cc5 100644 --- a/owrx/form/converter.py +++ b/owrx/form/converter.py @@ -22,15 +22,21 @@ class NullConverter(Converter): class OptionalConverter(Converter): """ - Maps None to an empty string, and reverse - useful for optional fields + 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 "" if value is None else value + return self.defaultFormValue if value is None else self.sub_converter.convert_to_form(value) def convert_from_form(self, value): - return value if value else None + return None if value == self.defaultFormValue else self.sub_converter.convert_to_form(value) class IntConverter(Converter): diff --git a/owrx/form/soapy.py b/owrx/form/soapy.py new file mode 100644 index 0000000..6580585 --- /dev/null +++ b/owrx/form/soapy.py @@ -0,0 +1,13 @@ +from owrx.form import FloatInput + + +class SoapyGainInput(FloatInput): + def __init__(self, id, label, gain_stages): + super().__init__(id, label) + self.gain_stages = gain_stages + + def render_input(self, value): + if not self.gain_stages: + return super().render_input(value) + # TODO implement input for multiple gain stages here + return "soapy gain stages here..." diff --git a/owrx/http.py b/owrx/http.py index 9360fb6..7fd53b0 100644 --- a/owrx/http.py +++ b/owrx/http.py @@ -117,6 +117,7 @@ class Router(object): ), StaticRoute("/settings/sdr", SdrDeviceListController), RegexRoute("/settings/sdr/(.+)", SdrDeviceController), + RegexRoute("/settings/sdr/(.+)", SdrDeviceController, method="POST", options={"action": "processFormData"}), StaticRoute("/settings/bookmarks", BookmarksController), StaticRoute("/settings/bookmarks", BookmarksController, method="POST", options={"action": "new"}), RegexRoute("/settings/bookmarks/(.+)", BookmarksController, method="POST", options={"action": "update"}), diff --git a/owrx/source/__init__.py b/owrx/source/__init__.py index f03e752..dfaa449 100644 --- a/owrx/source/__init__.py +++ b/owrx/source/__init__.py @@ -10,7 +10,8 @@ from abc import ABC, abstractmethod from owrx.command import CommandMapper from owrx.socket import getAvailablePort from owrx.property import PropertyStack, PropertyLayer -from owrx.form import Input, TextInput, NumberInput, CheckboxInput +from owrx.form import Input, TextInput, NumberInput, CheckboxInput, FloatInput +from owrx.form.converter import IntConverter, OptionalConverter from owrx.controllers.settings import Section from typing import List @@ -375,14 +376,25 @@ class SdrDeviceDescription(object): def getInputs(self) -> List[Input]: return [ TextInput("name", "Device name"), - NumberInput("ppm", "Frequency correction", append="ppm"), + NumberInput( + "ppm", + "Frequency correction", + append="ppm", + converter=OptionalConverter(IntConverter(), defaultFormValue="0"), + ), CheckboxInput( "always-on", "", checkboxText="Keep device running at all times", - infotext="Prevents shutdown of the device when idle. Useful for devices with unreliable startup." + infotext="Prevents shutdown of the device when idle. Useful for devices with unreliable startup.", ), - CheckboxInput("services", "", "Run services on this device"), + CheckboxInput( + "services", + "", + "Run background services on this device", + converter=OptionalConverter(defaultFormValue=True), + ), + FloatInput("rf_gain", "Device gain"), ] def mergeInputs(self, *args): diff --git a/owrx/source/connector.py b/owrx/source/connector.py index 60ce212..f757d7c 100644 --- a/owrx/source/connector.py +++ b/owrx/source/connector.py @@ -4,6 +4,7 @@ import socket from owrx.command import Flag, Option from typing import List from owrx.form import Input, NumberInput +from owrx.form.converter import OptionalConverter, IntConverter import logging @@ -84,6 +85,7 @@ class ConnectorDeviceDescription(SdrDeviceDescription): infotext="Activate an rtl_tcp compatible interface on the port number specified.
    " + "Note: Port is only available on the local machine, not on the network.
    " + "Note: IQ data may be degraded by the downsampling process to 8 bits.", + converter=OptionalConverter(IntConverter()), ) ], ) diff --git a/owrx/source/sdrplay.py b/owrx/source/sdrplay.py index 5b3ef23..0012140 100644 --- a/owrx/source/sdrplay.py +++ b/owrx/source/sdrplay.py @@ -20,4 +20,5 @@ class SdrplaySource(SoapyConnectorSource): class SdrplayDeviceDescription(SoapyConnectorDeviceDescription): - pass + def getGainStages(self): + return ["RFGR", "IFGR"] diff --git a/owrx/source/soapy.py b/owrx/source/soapy.py index d9c4b2e..e19833a 100644 --- a/owrx/source/soapy.py +++ b/owrx/source/soapy.py @@ -3,6 +3,7 @@ from owrx.command import Option from owrx.source.connector import ConnectorSource, ConnectorDeviceDescription from typing import List from owrx.form import Input, TextInput +from owrx.form.soapy import SoapyGainInput class SoapyConnectorSource(ConnectorSource, metaclass=ABCMeta): @@ -108,5 +109,13 @@ class SoapyConnectorDeviceDescription(ConnectorDeviceDescription): "Device Identifier", infotext='SoapySDR device identifier string (example: "serial=123456789")', ), + SoapyGainInput( + "rf_gain", + "Device Gain", + gain_stages=self.getGainStages(), + ), ], ) + + def getGainStages(self): + return [] From 86278ff44d86dfb8aad7c018eaaa9974affbef36 Mon Sep 17 00:00:00 2001 From: Jakob Ketterl Date: Fri, 19 Feb 2021 18:45:29 +0100 Subject: [PATCH 205/577] wire data parsing and storage --- owrx/controllers/settings/__init__.py | 7 +++++-- owrx/controllers/settings/sdr.py | 14 +++++++++----- owrx/form/converter.py | 2 +- owrx/source/__init__.py | 4 ++-- owrx/source/rtl_sdr.py | 8 +++++++- owrx/source/soapy.py | 2 ++ 6 files changed, 26 insertions(+), 11 deletions(-) diff --git a/owrx/controllers/settings/__init__.py b/owrx/controllers/settings/__init__.py index 030e9fc..11f056c 100644 --- a/owrx/controllers/settings/__init__.py +++ b/owrx/controllers/settings/__init__.py @@ -80,14 +80,17 @@ class SettingsFormController(AuthorizationMixin, WebpageController, metaclass=AB def processFormData(self): self.processData(self.parseFormData()) + self.store() self.send_redirect(self.request.path) def processData(self, data): - config = Config.get() + config = self.getData() for k, v in data.items(): if v is None: if k in config: del config[k] else: config[k] = v - config.store() + + def store(self): + Config.get().store() diff --git a/owrx/controllers/settings/sdr.py b/owrx/controllers/settings/sdr.py index 0b9f940..0f1c23c 100644 --- a/owrx/controllers/settings/sdr.py +++ b/owrx/controllers/settings/sdr.py @@ -54,14 +54,18 @@ class SdrDeviceListController(AuthorizationMixin, WebpageController): class SdrDeviceController(SettingsFormController): def __init__(self, handler, request, options): super().__init__(handler, request, options) - self.device = self._get_device() + self.device_id, self.device = self._get_device() def getData(self): return self.device - def processData(self, data): - # TODO implement storing of data here - logger.debug(data) + 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.getData() + config["sdrs"] = sdrs + super().store() def getSections(self): try: @@ -78,7 +82,7 @@ class SdrDeviceController(SettingsFormController): device_id = unquote(self.request.matches.group(1)) if device_id not in Config.get()["sdrs"]: return None - return Config.get()["sdrs"][device_id] + return device_id, Config.get()["sdrs"][device_id] def header_variables(self): variables = super().header_variables() diff --git a/owrx/form/converter.py b/owrx/form/converter.py index d825cc5..3616727 100644 --- a/owrx/form/converter.py +++ b/owrx/form/converter.py @@ -36,7 +36,7 @@ class OptionalConverter(Converter): 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_to_form(value) + return None if value == self.defaultFormValue else self.sub_converter.convert_from_form(value) class IntConverter(Converter): diff --git a/owrx/source/__init__.py b/owrx/source/__init__.py index dfaa449..0d33fb3 100644 --- a/owrx/source/__init__.py +++ b/owrx/source/__init__.py @@ -11,7 +11,7 @@ from owrx.command import CommandMapper from owrx.socket import getAvailablePort from owrx.property import PropertyStack, PropertyLayer from owrx.form import Input, TextInput, NumberInput, CheckboxInput, FloatInput -from owrx.form.converter import IntConverter, OptionalConverter +from owrx.form.converter import IntConverter, OptionalConverter, FloatConverter from owrx.controllers.settings import Section from typing import List @@ -394,7 +394,7 @@ class SdrDeviceDescription(object): "Run background services on this device", converter=OptionalConverter(defaultFormValue=True), ), - FloatInput("rf_gain", "Device gain"), + FloatInput("rf_gain", "Device gain", converter=OptionalConverter(FloatConverter())), ] def mergeInputs(self, *args): diff --git a/owrx/source/rtl_sdr.py b/owrx/source/rtl_sdr.py index 6a65daa..704c439 100644 --- a/owrx/source/rtl_sdr.py +++ b/owrx/source/rtl_sdr.py @@ -2,6 +2,7 @@ from owrx.source.connector import ConnectorSource, ConnectorDeviceDescription from owrx.command import Flag, Option from typing import List from owrx.form import Input, TextInput +from owrx.form.converter import OptionalConverter class RtlSdrSource(ConnectorSource): @@ -19,6 +20,11 @@ class RtlSdrDeviceDescription(ConnectorDeviceDescription): return self.mergeInputs( super().getInputs(), [ - TextInput("device", "Device identifier", infotext="Device serial number or index"), + TextInput( + "device", + "Device identifier", + infotext="Device serial number or index", + converter=OptionalConverter(), + ), ], ) diff --git a/owrx/source/soapy.py b/owrx/source/soapy.py index e19833a..b68636a 100644 --- a/owrx/source/soapy.py +++ b/owrx/source/soapy.py @@ -3,6 +3,7 @@ from owrx.command import Option from owrx.source.connector import ConnectorSource, ConnectorDeviceDescription from typing import List from owrx.form import Input, TextInput +from owrx.form.converter import OptionalConverter from owrx.form.soapy import SoapyGainInput @@ -108,6 +109,7 @@ class SoapyConnectorDeviceDescription(ConnectorDeviceDescription): "device", "Device Identifier", infotext='SoapySDR device identifier string (example: "serial=123456789")', + converter=OptionalConverter() ), SoapyGainInput( "rf_gain", From d0d946e09f95a9e0680ed46cd3a6c33cec5247f4 Mon Sep 17 00:00:00 2001 From: Jakob Ketterl Date: Fri, 19 Feb 2021 21:07:13 +0100 Subject: [PATCH 206/577] implement gain dialog with AGC option --- htdocs/lib/settings/GainInput.js | 22 +++++++++++++++++++++ htdocs/settings.js | 2 +- owrx/controllers/assets.py | 1 + owrx/form/device.py | 33 ++++++++++++++++++++++++++++++++ owrx/source/__init__.py | 7 ++++--- 5 files changed, 61 insertions(+), 4 deletions(-) create mode 100644 htdocs/lib/settings/GainInput.js create mode 100644 owrx/form/device.py diff --git a/htdocs/lib/settings/GainInput.js b/htdocs/lib/settings/GainInput.js new file mode 100644 index 0000000..31552bc --- /dev/null +++ b/htdocs/lib/settings/GainInput.js @@ -0,0 +1,22 @@ +$.fn.gainInput = function() { + this.each(function() { + var $container = $(this); + + var update = function(value){ + $container.find('.option').hide(); + $container.find('.option.' + value).show(); + } + + var $select = $container.find('select'); + $select.on('change', function(e) { + var value = $(e.target).val() + update(value); + if (value == 'auto') { + $input.val('auto'); + } else { + $input + } + }); + update($select.val()); + }); +} \ No newline at end of file diff --git a/htdocs/settings.js b/htdocs/settings.js index 1a76dbe..4a9903b 100644 --- a/htdocs/settings.js +++ b/htdocs/settings.js @@ -1,8 +1,8 @@ $(function(){ $('.map-input').mapInput(); - $('.sdrdevice').sdrdevice(); $('.imageupload').imageUpload(); $('.bookmarks').bookmarktable(); $('.wsjt-decoding-depths').wsjtDecodingDepthsInput(); $('#waterfall_scheme').waterfallDropdown(); + $('#rf_gain').gainInput(); }); \ No newline at end of file diff --git a/owrx/controllers/assets.py b/owrx/controllers/assets.py index 51343aa..f090fb6 100644 --- a/owrx/controllers/assets.py +++ b/owrx/controllers/assets.py @@ -150,6 +150,7 @@ class CompiledAssetsController(GzipMixin, ModificationAwareController): "lib/settings/BookmarkTable.js", "lib/settings/WsjtDecodingDepthsInput.js", "lib/settings/WaterfallDropdown.js", + "lib/settings/GainInput.js", "settings.js", ], } diff --git a/owrx/form/device.py b/owrx/form/device.py new file mode 100644 index 0000000..1da6791 --- /dev/null +++ b/owrx/form/device.py @@ -0,0 +1,33 @@ +from owrx.form import Input + + +class GainInput(Input): + def render_input(self, value): + auto_mode = value is None or value == "auto" + + return """ +
    + + +
    + """.format( + id=self.id, + classes=self.input_classes(), + value=value, + label=self.label, + auto_selected="selected" if auto_mode else "", + manual_selected="" if auto_mode else "selected", + ) + + def parse(self, data): + select_id = "{id}-select".format(id=self.id) + if select_id in data: + input_id = "{id}-manual".format(id=self.id) + if data[select_id][0] == "manual" and input_id in data: + return {self.id: float(data[input_id][0])} + return {self.id: None} diff --git a/owrx/source/__init__.py b/owrx/source/__init__.py index 0d33fb3..6324235 100644 --- a/owrx/source/__init__.py +++ b/owrx/source/__init__.py @@ -10,8 +10,9 @@ from abc import ABC, abstractmethod from owrx.command import CommandMapper from owrx.socket import getAvailablePort from owrx.property import PropertyStack, PropertyLayer -from owrx.form import Input, TextInput, NumberInput, CheckboxInput, FloatInput -from owrx.form.converter import IntConverter, OptionalConverter, FloatConverter +from owrx.form import Input, TextInput, NumberInput, CheckboxInput +from owrx.form.converter import IntConverter, OptionalConverter +from owrx.form.device import GainInput from owrx.controllers.settings import Section from typing import List @@ -394,7 +395,7 @@ class SdrDeviceDescription(object): "Run background services on this device", converter=OptionalConverter(defaultFormValue=True), ), - FloatInput("rf_gain", "Device gain", converter=OptionalConverter(FloatConverter())), + GainInput("rf_gain", "Device gain"), ] def mergeInputs(self, *args): From bd7e5b71666b0137213bc979651db1275ab4c735 Mon Sep 17 00:00:00 2001 From: Jakob Ketterl Date: Sat, 20 Feb 2021 00:16:32 +0100 Subject: [PATCH 207/577] implement individual gain stages option --- owrx/form/device.py | 102 ++++++++++++++++++++++++++++++++++++++----- owrx/form/soapy.py | 13 ------ owrx/soapy.py | 21 +++++++++ owrx/source/soapy.py | 30 +++---------- 4 files changed, 118 insertions(+), 48 deletions(-) delete mode 100644 owrx/form/soapy.py create mode 100644 owrx/soapy.py diff --git a/owrx/form/device.py b/owrx/form/device.py index 1da6791..d9f7653 100644 --- a/owrx/form/device.py +++ b/owrx/form/device.py @@ -1,33 +1,113 @@ from owrx.form import Input +from owrx.soapy import SoapySettings class GainInput(Input): - def render_input(self, value): - auto_mode = value is None or value == "auto" + def __init__(self, id, label, gain_stages=None): + super().__init__(id, label) + self.gain_stages = gain_stages + def render_input(self, value): return """
    + {stageoption}
    """.format( id=self.id, classes=self.input_classes(), - value=value, + value="0.0" if value is None else value, label=self.label, - auto_selected="selected" if auto_mode else "", - manual_selected="" if auto_mode else "selected", + options=self.render_options(value), + stageoption=self.render_stage_option(value), + ) + + def render_options(self, value): + options = [ + ("auto", "Enable hardware AGC"), + ("manual", "Specify manual gain"), + ] + if self.gain_stages: + options.append(("stages", "Specify gain stages individually")) + + mode = self.getMode(value) + + return "".join( + """ + + """.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 or value == "auto": + return "auto" + + try: + float(value) + return "manual" + except ValueError: + pass + + return "stages" + + def render_stage_option(self, value): + try: + value_dict = {k: v for item in SoapySettings.parse(value) for k, v in item.items()} + except (AttributeError, ValueError): + value_dict = {} + + return """ + + """.format( + inputs="".join( + """ +
    +
    {stage}
    + +
    + """.format( + id=self.id, + stage=stage, + value=value_dict[stage] if stage in value_dict else "", + classes=self.input_classes(), + ) + 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 0.0 + select_id = "{id}-select".format(id=self.id) if select_id in data: - input_id = "{id}-manual".format(id=self.id) - if data[select_id][0] == "manual" and input_id in data: - return {self.id: float(data[input_id][0])} + 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(float(data[input_id][0])) + except ValueError: + pass + return {self.id: value} + if data[select_id][0] == "stages": + settings_dict = [{s: getStageValue(s)} for s in self.gain_stages] + return {self.id: SoapySettings.encode(settings_dict)} + return {self.id: None} diff --git a/owrx/form/soapy.py b/owrx/form/soapy.py deleted file mode 100644 index 6580585..0000000 --- a/owrx/form/soapy.py +++ /dev/null @@ -1,13 +0,0 @@ -from owrx.form import FloatInput - - -class SoapyGainInput(FloatInput): - def __init__(self, id, label, gain_stages): - super().__init__(id, label) - self.gain_stages = gain_stages - - def render_input(self, value): - if not self.gain_stages: - return super().render_input(value) - # TODO implement input for multiple gain stages here - return "soapy gain stages here..." diff --git a/owrx/soapy.py b/owrx/soapy.py new file mode 100644 index 0000000..25b5f35 --- /dev/null +++ b/owrx/soapy.py @@ -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]) diff --git a/owrx/source/soapy.py b/owrx/source/soapy.py index b68636a..7603402 100644 --- a/owrx/source/soapy.py +++ b/owrx/source/soapy.py @@ -4,7 +4,8 @@ from owrx.source.connector import ConnectorSource, ConnectorDeviceDescription from typing import List from owrx.form import Input, TextInput from owrx.form.converter import OptionalConverter -from owrx.form.soapy import SoapyGainInput +from owrx.form.device import GainInput +from owrx.soapy import SoapySettings class SoapyConnectorSource(ConnectorSource, metaclass=ABCMeta): @@ -33,25 +34,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. @@ -79,11 +61,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 @@ -111,7 +93,7 @@ class SoapyConnectorDeviceDescription(ConnectorDeviceDescription): infotext='SoapySDR device identifier string (example: "serial=123456789")', converter=OptionalConverter() ), - SoapyGainInput( + GainInput( "rf_gain", "Device Gain", gain_stages=self.getGainStages(), @@ -120,4 +102,4 @@ class SoapyConnectorDeviceDescription(ConnectorDeviceDescription): ) def getGainStages(self): - return [] + return None From 058463a9b3876497823af576db7f6b4ff033a004 Mon Sep 17 00:00:00 2001 From: Jakob Ketterl Date: Sat, 20 Feb 2021 00:36:18 +0100 Subject: [PATCH 208/577] fix display and parsing issues --- owrx/form/device.py | 29 ++++++++++++++++++----------- 1 file changed, 18 insertions(+), 11 deletions(-) diff --git a/owrx/form/device.py b/owrx/form/device.py index d9f7653..d9e0b25 100644 --- a/owrx/form/device.py +++ b/owrx/form/device.py @@ -8,23 +8,29 @@ class GainInput(Input): self.gain_stages = gain_stages def render_input(self, value): + try: + display_value = float(value) + except (ValueError, TypeError): + display_value = "0.0" + return """
    {stageoption}
    """.format( id=self.id, classes=self.input_classes(), - value="0.0" if value is None else value, + value=display_value, label=self.label, options=self.render_options(value), - stageoption=self.render_stage_option(value), + stageoption="" if self.gain_stages is None else self.render_stage_option(value), ) def render_options(self, value): @@ -41,9 +47,7 @@ class GainInput(Input): """ """.format( - value=v[0], - text=v[1], - selected="selected" if mode == v[0] else "" + value=v[0], text=v[1], selected="selected" if mode == v[0] else "" ) for v in options ) @@ -55,7 +59,7 @@ class GainInput(Input): try: float(value) return "manual" - except ValueError: + except (ValueError, TypeError): pass return "stages" @@ -75,7 +79,8 @@ class GainInput(Input): """
    {stage}
    - +
    """.format( id=self.id, @@ -93,7 +98,7 @@ class GainInput(Input): if input_id in data: return data[input_id][0] else: - return 0.0 + return None select_id = "{id}-select".format(id=self.id) if select_id in data: @@ -102,12 +107,14 @@ class GainInput(Input): value = 0.0 if input_id in data: try: - value = float(float(data[input_id][0])) + value = float(data[input_id][0]) except ValueError: pass return {self.id: value} - if data[select_id][0] == "stages": + 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 {self.id: None} From 0e64f15e650aff6acbf8cf5f43aff084009d1c97 Mon Sep 17 00:00:00 2001 From: Jakob Ketterl Date: Sat, 20 Feb 2021 17:54:19 +0100 Subject: [PATCH 209/577] add more device inputs --- owrx/source/__init__.py | 11 +++++++++++ owrx/source/connector.py | 11 +++++++++-- owrx/source/soapy.py | 5 +++++ 3 files changed, 25 insertions(+), 2 deletions(-) diff --git a/owrx/source/__init__.py b/owrx/source/__init__.py index 6324235..2ea6eca 100644 --- a/owrx/source/__init__.py +++ b/owrx/source/__init__.py @@ -396,6 +396,17 @@ class SdrDeviceDescription(object): converter=OptionalConverter(defaultFormValue=True), ), GainInput("rf_gain", "Device gain"), + NumberInput( + "lfo_offset", + "Oscilator offset", + append="Hz", + infotext="Use this when the actual receiving frequency differs from the frequency to be tuned on the" + + " device.
    Formula: Center frequency + oscillator offset = sdr tune frequency", + converter=OptionalConverter(), + ), + NumberInput("waterfall_min_level", "Lowest waterfall level", append="dBFS", converter=OptionalConverter()), + NumberInput("waterfall_max_level", "Highest waterfall level", append="dBFS", converter=OptionalConverter()), + # TODO `schedule` ] def mergeInputs(self, *args): diff --git a/owrx/source/connector.py b/owrx/source/connector.py index f757d7c..40fd795 100644 --- a/owrx/source/connector.py +++ b/owrx/source/connector.py @@ -3,7 +3,7 @@ from owrx.socket import getAvailablePort import socket from owrx.command import Flag, Option from typing import List -from owrx.form import Input, NumberInput +from owrx.form import Input, NumberInput, CheckboxInput from owrx.form.converter import OptionalConverter, IntConverter import logging @@ -86,6 +86,13 @@ class ConnectorDeviceDescription(SdrDeviceDescription): + "Note: Port is only available on the local machine, not on the network.
    " + "Note: IQ data may be degraded by the downsampling process to 8 bits.", converter=OptionalConverter(IntConverter()), - ) + ), + CheckboxInput( + "iqswap", + "", + checkboxText="Swap I and Q channels", + infotext="Swapping inverts the spectrum, so this is useful in combination with an inverting mixer", + converter=OptionalConverter(defaultFormValue=False), + ), ], ) diff --git a/owrx/source/soapy.py b/owrx/source/soapy.py index 7603402..8e4778b 100644 --- a/owrx/source/soapy.py +++ b/owrx/source/soapy.py @@ -98,6 +98,11 @@ class SoapyConnectorDeviceDescription(ConnectorDeviceDescription): "Device Gain", gain_stages=self.getGainStages(), ), + TextInput( + "antenna", + "Antenna", + converter=OptionalConverter(), + ), ], ) From 0ab6729fcc6f2872fda2f4715c0e4e9b3756fb2a Mon Sep 17 00:00:00 2001 From: Jakob Ketterl Date: Sat, 20 Feb 2021 18:09:24 +0100 Subject: [PATCH 210/577] create device descriptions for all --- owrx/source/airspy.py | 7 +++++-- owrx/source/airspyhf.py | 6 +++++- owrx/source/direct.py | 6 +++++- owrx/source/fcdpp.py | 6 +++++- owrx/source/fifi_sdr.py | 6 +++++- owrx/source/hackrf.py | 6 +++++- owrx/source/hpsdr.py | 8 ++++++-- owrx/source/lime_sdr.py | 6 +++++- owrx/source/perseussdr.py | 8 ++++++-- owrx/source/pluto_sdr.py | 6 +++++- owrx/source/radioberry.py | 6 +++++- owrx/source/red_pitaya.py | 6 +++++- owrx/source/rtl_sdr_soapy.py | 6 +++++- owrx/source/rtl_tcp.py | 6 +++++- owrx/source/runds.py | 6 +++++- owrx/source/sddc.py | 6 +++++- owrx/source/soapy_remote.py | 6 +++++- owrx/source/uhd.py | 6 +++++- 18 files changed, 92 insertions(+), 21 deletions(-) diff --git a/owrx/source/airspy.py b/owrx/source/airspy.py index 97221a3..d909d96 100644 --- a/owrx/source/airspy.py +++ b/owrx/source/airspy.py @@ -1,5 +1,4 @@ -from owrx.command import Flag -from .soapy import SoapyConnectorSource +from owrx.source.soapy import SoapyConnectorSource, SoapyConnectorDeviceDescription class AirspySource(SoapyConnectorSource): @@ -15,3 +14,7 @@ class AirspySource(SoapyConnectorSource): def getDriver(self): return "airspy" + + +class AirspyDeviceDescription(SoapyConnectorDeviceDescription): + pass diff --git a/owrx/source/airspyhf.py b/owrx/source/airspyhf.py index 33b00df..5995b4b 100644 --- a/owrx/source/airspyhf.py +++ b/owrx/source/airspyhf.py @@ -1,6 +1,10 @@ -from .soapy import SoapyConnectorSource +from owrx.source.soapy import SoapyConnectorSource, SoapyConnectorDeviceDescription class AirspyhfSource(SoapyConnectorSource): def getDriver(self): return "airspyhf" + + +class AirspyhfDeviceDescription(SoapyConnectorDeviceDescription): + pass diff --git a/owrx/source/direct.py b/owrx/source/direct.py index e5d0024..e51f0b8 100644 --- a/owrx/source/direct.py +++ b/owrx/source/direct.py @@ -1,5 +1,5 @@ from abc import ABCMeta -from . import SdrSource +from owrx.source import SdrSource, SdrDeviceDescription import logging @@ -51,3 +51,7 @@ class DirectSource(SdrSource, metaclass=ABCMeta): # override this in subclasses, if necessary def sleepOnRestart(self): pass + + +class DirectSourceDeviceDescription(SdrDeviceDescription): + pass diff --git a/owrx/source/fcdpp.py b/owrx/source/fcdpp.py index bb121ee..342ad73 100644 --- a/owrx/source/fcdpp.py +++ b/owrx/source/fcdpp.py @@ -1,6 +1,10 @@ -from owrx.source.soapy import SoapyConnectorSource +from owrx.source.soapy import SoapyConnectorSource, SoapyConnectorDeviceDescription class FcdppSource(SoapyConnectorSource): def getDriver(self): return "fcdpp" + + +class FcdppDeviceDescription(SoapyConnectorDeviceDescription): + pass diff --git a/owrx/source/fifi_sdr.py b/owrx/source/fifi_sdr.py index 5b51558..700cb53 100644 --- a/owrx/source/fifi_sdr.py +++ b/owrx/source/fifi_sdr.py @@ -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,7 @@ class FifiSdrSource(DirectSource): def onPropertyChange(self, changes): if "center_freq" in changes: self.sendRockProgFrequency(changes["center_freq"]) + + +class FifiSdrDeviceDescription(DirectSourceDeviceDescription): + pass diff --git a/owrx/source/hackrf.py b/owrx/source/hackrf.py index 9ced99e..85bf368 100644 --- a/owrx/source/hackrf.py +++ b/owrx/source/hackrf.py @@ -1,4 +1,4 @@ -from .soapy import SoapyConnectorSource +from .soapy import SoapyConnectorSource, SoapyConnectorDeviceDescription class HackrfSource(SoapyConnectorSource): @@ -9,3 +9,7 @@ class HackrfSource(SoapyConnectorSource): def getDriver(self): return "hackrf" + + +class HackrfDeviceDescription(SoapyConnectorDeviceDescription): + pass diff --git a/owrx/source/hpsdr.py b/owrx/source/hpsdr.py index 6d88c67..63571d0 100644 --- a/owrx/source/hpsdr.py +++ b/owrx/source/hpsdr.py @@ -1,5 +1,5 @@ -from .connector import ConnectorSource -from owrx.command import Flag, Option +from owrx.source.connector import ConnectorSource, ConnectorDeviceDescription +from owrx.command import Option # 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 +33,7 @@ class HpsdrSource(ConnectorSource): } ) ) + + +class HpsdrDeviceDescription(ConnectorDeviceDescription): + pass diff --git a/owrx/source/lime_sdr.py b/owrx/source/lime_sdr.py index 6220b68..dedfacf 100644 --- a/owrx/source/lime_sdr.py +++ b/owrx/source/lime_sdr.py @@ -1,6 +1,10 @@ -from .soapy import SoapyConnectorSource +from owrx.source.soapy import SoapyConnectorSource, SoapyConnectorDeviceDescription class LimeSdrSource(SoapyConnectorSource): def getDriver(self): return "lime" + + +class LimeSdrDeviceDescription(SoapyConnectorDeviceDescription): + pass diff --git a/owrx/source/perseussdr.py b/owrx/source/perseussdr.py index 1e2b8ec..88b6319 100644 --- a/owrx/source/perseussdr.py +++ b/owrx/source/perseussdr.py @@ -1,5 +1,5 @@ -from .direct import DirectSource -from owrx.command import Flag, Option +from owrx.source.direct import DirectSource, DirectSourceDeviceDescription +from owrx.command import Option # @@ -35,3 +35,7 @@ class PerseussdrSource(DirectSource): } ) ) + + +class PerseussdrDeviceDescription(DirectSourceDeviceDescription): + pass diff --git a/owrx/source/pluto_sdr.py b/owrx/source/pluto_sdr.py index 19fdeab..1f6dad1 100644 --- a/owrx/source/pluto_sdr.py +++ b/owrx/source/pluto_sdr.py @@ -1,6 +1,10 @@ -from .soapy import SoapyConnectorSource +from owrx.source.soapy import SoapyConnectorSource, SoapyConnectorDeviceDescription class PlutoSdrSource(SoapyConnectorSource): def getDriver(self): return "plutosdr" + + +class PlutoSdrDeviceDescription(SoapyConnectorDeviceDescription): + pass diff --git a/owrx/source/radioberry.py b/owrx/source/radioberry.py index 5bb95c7..2ec0e09 100644 --- a/owrx/source/radioberry.py +++ b/owrx/source/radioberry.py @@ -1,6 +1,10 @@ -from .soapy import SoapyConnectorSource +from owrx.source.soapy import SoapyConnectorSource, SoapyConnectorDeviceDescription class RadioberrySource(SoapyConnectorSource): def getDriver(self): return "radioberry" + + +class RadioberryDeviceDescription(SoapyConnectorDeviceDescription): + pass diff --git a/owrx/source/red_pitaya.py b/owrx/source/red_pitaya.py index 06431f0..00a484a 100644 --- a/owrx/source/red_pitaya.py +++ b/owrx/source/red_pitaya.py @@ -1,6 +1,10 @@ -from .soapy import SoapyConnectorSource +from owrx.source.soapy import SoapyConnectorSource, SoapyConnectorDeviceDescription class RedPitayaSource(SoapyConnectorSource): def getDriver(self): return "redpitaya" + + +class RedPitayaDeviceDescription(SoapyConnectorDeviceDescription): + pass diff --git a/owrx/source/rtl_sdr_soapy.py b/owrx/source/rtl_sdr_soapy.py index 55744d5..c6d3565 100644 --- a/owrx/source/rtl_sdr_soapy.py +++ b/owrx/source/rtl_sdr_soapy.py @@ -1,4 +1,4 @@ -from .soapy import SoapyConnectorSource +from owrx.source.soapy import SoapyConnectorSource, SoapyConnectorDeviceDescription class RtlSdrSoapySource(SoapyConnectorSource): @@ -9,3 +9,7 @@ class RtlSdrSoapySource(SoapyConnectorSource): def getDriver(self): return "rtlsdr" + + +class RtlSdrSoapyDeviceDescription(SoapyConnectorDeviceDescription): + pass diff --git a/owrx/source/rtl_tcp.py b/owrx/source/rtl_tcp.py index ba6ac1f..8e606d5 100644 --- a/owrx/source/rtl_tcp.py +++ b/owrx/source/rtl_tcp.py @@ -1,4 +1,4 @@ -from .connector import ConnectorSource +from owrx.source.connector import ConnectorSource, ConnectorDeviceDescription from owrx.command import Flag, Option, Argument @@ -16,3 +16,7 @@ class RtlTcpSource(ConnectorSource): } ) ) + + +class RtlTcpDeviceDescription(ConnectorDeviceDescription): + pass diff --git a/owrx/source/runds.py b/owrx/source/runds.py index f12f003..ba7bd99 100644 --- a/owrx/source/runds.py +++ b/owrx/source/runds.py @@ -1,4 +1,4 @@ -from owrx.source.connector import ConnectorSource +from owrx.source.connector import ConnectorSource, ConnectorDeviceDescription from owrx.command import Argument, Flag, Option @@ -16,3 +16,7 @@ class RundsSource(ConnectorSource): } ) ) + + +class RundsDeviceDescription(ConnectorDeviceDescription): + pass diff --git a/owrx/source/sddc.py b/owrx/source/sddc.py index 58e7380..e729116 100644 --- a/owrx/source/sddc.py +++ b/owrx/source/sddc.py @@ -1,6 +1,10 @@ -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): + pass diff --git a/owrx/source/soapy_remote.py b/owrx/source/soapy_remote.py index 53a3196..5f49653 100644 --- a/owrx/source/soapy_remote.py +++ b/owrx/source/soapy_remote.py @@ -1,4 +1,4 @@ -from .soapy import SoapyConnectorSource +from owrx.source.soapy import SoapyConnectorSource, SoapyConnectorDeviceDescription class SoapyRemoteSource(SoapyConnectorSource): @@ -15,3 +15,7 @@ class SoapyRemoteSource(SoapyConnectorSource): if "remote_driver" in values and values["remote_driver"] is not None: params += [{"remote:driver": values["remote_driver"]}] return params + + +class SoapyRemoteDeviceDescription(SoapyConnectorDeviceDescription): + pass diff --git a/owrx/source/uhd.py b/owrx/source/uhd.py index 29e2909..97418d8 100644 --- a/owrx/source/uhd.py +++ b/owrx/source/uhd.py @@ -1,6 +1,10 @@ -from .soapy import SoapyConnectorSource +from owrx.source.soapy import SoapyConnectorSource, SoapyConnectorDeviceDescription class UhdSource(SoapyConnectorSource): def getDriver(self): return "uhd" + + +class UhdDeviceDescription(SoapyConnectorDeviceDescription): + pass From 18e8ca5e43b5009dd65a8eda99349b2e36505ef1 Mon Sep 17 00:00:00 2001 From: Jakob Ketterl Date: Sat, 20 Feb 2021 18:48:12 +0100 Subject: [PATCH 211/577] add bias_tee and direct_sampling options --- owrx/form/device.py | 39 +++++++++++++++++++++++++++++++++++- owrx/source/airspy.py | 6 +++++- owrx/source/hackrf.py | 8 ++++++-- owrx/source/rtl_sdr.py | 3 +++ owrx/source/rtl_sdr_soapy.py | 6 +++++- owrx/source/sdrplay.py | 6 ++++++ 6 files changed, 63 insertions(+), 5 deletions(-) diff --git a/owrx/form/device.py b/owrx/form/device.py index d9e0b25..0145eb0 100644 --- a/owrx/form/device.py +++ b/owrx/form/device.py @@ -1,4 +1,5 @@ -from owrx.form import Input +from owrx.form import Input, CheckboxInput, DropdownInput, DropdownEnum +from owrx.form.converter import OptionalConverter, EnumConverter from owrx.soapy import SoapySettings @@ -118,3 +119,39 @@ class GainInput(Input): return {self.id: SoapySettings.encode(settings_dict)} return {self.id: None} + + +class BiasTeeInput(CheckboxInput): + def __init__(self): + super().__init__( + "bias_tee", "", "Enable Bias-Tee power supply", converter=OptionalConverter(defaultFormValue=False) + ) + + +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, + converter=OptionalConverter( + EnumConverter(DirectSamplingOptions), + defaultFormValue=DirectSamplingOptions.DIRECT_SAMPLING_OFF.name, + ), + ) diff --git a/owrx/source/airspy.py b/owrx/source/airspy.py index d909d96..34f6b3c 100644 --- a/owrx/source/airspy.py +++ b/owrx/source/airspy.py @@ -1,4 +1,7 @@ from owrx.source.soapy import SoapyConnectorSource, SoapyConnectorDeviceDescription +from owrx.form import Input +from owrx.form.device import BiasTeeInput +from typing import List class AirspySource(SoapyConnectorSource): @@ -17,4 +20,5 @@ class AirspySource(SoapyConnectorSource): class AirspyDeviceDescription(SoapyConnectorDeviceDescription): - pass + def getInputs(self) -> List[Input]: + return self.mergeInputs(super().getInputs(), [BiasTeeInput()]) diff --git a/owrx/source/hackrf.py b/owrx/source/hackrf.py index 85bf368..a4a17cc 100644 --- a/owrx/source/hackrf.py +++ b/owrx/source/hackrf.py @@ -1,4 +1,7 @@ -from .soapy import SoapyConnectorSource, SoapyConnectorDeviceDescription +from owrx.source.soapy import SoapyConnectorSource, SoapyConnectorDeviceDescription +from owrx.form import Input +from owrx.form.device import BiasTeeInput +from typing import List class HackrfSource(SoapyConnectorSource): @@ -12,4 +15,5 @@ class HackrfSource(SoapyConnectorSource): class HackrfDeviceDescription(SoapyConnectorDeviceDescription): - pass + def getInputs(self) -> List[Input]: + return self.mergeInputs(super().getInputs(), [BiasTeeInput]) diff --git a/owrx/source/rtl_sdr.py b/owrx/source/rtl_sdr.py index 704c439..91b97b5 100644 --- a/owrx/source/rtl_sdr.py +++ b/owrx/source/rtl_sdr.py @@ -3,6 +3,7 @@ from owrx.command import Flag, Option from typing import List from owrx.form import Input, TextInput from owrx.form.converter import OptionalConverter +from owrx.form.device import BiasTeeInput, DirectSamplingInput class RtlSdrSource(ConnectorSource): @@ -26,5 +27,7 @@ class RtlSdrDeviceDescription(ConnectorDeviceDescription): infotext="Device serial number or index", converter=OptionalConverter(), ), + BiasTeeInput(), + DirectSamplingInput() ], ) diff --git a/owrx/source/rtl_sdr_soapy.py b/owrx/source/rtl_sdr_soapy.py index c6d3565..fec40bd 100644 --- a/owrx/source/rtl_sdr_soapy.py +++ b/owrx/source/rtl_sdr_soapy.py @@ -1,4 +1,7 @@ from owrx.source.soapy import SoapyConnectorSource, SoapyConnectorDeviceDescription +from owrx.form import Input +from owrx.form.device import BiasTeeInput, DirectSamplingInput +from typing import List class RtlSdrSoapySource(SoapyConnectorSource): @@ -12,4 +15,5 @@ class RtlSdrSoapySource(SoapyConnectorSource): class RtlSdrSoapyDeviceDescription(SoapyConnectorDeviceDescription): - pass + def getInputs(self) -> List[Input]: + return self.mergeInputs(super().getInputs(), [BiasTeeInput(), DirectSamplingInput()]) diff --git a/owrx/source/sdrplay.py b/owrx/source/sdrplay.py index 0012140..a3ae14c 100644 --- a/owrx/source/sdrplay.py +++ b/owrx/source/sdrplay.py @@ -1,4 +1,7 @@ from owrx.source.soapy import SoapyConnectorSource, SoapyConnectorDeviceDescription +from owrx.form import Input +from owrx.form.device import BiasTeeInput +from typing import List class SdrplaySource(SoapyConnectorSource): @@ -22,3 +25,6 @@ class SdrplaySource(SoapyConnectorSource): class SdrplayDeviceDescription(SoapyConnectorDeviceDescription): def getGainStages(self): return ["RFGR", "IFGR"] + + def getInputs(self) -> List[Input]: + return self.mergeInputs(super().getInputs(), [BiasTeeInput()]) From 8b24eff72ed5e7ec1067272f08b3da7f0de88aec Mon Sep 17 00:00:00 2001 From: Jakob Ketterl Date: Sat, 20 Feb 2021 19:00:28 +0100 Subject: [PATCH 212/577] add sdrplay specific options --- owrx/source/sdrplay.py | 40 ++++++++++++++++++++++++++++++++++++++-- 1 file changed, 38 insertions(+), 2 deletions(-) diff --git a/owrx/source/sdrplay.py b/owrx/source/sdrplay.py index a3ae14c..5cf5587 100644 --- a/owrx/source/sdrplay.py +++ b/owrx/source/sdrplay.py @@ -1,6 +1,7 @@ from owrx.source.soapy import SoapyConnectorSource, SoapyConnectorDeviceDescription -from owrx.form import Input +from owrx.form import Input, CheckboxInput, DropdownInput, DropdownEnum from owrx.form.device import BiasTeeInput +from owrx.form.converter import OptionalConverter, EnumConverter from typing import List @@ -22,9 +23,44 @@ class SdrplaySource(SoapyConnectorSource): 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 getGainStages(self): return ["RFGR", "IFGR"] def getInputs(self) -> List[Input]: - return self.mergeInputs(super().getInputs(), [BiasTeeInput()]) + return self.mergeInputs( + super().getInputs(), + [ + BiasTeeInput(), + CheckboxInput( + "rf_notch", + "", + checkboxText="Enable RF notch filter", + converter=OptionalConverter(defaultFormValue=True), + ), + CheckboxInput( + "dab_notch", + "", + checkboxText="Enable DAB notch filter", + converter=OptionalConverter(defaultFormValue=True), + ), + DropdownInput( + "if_mode", + "IF Mode", + IfModeOptions, + converter=OptionalConverter( + EnumConverter(IfModeOptions), defaultFormValue=IfModeOptions.IFMODE_ZERO_IF.name + ), + ), + ], + ) From 361ed55b9332b378f644474ac076c76bca1a15d1 Mon Sep 17 00:00:00 2001 From: Jakob Ketterl Date: Sat, 20 Feb 2021 19:20:31 +0100 Subject: [PATCH 213/577] add more device-specific options --- owrx/form/device.py | 9 ++++++++- owrx/source/airspy.py | 18 ++++++++++++++++-- owrx/source/rtl_tcp.py | 6 +++++- owrx/source/runds.py | 31 ++++++++++++++++++++++++++++++- owrx/source/soapy_remote.py | 14 +++++++++++++- 5 files changed, 72 insertions(+), 6 deletions(-) diff --git a/owrx/form/device.py b/owrx/form/device.py index 0145eb0..627e38b 100644 --- a/owrx/form/device.py +++ b/owrx/form/device.py @@ -1,4 +1,4 @@ -from owrx.form import Input, CheckboxInput, DropdownInput, DropdownEnum +from owrx.form import Input, CheckboxInput, DropdownInput, DropdownEnum, TextInput from owrx.form.converter import OptionalConverter, EnumConverter from owrx.soapy import SoapySettings @@ -155,3 +155,10 @@ class DirectSamplingInput(DropdownInput): defaultFormValue=DirectSamplingOptions.DIRECT_SAMPLING_OFF.name, ), ) + + +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" + ) diff --git a/owrx/source/airspy.py b/owrx/source/airspy.py index 34f6b3c..773d8b2 100644 --- a/owrx/source/airspy.py +++ b/owrx/source/airspy.py @@ -1,6 +1,7 @@ from owrx.source.soapy import SoapyConnectorSource, SoapyConnectorDeviceDescription -from owrx.form import Input +from owrx.form import Input, CheckboxInput from owrx.form.device import BiasTeeInput +from owrx.form.converter import OptionalConverter from typing import List @@ -21,4 +22,17 @@ class AirspySource(SoapyConnectorSource): class AirspyDeviceDescription(SoapyConnectorDeviceDescription): def getInputs(self) -> List[Input]: - return self.mergeInputs(super().getInputs(), [BiasTeeInput()]) + return self.mergeInputs( + super().getInputs(), + [ + BiasTeeInput(), + CheckboxInput( + "bitpack", + "", + checkboxText="Enable bit-packing", + infotext="Packs two 12-bit samples into 3 bytes." + + " Lowers USB bandwidth consumption, increases CPU load", + converter=OptionalConverter(defaultFormValue=False), + ), + ], + ) diff --git a/owrx/source/rtl_tcp.py b/owrx/source/rtl_tcp.py index 8e606d5..ef80c38 100644 --- a/owrx/source/rtl_tcp.py +++ b/owrx/source/rtl_tcp.py @@ -1,5 +1,8 @@ from owrx.source.connector import ConnectorSource, ConnectorDeviceDescription from owrx.command import Flag, Option, Argument +from owrx.form import Input +from owrx.form.device import RemoteInput +from typing import List class RtlTcpSource(ConnectorSource): @@ -19,4 +22,5 @@ class RtlTcpSource(ConnectorSource): class RtlTcpDeviceDescription(ConnectorDeviceDescription): - pass + def getInputs(self) -> List[Input]: + return self.mergeInputs(super().getInputs(), [RemoteInput()]) diff --git a/owrx/source/runds.py b/owrx/source/runds.py index ba7bd99..d820dfd 100644 --- a/owrx/source/runds.py +++ b/owrx/source/runds.py @@ -1,5 +1,9 @@ from owrx.source.connector import ConnectorSource, ConnectorDeviceDescription from owrx.command import Argument, Flag, Option +from owrx.form import Input, DropdownInput, DropdownEnum, CheckboxInput +from owrx.form.device import RemoteInput +from owrx.form.converter import OptionalConverter +from typing import List class RundsSource(ConnectorSource): @@ -18,5 +22,30 @@ class RundsSource(ConnectorSource): ) +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): - pass + def getInputs(self) -> List[Input]: + return self.mergeInputs( + super().getInputs(), + [ + RemoteInput(), + DropdownInput("protocol", "Protocol", ProtocolOptions), + CheckboxInput( + "long", "", "Use 32-bit sample size (LONG)", converter=OptionalConverter(defaultFormValue=False) + ), + ], + ) diff --git a/owrx/source/soapy_remote.py b/owrx/source/soapy_remote.py index 5f49653..080ee7d 100644 --- a/owrx/source/soapy_remote.py +++ b/owrx/source/soapy_remote.py @@ -1,4 +1,7 @@ from owrx.source.soapy import SoapyConnectorSource, SoapyConnectorDeviceDescription +from owrx.form import Input, TextInput +from owrx.form.device import RemoteInput +from typing import List class SoapyRemoteSource(SoapyConnectorSource): @@ -18,4 +21,13 @@ class SoapyRemoteSource(SoapyConnectorSource): class SoapyRemoteDeviceDescription(SoapyConnectorDeviceDescription): - pass + def getInputs(self) -> List[Input]: + return self.mergeInputs( + super().getInputs(), + [ + RemoteInput(), + TextInput( + "remote_driver", "Remote driver", infotext="SoapySDR driver to be used on the remote SoapySDRServer" + ), + ], + ) From dd5ab32b471f3f341ba6256b5780c6b3ab83d323 Mon Sep 17 00:00:00 2001 From: Jakob Ketterl Date: Sat, 20 Feb 2021 19:43:04 +0100 Subject: [PATCH 214/577] set always-on default to false --- owrx/source/__init__.py | 1 + 1 file changed, 1 insertion(+) diff --git a/owrx/source/__init__.py b/owrx/source/__init__.py index 2ea6eca..688f0de 100644 --- a/owrx/source/__init__.py +++ b/owrx/source/__init__.py @@ -388,6 +388,7 @@ class SdrDeviceDescription(object): "", checkboxText="Keep device running at all times", infotext="Prevents shutdown of the device when idle. Useful for devices with unreliable startup.", + converter=OptionalConverter(defaultFormValue=False), ), CheckboxInput( "services", From c2e8ac516c6ce1aba93d93684966217e4ace734f Mon Sep 17 00:00:00 2001 From: Jakob Ketterl Date: Sat, 20 Feb 2021 22:54:07 +0100 Subject: [PATCH 215/577] introduce enums for state management --- owrx/connection.py | 14 ++++---- owrx/dsp.py | 16 ++++----- owrx/fft.py | 14 ++++---- owrx/service/__init__.py | 19 +++++----- owrx/service/schedule.py | 20 +++++------ owrx/source/__init__.py | 78 ++++++++++++++++++++++------------------ 6 files changed, 86 insertions(+), 75 deletions(-) diff --git a/owrx/connection.py b/owrx/connection.py index bf0e38d..03c3350 100644 --- a/owrx/connection.py +++ b/owrx/connection.py @@ -2,7 +2,7 @@ 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, SdrBusyState, SdrClientClass, SdrSourceEventClient from owrx.client import ClientRegistry, TooManyClientsException from owrx.feature import FeatureDetector from owrx.version import openwebrx_version @@ -216,10 +216,10 @@ class OpenWebRxReceiverClient(OpenWebRxClient, SdrSourceEventClient): 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: + elif state is SdrSourceState.FAILED: self.handleSdrFailed() def handleSdrFailed(self): @@ -227,11 +227,11 @@ class OpenWebRxReceiverClient(OpenWebRxClient, SdrSourceEventClient): self.write_log_message('SDR device "{0}" has failed, selecting new device'.format(self.sdr.getName())) self.setSdr() - def onBusyStateChange(self, state): + def onBusyStateChange(self, state: SdrBusyState): pass - def getClientClass(self): - return SdrSource.CLIENT_USER + def getClientClass(self) -> SdrClientClass: + return SdrClientClass.USER def __sendProfiles(self): profiles = [ diff --git a/owrx/dsp.py b/owrx/dsp.py index 9bceed4..0f34998 100644 --- a/owrx/dsp.py +++ b/owrx/dsp.py @@ -3,7 +3,7 @@ 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.source import SdrSourceEventClient, SdrSourceState, SdrBusyState, SdrClientClass from owrx.property import PropertyStack, PropertyLayer, PropertyValidator from owrx.property.validators import OrValidator, RegexValidator, BoolValidator from owrx.modes import Modes @@ -198,21 +198,21 @@ class DspManager(csdr.output, SdrSourceEventClient): def setProperty(self, 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: + elif state is SdrSourceState.FAILED: logger.debug("received STATE_FAILED, shutting down DspSource") self.dsp.stop() - def onBusyStateChange(self, state): + def onBusyStateChange(self, state: SdrBusyState): pass diff --git a/owrx/fft.py b/owrx/fft.py index 53bdc5b..f210313 100644 --- a/owrx/fft.py +++ b/owrx/fft.py @@ -2,7 +2,7 @@ from owrx.config.core import CoreConfig from owrx.config import Config from csdr import csdr import threading -from owrx.source import SdrSource, SdrSourceEventClient +from owrx.source import SdrSourceEventClient, SdrSourceState, SdrBusyState, SdrClientClass from owrx.property import PropertyStack import logging @@ -73,14 +73,14 @@ class SpectrumThread(csdr.output, SdrSourceEventClient): c.cancel() self.subscriptions = [] - 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 in [SdrSourceState.STOPPING, SdrSourceState.FAILED]: self.dsp.stop() - elif state == SdrSource.STATE_RUNNING: + elif state is SdrSourceState.RUNNING: self.dsp.start() - def onBusyStateChange(self, state): + def onBusyStateChange(self, state: SdrBusyState): pass diff --git a/owrx/service/__init__.py b/owrx/service/__init__.py index 3baa015..ecd4b6e 100644 --- a/owrx/service/__init__.py +++ b/owrx/service/__init__.py @@ -1,5 +1,5 @@ import threading -from owrx.source import SdrSource, SdrSourceEventClient +from owrx.source import SdrSourceEventClient, SdrSourceState, SdrBusyState, SdrClientClass from owrx.sdr import SdrService from owrx.bands import Bandplan from csdr.csdr import dsp, output @@ -75,22 +75,22 @@ class ServiceHandler(SdrSourceEventClient): if "schedule" in props or "scheduler" in props: self.scheduler = ServiceScheduler(self.source) - def getClientClass(self): - return SdrSource.CLIENT_INACTIVE + def getClientClass(self) -> SdrClientClass: + return SdrClientClass.INACTIVE - def onStateChange(self, state): - if state == SdrSource.STATE_RUNNING: + def onStateChange(self, state: SdrSourceState): + if state is SdrSourceState.RUNNING: self.scheduleServiceStartup() - elif state == SdrSource.STATE_STOPPING: + elif state is SdrSourceState.STOPPING: logger.debug("sdr source becoming unavailable; stopping services.") self.stopServices() - elif state == SdrSource.STATE_FAILED: + elif state is SdrSourceState.FAILED: logger.debug("sdr source failed; stopping services.") self.stopServices() if self.scheduler: self.scheduler.shutdown() - def onBusyStateChange(self, state): + def onBusyStateChange(self, state: SdrBusyState): pass def isSupported(self, mode): @@ -164,7 +164,8 @@ class ServiceHandler(SdrSourceEventClient): 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 + # resampler goes in after the services since it must not be shutdown as long as the services are + # still running self.services.append(resampler) def get_min_max(self, group): diff --git a/owrx/service/schedule.py b/owrx/service/schedule.py index c024d33..6f9b5ad 100644 --- a/owrx/service/schedule.py +++ b/owrx/service/schedule.py @@ -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 @@ -220,7 +220,7 @@ class ServiceScheduler(SdrSourceEventClient): self.source.removeClient(self) def scheduleSelection(self, time=None): - if self.source.getState() == SdrSource.STATE_FAILED: + if self.source.getState() is SdrSourceState.FAILED: return seconds = 10 if time is not None: @@ -234,24 +234,24 @@ class ServiceScheduler(SdrSourceEventClient): if self.selectionTimer: self.selectionTimer.cancel() - def getClientClass(self): - return SdrSource.CLIENT_BACKGROUND + def getClientClass(self) -> SdrClientClass: + 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: + elif state is SdrSourceState.FAILED: self.cancelTimer() - def onBusyStateChange(self, state): - if state == SdrSource.BUSYSTATE_IDLE: + def onBusyStateChange(self, state: SdrBusyState): + if state is SdrBusyState.IDLE: self.scheduleSelection() def onFrequencyChange(self, changes): self.scheduleSelection() 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") diff --git a/owrx/source/__init__.py b/owrx/source/__init__.py index 688f0de..16d9a3d 100644 --- a/owrx/source/__init__.py +++ b/owrx/source/__init__.py @@ -15,40 +15,50 @@ from owrx.form.converter import IntConverter, OptionalConverter from owrx.form.device import GainInput from owrx.controllers.settings import Section from typing import List +from enum import Enum, auto import logging logger = logging.getLogger(__name__) +class SdrSourceState(Enum): + STOPPED = "Stopped" + STARTING = "Starting" + RUNNING = "Running" + STOPPING = "Stopping" + TUNING = "Tuning" + FAILED = "Failed" + + def __str__(self): + return self.value + + +class SdrBusyState(Enum): + IDLE = auto() + BUSY = auto() + + +class SdrClientClass(Enum): + INACTIVE = auto() + BACKGROUND = auto() + USER = auto() + + class SdrSourceEventClient(ABC): @abstractmethod - def onStateChange(self, state): + 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 getClientClass(self) -> SdrClientClass: + return SdrClientClass.INACTIVE 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 @@ -78,8 +88,8 @@ class SdrSource(ABC): self.process = None self.modificationLock = threading.Lock() self.failed = False - self.state = SdrSource.STATE_STOPPED - self.busyState = SdrSource.BUSYSTATE_IDLE + self.state = SdrSourceState.STOPPED + self.busyState = SdrBusyState.IDLE self.validateProfiles() @@ -218,11 +228,11 @@ class SdrSource(ABC): rc = self.process.wait() logger.debug("shut down with RC={0}".format(rc)) self.monitor = None - if self.getState() == SdrSource.STATE_RUNNING: + if self.getState() is SdrSourceState.RUNNING: self.failed = True - self.setState(SdrSource.STATE_FAILED) + self.setState(SdrSourceState.FAILED) else: - self.setState(SdrSource.STATE_STOPPED) + self.setState(SdrSourceState.STOPPED) self.monitor = threading.Thread(target=wait_for_process_to_end, name="source_monitor") self.monitor.start() @@ -250,7 +260,7 @@ class SdrSource(ABC): logger.exception("Exception during postStart()") self.failed = True - self.setState(SdrSource.STATE_FAILED if self.failed else SdrSource.STATE_RUNNING) + self.setState(SdrSourceState.FAILED if self.failed else SdrSourceState.RUNNING) def preStart(self): """ @@ -271,7 +281,7 @@ class SdrSource(ABC): return self.failed def stop(self): - self.setState(SdrSource.STATE_STOPPING) + self.setState(SdrSourceState.STOPPING) with self.modificationLock: @@ -291,11 +301,11 @@ class SdrSource(ABC): def addClient(self, c: SdrSourceEventClient): 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: @@ -303,14 +313,14 @@ class SdrSource(ABC): except ValueError: pass - hasUsers = self.hasClients(SdrSource.CLIENT_USER) - self.setBusyState(SdrSource.BUSYSTATE_BUSY if hasUsers else SdrSource.BUSYSTATE_IDLE) + 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() @@ -341,17 +351,17 @@ 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: c.onStateChange(state) - def setBusyState(self, state): + def setBusyState(self, state: SdrBusyState): if state == self.busyState: return self.busyState = state From 44250f9719b87423f67537872dbcc21362cf1c26 Mon Sep 17 00:00:00 2001 From: Jakob Ketterl Date: Sat, 20 Feb 2021 22:57:17 +0100 Subject: [PATCH 216/577] add some device details on the list page --- owrx/controllers/settings/sdr.py | 27 ++++++++++++++------------- 1 file changed, 14 insertions(+), 13 deletions(-) diff --git a/owrx/controllers/settings/sdr.py b/owrx/controllers/settings/sdr.py index 0f1c23c..6ce60c7 100644 --- a/owrx/controllers/settings/sdr.py +++ b/owrx/controllers/settings/sdr.py @@ -4,10 +4,7 @@ from owrx.controllers.settings import SettingsFormController from owrx.source import SdrDeviceDescription, SdrDeviceDescriptionMissing from owrx.config import Config from urllib.parse import quote, unquote - -import logging - -logger = logging.getLogger(__name__) +from owrx.sdr import SdrService class SdrDeviceListController(AuthorizationMixin, WebpageController): @@ -25,18 +22,22 @@ class SdrDeviceListController(AuthorizationMixin, WebpageController): def render_devices(self): def render_device(device_id, config): + # TODO: this only returns non-failed sources... + source = SdrService.getSource(device_id) + return """ -
  • - -

    {device_name}

    -
    -
    - some more device info here -
    -
  • - """.format( +
  • + +

    {device_name}

    +
    +
    State: {state}
    +
    {num_profiles} profile(s)
    +
  • + """.format( device_name=config["name"], device_link="{}/{}".format(self.request.path, quote(device_id)), + state="Unknown" if source is None else source.getState(), + num_profiles=len(config["profiles"]), ) return """ From 7f3d421b254e63f8b67bbcf3ce57600352f989ea Mon Sep 17 00:00:00 2001 From: Jakob Ketterl Date: Sat, 20 Feb 2021 23:45:06 +0100 Subject: [PATCH 217/577] introduce profile list --- htdocs/css/admin.css | 3 ++- owrx/controllers/settings/sdr.py | 23 +++++++++++++++++++++++ owrx/http.py | 16 ++++++++-------- 3 files changed, 33 insertions(+), 9 deletions(-) diff --git a/htdocs/css/admin.css b/htdocs/css/admin.css index 5c94caa..038c25c 100644 --- a/htdocs/css/admin.css +++ b/htdocs/css/admin.css @@ -82,6 +82,7 @@ table.bookmarks .frequency { padding-left: 0; } -.sdr-device-list .list-group-item { +.sdr-device-list .list-group-item, +.sdr-profile-list .list-group-item { background: initial; } \ No newline at end of file diff --git a/owrx/controllers/settings/sdr.py b/owrx/controllers/settings/sdr.py index 6ce60c7..926001e 100644 --- a/owrx/controllers/settings/sdr.py +++ b/owrx/controllers/settings/sdr.py @@ -95,6 +95,29 @@ class SdrDeviceController(SettingsFormController): variables["assets_prefix"] = "../../" return variables + def render_sections(self): + return super().render_sections() + self.render_profile_list(self.device["profiles"]) + + def render_profile_list(self, profiles): + def render_profile(profile_id, profile): + return """ +
  • + {profile_name} +
  • + """.format( + profile_name=profile["name"], + profile_link="{}/{}".format(self.request.path, quote(profile_id)), + ) + + return """ +

    Profiles

    +
      + {profiles} +
    + """.format( + profiles="".join(render_profile(p_id, p) for p_id, p in profiles.items()) + ) + def indexAction(self): if self.device is None: self.send_response("device not found", code=404) diff --git a/owrx/http.py b/owrx/http.py index 7fd53b0..e3db2e6 100644 --- a/owrx/http.py +++ b/owrx/http.py @@ -100,11 +100,11 @@ class Router(object): self.routes = [ StaticRoute("/", IndexController), 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), @@ -116,12 +116,12 @@ class Router(object): "/settings/general", GeneralSettingsController, method="POST", options={"action": "processFormData"} ), StaticRoute("/settings/sdr", SdrDeviceListController), - RegexRoute("/settings/sdr/(.+)", SdrDeviceController), - RegexRoute("/settings/sdr/(.+)", SdrDeviceController, method="POST", options={"action": "processFormData"}), + RegexRoute("^/settings/sdr/([^/]+)$", SdrDeviceController), + RegexRoute("^/settings/sdr/([^/]+)$", SdrDeviceController, method="POST", options={"action": "processFormData"}), 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"}), + 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"} From bd31fa51498db379099aeb3dea10d2356bb87af0 Mon Sep 17 00:00:00 2001 From: Jakob Ketterl Date: Sun, 21 Feb 2021 18:11:08 +0100 Subject: [PATCH 218/577] add the ability to disable devices --- owrx/sdr.py | 7 ++++++- owrx/source/__init__.py | 29 ++++++++++++++++------------- 2 files changed, 22 insertions(+), 14 deletions(-) diff --git a/owrx/sdr.py b/owrx/sdr.py index 87cc9bd..c926a3d 100644 --- a/owrx/sdr.py +++ b/owrx/sdr.py @@ -1,6 +1,7 @@ from owrx.config import Config from owrx.property import PropertyLayer from owrx.feature import FeatureDetector, UnknownFeatureException +from owrx.source import SdrSourceState import logging @@ -78,4 +79,8 @@ class SdrService(object): 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()} + return { + key: s + for key, s in SdrService.sources.items() + if s.getState() not in [SdrSourceState.FAILED, SdrSourceState.DISABLED] + } diff --git a/owrx/source/__init__.py b/owrx/source/__init__.py index 16d9a3d..423a8b7 100644 --- a/owrx/source/__init__.py +++ b/owrx/source/__init__.py @@ -29,6 +29,7 @@ class SdrSourceState(Enum): STOPPING = "Stopping" TUNING = "Tuning" FAILED = "Failed" + DISABLED = "Disabled" def __str__(self): return self.value @@ -87,13 +88,12 @@ class SdrSource(ABC): self.spectrumLock = threading.Lock() self.process = None self.modificationLock = threading.Lock() - self.failed = False - self.state = SdrSourceState.STOPPED + self.state = SdrSourceState.STOPPED if "enabled" not in props or props["enabled"] else SdrSourceState.DISABLED self.busyState = SdrBusyState.IDLE self.validateProfiles() - if self.isAlwaysOn(): + if self.isAlwaysOn() and self.state is not SdrSourceState.DISABLED: self.start() def validateProfiles(self): @@ -198,7 +198,7 @@ class SdrSource(ABC): if self.monitor: return - if self.isFailed(): + if self.getState() is SdrSourceState.FAILED: return try: @@ -223,13 +223,15 @@ 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.monitor = None if self.getState() is SdrSourceState.RUNNING: - self.failed = True + failed = True self.setState(SdrSourceState.FAILED) else: self.setState(SdrSourceState.STOPPED) @@ -238,7 +240,7 @@ class SdrSource(ABC): 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 @@ -252,15 +254,15 @@ 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(SdrSourceState.FAILED if self.failed else SdrSourceState.RUNNING) + self.setState(SdrSourceState.FAILED if failed else SdrSourceState.RUNNING) def preStart(self): """ @@ -277,11 +279,11 @@ class SdrSource(ABC): def isAvailable(self): return self.monitor is not None - def isFailed(self): - return self.failed - def stop(self): - self.setState(SdrSourceState.STOPPING) + # don't overwrite failed flag + # TODO introduce a better solution? + if self.getState() is not SdrSourceState.FAILED: + self.setState(SdrSourceState.STOPPING) with self.modificationLock: @@ -387,6 +389,7 @@ class SdrDeviceDescription(object): def getInputs(self) -> List[Input]: return [ TextInput("name", "Device name"), + CheckboxInput("enabled", "", "Enable this device", converter=OptionalConverter(defaultFormValue=True)), NumberInput( "ppm", "Frequency correction", From 683a711b49f745a8830e6e29efa375242e70e631 Mon Sep 17 00:00:00 2001 From: Jakob Ketterl Date: Sun, 21 Feb 2021 18:11:28 +0100 Subject: [PATCH 219/577] fix bias_tee for hackrf --- owrx/source/hackrf.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/owrx/source/hackrf.py b/owrx/source/hackrf.py index a4a17cc..f597168 100644 --- a/owrx/source/hackrf.py +++ b/owrx/source/hackrf.py @@ -16,4 +16,4 @@ class HackrfSource(SoapyConnectorSource): class HackrfDeviceDescription(SoapyConnectorDeviceDescription): def getInputs(self) -> List[Input]: - return self.mergeInputs(super().getInputs(), [BiasTeeInput]) + return self.mergeInputs(super().getInputs(), [BiasTeeInput()]) From 770fd749cd5d4bc888bae8b762a4213417e94f0b Mon Sep 17 00:00:00 2001 From: Jakob Ketterl Date: Mon, 22 Feb 2021 00:35:47 +0100 Subject: [PATCH 220/577] introduce the basic concept of optional keys --- owrx/controllers/settings/sdr.py | 2 +- owrx/source/__init__.py | 19 ++++++++---- owrx/source/airspy.py | 32 +++++++++++--------- owrx/source/connector.py | 40 ++++++++++++------------- owrx/source/hackrf.py | 9 +++++- owrx/source/rtl_sdr.py | 26 ++++++++--------- owrx/source/rtl_sdr_soapy.py | 5 +++- owrx/source/rtl_tcp.py | 5 +++- owrx/source/runds.py | 20 ++++++------- owrx/source/sdrplay.py | 50 ++++++++++++++++---------------- owrx/source/soapy.py | 42 +++++++++++++-------------- owrx/source/soapy_remote.py | 18 ++++++------ 12 files changed, 146 insertions(+), 122 deletions(-) diff --git a/owrx/controllers/settings/sdr.py b/owrx/controllers/settings/sdr.py index 926001e..90bb131 100644 --- a/owrx/controllers/settings/sdr.py +++ b/owrx/controllers/settings/sdr.py @@ -71,7 +71,7 @@ class SdrDeviceController(SettingsFormController): def getSections(self): try: description = SdrDeviceDescription.getByType(self.device["type"]) - return [description.getSection()] + return [description.getSection(self.device)] except SdrDeviceDescriptionMissing: # TODO provide a generic interface that allows to switch the type return [] diff --git a/owrx/source/__init__.py b/owrx/source/__init__.py index 423a8b7..ecea90d 100644 --- a/owrx/source/__init__.py +++ b/owrx/source/__init__.py @@ -376,6 +376,9 @@ class SdrDeviceDescriptionMissing(Exception): class SdrDeviceDescription(object): + def __init__(self): + self.indexedInputs = {input.id: input for input in self.getInputs()} + @staticmethod def getByType(sdr_type: str) -> "SdrDeviceDescription": try: @@ -423,10 +426,14 @@ class SdrDeviceDescription(object): # TODO `schedule` ] - def mergeInputs(self, *args): - # build a dictionary indexed by the input id to make sure every id only exists once - inputs = {input.id: input for input_list in args for input in input_list} - return inputs.values() + def getMandatoryKeys(self): + return ["name", "enabled"] - def getSection(self): - return Section("Device settings", *self.getInputs()) + def getOptionalKeys(self): + return ["ppm", "always-on", "services", "rf_gain", "lfo_offset", "waterfall_min_level", "waterfall_max_level"] + + def getSection(self, data): + visible_keys = set(self.getMandatoryKeys() + [k for k in self.getOptionalKeys() if k in data]) + inputs = [input for k, input in self.indexedInputs.items() if k in visible_keys] + # TODO: render remaining keys in optional area + return Section("Device settings", *inputs) diff --git a/owrx/source/airspy.py b/owrx/source/airspy.py index 773d8b2..70005be 100644 --- a/owrx/source/airspy.py +++ b/owrx/source/airspy.py @@ -22,17 +22,21 @@ class AirspySource(SoapyConnectorSource): class AirspyDeviceDescription(SoapyConnectorDeviceDescription): def getInputs(self) -> List[Input]: - return self.mergeInputs( - super().getInputs(), - [ - BiasTeeInput(), - CheckboxInput( - "bitpack", - "", - checkboxText="Enable bit-packing", - infotext="Packs two 12-bit samples into 3 bytes." - + " Lowers USB bandwidth consumption, increases CPU load", - converter=OptionalConverter(defaultFormValue=False), - ), - ], - ) + return super().getInputs() + [ + BiasTeeInput(), + CheckboxInput( + "bitpack", + "", + checkboxText="Enable bit-packing", + infotext="Packs two 12-bit samples into 3 bytes." + + " Lowers USB bandwidth consumption, increases CPU load", + converter=OptionalConverter(defaultFormValue=False), + ), + ] + + def getOptionalKeys(self): + return super().getOptionalKeys() + ["bias_tee", "bitpack"] + + # TODO: find actual gain stages for airspay + # def getGainStages(self): + # return None diff --git a/owrx/source/connector.py b/owrx/source/connector.py index 40fd795..10d6bcb 100644 --- a/owrx/source/connector.py +++ b/owrx/source/connector.py @@ -76,23 +76,23 @@ class ConnectorSource(SdrSource): class ConnectorDeviceDescription(SdrDeviceDescription): def getInputs(self) -> List[Input]: - return self.mergeInputs( - super().getInputs(), - [ - NumberInput( - "rtltcp_compat", - "Port for rtl_tcp compatible data", - infotext="Activate an rtl_tcp compatible interface on the port number specified.
    " - + "Note: Port is only available on the local machine, not on the network.
    " - + "Note: IQ data may be degraded by the downsampling process to 8 bits.", - converter=OptionalConverter(IntConverter()), - ), - CheckboxInput( - "iqswap", - "", - checkboxText="Swap I and Q channels", - infotext="Swapping inverts the spectrum, so this is useful in combination with an inverting mixer", - converter=OptionalConverter(defaultFormValue=False), - ), - ], - ) + return super().getInputs() + [ + NumberInput( + "rtltcp_compat", + "Port for rtl_tcp compatible data", + infotext="Activate an rtl_tcp compatible interface on the port number specified.
    " + + "Note: Port is only available on the local machine, not on the network.
    " + + "Note: IQ data may be degraded by the downsampling process to 8 bits.", + converter=OptionalConverter(IntConverter()), + ), + CheckboxInput( + "iqswap", + "", + checkboxText="Swap I and Q channels", + infotext="Swapping inverts the spectrum, so this is useful in combination with an inverting mixer", + converter=OptionalConverter(defaultFormValue=False), + ), + ] + + def getOptionalKeys(self): + return super().getOptionalKeys() + ["rtltcp_compat", "iqswap"] diff --git a/owrx/source/hackrf.py b/owrx/source/hackrf.py index f597168..978d152 100644 --- a/owrx/source/hackrf.py +++ b/owrx/source/hackrf.py @@ -16,4 +16,11 @@ class HackrfSource(SoapyConnectorSource): class HackrfDeviceDescription(SoapyConnectorDeviceDescription): def getInputs(self) -> List[Input]: - return self.mergeInputs(super().getInputs(), [BiasTeeInput()]) + return super().getInputs() + [BiasTeeInput()] + + def getOptionalKeys(self): + return super().getOptionalKeys() + ["bias_tee"] + + # TODO: find actual gain stages for hackrf + # def getGainStages(self): + # return None diff --git a/owrx/source/rtl_sdr.py b/owrx/source/rtl_sdr.py index 91b97b5..a01e41d 100644 --- a/owrx/source/rtl_sdr.py +++ b/owrx/source/rtl_sdr.py @@ -18,16 +18,16 @@ class RtlSdrSource(ConnectorSource): class RtlSdrDeviceDescription(ConnectorDeviceDescription): def getInputs(self) -> List[Input]: - return self.mergeInputs( - super().getInputs(), - [ - TextInput( - "device", - "Device identifier", - infotext="Device serial number or index", - converter=OptionalConverter(), - ), - BiasTeeInput(), - DirectSamplingInput() - ], - ) + return super().getInputs() + [ + TextInput( + "device", + "Device identifier", + infotext="Device serial number or index", + converter=OptionalConverter(), + ), + BiasTeeInput(), + DirectSamplingInput() + ] + + def getOptionalKeys(self): + return super().getOptionalKeys() + ["device", "bias_tee", "direct_sampling"] diff --git a/owrx/source/rtl_sdr_soapy.py b/owrx/source/rtl_sdr_soapy.py index fec40bd..0257810 100644 --- a/owrx/source/rtl_sdr_soapy.py +++ b/owrx/source/rtl_sdr_soapy.py @@ -16,4 +16,7 @@ class RtlSdrSoapySource(SoapyConnectorSource): class RtlSdrSoapyDeviceDescription(SoapyConnectorDeviceDescription): def getInputs(self) -> List[Input]: - return self.mergeInputs(super().getInputs(), [BiasTeeInput(), DirectSamplingInput()]) + return super().getInputs() + [BiasTeeInput(), DirectSamplingInput()] + + def getOptionalKeys(self): + return super().getOptionalKeys() + ["bias_tee", "direct_sampling"] diff --git a/owrx/source/rtl_tcp.py b/owrx/source/rtl_tcp.py index ef80c38..5bf2712 100644 --- a/owrx/source/rtl_tcp.py +++ b/owrx/source/rtl_tcp.py @@ -23,4 +23,7 @@ class RtlTcpSource(ConnectorSource): class RtlTcpDeviceDescription(ConnectorDeviceDescription): def getInputs(self) -> List[Input]: - return self.mergeInputs(super().getInputs(), [RemoteInput()]) + return super().getInputs() + [RemoteInput()] + + def getMandatoryKeys(self): + return super().getMandatoryKeys() + ["device"] diff --git a/owrx/source/runds.py b/owrx/source/runds.py index d820dfd..9b33da5 100644 --- a/owrx/source/runds.py +++ b/owrx/source/runds.py @@ -39,13 +39,13 @@ class ProtocolOptions(DropdownEnum): class RundsDeviceDescription(ConnectorDeviceDescription): def getInputs(self) -> List[Input]: - return self.mergeInputs( - super().getInputs(), - [ - RemoteInput(), - DropdownInput("protocol", "Protocol", ProtocolOptions), - CheckboxInput( - "long", "", "Use 32-bit sample size (LONG)", converter=OptionalConverter(defaultFormValue=False) - ), - ], - ) + return super().getInputs() + [ + RemoteInput(), + DropdownInput("protocol", "Protocol", ProtocolOptions), + CheckboxInput( + "long", "", "Use 32-bit sample size (LONG)", converter=OptionalConverter(defaultFormValue=False) + ), + ] + + def getMandatoryKeys(self): + return super().getMandatoryKeys() + ["device"] diff --git a/owrx/source/sdrplay.py b/owrx/source/sdrplay.py index 5cf5587..63b0cef 100644 --- a/owrx/source/sdrplay.py +++ b/owrx/source/sdrplay.py @@ -38,29 +38,29 @@ class SdrplayDeviceDescription(SoapyConnectorDeviceDescription): return ["RFGR", "IFGR"] def getInputs(self) -> List[Input]: - return self.mergeInputs( - super().getInputs(), - [ - BiasTeeInput(), - CheckboxInput( - "rf_notch", - "", - checkboxText="Enable RF notch filter", - converter=OptionalConverter(defaultFormValue=True), + return super().getInputs() + [ + BiasTeeInput(), + CheckboxInput( + "rf_notch", + "", + checkboxText="Enable RF notch filter", + converter=OptionalConverter(defaultFormValue=True), + ), + CheckboxInput( + "dab_notch", + "", + checkboxText="Enable DAB notch filter", + converter=OptionalConverter(defaultFormValue=True), + ), + DropdownInput( + "if_mode", + "IF Mode", + IfModeOptions, + converter=OptionalConverter( + EnumConverter(IfModeOptions), defaultFormValue=IfModeOptions.IFMODE_ZERO_IF.name ), - CheckboxInput( - "dab_notch", - "", - checkboxText="Enable DAB notch filter", - converter=OptionalConverter(defaultFormValue=True), - ), - DropdownInput( - "if_mode", - "IF Mode", - IfModeOptions, - converter=OptionalConverter( - EnumConverter(IfModeOptions), defaultFormValue=IfModeOptions.IFMODE_ZERO_IF.name - ), - ), - ], - ) + ), + ] + + def getOptionalKeys(self): + return super().getOptionalKeys() + ["bias_tee", "rf_notch", "dab_notch", "if_mode"] diff --git a/owrx/source/soapy.py b/owrx/source/soapy.py index 8e4778b..0db1c45 100644 --- a/owrx/source/soapy.py +++ b/owrx/source/soapy.py @@ -84,27 +84,27 @@ class SoapyConnectorSource(ConnectorSource, metaclass=ABCMeta): class SoapyConnectorDeviceDescription(ConnectorDeviceDescription): def getInputs(self) -> List[Input]: - return self.mergeInputs( - super().getInputs(), - [ - TextInput( - "device", - "Device Identifier", - infotext='SoapySDR device identifier string (example: "serial=123456789")', - converter=OptionalConverter() - ), - GainInput( - "rf_gain", - "Device Gain", - gain_stages=self.getGainStages(), - ), - TextInput( - "antenna", - "Antenna", - converter=OptionalConverter(), - ), - ], - ) + return super().getInputs() + [ + TextInput( + "device", + "Device Identifier", + infotext='SoapySDR device identifier string (example: "serial=123456789")', + converter=OptionalConverter() + ), + GainInput( + "rf_gain", + "Device Gain", + gain_stages=self.getGainStages(), + ), + TextInput( + "antenna", + "Antenna", + converter=OptionalConverter(), + ), + ] + + def getOptionalKeys(self): + return super().getOptionalKeys() + ["device", "rf_gain", "antenna"] def getGainStages(self): return None diff --git a/owrx/source/soapy_remote.py b/owrx/source/soapy_remote.py index 080ee7d..19be5a5 100644 --- a/owrx/source/soapy_remote.py +++ b/owrx/source/soapy_remote.py @@ -22,12 +22,12 @@ class SoapyRemoteSource(SoapyConnectorSource): class SoapyRemoteDeviceDescription(SoapyConnectorDeviceDescription): def getInputs(self) -> List[Input]: - return self.mergeInputs( - super().getInputs(), - [ - RemoteInput(), - TextInput( - "remote_driver", "Remote driver", infotext="SoapySDR driver to be used on the remote SoapySDRServer" - ), - ], - ) + return super().getInputs() + [ + RemoteInput(), + TextInput( + "remote_driver", "Remote driver", infotext="SoapySDR driver to be used on the remote SoapySDRServer" + ), + ] + + def getOptionalKeys(self): + return super().getOptionalKeys() + ["remote_driver"] From 9beb3b916857f89a7b83c281bedda1d6a872111e Mon Sep 17 00:00:00 2001 From: Jakob Ketterl Date: Mon, 22 Feb 2021 00:57:02 +0100 Subject: [PATCH 221/577] remove the label attribute from the checkboxes --- owrx/controllers/settings/backgrounddecoding.py | 3 +-- owrx/controllers/settings/decoding.py | 5 ++--- owrx/controllers/settings/reporting.py | 12 ++++-------- owrx/form/__init__.py | 4 ++-- owrx/form/device.py | 2 +- owrx/source/__init__.py | 5 ++--- owrx/source/airspy.py | 3 +-- owrx/source/connector.py | 3 +-- owrx/source/sdrplay.py | 6 ++---- 9 files changed, 16 insertions(+), 27 deletions(-) diff --git a/owrx/controllers/settings/backgrounddecoding.py b/owrx/controllers/settings/backgrounddecoding.py index 9ee9596..2951de7 100644 --- a/owrx/controllers/settings/backgrounddecoding.py +++ b/owrx/controllers/settings/backgrounddecoding.py @@ -12,8 +12,7 @@ class BackgroundDecodingController(SettingsFormController): "Background decoding", CheckboxInput( "services_enabled", - "Service", - checkboxText="Enable background decoding services", + "Enable background decoding services", ), ServicesCheckboxInput("services_decoders", "Enabled services"), ), diff --git a/owrx/controllers/settings/decoding.py b/owrx/controllers/settings/decoding.py index 92427b8..105304f 100644 --- a/owrx/controllers/settings/decoding.py +++ b/owrx/controllers/settings/decoding.py @@ -37,14 +37,13 @@ class DecodingSettingsController(SettingsFormController): ), CheckboxInput( "digital_voice_dmr_id_lookup", - "DMR id lookup", - checkboxText='Enable lookup of DMR ids in the ' + 'Enable lookup of DMR ids in the ' + "radioid database to show callsigns and names", ), ), Section( "Digimodes", - CheckboxInput("digimodes_enable", "", checkboxText="Enable Digimodes"), + CheckboxInput("digimodes_enable", "Enable Digimodes"), NumberInput("digimodes_fft_size", "Digimodes FFT size", append="bins"), ), Section( diff --git a/owrx/controllers/settings/reporting.py b/owrx/controllers/settings/reporting.py index dfd8c3c..1ae6a40 100644 --- a/owrx/controllers/settings/reporting.py +++ b/owrx/controllers/settings/reporting.py @@ -14,8 +14,7 @@ class ReportingController(SettingsFormController): "APRS-IS reporting", CheckboxInput( "aprs_igate_enabled", - "APRS I-Gate", - checkboxText="Send received APRS data to APRS-IS", + "Send received APRS data to APRS-IS", ), TextInput( "aprs_callsign", @@ -26,8 +25,7 @@ class ReportingController(SettingsFormController): TextInput("aprs_igate_password", "APRS-IS network password"), CheckboxInput( "aprs_igate_beacon", - "APRS beacon", - checkboxText="Send the receiver position to the APRS-IS network", + "Send the receiver position to the APRS-IS network", infotext="Please check that your receiver location is setup correctly before enabling the beacon", ), DropdownInput( @@ -60,8 +58,7 @@ class ReportingController(SettingsFormController): "pskreporter settings", CheckboxInput( "pskreporter_enabled", - "Reporting", - checkboxText="Enable sending spots to pskreporter.info", + "Enable sending spots to pskreporter.info", ), TextInput( "pskreporter_callsign", @@ -79,8 +76,7 @@ class ReportingController(SettingsFormController): "WSPRnet settings", CheckboxInput( "wsprnet_enabled", - "Reporting", - checkboxText="Enable sending spots to wsprnet.org", + "Enable sending spots to wsprnet.org", ), TextInput( "wsprnet_callsign", diff --git a/owrx/form/__init__.py b/owrx/form/__init__.py index 27ad9ed..fbbbc42 100644 --- a/owrx/form/__init__.py +++ b/owrx/form/__init__.py @@ -139,8 +139,8 @@ class TextAreaInput(Input): class CheckboxInput(Input): - def __init__(self, id, label, checkboxText, infotext=None, converter: Converter = None): - super().__init__(id, label, infotext=infotext, converter=converter) + 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): diff --git a/owrx/form/device.py b/owrx/form/device.py index 627e38b..cd987f1 100644 --- a/owrx/form/device.py +++ b/owrx/form/device.py @@ -124,7 +124,7 @@ class GainInput(Input): class BiasTeeInput(CheckboxInput): def __init__(self): super().__init__( - "bias_tee", "", "Enable Bias-Tee power supply", converter=OptionalConverter(defaultFormValue=False) + "bias_tee", "Enable Bias-Tee power supply", converter=OptionalConverter(defaultFormValue=False) ) diff --git a/owrx/source/__init__.py b/owrx/source/__init__.py index ecea90d..46be223 100644 --- a/owrx/source/__init__.py +++ b/owrx/source/__init__.py @@ -392,7 +392,7 @@ class SdrDeviceDescription(object): def getInputs(self) -> List[Input]: return [ TextInput("name", "Device name"), - CheckboxInput("enabled", "", "Enable this device", converter=OptionalConverter(defaultFormValue=True)), + CheckboxInput("enabled", "Enable this device", converter=OptionalConverter(defaultFormValue=True)), NumberInput( "ppm", "Frequency correction", @@ -401,8 +401,7 @@ class SdrDeviceDescription(object): ), CheckboxInput( "always-on", - "", - checkboxText="Keep device running at all times", + "Keep device running at all times", infotext="Prevents shutdown of the device when idle. Useful for devices with unreliable startup.", converter=OptionalConverter(defaultFormValue=False), ), diff --git a/owrx/source/airspy.py b/owrx/source/airspy.py index 70005be..5a907ff 100644 --- a/owrx/source/airspy.py +++ b/owrx/source/airspy.py @@ -26,8 +26,7 @@ class AirspyDeviceDescription(SoapyConnectorDeviceDescription): BiasTeeInput(), CheckboxInput( "bitpack", - "", - checkboxText="Enable bit-packing", + "Enable bit-packing", infotext="Packs two 12-bit samples into 3 bytes." + " Lowers USB bandwidth consumption, increases CPU load", converter=OptionalConverter(defaultFormValue=False), diff --git a/owrx/source/connector.py b/owrx/source/connector.py index 10d6bcb..e4b5a1a 100644 --- a/owrx/source/connector.py +++ b/owrx/source/connector.py @@ -87,8 +87,7 @@ class ConnectorDeviceDescription(SdrDeviceDescription): ), CheckboxInput( "iqswap", - "", - checkboxText="Swap I and Q channels", + "Swap I and Q channels", infotext="Swapping inverts the spectrum, so this is useful in combination with an inverting mixer", converter=OptionalConverter(defaultFormValue=False), ), diff --git a/owrx/source/sdrplay.py b/owrx/source/sdrplay.py index 63b0cef..dbf6d7d 100644 --- a/owrx/source/sdrplay.py +++ b/owrx/source/sdrplay.py @@ -42,14 +42,12 @@ class SdrplayDeviceDescription(SoapyConnectorDeviceDescription): BiasTeeInput(), CheckboxInput( "rf_notch", - "", - checkboxText="Enable RF notch filter", + "Enable RF notch filter", converter=OptionalConverter(defaultFormValue=True), ), CheckboxInput( "dab_notch", - "", - checkboxText="Enable DAB notch filter", + "Enable DAB notch filter", converter=OptionalConverter(defaultFormValue=True), ), DropdownInput( From f8beae5f460725f52782b919eefcc585eaa6764b Mon Sep 17 00:00:00 2001 From: Jakob Ketterl Date: Mon, 22 Feb 2021 23:47:19 +0100 Subject: [PATCH 222/577] fix javascript errors --- htdocs/lib/settings/GainInput.js | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/htdocs/lib/settings/GainInput.js b/htdocs/lib/settings/GainInput.js index 31552bc..a5e26bf 100644 --- a/htdocs/lib/settings/GainInput.js +++ b/htdocs/lib/settings/GainInput.js @@ -9,13 +9,8 @@ $.fn.gainInput = function() { var $select = $container.find('select'); $select.on('change', function(e) { - var value = $(e.target).val() + var value = $(e.target).val(); update(value); - if (value == 'auto') { - $input.val('auto'); - } else { - $input - } }); update($select.val()); }); From 54a34b20842d04140c73ad4a4a33c5bf5b578cc6 Mon Sep 17 00:00:00 2001 From: Jakob Ketterl Date: Mon, 22 Feb 2021 23:49:28 +0100 Subject: [PATCH 223/577] implement optional device fields --- htdocs/css/admin.css | 17 ++++ htdocs/lib/settings/OptionalSection.js | 26 ++++++ htdocs/settings.js | 1 + owrx/controllers/assets.py | 1 + owrx/controllers/settings/__init__.py | 12 ++- owrx/controllers/settings/sdr.py | 2 +- owrx/form/__init__.py | 110 ++++++++++++++++++------- owrx/form/device.py | 10 ++- owrx/form/wsjt.py | 5 +- owrx/source/__init__.py | 80 ++++++++++++++++-- 10 files changed, 213 insertions(+), 51 deletions(-) create mode 100644 htdocs/lib/settings/OptionalSection.js diff --git a/htdocs/css/admin.css b/htdocs/css/admin.css index 038c25c..24aa8fc 100644 --- a/htdocs/css/admin.css +++ b/htdocs/css/admin.css @@ -85,4 +85,21 @@ table.bookmarks .frequency { .sdr-device-list .list-group-item, .sdr-profile-list .list-group-item { background: initial; +} + +.removable-group.removable { + display: flex; + flex-direction: row; +} + +.removable-group.removable .removable-item { + flex: 1 0 auto; +} + +.removable-group.removable .option-remove-button { + flex: 0 0 70px; +} + +.option-add-button, .option-remove-button { + width: 70px; } \ No newline at end of file diff --git a/htdocs/lib/settings/OptionalSection.js b/htdocs/lib/settings/OptionalSection.js new file mode 100644 index 0000000..8c60fa4 --- /dev/null +++ b/htdocs/lib/settings/OptionalSection.js @@ -0,0 +1,26 @@ +$.fn.optionalSection = function(){ + this.each(function() { + var $section = $(this); + var $select = $section.find('.optional-select'); + var $optionalInputs = $section.find('.optional-inputs'); + $section.on('click', '.option-add-button', function(e){ + var field = $select.val(); + var group = $optionalInputs.find(".form-group[data-field='" + field + "']"); + group.find('input, select').prop('disabled', false); + $select.parents('.form-group').before(group); + $select.find('option[value=\'' + field + '\']').remove(); + + return false; + }); + $section.on('click', '.option-remove-button', function(e) { + var group = $(e.target).parents('.form-group') + group.find('input, select').prop('disabled', true); + $optionalInputs.append(group); + var $label = group.find('> label'); + var $option = $(''); + $select.append($option); + + return false; + }) + }); +} \ No newline at end of file diff --git a/htdocs/settings.js b/htdocs/settings.js index 4a9903b..30aacad 100644 --- a/htdocs/settings.js +++ b/htdocs/settings.js @@ -5,4 +5,5 @@ $(function(){ $('.wsjt-decoding-depths').wsjtDecodingDepthsInput(); $('#waterfall_scheme').waterfallDropdown(); $('#rf_gain').gainInput(); + $('.optional-section').optionalSection(); }); \ No newline at end of file diff --git a/owrx/controllers/assets.py b/owrx/controllers/assets.py index f090fb6..696d541 100644 --- a/owrx/controllers/assets.py +++ b/owrx/controllers/assets.py @@ -151,6 +151,7 @@ class CompiledAssetsController(GzipMixin, ModificationAwareController): "lib/settings/WsjtDecodingDepthsInput.js", "lib/settings/WaterfallDropdown.js", "lib/settings/GainInput.js", + "lib/settings/OptionalSection.js", "settings.js", ], } diff --git a/owrx/controllers/settings/__init__.py b/owrx/controllers/settings/__init__.py index 11f056c..12d8b09 100644 --- a/owrx/controllers/settings/__init__.py +++ b/owrx/controllers/settings/__init__.py @@ -10,19 +10,25 @@ class Section(object): self.title = title self.inputs = inputs + def render_input(self, input, data): + return input.render(data) + def render_inputs(self, data): - return "".join([i.render(data) for i in self.inputs]) + return "".join([self.render_input(i, data) for i in self.inputs]) + + def classes(self): + return ["col-12", "settings-section"] def render(self, data): return """ -
    +

    {title}

    {inputs}
    """.format( - title=self.title, inputs=self.render_inputs(data) + classes=" ".join(self.classes()), title=self.title, inputs=self.render_inputs(data) ) def parse(self, data): diff --git a/owrx/controllers/settings/sdr.py b/owrx/controllers/settings/sdr.py index 90bb131..926001e 100644 --- a/owrx/controllers/settings/sdr.py +++ b/owrx/controllers/settings/sdr.py @@ -71,7 +71,7 @@ class SdrDeviceController(SettingsFormController): def getSections(self): try: description = SdrDeviceDescription.getByType(self.device["type"]) - return [description.getSection(self.device)] + return [description.getSection()] except SdrDeviceDescriptionMissing: # TODO provide a generic interface that allows to switch the type return [] diff --git a/owrx/form/__init__.py b/owrx/form/__init__.py index fbbbc42..8a88b5d 100644 --- a/owrx/form/__init__.py +++ b/owrx/form/__init__.py @@ -6,11 +6,19 @@ from enum import Enum class Input(ABC): - def __init__(self, id, label, infotext=None, converter: Converter = None): + def __init__(self, id, label, infotext=None, converter: Converter = 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.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() @@ -18,23 +26,47 @@ class Input(ABC): def bootstrap_decorate(self, input): infotext = "{text}".format(text=self.infotext) if self.infotext else "" return """ -
    +
    -
    - {input} - {infotext} +
    +
    + {input} + {infotext} +
    + {removebutton}
    """.format( - id=self.id, label=self.label, input=input, infotext=infotext + id=self.id, + label=self.label, + input=input, + infotext=infotext, + removable="removable" if self.removable else "", + removebutton='' + if self.removable + else "", ) def input_classes(self): return " ".join(["form-control", "form-control-sm"]) - @abstractmethod + def input_properties(self, value): + props = { + "class": self.input_classes(), + "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): + return " ".join('{}="{}"'.format(prop, value) for prop, value in self.input_properties(value).items()) + def render_input(self, value): - pass + return "".format(properties=self.render_input_properties(value)) def render(self, config): value = config[self.id] if self.id in config else None @@ -43,14 +75,15 @@ class Input(ABC): def parse(self, data): return {self.id: self.converter.convert_from_form(data[self.id][0])} if self.id in data else {} + def getLabel(self): + return self.label + class TextInput(Input): - def render_input(self, value): - return """ - - """.format( - id=self.id, label=self.label, classes=self.input_classes(), value=value - ) + def input_properties(self, value): + props = super().input_properties(value) + props["type"] = "text" + return props class NumberInput(Input): @@ -62,6 +95,13 @@ class NumberInput(Input): def defaultConverter(self): return IntConverter() + def input_properties(self, value): + props = super().input_properties(value) + props["type"] = "number" + if self.step: + props["step"] = self.step + return props + def render_input(self, value): if self.append: append = """ @@ -76,15 +116,11 @@ class NumberInput(Input): return """
    - + {input} {append}
    """.format( - id=self.id, - label=self.label, - classes=self.input_classes(), - value=value, - step='step="{0}"'.format(self.step) if self.step else "", + input=super().render_input(value), append=append, ) @@ -116,13 +152,15 @@ class LocationInput(Input): def render_sub_input(self, value, id): return """
    - +
    """.format( id="{0}-{1}".format(self.id, id), label=self.label, classes=self.input_classes(), value=value[id], + disabled="disabled" if self.disabled else "", ) def parse(self, data): @@ -132,9 +170,9 @@ class LocationInput(Input): class TextAreaInput(Input): def render_input(self, value): return """ - + """.format( - id=self.id, classes=self.input_classes(), value=value + id=self.id, classes=self.input_classes(), value=value, disabled="disabled" if self.disabled else "" ) @@ -145,16 +183,17 @@ class CheckboxInput(Input): def render_input(self, value): return """ -
    - - -
    +
    + + +
    """.format( id=self.id, classes=self.input_classes(), checked="checked" if value else "", + disabled="disabled" if self.disabled else "", checkboxText=self.checkboxText, ) @@ -164,6 +203,9 @@ class CheckboxInput(Input): def parse(self, data): return {self.id: self.converter.convert_from_form(self.id in data and data[self.id][0] == "on")} + def getLabel(self): + return self.checkboxText + class Option(object): # used for both MultiCheckboxInput and DropdownInput @@ -186,7 +228,7 @@ class MultiCheckboxInput(Input): def render_checkbox(self, option, value): return """
    - + @@ -196,6 +238,7 @@ class MultiCheckboxInput(Input): classes=self.input_classes(), checked="checked" if option.value in value else "", checkboxText=option.text, + disabled="disabled" if self.disabled else "", ) def parse(self, data): @@ -242,9 +285,12 @@ class DropdownInput(Input): def render_input(self, value): return """ - + """.format( - classes=self.input_classes(), id=self.id, options=self.render_options(value) + classes=self.input_classes(), + id=self.id, + options=self.render_options(value), + disabled="disabled" if self.disabled else "", ) def render_options(self, value): diff --git a/owrx/form/device.py b/owrx/form/device.py index cd987f1..d1c0c92 100644 --- a/owrx/form/device.py +++ b/owrx/form/device.py @@ -16,12 +16,12 @@ class GainInput(Input): return """
    - {options} {stageoption}
    @@ -32,6 +32,7 @@ class GainInput(Input): label=self.label, options=self.render_options(value), stageoption="" if self.gain_stages is None else self.render_stage_option(value), + disabled="disabled" if self.disabled else "" ) def render_options(self, value): @@ -79,15 +80,16 @@ class GainInput(Input): inputs="".join( """
    -
    {stage}
    + + class="col-9 {classes}" placeholder="{stage}" step="any" {disabled}>
    """.format( id=self.id, stage=stage, value=value_dict[stage] if stage in value_dict else "", classes=self.input_classes(), + disabled="disabled" if self.disabled else "", ) for stage in self.gain_stages ) diff --git a/owrx/form/wsjt.py b/owrx/form/wsjt.py index a5e1af0..215b1b7 100644 --- a/owrx/form/wsjt.py +++ b/owrx/form/wsjt.py @@ -22,7 +22,7 @@ class Q65ModeMatrix(Input): 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) else "disabled", + disabled="" if interval.is_available(mode) and not self.disabled else "disabled", ) def render_input(self, value): @@ -69,7 +69,7 @@ class WsjtDecodingDepthsInput(Input): ) return """ - +
    ').append(i); @@ -46,7 +46,7 @@ $.fn.wsjtDecodingDepthsInput = function() { $table.on('change', updateValue); $el.append($table); - var $addButton = $(''); + var $addButton = $(''); $addButton.on('click', function() { var row = new WsjtDecodingDepthRow(inputs) diff --git a/htdocs/settings/bookmarks.html b/htdocs/settings/bookmarks.html index 7518427..185d664 100644 --- a/htdocs/settings/bookmarks.html +++ b/htdocs/settings/bookmarks.html @@ -20,7 +20,7 @@ ${header}
    ${bookmarks}
    - +
    diff --git a/owrx/controllers/settings/bookmarks.py b/owrx/controllers/settings/bookmarks.py index 83c9840..4e802e1 100644 --- a/owrx/controllers/settings/bookmarks.py +++ b/owrx/controllers/settings/bookmarks.py @@ -55,7 +55,7 @@ class BookmarksController(AuthorizationMixin, WebpageController):
    {frequency} {modulation_name} - +
    '); - if (i) { - i.prop('disabled', false); - i.val(''); - cell.html(i); - } else { - cell.html( - '
    ' + - '' + - '' + - '
    ' - ); - } + + var inputs = Object.fromEntries( + Object.entries(editors).map(function(e) { + return [e[0], new e[1]($table)]; + }) + ); + + row.append($.map(inputs, function(editor, name){ + var cell = $('
    '); + editor.render(cell); return cell; })); + row.append($( + '' + + '
    ' + + '' + + '' + + '
    ' + + '
    +
    @@ -35,25 +28,35 @@ class BookmarksController(AuthorizationMixin, WebpageController): {bookmarks} - - - - - -
    Name FrequencyActions
    """.format( bookmarks="".join(self.render_bookmark(b) for b in bookmarks.getBookmarks()), - options="".join(render_mode(m) for m in Modes.getAvailableModes()), + 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 = 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 """
    {name}{frequency}{modulation_name}{name}{rendered_frequency}{modulation_name}
    +
    From e297cffbfe9fac03d1c7afaa3833ba8e3139e79c Mon Sep 17 00:00:00 2001 From: Jakob Ketterl Date: Tue, 30 Mar 2021 15:14:35 +0200 Subject: [PATCH 353/577] update to wsjt-x 2.3.1 --- docker/scripts/install-dependencies.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docker/scripts/install-dependencies.sh b/docker/scripts/install-dependencies.sh index f833bb5..1ee1b77 100755 --- a/docker/scripts/install-dependencies.sh +++ b/docker/scripts/install-dependencies.sh @@ -60,7 +60,7 @@ rm /js8call-hamlib.patch CMAKE_ARGS="-D CMAKE_CXX_FLAGS=-DJS8_USE_HAMLIB_THREE" cmakebuild ${JS8CALL_DIR} rm ${JS8CALL_TGZ} -WSJT_DIR=wsjtx-2.3.0 +WSJT_DIR=wsjtx-2.3.1 WSJT_TGZ=${WSJT_DIR}.tgz wget http://physics.princeton.edu/pulsar/k1jt/${WSJT_TGZ} tar xfz ${WSJT_TGZ} From 620ba115657b35015e0da420bd09f8dfc2376a85 Mon Sep 17 00:00:00 2001 From: Jakob Ketterl Date: Tue, 30 Mar 2021 16:15:05 +0200 Subject: [PATCH 354/577] update wsjt-x patchset --- docker/files/wsjtx/wsjtx-hamlib.patch | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/docker/files/wsjtx/wsjtx-hamlib.patch b/docker/files/wsjtx/wsjtx-hamlib.patch index 21b451f..47f37d9 100644 --- a/docker/files/wsjtx/wsjtx-hamlib.patch +++ b/docker/files/wsjtx/wsjtx-hamlib.patch @@ -1,5 +1,5 @@ ---- CMakeLists.txt.orig 2021-02-01 21:00:44.879969236 +0100 -+++ CMakeLists.txt 2021-02-01 21:04:58.184642042 +0100 +--- CMakeLists.txt.orig 2021-03-30 15:28:36.956587995 +0200 ++++ CMakeLists.txt 2021-03-30 15:29:45.719326832 +0200 @@ -106,24 +106,6 @@ @@ -11,7 +11,7 @@ - GIT_REPOSITORY ${hamlib_repo} - GIT_TAG ${hamlib_TAG} - GIT_SHALLOW False -- URL ${CMAKE_CURRENT_SOURCE_DIR}/src/${__hamlib_upstream} +- URL ${CMAKE_CURRENT_SOURCE_DIR}/src/${__hamlib_upstream}.tar.gz - URL_HASH MD5=${hamlib_md5sum} - #UPDATE_COMMAND ${CMAKE_COMMAND} -E env "[ -f ./bootstrap ] && ./bootstrap" - PATCH_COMMAND ${PATCH_EXECUTABLE} -p1 -N < ${CMAKE_CURRENT_SOURCE_DIR}/hamlib.patch From 2d142e45ed3c6452c4a2328fc4a37e52a5041fb5 Mon Sep 17 00:00:00 2001 From: Jakob Ketterl Date: Tue, 30 Mar 2021 18:19:23 +0200 Subject: [PATCH 355/577] implement dialog to import personal bookmarks --- htdocs/css/admin.css | 6 +- htdocs/lib/BookmarkBar.js | 18 ------ htdocs/lib/BookmarkLocalStorage.js | 17 ++++++ htdocs/lib/settings/BookmarkTable.js | 77 ++++++++++++++++++++------ htdocs/settings/bookmarks.html | 21 +++++++ owrx/controllers/assets.py | 2 + owrx/controllers/settings/bookmarks.py | 4 +- 7 files changed, 108 insertions(+), 37 deletions(-) create mode 100644 htdocs/lib/BookmarkLocalStorage.js diff --git a/htdocs/css/admin.css b/htdocs/css/admin.css index fc2bfe6..88d8da8 100644 --- a/htdocs/css/admin.css +++ b/htdocs/css/admin.css @@ -80,7 +80,7 @@ h1 { padding-right: 15px; } -.bookmarks table .frequency { +.bookmarks table .frequency, .bookmark-list table .frequency { text-align: right; } @@ -90,6 +90,10 @@ h1 { display: initial; } +.bookmark-list table .form-check-input { + margin-left: 0; +} + .actions { margin: 1rem 0; } diff --git a/htdocs/lib/BookmarkBar.js b/htdocs/lib/BookmarkBar.js index 35e8919..2284ca5 100644 --- a/htdocs/lib/BookmarkBar.js +++ b/htdocs/lib/BookmarkBar.js @@ -145,21 +145,3 @@ BookmarkBar.prototype.getDemodulatorPanel = function() { BookmarkBar.prototype.getDemodulator = function() { return this.getDemodulatorPanel().getDemodulator(); }; - -BookmarkLocalStorage = function(){ -}; - -BookmarkLocalStorage.prototype.getBookmarks = function(){ - return JSON.parse(window.localStorage.getItem("bookmarks")) || []; -}; - -BookmarkLocalStorage.prototype.setBookmarks = function(bookmarks){ - window.localStorage.setItem("bookmarks", JSON.stringify(bookmarks)); -}; - -BookmarkLocalStorage.prototype.deleteBookmark = function(data) { - if (data.id) data = data.id; - var bookmarks = this.getBookmarks(); - bookmarks = bookmarks.filter(function(b) { return b.id !== data; }); - this.setBookmarks(bookmarks); -}; diff --git a/htdocs/lib/BookmarkLocalStorage.js b/htdocs/lib/BookmarkLocalStorage.js new file mode 100644 index 0000000..edb4992 --- /dev/null +++ b/htdocs/lib/BookmarkLocalStorage.js @@ -0,0 +1,17 @@ +BookmarkLocalStorage = function(){ +}; + +BookmarkLocalStorage.prototype.getBookmarks = function(){ + return JSON.parse(window.localStorage.getItem("bookmarks")) || []; +}; + +BookmarkLocalStorage.prototype.setBookmarks = function(bookmarks){ + window.localStorage.setItem("bookmarks", JSON.stringify(bookmarks)); +}; + +BookmarkLocalStorage.prototype.deleteBookmark = function(data) { + if (data.id) data = data.id; + var bookmarks = this.getBookmarks(); + bookmarks = bookmarks.filter(function(b) { return b.id !== data; }); + this.setBookmarks(bookmarks); +}; diff --git a/htdocs/lib/settings/BookmarkTable.js b/htdocs/lib/settings/BookmarkTable.js index c01f3dc..136a66f 100644 --- a/htdocs/lib/settings/BookmarkTable.js +++ b/htdocs/lib/settings/BookmarkTable.js @@ -66,14 +66,16 @@ NameEditor.prototype.getInputHtml = function() { function FrequencyEditor(table) { Editor.call(this, table); - this.suffixes = { - 'K': 3, - 'M': 6, - 'G': 9, - 'T': 12 - }; } +FrequencyEditor.suffixes = { + '': 0, + 'K': 3, + 'M': 6, + 'G': 9, + 'T': 12 +}; + FrequencyEditor.prototype = new Editor(); FrequencyEditor.prototype.getInputHtml = function() { @@ -81,8 +83,7 @@ FrequencyEditor.prototype.getInputHtml = function() { '' + '
    ' + '
    Name Frequency
    '); + $list.append(bookmarks.map(function(b) { + var modulation = b.modulation; + if (modulation in modes) { + modulation = modes[modulation]; + } + var row = $( + '' + + '' + + '' + + '' + + '' + + '' + ); + row.data('bookmark', b); + return row; + })); + $importModal.find('.bookmark-list').html($list); + } else { + $importModal.find('.bookmark-list').html('No personal bookmarks found in this browser'); + } + $importModal.modal('show'); + }); }); }; diff --git a/htdocs/settings/bookmarks.html b/htdocs/settings/bookmarks.html index 1a25d23..f5013a5 100644 --- a/htdocs/settings/bookmarks.html +++ b/htdocs/settings/bookmarks.html @@ -20,6 +20,7 @@ ${header}
    ${bookmarks}
    +
    @@ -43,4 +44,24 @@ ${header} + \ No newline at end of file diff --git a/owrx/controllers/assets.py b/owrx/controllers/assets.py index ef1cad4..cda5924 100644 --- a/owrx/controllers/assets.py +++ b/owrx/controllers/assets.py @@ -125,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", @@ -148,6 +149,7 @@ class CompiledAssetsController(GzipMixin, ModificationAwareController): "lib/Header.js", "lib/settings/MapInput.js", "lib/settings/ImageUpload.js", + "lib/BookmarkLocalStorage.js", "lib/settings/BookmarkTable.js", "lib/settings/WsjtDecodingDepthsInput.js", "lib/settings/WaterfallDropdown.js", diff --git a/owrx/controllers/settings/bookmarks.py b/owrx/controllers/settings/bookmarks.py index 38de38a..1778589 100644 --- a/owrx/controllers/settings/bookmarks.py +++ b/owrx/controllers/settings/bookmarks.py @@ -43,7 +43,9 @@ class BookmarksController(AuthorizationMixin, WebpageController): 9: "G", 12: "T", } - exp = int(math.log10(freq) / 3) * 3 + exp = 0 + if freq > 0: + exp = int(math.log10(freq) / 3) * 3 num = freq suffix = "" if exp in suffixes: From 1b9e77982d0338c928efe91dd38f8e9bc13b9ae5 Mon Sep 17 00:00:00 2001 From: Jakob Ketterl Date: Tue, 30 Mar 2021 18:30:08 +0200 Subject: [PATCH 356/577] make "new bookmark" api work with arrays --- htdocs/lib/settings/BookmarkTable.js | 6 +++--- owrx/controllers/settings/bookmarks.py | 18 +++++++++++------- 2 files changed, 14 insertions(+), 10 deletions(-) diff --git a/htdocs/lib/settings/BookmarkTable.js b/htdocs/lib/settings/BookmarkTable.js index 136a66f..a4c6025 100644 --- a/htdocs/lib/settings/BookmarkTable.js +++ b/htdocs/lib/settings/BookmarkTable.js @@ -297,12 +297,12 @@ $.fn.bookmarktable = function() { ); $.ajax(document.location.href, { - data: JSON.stringify(data), + data: JSON.stringify([data]), contentType: 'application/json', method: 'POST' }).done(function(data){ - if ('bookmark_id' in data) { - row.attr('data-id', data['bookmark_id']); + if (data.length && data.length === 1 && 'bookmark_id' in data[0]) { + row.attr('data-id', data[0]['bookmark_id']); var tds = row.find('td'); Object.values(inputs).forEach(function(input, index) { diff --git a/owrx/controllers/settings/bookmarks.py b/owrx/controllers/settings/bookmarks.py index 1778589..b4a461d 100644 --- a/owrx/controllers/settings/bookmarks.py +++ b/owrx/controllers/settings/bookmarks.py @@ -103,19 +103,23 @@ class BookmarksController(AuthorizationMixin, WebpageController): def new(self): bookmarks = Bookmarks.getSharedInstance() - try: - data = json.loads(self.get_body()) + + def create(bookmark_data): # sanitize data = { - "name": data["name"], - "frequency": int(data["frequency"]), - "modulation": data["modulation"], + "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()) + result = [create(b) for b in data] bookmarks.store() - self.send_response(json.dumps({"bookmark_id": id(bookmark)}), content_type="application/json", code=200) + self.send_response(json.dumps(result), content_type="application/json", code=200) except json.JSONDecodeError: self.send_response("{}", content_type="application/json", code=400) From 8e7b758ef8082e50e7de1e8fc4bd406dac9f2a5b Mon Sep 17 00:00:00 2001 From: Jakob Ketterl Date: Tue, 30 Mar 2021 18:50:30 +0200 Subject: [PATCH 357/577] send personal bookmarks to the server --- htdocs/lib/settings/BookmarkTable.js | 42 +++++++++++++++++++++++++++- 1 file changed, 41 insertions(+), 1 deletion(-) diff --git a/htdocs/lib/settings/BookmarkTable.js b/htdocs/lib/settings/BookmarkTable.js index a4c6025..a0927af 100644 --- a/htdocs/lib/settings/BookmarkTable.js +++ b/htdocs/lib/settings/BookmarkTable.js @@ -341,7 +341,7 @@ $.fn.bookmarktable = function() { } var row = $( '' + - '' + + '' + '' + '' + '' + @@ -356,5 +356,45 @@ $.fn.bookmarktable = function() { } $importModal.modal('show'); }); + + $importModal.on('click', '.confirm', function() { + var $list = $importModal.find('.bookmark-list table'); + if ($list.length) { + var selected = $list.find('tr').filter(function(){ + return $(this).find('.select').is(':checked'); + }).map(function(){ + return $(this).data('bookmark'); + }).toArray(); + if (selected.length) { + $.ajax(document.location.href, { + data: JSON.stringify(selected), + contentType: 'application/json', + method: 'POST' + }).done(function(data){ + var modes = $table.data('modes'); + if (data.length && data.length == selected.length) { + $table.append(data.map(function(obj, index) { + var bookmark = selected[index]; + var modulation_name = bookmark.modulation; + if (modulation_name in modes) { + modulation_name = modes[modulation_name]; + } + return $( + '' + + '' + + '' + + '' + + '' + + '' + ) + })); + } + }); + } + } + $importModal.modal('hide'); + }); }); }; From c6962b4f4260e193e1733176c42b088ac77ab1a0 Mon Sep 17 00:00:00 2001 From: Jakob Ketterl Date: Tue, 30 Mar 2021 23:41:26 +0200 Subject: [PATCH 358/577] change headline wording --- htdocs/settings/bookmarks.html | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/htdocs/settings/bookmarks.html b/htdocs/settings/bookmarks.html index f5013a5..80ebe64 100644 --- a/htdocs/settings/bookmarks.html +++ b/htdocs/settings/bookmarks.html @@ -48,7 +48,7 @@ ${header}
    ' + b.name + '' + renderFrequency(b.frequency) + '' + modulation + '
    ' + b.name + '' + renderFrequency(b.frequency) + '' + modulation + '
    ' + bookmark.name + '' + renderFrequency(bookmark.frequency) +'' + modulation_name + '' + + '' + + '
    @@ -19,5 +20,6 @@
    Available
    + ${breadcrumb}
    \ No newline at end of file diff --git a/htdocs/features.js b/htdocs/features.js index 5ab0972..fef2817 100644 --- a/htdocs/features.js +++ b/htdocs/features.js @@ -13,8 +13,7 @@ $(function(){ }); $table.append( '' + - '' + name + '' + - '' + converter.makeHtml(details.description) + '' + + '' + name + '' + '' + (details.available ? 'YES' : 'NO') + '' + '' + requirements.join("") diff --git a/htdocs/settings/bookmarks.html b/htdocs/settings/bookmarks.html index 80ebe64..046015b 100644 --- a/htdocs/settings/bookmarks.html +++ b/htdocs/settings/bookmarks.html @@ -11,6 +11,7 @@ ${header}
    + ${breadcrumb}

    Bookmarks

    @@ -24,6 +25,7 @@ ${header}
    + ${breadcrumb}
    + """.format( + options="".join( + """ + + """.format( + value=input.id, + name=input.getLabel(), + ) + for input in self.optional_inputs + ) + ) + + def render_optional_inputs(self, data, errors): + return """ + + """.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]) + optional_keys = set(k for k in self.optional if k not in data) + 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) + # filter out errors for optional fields + errors = [e for e in errors if e.getKey() not in self.optional or e.getKey() in 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 diff --git a/owrx/source/__init__.py b/owrx/source/__init__.py index e5801be..7f5e0a4 100644 --- a/owrx/source/__init__.py +++ b/owrx/source/__init__.py @@ -16,7 +16,7 @@ from owrx.form.input import Input, TextInput, NumberInput, CheckboxInput, ModesI 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.controllers.settings import Section +from owrx.form.section import OptionalSection from owrx.feature import FeatureDetector from typing import List from enum import Enum @@ -447,90 +447,6 @@ class SdrDeviceDescriptionMissing(Exception): pass -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 """ -
    -
    - -
    -
    - -
    - -
    -
    - """.format( - options="".join( - """ - - """.format( - value=input.id, - name=input.getLabel(), - ) - for input in self.optional_inputs - ) - ) - - def render_optional_inputs(self, data, errors): - return """ - - """.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]) - optional_keys = set(k for k in self.optional if k not in data) - 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) - # filter out errors for optional fields - errors = [e for e in errors if e.getKey() not in self.optional or e.getKey() in 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 - - class SdrDeviceDescription(object): @staticmethod def getByType(sdr_type: str) -> "SdrDeviceDescription": From 7642341b2ef7d976e792f6c3c5a337f9d8b2ed4a Mon Sep 17 00:00:00 2001 From: Jakob Ketterl Date: Thu, 29 Apr 2021 15:34:46 +0200 Subject: [PATCH 452/577] fix checkbox labels when removing their optional fields --- htdocs/lib/settings/OptionalSection.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/htdocs/lib/settings/OptionalSection.js b/htdocs/lib/settings/OptionalSection.js index 6c4ce74..16eff54 100644 --- a/htdocs/lib/settings/OptionalSection.js +++ b/htdocs/lib/settings/OptionalSection.js @@ -19,7 +19,7 @@ $.fn.optionalSection = function(){ var group = $(e.target).parents('.form-group') group.find('input, select').prop('disabled', true); $optionalInputs.append(group); - var $label = group.find('> label'); + var $label = group.find('label'); var $option = $(''); $select.append($option); From 7115d5c9514edf959807ea4b943305f40ef169fc Mon Sep 17 00:00:00 2001 From: Jakob Ketterl Date: Thu, 29 Apr 2021 16:23:51 +0200 Subject: [PATCH 453/577] prefer native sample rate, if good - closes #201 --- htdocs/lib/AudioEngine.js | 52 +++++++++++++++++++++++++++------------ 1 file changed, 36 insertions(+), 16 deletions(-) diff --git a/htdocs/lib/AudioEngine.js b/htdocs/lib/AudioEngine.js index fb08bfc..6cd2c3a 100644 --- a/htdocs/lib/AudioEngine.js +++ b/htdocs/lib/AudioEngine.js @@ -6,27 +6,15 @@ function AudioEngine(maxBufferLength, audioReporter) { this.audioReporter = audioReporter; this.initStats(); this.resetStats(); - var ctx = window.AudioContext || window.webkitAudioContext; - if (!ctx) { - return; - } this.onStartCallbacks = []; this.started = false; - // try common working sample rates - if (![48000, 44100].some(function(sr) { - try { - this.audioContext = new ctx({sampleRate: sr}); - return true; - } catch (e) { - return false; - } - }, this)) { - // fallback: let the browser decide - // this may cause playback problems down the line - this.audioContext = new ctx(); + this.audioContext = this.buildAudioContext(); + if (!this.audioContext) { + return; } + var me = this; this.audioContext.onstatechange = function() { if (me.audioContext.state !== 'running') return; @@ -43,6 +31,38 @@ function AudioEngine(maxBufferLength, audioReporter) { this.maxBufferSize = maxBufferLength * this.getSampleRate(); } +AudioEngine.prototype.buildAudioContext = function() { + var ctxClass = window.AudioContext || window.webkitAudioContext; + if (!ctxClass) { + return; + } + + // known good sample rates + var goodRates = [48000, 44100] + + // let the browser chose the sample rate, if it is good, use it + var ctx = new ctxClass(); + if (goodRates.indexOf(ctx.sampleRate) >= 0) { + return ctx; + } + + // if that didn't work, try if any of the good rates work + if (goodRates.some(function(sr) { + try { + ctx = new ctxClass({sampleRate: sr}); + return true; + } catch (e) { + return false; + } + }, this)) { + return ctx; + } + + // fallback: let the browser decide + // this may cause playback problems down the line + return new ctxClass(); +} + AudioEngine.prototype.resume = function(){ this.audioContext.resume(); } From af553c422d2b2e9dea6f8e7ac9d2623e687f4c74 Mon Sep 17 00:00:00 2001 From: Jakob Ketterl Date: Thu, 29 Apr 2021 18:18:18 +0200 Subject: [PATCH 454/577] implement file size upload limit --- htdocs/lib/settings/ImageUpload.js | 14 ++++++++- owrx/controllers/__init__.py | 8 ++++- owrx/controllers/imageupload.py | 50 ++++++++++++++++++++++-------- owrx/form/input/gfx.py | 20 ++++++++++-- 4 files changed, 75 insertions(+), 17 deletions(-) diff --git a/htdocs/lib/settings/ImageUpload.js b/htdocs/lib/settings/ImageUpload.js index 02edbce..b5be1cd 100644 --- a/htdocs/lib/settings/ImageUpload.js +++ b/htdocs/lib/settings/ImageUpload.js @@ -6,6 +6,7 @@ $.fn.imageUpload = function() { var originalUrl = $img.prop('src'); var $input = $(this).find('input'); var id = $input.prop('id'); + var maxSize = $(this).data('max-size'); $uploadButton.click(function(){ $uploadButton.prop('disabled', true); var input = document.createElement('input'); @@ -14,9 +15,20 @@ $.fn.imageUpload = function() { input.onchange = function(e) { var reader = new FileReader() - // TODO: implement file size check reader.readAsArrayBuffer(e.target.files[0]); + reader.onprogress = function(e) { + if (e.loaded > maxSize) { + console.error('maximum file size exceeded, aborting file upload'); + $uploadButton.prop('disabled', false); + reader.abort(); + } + }; reader.onload = function(e) { + if (e.loaded > maxSize) { + console.error('maximum file size exceeded, aborting file upload'); + $uploadButton.prop('disabled', false); + return; + } $.ajax({ url: '/imageupload?id=' + id, type: 'POST', diff --git a/owrx/controllers/__init__.py b/owrx/controllers/__init__.py index e1b5fbd..f0ad9a7 100644 --- a/owrx/controllers/__init__.py +++ b/owrx/controllers/__init__.py @@ -1,6 +1,10 @@ from datetime import datetime, timezone +class BodySizeError(Exception): + pass + + class Controller(object): def __init__(self, handler, request, options): self.handler = handler @@ -33,10 +37,12 @@ class Controller(object): self.handler.send_header("Location", location) self.handler.end_headers() - def get_body(self): + 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): diff --git a/owrx/controllers/imageupload.py b/owrx/controllers/imageupload.py index b3f0100..a65a822 100644 --- a/owrx/controllers/imageupload.py +++ b/owrx/controllers/imageupload.py @@ -1,11 +1,20 @@ +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 @@ -29,22 +38,37 @@ class ImageUploadController(AuthorizationMixin, AssetsController): def processImage(self): if "id" not in self.request.query: - self.send_response("{}", content_type="application/json", code=400) - # TODO: limit file size - contents = self.get_body() + 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" if self._is_jpg(contents): filetype = "jpg" if filetype is None: - self.send_response("{}", content_type="application/json", code=400) - else: - self.file = "{id}-{uuid}.{ext}".format( - id=self.request.query["id"][0], - uuid=uuid.uuid4().hex, - ext=filetype, - ) - with open(self.getFilePath(), "wb") as f: - f.write(contents) - self.send_response(json.dumps({"file": self.file}), content_type="application/json") + 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") diff --git a/owrx/form/input/gfx.py b/owrx/form/input/gfx.py index eb9f181..24516b4 100644 --- a/owrx/form/input/gfx.py +++ b/owrx/form/input/gfx.py @@ -7,7 +7,7 @@ class ImageInput(Input, metaclass=ABCMeta): def render_input(self, value, errors): # TODO display errors return """ -
    +
    {label} @@ -16,7 +16,11 @@ class ImageInput(Input, metaclass=ABCMeta):
    """.format( - id=self.id, label=self.label, url=self.cachebuster(self.getUrl()), classes=" ".join(self.getImgClasses()) + id=self.id, + label=self.label, + url=self.cachebuster(self.getUrl()), + classes=" ".join(self.getImgClasses()), + maxsize=self.getMaxSize(), ) def cachebuster(self, url: str): @@ -34,6 +38,10 @@ class ImageInput(Input, metaclass=ABCMeta): def getImgClasses(self) -> list: pass + @abstractmethod + def getMaxSize(self) -> int: + pass + class AvatarInput(ImageInput): def getUrl(self) -> str: @@ -42,6 +50,10 @@ class AvatarInput(ImageInput): def getImgClasses(self) -> list: return ["webrx-rx-avatar"] + def getMaxSize(self) -> int: + # 256 kB + return 250 * 1024 + class TopPhotoInput(ImageInput): def getUrl(self) -> str: @@ -49,3 +61,7 @@ class TopPhotoInput(ImageInput): def getImgClasses(self) -> list: return ["webrx-top-photo"] + + def getMaxSize(self) -> int: + # 2 MB + return 2 * 1024 * 1024 From f481c3f8e3b536059de375cafb6af37becefdd6c Mon Sep 17 00:00:00 2001 From: Jakob Ketterl Date: Thu, 29 Apr 2021 19:07:10 +0200 Subject: [PATCH 455/577] implement image upload error handling --- htdocs/css/admin.css | 4 ++++ htdocs/lib/settings/ImageUpload.js | 35 ++++++++++++++++++++++++------ 2 files changed, 32 insertions(+), 7 deletions(-) diff --git a/htdocs/css/admin.css b/htdocs/css/admin.css index 2c2e52b..b956e9e 100644 --- a/htdocs/css/admin.css +++ b/htdocs/css/admin.css @@ -155,4 +155,8 @@ h1 { .breadcrumb { margin-top: .5rem; +} + +.imageupload.is-invalid ~ .invalid-feedback { + display: block; } \ No newline at end of file diff --git a/htdocs/lib/settings/ImageUpload.js b/htdocs/lib/settings/ImageUpload.js index b5be1cd..ba5f133 100644 --- a/htdocs/lib/settings/ImageUpload.js +++ b/htdocs/lib/settings/ImageUpload.js @@ -1,12 +1,24 @@ $.fn.imageUpload = function() { $.each(this, function(){ - var $uploadButton = $(this).find('button.upload'); - var $restoreButton = $(this).find('button.restore'); - var $img = $(this).find('img'); + var $this = $(this); + var $uploadButton = $this.find('button.upload'); + var $restoreButton = $this.find('button.restore'); + var $img = $this.find('img'); var originalUrl = $img.prop('src'); - var $input = $(this).find('input'); + var $input = $this.find('input'); var id = $input.prop('id'); - var maxSize = $(this).data('max-size'); + var maxSize = $this.data('max-size'); + var $error; + var handleError = function(message) { + clearError(); + $error = $('
    ' + message + '
    '); + $this.after($error); + $this.addClass('is-invalid'); + }; + var clearError = function(message) { + if ($error) $error.remove(); + $this.removeClass('is-invalid'); + }; $uploadButton.click(function(){ $uploadButton.prop('disabled', true); var input = document.createElement('input'); @@ -18,14 +30,14 @@ $.fn.imageUpload = function() { reader.readAsArrayBuffer(e.target.files[0]); reader.onprogress = function(e) { if (e.loaded > maxSize) { - console.error('maximum file size exceeded, aborting file upload'); + handleError('Maximum file size exceeded'); $uploadButton.prop('disabled', false); reader.abort(); } }; reader.onload = function(e) { if (e.loaded > maxSize) { - console.error('maximum file size exceeded, aborting file upload'); + handleError('Maximum file size exceeded'); $uploadButton.prop('disabled', false); return; } @@ -38,6 +50,14 @@ $.fn.imageUpload = function() { }).done(function(data){ $input.val(data.file); $img.prop('src', '/imageupload?file=' + data.file); + clearError(); + }).fail(function(xhr, error){ + try { + var res = JSON.parse(xhr.responseText); + handleError(res.error || error); + } catch (e) { + handleError(error); + } }).always(function(){ $uploadButton.prop('disabled', false); }); @@ -51,6 +71,7 @@ $.fn.imageUpload = function() { $restoreButton.click(function(){ $input.val('restore'); $img.prop('src', originalUrl + "&mapped=false"); + clearError(); return false; }); }); From 318cb728e10348b2bb705fea6914ba4966d92712 Mon Sep 17 00:00:00 2001 From: Jakob Ketterl Date: Thu, 29 Apr 2021 19:41:06 +0200 Subject: [PATCH 456/577] fix imageupload path --- htdocs/lib/settings/ImageUpload.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/htdocs/lib/settings/ImageUpload.js b/htdocs/lib/settings/ImageUpload.js index ba5f133..d7c0c18 100644 --- a/htdocs/lib/settings/ImageUpload.js +++ b/htdocs/lib/settings/ImageUpload.js @@ -42,7 +42,7 @@ $.fn.imageUpload = function() { return; } $.ajax({ - url: '/imageupload?id=' + id, + url: '../imageupload?id=' + id, type: 'POST', data: e.target.result, processData: false, From 48d498941e92f9e9f9b51381d75d65f7b62da5f9 Mon Sep 17 00:00:00 2001 From: Jakob Ketterl Date: Thu, 29 Apr 2021 19:53:43 +0200 Subject: [PATCH 457/577] fix url for image replacement, too --- htdocs/lib/settings/ImageUpload.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/htdocs/lib/settings/ImageUpload.js b/htdocs/lib/settings/ImageUpload.js index d7c0c18..c158342 100644 --- a/htdocs/lib/settings/ImageUpload.js +++ b/htdocs/lib/settings/ImageUpload.js @@ -49,7 +49,7 @@ $.fn.imageUpload = function() { contentType: 'application/octet-stream', }).done(function(data){ $input.val(data.file); - $img.prop('src', '/imageupload?file=' + data.file); + $img.prop('src', '../imageupload?file=' + data.file); clearError(); }).fail(function(xhr, error){ try { From 540198b12a6f8910f6b55df16e97adecf8ca1730 Mon Sep 17 00:00:00 2001 From: Jakob Ketterl Date: Thu, 29 Apr 2021 20:15:07 +0200 Subject: [PATCH 458/577] 96kHz is reported as working, too - refs #201 --- htdocs/lib/AudioEngine.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/htdocs/lib/AudioEngine.js b/htdocs/lib/AudioEngine.js index 6cd2c3a..5c6415b 100644 --- a/htdocs/lib/AudioEngine.js +++ b/htdocs/lib/AudioEngine.js @@ -38,7 +38,7 @@ AudioEngine.prototype.buildAudioContext = function() { } // known good sample rates - var goodRates = [48000, 44100] + var goodRates = [48000, 44100, 96000] // let the browser chose the sample rate, if it is good, use it var ctx = new ctxClass(); From f8971ac704c3e74a3c5b170c597443cb0a3ea792 Mon Sep 17 00:00:00 2001 From: Jakob Ketterl Date: Fri, 30 Apr 2021 01:20:33 +0200 Subject: [PATCH 459/577] protect against low-level errors during switching --- owrx/audio/wav.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/owrx/audio/wav.py b/owrx/audio/wav.py index cccdc96..b40c40c 100644 --- a/owrx/audio/wav.py +++ b/owrx/audio/wav.py @@ -91,7 +91,11 @@ class AudioWriter(object): pid=id(profile), timestamp=datetime.utcnow().strftime(profile.getFileTimestampFormat()), ) - os.link(file.getFileName(), filename) + try: + os.link(file.getFileName(), filename) + except OSError: + logger.warning("Error while linking job files") + continue job = QueueJob(profile, self.outputWriter, filename, self.dsp.get_operating_freq()) try: From 2152184bf90407f563bc983188a8c508eae3bc50 Mon Sep 17 00:00:00 2001 From: Jakob Ketterl Date: Sat, 1 May 2021 16:49:53 +0200 Subject: [PATCH 460/577] fix compatibility issues with python 3.5 --- owrx/controllers/settings/bookmarks.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/owrx/controllers/settings/bookmarks.py b/owrx/controllers/settings/bookmarks.py index 771f120..2704bb6 100644 --- a/owrx/controllers/settings/bookmarks.py +++ b/owrx/controllers/settings/bookmarks.py @@ -97,7 +97,7 @@ class BookmarksController(AuthorizationMixin, BreadcrumbMixin, WebpageController self.send_response("{}", content_type="application/json", code=404) return try: - data = json.loads(self.get_body()) + data = json.loads(self.get_body().decode("utf-8")) for key in ["name", "frequency", "modulation"]: if key in data: value = data[key] @@ -126,7 +126,7 @@ class BookmarksController(AuthorizationMixin, BreadcrumbMixin, WebpageController return {"bookmark_id": id(bookmark)} try: - data = json.loads(self.get_body()) + 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) From 11568256ed5640085d51b45d09301a861242ad83 Mon Sep 17 00:00:00 2001 From: Jakob Ketterl Date: Sat, 1 May 2021 16:51:02 +0200 Subject: [PATCH 461/577] remove unused imports --- owrx/audio/chopper.py | 6 ++++-- owrx/audio/wav.py | 1 - 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/owrx/audio/chopper.py b/owrx/audio/chopper.py index d26db9e..4842432 100644 --- a/owrx/audio/chopper.py +++ b/owrx/audio/chopper.py @@ -4,7 +4,7 @@ from itertools import groupby import threading from owrx.audio import ProfileSourceSubscriber from owrx.audio.wav import AudioWriter -from multiprocessing.connection import Pipe, wait +from multiprocessing.connection import Pipe import logging @@ -33,7 +33,9 @@ class AudioChopper(threading.Thread, Output, ProfileSourceSubscriber): 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()] + writers = [ + AudioWriter(self.dsp, self.outputWriter, interval, profiles) for interval, profiles in groups.items() + ] for w in writers: w.start() self.writers = writers diff --git a/owrx/audio/wav.py b/owrx/audio/wav.py index b40c40c..253e905 100644 --- a/owrx/audio/wav.py +++ b/owrx/audio/wav.py @@ -4,7 +4,6 @@ from owrx.audio.queue import QueueJob, DecoderQueue import threading import wave import os -from multiprocessing.connection import Pipe from datetime import datetime, timedelta from queue import Full from typing import List From 53c5c0f0457dac7ca9eeffa5778ca3119755d87e Mon Sep 17 00:00:00 2001 From: Jakob Ketterl Date: Sat, 1 May 2021 16:55:08 +0200 Subject: [PATCH 462/577] add a latencyHint to improve audio playback --- htdocs/lib/AudioEngine.js | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/htdocs/lib/AudioEngine.js b/htdocs/lib/AudioEngine.js index 5c6415b..8a3df67 100644 --- a/htdocs/lib/AudioEngine.js +++ b/htdocs/lib/AudioEngine.js @@ -41,7 +41,7 @@ AudioEngine.prototype.buildAudioContext = function() { var goodRates = [48000, 44100, 96000] // let the browser chose the sample rate, if it is good, use it - var ctx = new ctxClass(); + var ctx = new ctxClass({latencyHint: 'playback'}); if (goodRates.indexOf(ctx.sampleRate) >= 0) { return ctx; } @@ -49,7 +49,7 @@ AudioEngine.prototype.buildAudioContext = function() { // if that didn't work, try if any of the good rates work if (goodRates.some(function(sr) { try { - ctx = new ctxClass({sampleRate: sr}); + ctx = new ctxClass({sampleRate: sr, latencyHint: 'playback'}); return true; } catch (e) { return false; @@ -60,7 +60,7 @@ AudioEngine.prototype.buildAudioContext = function() { // fallback: let the browser decide // this may cause playback problems down the line - return new ctxClass(); + return new ctxClass({latencyHint: 'playback'}); } AudioEngine.prototype.resume = function(){ From 0fa8774493fa0c33a13bf3be029b8455805c1f6a Mon Sep 17 00:00:00 2001 From: Jakob Ketterl Date: Sat, 1 May 2021 18:27:15 +0200 Subject: [PATCH 463/577] increase bandwidth for digital modes to 12.5 --- htdocs/lib/Demodulator.js | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/htdocs/lib/Demodulator.js b/htdocs/lib/Demodulator.js index bd0e579..352ebe1 100644 --- a/htdocs/lib/Demodulator.js +++ b/htdocs/lib/Demodulator.js @@ -5,14 +5,14 @@ function Filter(demodulator) { Filter.prototype.getLimits = function() { var max_bw; - if (this.demodulator.get_secondary_demod() === 'pocsag') { + if (['pocsag', 'packet'].indexOf(this.demodulator.get_secondary_demod()) >= 0) { max_bw = 12500; + } else if (['dmr', 'dstar', 'nxdn', 'ysf', 'm17'].indexOf(this.demodulator.get_modulation()) >= 0) { + max_bw = 6250; } else if (this.demodulator.get_modulation() === 'wfm') { max_bw = 100000; } else if (this.demodulator.get_modulation() === 'drm') { - max_bw = 100000; - } else if (this.demodulator.get_secondary_demod() === 'packet') { - max_bw = 12500; + max_bw = 50000; } else { max_bw = (audioEngine.getOutputRate() / 2) - 1; } From 290f67735db3d310b3f60cef7dccb14693310661 Mon Sep 17 00:00:00 2001 From: Jakob Ketterl Date: Sun, 2 May 2021 00:06:50 +0200 Subject: [PATCH 464/577] improve decoding file switchover --- owrx/audio/wav.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/owrx/audio/wav.py b/owrx/audio/wav.py index 253e905..4843c25 100644 --- a/owrx/audio/wav.py +++ b/owrx/audio/wav.py @@ -56,7 +56,9 @@ class AudioWriter(object): return WaveFile(filename) def getNextDecodingTime(self): - t = datetime.utcnow() + # 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 From d9fe604171a054f60a71daee1c2897205a12fb59 Mon Sep 17 00:00:00 2001 From: Jakob Ketterl Date: Sun, 2 May 2021 00:07:24 +0200 Subject: [PATCH 465/577] improve error handling on file switches --- owrx/audio/wav.py | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/owrx/audio/wav.py b/owrx/audio/wav.py index 4843c25..3af9435 100644 --- a/owrx/audio/wav.py +++ b/owrx/audio/wav.py @@ -83,9 +83,9 @@ class AudioWriter(object): self.wavefile = self.getWaveFile() file.close() - for profile in self.profiles: - tmp_dir = CoreConfig().get_temporary_directory() + 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, @@ -95,7 +95,7 @@ class AudioWriter(object): try: os.link(file.getFileName(), filename) except OSError: - logger.warning("Error while linking job files") + logger.exception("Error while linking job files") continue job = QueueJob(profile, self.outputWriter, filename, self.dsp.get_operating_freq()) @@ -105,8 +105,11 @@ class AudioWriter(object): logger.warning("decoding queue overflow; dropping one file") job.unlink() - # our master can be deleted now, the profiles will delete their hardlinked copies after processing - file.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() From 041e8930bf128d95a50df59f0abc3ee4a3206551 Mon Sep 17 00:00:00 2001 From: Jakob Ketterl Date: Mon, 3 May 2021 19:28:03 +0200 Subject: [PATCH 466/577] don't send native deletions --- owrx/source/connector.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/owrx/source/connector.py b/owrx/source/connector.py index 6c9a467..58c535d 100644 --- a/owrx/source/connector.py +++ b/owrx/source/connector.py @@ -1,5 +1,6 @@ from owrx.source import SdrSource, SdrDeviceDescription from owrx.socket import getAvailablePort +from owrx.property import PropertyDeleted import socket from owrx.command import Flag, Option from typing import List @@ -37,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()) From fe1a1207e6ab16e1ef041957da3c8a4ea95c82aa Mon Sep 17 00:00:00 2001 From: Jakob Ketterl Date: Mon, 3 May 2021 23:07:27 +0200 Subject: [PATCH 467/577] implement session timeout --- owrx/controllers/__init__.py | 12 +++++++++--- owrx/controllers/admin.py | 11 ++++++++--- owrx/controllers/session.py | 24 +++++++++++++++++++++--- 3 files changed, 38 insertions(+), 9 deletions(-) diff --git a/owrx/controllers/__init__.py b/owrx/controllers/__init__.py index f0ad9a7..791b7b1 100644 --- a/owrx/controllers/__init__.py +++ b/owrx/controllers/__init__.py @@ -10,6 +10,7 @@ class Controller(object): 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 @@ -25,18 +26,23 @@ 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 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 diff --git a/owrx/controllers/admin.py b/owrx/controllers/admin.py index f232adf..8777164 100644 --- a/owrx/controllers/admin.py +++ b/owrx/controllers/admin.py @@ -11,16 +11,21 @@ class Authentication(object): def getUser(self, request): if "owrx-session" not in request.cookies: return None - session = SessionStorage.getSharedInstance().getSession(request.cookies["owrx-session"].value) + 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: - return userList[session["user"]] + user = userList[session["user"]] + storage.prolongSession(session_id) except KeyError: - return None + pass + return user class AuthorizationMixin(object): diff --git a/owrx/controllers/session.py b/owrx/controllers/session.py index 44f1e14..a140683 100644 --- a/owrx/controllers/session.py +++ b/owrx/controllers/session.py @@ -3,10 +3,16 @@ 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): @@ -52,7 +69,8 @@ class SessionController(WebpageController): target = self.request.query["ref"][0] if "ref" in self.request.query else "/settings" if user.must_change_password: target = "/pwchange?{0}".format(urlencode({"ref": target})) - self.send_redirect(target, cookies=cookie) + self.set_response_cookies(cookie) + self.send_redirect(target) return target = "?{}".format(urlencode({"ref": self.request.query["ref"][0]})) if "ref" in self.request.query else "" self.send_redirect(self.request.path + target) From a17690dc91308773acc4d9970b61b0e993f4850e Mon Sep 17 00:00:00 2001 From: Jakob Ketterl Date: Mon, 3 May 2021 23:22:28 +0200 Subject: [PATCH 468/577] clear session cookie if invalid --- owrx/controllers/admin.py | 7 ++++++- owrx/controllers/session.py | 2 +- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/owrx/controllers/admin.py b/owrx/controllers/admin.py index 8777164..803eeb8 100644 --- a/owrx/controllers/admin.py +++ b/owrx/controllers/admin.py @@ -1,6 +1,7 @@ -from .session import SessionStorage +from owrx.controllers.session import SessionStorage from owrx.users import UserList from urllib import parse +from http.cookies import SimpleCookie import logging @@ -41,6 +42,10 @@ class AuthorizationMixin(object): if self.isAuthorized(): super().handle_request() else: + 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" diff --git a/owrx/controllers/session.py b/owrx/controllers/session.py index a140683..6807a91 100644 --- a/owrx/controllers/session.py +++ b/owrx/controllers/session.py @@ -1,4 +1,4 @@ -from .template import WebpageController +from owrx.controllers.template import WebpageController from urllib.parse import parse_qs, urlencode from uuid import uuid4 from http.cookies import SimpleCookie From cd935c0dcbf42ef5940457307a727e9ddb8783d5 Mon Sep 17 00:00:00 2001 From: Jakob Ketterl Date: Tue, 4 May 2021 16:05:44 +0200 Subject: [PATCH 469/577] check for empty return --- owrx/meta.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/owrx/meta.py b/owrx/meta.py index 85de1ba..f246d15 100644 --- a/owrx/meta.py +++ b/owrx/meta.py @@ -66,7 +66,7 @@ class DmrMetaEnricher(object): self.threads[id].start() return meta data = cache.get(id) - if "count" in data and data["count"] > 0 and "results" in data: + if data is not None and "count" in data and data["count"] > 0 and "results" in data: meta["additional"] = data["results"][0] return meta From 55254b1c44fddd9ae7e5fecfdc1a9b6adb1332e1 Mon Sep 17 00:00:00 2001 From: Jakob Ketterl Date: Wed, 5 May 2021 18:43:24 +0200 Subject: [PATCH 470/577] compress png images for performance --- htdocs/gfx/openwebrx-background-cool-blue.png | Bin 91822 -> 66934 bytes htdocs/gfx/openwebrx-logo-big.png | Bin 44930 -> 18726 bytes htdocs/gfx/openwebrx-scale-background.png | Bin 65075 -> 21578 bytes htdocs/gfx/openwebrx-top-logo.png | Bin 16567 -> 6883 bytes 4 files changed, 0 insertions(+), 0 deletions(-) diff --git a/htdocs/gfx/openwebrx-background-cool-blue.png b/htdocs/gfx/openwebrx-background-cool-blue.png index 7430bd8a8461217a5c07ef86558afc2bcfcf98c3..236b366b33b822bed602f0907c18ab835cedb13f 100644 GIT binary patch literal 66934 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%0DZGM*PZhqa#rmq&db4a{qoE6 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

    4oCtO&#xZlz`{J?qeoNP9ml#I$3=7fWW?ZX2 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 em;!;odh!FUM%$D_&L@Zf0000I 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 N002ovPDHLkV1fYIL5lzY literal 65075 zcma%?c{o(>|NqN7$r4GnVq}RTgvdH7WH(9KnJigil6@U236Zg8H?qqz+4oVx*fQDo zkr|AAj4_58!_ViB@BhDZU1zz^bzj%Hu5+Dp->>t0JWlLW6Wz-!*ICY;J9k-MPy5-q zb4+Px$Fi3$o^5~kF8Dsye}3s~3A^MRbJk{lt7qkV?wlz2f9v_3pGw7NC;9z! zEd9*9T>JtZd|sUk3=EWUf8*inDjF*pV_Ky1XbLVcJ)7O6VJSb$!e>Zuia6%_bLMNS=(E7>V=&FXG&c$zN8;=@7 z=su>90^sDt2b-XC@ccBSu68_rKl8+VE`0q2r(AN@X(Ut#3di#kC~A$6ILO?S>>}4i zigJQzBP5$T*9fUEC141Y?L3_FlL)GGt1|n7Y+j6PNFHvqZbMU~SL1a`r$3xJkwI?R z5r$cNIN8E`%Q}T%sAd8&$f(w|tzhh^FBFE0A*rJdbwp7o>k&lBIf8c{eUg{Frkvm& zU!dc}X_uURvU(Z`c7@gPA{J3<1*JyRnL>z)(gCO?2gMJO3fChHlX9H%U6Vl?jgunz zC)Dt{#v5Of1%svR4MAk9^(Fo)2r;}f+Lm-eZ>2Q%_mL0O(Lq}gnPaZ;a<(r}-(?(19CuqAX4u^953l6fVAG^UmB&9tpS)3Yp}uN(1PB>2Q+J}J zMxv6sSa01}xxXZ3mXmsKP}3du3i=dH(dQVzi8gGMI^HFd{g3e7;nxY?0%mOeClwb9<)qjvjAzP` zuD0KHluI1{4Ab#jzH@w`j-~q(#j>1t3Z!^DmRlgw2hcc@o~H8DnnHT%jv;8CBlJ&l z!!d=XvBWb8C`MoR*YMLI~LrE zC}pESVFUt!^S@>E9pCj&C?ix!UBoWc@J53wn$eac>eo2HBMDR5E3@D)fL2k)LVEpY zxuLPBa7W4BD~|HLwg;sFnN7-=WLeHP2O(9Y9Pf{o@xVo>n1}RrAtIBn7W&bCQ!mc9 z;(GdyDY6RPKB>oFy?YfkTocRQkEPKT@m5B8gy3i?6=rlbyIDb;-T@N!G z2h#)&9rPxls{hL<-+icyuao?ox9OXa=?)5;^wTtZfT+ zz%2**WPoS(<92f{lw@HO?Oa}Jm#8YJ$TO9{Mhx0Yj~s{<|BUkAQa6N?C@arH2YiZ4 z?T7qk{WzdZzm8e>c!JW6e2oM|A1TfSz(q_OJwCKLOFh-tbbjBgc38wV&|6m(ev88u zHvKd#0w}b5eokx(cl+j@j;o++f0<{Cf4?H2{!4+aOQ(OWb;-ChcQdV+SjlwR!FU}@h9J!r2SX#SPz$9#K)Ay zKIb-lrTgNaXW=%6z?I3llFd(BPT8aJ2XQH^DKzJ)EnA30P zg~}I2{dXOKlc(H@U%2~I73h#rAX(8^N(<}K*Au;=O*?6_qa zqx_IsqFf~Me%7zeFEkPJr2F=`CvBl=FnYZ1^PgK0GT4`G<2ObIyO?N4MW?AMtb^F} z!Pq^=z1dLX-H_VV<8Q{wcHRGVaSmIMrL(LqfkzC2zw>l12HgNIGLz*RB$PAEP%f*PUM0qb+Pq~=PBZS$NpcZvo&DTjm$?h+-mO-Wh` z>1Pc-DN++4mCpWUL7U|*yLQ`F;_&voS1tn4PRrhvyjwJYO~vs~r&)>lW#AYznRkpY z-A*=x5L`K4pu|IuH54HI-N}`KWwu({a?=w^EyY~oiO1uwHkAg@Zf1ACdNAA(DPc^> zYIh#u?0EBA>iXJZL=(-tY3*Y^8rb$O(ChF4Jli0-} zEs7miqBrp^2Smi(_#hm7O=@V`+JxO+i`6(|HL#1nF3me|=CMAd$hTdf zu6DAmmY0~KBn^CSY^0QjtX6VKg;>xk8+sIHvix&Lf1!a-AR~*^yySk}gKs>pjIeuE)7Sm@Y8+QaoIm&bi5_&n+# z-{5A=zBgnXzk0m{brp(o9Rrme_xsK*?F{Lf; zBDi-%#OXTcR)PHW_I~)i?ydd)$Rl#>l_1yT-;cG2l%OrMd0z9j%6pB{a^KxoScM0^WU8J6E2iMMjDmY z{%~uud}U5pQ|8ZDeetgj!1FUh0>dv5ed@+3xF@IvLF1Ak;!<~KRY7*)fSYR-8C#!A zNb0|sjN===WnbocMWBRQUlutoBoX~Q2Uib>8#gR>B`TaK2KJP6xd6# zAkZb78$3jiSD~fG7UHb5FA}YR^O^vz2m=X$aWSDmj(c~Uy8ON7SQ*nSZ)sBQJTXAH zpH|egnv_9%L3F^u>G{aY@HifDcO%2v-HWY+Jv2QSD@5<;5);@o@S1@|hEN!_%d6{;4d!cw<$u!h#1g%k?^7K>;I zbu{^Tp#Pw#@Q7@LQtu6x>oWiaX_7ziy(!j;BCV$I2FbZsrC2(C))*zeu>W?Nn*iqa zj@|C#Jt+e@GCS}$LGeyJ877;Ng_D>aTp*etR?u(9c)PTnwt@ht_dlf*RUqVVs&{WDlB5)4~-!Hm*-yC0#41}*)qF!~@64Jt(+xb9)5*E^rs0cz2ElD$j z;M}$>g{y?c-A}{=0d#t~hF5f>7SQ)N4;B8|w1ZM?5>~6#q<_Si5O?D?a7k$+4sURs zD{xKOOQ+5AeSMV=6O=$!+e`8BX>C^c%GlXQ#=P+D zlWQCr_F(RJn_@SpE>Lh=HtJARBwS1wtqoODVTe_lNnM? zUzDbpMO`%ELBbLMuILV@IZ99mMrB5shiw>|*1p>OJH(;iv-d^)SQ=prM%XLznt)BI z3D(uZF`?~?h!{5~br_RLmKsc$@RPj{I_2!TB-W`XgiDL)?WrhAlK827A_7dzp%`-k zwhQj?`ffar{cNhxI7`tWv;>e}qug2lzq*<9n(5+bqa5>_1h)UH2H_0K!C*@?Vf zDoQAVO~qTrU=LUDtl`&X&V5k39*6%I6*g#eP}4x#ZC}b@mvXlxk`%Z|xgj2yC3xd% z=ilg*#p|qcUxB3&UOiQ)1Qd`+VP#X+i1=s_XJxK|!MF-5kWM zCj=Of`m@X8<_}Hg(aX+`K^3_B#eM>t-+%t=&!$ZG0`O+zHfb_J1R|&+8 z;d(CHv(1l)0BGu*SLArPna?qA?c;Rx8LkSI9+u%e)ngOcj)T$#SOevzx?^&320ypG zT)SP12AbQpDBL=G{`(e3(GTB!|G=}>H<0+p(Bk-`4rb*P1sAm>PRnv~LP(Ba%UmzWZML%#y3h;3**6!u4PY02)TzM1%N^@xwBd0lI`E}&|Ns>WLvJfyyzrP|D(LN|E!O4gv6 zqQUiCaICvfXtU@;sJ!IKhHobyufTn(0suE4!+MTVCYIFJsu<3r66Tm9Z*^o>s>XZs zkD8}lbo1k9;Zwhs2JG8q_a3&^e{QI7z2oXwJ~>-20w>PRfxpB>-Cq#Jcy~SV+YDt0 zAISMRO|zX@F$j2V2u=1n#yBO>q(9I=)b+zJXf>p_3L=bcT^KnL*qxrByHFbBnd=VH z%htqv}knD*vU#iZla z(SNNdw1rZ4=)MZ;^th{uObWY(sNhIlsswnmkQfyIHuONQ3VS`+cy5x^-8IUJ4P1KV z@B=d#7~D>3UaFY^dk90*hDRm#tN?LRtznMCkjR=ICpF&?%I~a~(~TO{?-Yp`T;W(I z^!YEjUYT!B%W~uYZDjZ%?OLiq3@Pa{p&Kbp6&~d8N>u3qcrfFU7O?tQocz9?N~%^v z{xhb^<~7T3N*r$5#)NLhv;7=nBNBem#w?D7tZ` z?X_}RdViyhnQ`%kCBVtt@_qeNuYDB>e_4v$40Yf^NY1M&(-tmx>7NixC&8wD(fYzy zF2e?{qnfRh5UcPK4#1OoWteDVM?fk5@%g3mkg^z7u8D+g)jJAc2OSN&rPsYVF|`^q z=VW^$W3t9oX#CP50GKLc*^g;jBWE<-r?hs?8e6vM*S%Kb1sM-3GP8dB17b7T;W%7u z^}t+{6VlF*lmUs@no3w!dD9T1Y?7-gVq_(;p|f^>$p4M7eQ0>j;=~7}K#wVuz~e)r=*LBF)~jA#qNNibRpdnJ(zur+^H_TL zW!v6ntwoWLdgVcKEHbu*iFOqd1@Uo+M!nH6j2FJXCOXd>YG3I(qeQ?hSJ46-HDGed zksC;)S-YQ0cvOs!F7YGcsz_D4?y;rx+owXKjO!)KMu6~v%?bAHMBfYJy8U+HgE(HN z^d`fzOih87FoUn{JPU8$M}={gI@zOPB93{_OMVUH-@0-q`%j(&j;Y{q@o}I}0 zS0UcaZ`-o>X*kX=1vXkrx8|OmRV*G8^WA38VzsP~&JmMh(mpDWuv7WUFu>5?zj@c0 zo3Hw3RVSJKiY%L{JsaDyDE$fqa-kO&0|CAD7hpa)+EG=p;~o$5e|;cKx8;p&n#3xT zj;tMW*d&y{?vTo65eNh^m*Eu^$0|4{5woA0dp57Gg)b`j5q>d8@H*mcLb$(k<~{~ zBzVhP<5k<1jsCUmv>wqfb~?9alyF(Rj|Hcbc~G|P%J%jZa%$+?S_64O@96i!Q#2C> z$D>K9Cmy#nH=>a#;byS?oxp(yaugnQ>&jk{P7S!D7Ci-=x)^wUJ+e=MjEkN73<{gN zORT2eUZk^22RMx43Ew9aJX3<+`xLWTK)io_a{_O`=V%6#VF54LL=ybi2#`(E3)8P5 z8>-b~56t<}<%vqip&Atww%42{OO5+=X>-MZ5fI64K$0g39v@k3(eN);$s}t9wH6^3 zG4Kd|MfRyh7^gh(0<@U)jJl!2gmy0r9OoS{Bfe{4uo$6rF&s!NmZwqjKEqQ2&t$sv`$I#d~>-_}~)cgI>GZXTxMs_!8p zP#XCb`rsQ34RL5MAY`(wiCRs~)>nxVqfDzU8Wp2<+i9 zdoxv(KWhwhnHx9Tcwt-mn|Vr}kO)q?YcC6_oT;7<5-LTNC+vrXU)Fp)+wgRdm0B-H zE}m5;tGM@3S2qXN{bN_Zb5AUeXD&}Q{9WA|Q8MlVD<(`<2To67rDyVHlQ0obsPWMc zJg~A#c`}*Z`_cK83MJj)Nf8tsyB=M+zAW~J0Bvvm1Vp(%rN4kKSDONC=m7a0S2Q!; z<8kv^d%&Q*A`G*R>M2!P8_KXq`JLI>r=%#{q_bb?WcC$Xj=wwPQKVTfXHx%NnnF6t z%!}PDjecG5{e6}5Z76cna?*yB=|lwvrtk_`#ih|#EWaytKwwGPqo^x}Kb|xa=E|ff zhEq|RKev3T&T1|>1JJPdiJAR52&07#vf0#kyq!`PF5)0JPEqD z`o^$+1Aepm}q;@5RC2In8UhWd# z6O(^rwm-ei_Q51hA?8UR!^HP|Lzk!gcA0ki6K6#v{He2AYuPmWx$=7#P*~2D9)@!k zU^7#ZRc<$d2f0ydjC7f|PkT|=1X42Br)SR^R$dOS=%K#nAB9^yUfUZbd4f7Mh_}uq znwD8=AvaZI^loZ0*N+2WrQ;mrm_4x|ZpZ~5lJbRu*^GC|J1TCVfc54AY~cIdoZH#7 zFKZnEqpCkje~%IY9Vo^Se78Az;FEqKFa6Rd5(dRMe~>LLahbF+v=!-L8(z zxjafmlE~Bq+biE*{GR4~SFv_mFu&7GvlvFUJ)&8cmrjJqX!2wQy~@tZKlsK`ESg!C zquYt8yE_}2(}CiuaOQWjgSn@iw1@|iP57zTF`G4p>9l)9y@zSucL)evln=MhdNJm| zF>BijKgPU8T;>Zo&&dja>ZH%t!T@s4$Sm+PH_03O!q537tpxjjEYAky)NRl5K$TS?UW$*>`XMUap3gQ+S_%GM_;A< zjRF zWVa^EaX+v&-aGtQ1$wJIEOJ=@Hi#l85T8sLxLT+?%01*IK9Kt|W)i=#ukyxf#P}q9 zPim*soG!!h!+{YhuTyGOv!d`9UeE4NRfOV(?thuD@I zM>)i{fY{aqm7P_u1F{s|uKxvMN&hU>gsbngR;s&DEj1>qmY0v#Oi*7y6$;nl1+ao`FlXY%c?QHnJJQuLwf0Vq)86|3|?i=cVY7}3HE{djR zN4;LK&=p@DHXeX;P2z#MZq9uF(56+hpm>H|DEnFsIyF^3}wJPdgrAUD3y4xd}BE z-tUMR$lz+}S8p~CZs%lBUZ*^^&xvs`j+3`HR`Rf2hEbQ>L>qkN3dHA&B?LlGhUMJk z!{+MCg?aItVt4Jwy-)((rm4kK%g=aTkYYB)s&RdRdQDQ`68oA)-JC})e=Ie9@kMeH zl`k|4OyZ;F-8Stl^nkgpdGhI8qc2X!XlWG(DP##_d7;S3BKO6nn8^Y-r}|2DgA!yQ z3;atCq}*W6x*<4-*iG3qZWH&e=#a*sJXDcwU`1J(E?0~tYl&$6+CO@TZoRs(j>=f$ z_DF&54XTXK&G0il%6k2P(_2s%P-tE^VCc+SwIcUtng8fXIC+g2 z&TX9BL9?2z7q4@U+yixIRITiIqpj4N(sC5-AuiS{Gk{Z@dgaj0LqD)`Dxn(O=>sF!pOq8yfC2j?bO_kMa0~&5mm7F5!Ad!jeJJPKPi>E z9p`vh&cN2b**^m*!;{rzG~2Ov)+?HCbt=lL8+POc@{N_Oj1@;Bx{^C%EmUS809^qhT=C|pSS-E# zO^>)`4V2ETxw2E5!qvss#|c)kPAUO=2s#-Nv3&t4@^U38?AgO71K9WRIS(gUrR4WO zH@m(V7@7MN1Qdw@GMcGQi~A!T#uysSx+#f$eFOvlFW@Bb{sx( z`fI?hDh-&Z3b%8(6m;pfEwjdXpQn^{$6Z{)Lxu|jy@HKyY2W(!7y*`^EA;z&FXW82 zeS5xQ{uI5K+k$=4!FP#VW(CU#jXbiCagn{bX8-<5Rc{5_&$CbIOOz_VoEnCt?w$Gv z_<@Yvp1QCmcxAF13gA?b7;M2!=UV<-TSsZBnGg0K?r;!ygM@IoeFo~>0}&%g!! zJyFTti4FbtS2S#N+sXQ%6`LZindktf7mtGguIimEL(N`14_!DvLgX(h{~c{ky(Skc zNi)hDD6zc%>KT?FprQL_u#tAf{+uO~5M#E*K)MH{3 zImmn9onxkrFk}8_vu>txal{~zfWy(}dUG>2^}iK0;79N_JvxNQhiSw`3(UF~tQ3w> zD<<&qYB1kcB`{w@?fgrPQpFIE@zlzL6^(y%G*6)%)*1b0 zq8cT=HwL|^>GZ*V8movcVQC5m|Lbd`G5om^pkVtrm&hkV$B&Fk@|Xq&B@aoW9ZoB9 z#vm3z5l5(c)=7zd<#F3dV1!goKW$m6{nG08$YP8M)p_yq2W`_J1&>w7o+&xJH*{<_IaT}@WY0#LjY4%AX#ST(|TX$^(7VvP) zgT8-`2@)c#>B0bV#+GC3hKq{21&o$+&lg8HHkB0F+IP$6aM`Pw&&>5K559> zT@$99(CtNONxQVDja9tjcxAx0G2BY;LI5O16X3i5I;mNyzE9lW9TO8`l55)&I(y#( z73NiY+*3La#*q%oh;Ne`PGB*n=?*uR&x6H#n%b;Ug#W;#$B3}I&8u*AqvZT zms2P?^Wp#zgz}qyi2cVGlpl?v@WPs1e?6&FoKyZA1Byj(#$oXo(l1zGcQytVd+ez0 z(;@cBp~FG3vqvr{VeLR`F2HW4QF^wMKG#Wp@sj*8{=(tP`F4t`i21`e{#$~AmzZ#| z@ngTZCtGzNi}!`zpoyy&I^2vf|pTJ*gmZH){(T{Pj#TtHpNQtCR z)Gf=h0xwu!aC`ndP`UMzi88?C-RRTWFKieYn=kL%R|?+4H6u(aM42By+3oSZ;8)Eq zudkU42oy}Qkj&xK^Y#~CJio;F(k|78v5{{d@$QD;TcVBBqqHDkK?8ZG=!{oKC5R-{ zp(baWn-VyynIDrYC`@mMIJ30XbeC*iW-v3RM6Bm6KV#2P{a$kr6K;G>tI20?6N$yJ zK3aZ7%=fclwvv7t4DL5KKE9M%D!Vr2T%ItaRv1{7Kh$$h;l~}|=#QII^DFz7tC zRd2nT8~!A2e&vKtR|r2olsi(UyCjRBOm<}*DcAGY7r_cn^DCS6VopBqEq#>WwO(tI z-Tek4Xxv+sv^K0R|E+rQi_RCLXUQ0W|6myhQLOguPeToi_y{cL^eIxp$TLt|LyzhA-oVC4Y{Uzz2>`^Fu?WNpT^p!1x zHwI#E>D}Vp<|p=mft08_4HEp94t+#yDZ9aYX%Q*#eW?V+4!hZ%t>1*7P5a~=&*!IH zTsqy|tl%x(R=obDP`Tq zy5t1f&=w8;M)>&t+Hbpq>i?F7{r^I;AvyRkZRrS7IHk=fEDVcAq#+JMHPGIfW)e9{ z;y&Y-XEb>~B;w--Q>T!4&g!|ZJ?7|?d3!wt_otev;mA^yemk<5JzC_+5@ zc~XyD)aLagwEA#O>m1^+3tJO*e*0z5gW0%EzTnk*RalP2^~g0&@NL&dl(+n%bJCOa z-T{VeShSL<&V0*4*Ti}#OCY}B8TR?cl%&jB1l4Nqi<7fjJ!Ev{BsEGA+*j79{~r#p zqNCyJ@CE(3b7#?AGqtH{+X=ir@Vx{}>^m~ZJd*omQb_XF;abOPgoN9L!D0i8U_IK@ z^WdD+2v^{S(-dXrhQ8B*DZ&{`f^Ye+pxeO%6YIOpkHAaB)aKscM(YbLT}ANT!p;Ry zl^-f@de~!1|47nn5z4dD6?(rUX4Fe2$3eq@^7KL2w=5cG)B1}oY`5^ro_=HN6xnG7 zpBugZ3sXwb%2SZJMsLV`Xz|5@>-&UDctrO}P<){Wou=4Waa~~j&nf=$%)vlbxG^b_a*bt(BT*6TH<-X%adZG3ZKh6#$_29+)47oOyPQsh|I8ntNn z1qvTK3P(ia-#ExLzinSaKj~ifGCzFU`tu84QEttX6t`Qy#{>6r{jM;vd1@Y3j~zZE z$b8{%j%+BJbQG|Xwf-KSmeMxP2xFzj1LIkL7Ek#?+dQiq>S5uzVR{jEa*j9JaXDS# zf>n8)w{-iJc()9^+%)wgX1k;h>I0114Pkyo2bDHw%4z|=JNN?V{U6mjXR}uO02~R_ zTJ~UBiWu7~bKRUIb|QHZYX;}?9+Ey%0ABh1ci7xpLZr9<{mjx* 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 MWc0A~zU{~V1GJfD3jhEB diff --git a/htdocs/gfx/openwebrx-top-logo.png b/htdocs/gfx/openwebrx-top-logo.png index 477242524b9434230467dcccadfbd489c37607e0..b1f28a60049a16f25631acb162a98546720966a0 100644 GIT binary patch literal 6883 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 G2mBxY3Ze%9 From 1b4b87b14e585c162c48acba662feae61afcf54c Mon Sep 17 00:00:00 2001 From: Jakob Ketterl Date: Wed, 5 May 2021 19:27:03 +0200 Subject: [PATCH 471/577] replace play button with an svg --- htdocs/gfx/openwebrx-play-button.png | Bin 25904 -> 0 bytes htdocs/gfx/openwebrx-play-button.svg | 1 + htdocs/index.html | 2 +- 3 files changed, 2 insertions(+), 1 deletion(-) delete mode 100644 htdocs/gfx/openwebrx-play-button.png create mode 100644 htdocs/gfx/openwebrx-play-button.svg diff --git a/htdocs/gfx/openwebrx-play-button.png b/htdocs/gfx/openwebrx-play-button.png deleted file mode 100644 index 4a0652178d0589e0056b83409ce7df62ab5a34ba..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 25904 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 A>i_@% diff --git a/htdocs/gfx/openwebrx-play-button.svg b/htdocs/gfx/openwebrx-play-button.svg new file mode 100644 index 0000000..208b4f0 --- /dev/null +++ b/htdocs/gfx/openwebrx-play-button.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/htdocs/index.html b/htdocs/index.html index 8b3af5a..f51218d 100644 --- a/htdocs/index.html +++ b/htdocs/index.html @@ -159,7 +159,7 @@

    From 282ba4d095935d189406efaf56c1cb3e59909657 Mon Sep 17 00:00:00 2001 From: Jakob Ketterl Date: Wed, 5 May 2021 19:56:14 +0200 Subject: [PATCH 472/577] move play button overlay to javascript to avoid downloading the image --- htdocs/index.html | 6 ------ htdocs/openwebrx.js | 12 ++++++++++-- 2 files changed, 10 insertions(+), 8 deletions(-) diff --git a/htdocs/index.html b/htdocs/index.html index f51218d..316a01d 100644 --- a/htdocs/index.html +++ b/htdocs/index.html @@ -157,12 +157,6 @@
    - +