diff --git a/owrx/config.py b/owrx/config.py index 2aff716..a87d066 100644 --- a/owrx/config.py +++ b/owrx/config.py @@ -1,4 +1,4 @@ -from owrx.property import PropertyManager +from owrx.property import PropertyLayer import importlib.util import os import logging @@ -24,7 +24,7 @@ class Config: @staticmethod def _loadConfig(): - pm = PropertyManager() + pm = PropertyLayer() for file in ["/etc/openwebrx/config_webrx.py", "./config_webrx.py"]: try: spec = importlib.util.spec_from_file_location("config_webrx", file) @@ -55,7 +55,7 @@ class Config: return [e for e in errors if e is not None] @staticmethod - def checkTempDirectory(pm: PropertyManager): + def checkTempDirectory(pm: PropertyLayer): key = "temporary_directory" if key not in pm or pm[key] is None: return ConfigError(key, "temporary directory is not set") diff --git a/owrx/connection.py b/owrx/connection.py index 56d5748..b420568 100644 --- a/owrx/connection.py +++ b/owrx/connection.py @@ -10,6 +10,7 @@ from owrx.bands import Bandplan from owrx.bookmarks import Bookmarks from owrx.map import Map from owrx.locator import Locator +from owrx.property import PropertyStack from multiprocessing import Queue from queue import Full import json @@ -195,14 +196,14 @@ class OpenWebRxReceiverClient(Client): # send initial config self.setDspProperties(self.connectionProperties) - configProps = ( - self.sdr.getProps() - .collect(*OpenWebRxReceiverClient.config_keys) - .defaults(Config.get()) - ) + stack = PropertyStack() + stack.addLayer(0, self.sdr.getProps()) + stack.addLayer(1, Config.get()) + configProps = stack.collect(*OpenWebRxReceiverClient.config_keys) def sendConfig(key, value): - config = dict((key, configProps[key]) for key in OpenWebRxReceiverClient.config_keys) + #config = dict((key, configProps[key]) for key in OpenWebRxReceiverClient.config_keys) + config = configProps.__dict__() # TODO mathematical properties? hmmmm config["start_offset_freq"] = configProps["start_freq"] - configProps["center_freq"] # TODO this is a hack to support multiple sdrs @@ -253,11 +254,10 @@ class OpenWebRxReceiverClient(Client): if not keys: return # only the keys in the protected property manager can be overridden from the web - protected = ( - self.sdr.getProps() - .collect(*keys) - .defaults(config) - ) + stack = PropertyStack() + stack.addLayer(0, self.sdr.getProps()) + stack.addLayer(1, config) + protected = stack.collect(*keys) for key, value in params.items(): protected[key] = value diff --git a/owrx/dsp.py b/owrx/dsp.py index 3f155c6..693d12f 100644 --- a/owrx/dsp.py +++ b/owrx/dsp.py @@ -4,6 +4,7 @@ from owrx.wsjt import WsjtParser from owrx.aprs import AprsParser from owrx.pocsag import PocsagParser from owrx.source import SdrSource +from owrx.property import PropertyStack from csdr import csdr import threading @@ -23,23 +24,30 @@ class DspManager(csdr.output): "pocsag_demod": PocsagParser(self.handler), } - self.localProps = ( - self.sdrSource.getProps() - .collect( - "audio_compression", - "fft_compression", - "digimodes_fft_size", - "csdr_dynamic_bufsize", - "csdr_print_bufsizes", - "csdr_through", - "digimodes_enable", - "samp_rate", - "digital_voice_unvoiced_quality", - "dmr_filter", - "temporary_directory", - "center_freq", - ) - .defaults(Config.get()) + stack = PropertyStack() + stack.addLayer(0, self.sdrSource.getProps()) + stack.addLayer(1, Config.get()) + self.localProps = stack.collect( + "audio_compression", + "fft_compression", + "digimodes_fft_size", + "csdr_dynamic_bufsize", + "csdr_print_bufsizes", + "csdr_through", + "digimodes_enable", + "samp_rate", + "digital_voice_unvoiced_quality", + "dmr_filter", + "temporary_directory", + "center_freq", + + # TODO: following properties are set from the client + "output_rate", + "squelch_level", + "secondary_mod", + "low_cut", + "high_cut", + "offset_freq", ) self.dsp = csdr.dsp(self) @@ -61,19 +69,19 @@ class DspManager(csdr.output): parser.setDialFrequency(freq) self.subscriptions = [ - self.localProps.getProperty("audio_compression").wire(self.dsp.set_audio_compression), - self.localProps.getProperty("fft_compression").wire(self.dsp.set_fft_compression), - self.localProps.getProperty("digimodes_fft_size").wire(self.dsp.set_secondary_fft_size), - self.localProps.getProperty("samp_rate").wire(self.dsp.set_samp_rate), - self.localProps.getProperty("output_rate").wire(self.dsp.set_output_rate), - self.localProps.getProperty("offset_freq").wire(self.dsp.set_offset_freq), - self.localProps.getProperty("squelch_level").wire(self.dsp.set_squelch_level), - self.localProps.getProperty("low_cut").wire(set_low_cut), - self.localProps.getProperty("high_cut").wire(set_high_cut), - self.localProps.getProperty("mod").wire(self.dsp.set_demodulator), - self.localProps.getProperty("digital_voice_unvoiced_quality").wire(self.dsp.set_unvoiced_quality), - self.localProps.getProperty("dmr_filter").wire(self.dsp.set_dmr_filter), - self.localProps.getProperty("temporary_directory").wire(self.dsp.set_temporary_directory), + self.localProps.wireProperty("audio_compression", self.dsp.set_audio_compression), + self.localProps.wireProperty("fft_compression", self.dsp.set_fft_compression), + self.localProps.wireProperty("digimodes_fft_size", self.dsp.set_secondary_fft_size), + self.localProps.wireProperty("samp_rate", self.dsp.set_samp_rate), + self.localProps.wireProperty("output_rate", self.dsp.set_output_rate), + self.localProps.wireProperty("offset_freq", self.dsp.set_offset_freq), + self.localProps.wireProperty("squelch_level", self.dsp.set_squelch_level), + self.localProps.wireProperty("low_cut", set_low_cut), + self.localProps.wireProperty("high_cut", set_high_cut), + self.localProps.wireProperty("mod", self.dsp.set_demodulator), + self.localProps.wireProperty("digital_voice_unvoiced_quality", self.dsp.set_unvoiced_quality), + self.localProps.wireProperty("dmr_filter", self.dsp.set_dmr_filter), + self.localProps.wireProperty("temporary_directory", self.dsp.set_temporary_directory), self.localProps.collect("center_freq", "offset_freq").wire(set_dial_freq), ] @@ -99,8 +107,8 @@ class DspManager(csdr.output): ) self.subscriptions += [ - self.localProps.getProperty("secondary_mod").wire(set_secondary_mod), - self.localProps.getProperty("secondary_offset_freq").wire(self.dsp.set_secondary_offset_freq), + self.localProps.collect("secondary_mod").wire(set_secondary_mod), + self.localProps.collect("secondary_offset_freq").wire(self.dsp.set_secondary_offset_freq), ] self.sdrSource.addClient(self) @@ -134,7 +142,7 @@ class DspManager(csdr.output): self.subscriptions = [] def setProperty(self, prop, value): - self.localProps.getProperty(prop).setValue(value) + self.localProps[prop] = value def getClientClass(self): return SdrSource.CLIENT_USER diff --git a/owrx/fft.py b/owrx/fft.py index 93cceef..9d37bcf 100644 --- a/owrx/fft.py +++ b/owrx/fft.py @@ -2,6 +2,7 @@ from owrx.config import Config from csdr import csdr import threading from owrx.source import SdrSource +from owrx.property import PropertyStack import logging @@ -13,7 +14,10 @@ class SpectrumThread(csdr.output): self.sdrSource = sdrSource super().__init__() - self.props = props = self.sdrSource.props.collect( + stack = PropertyStack() + stack.addLayer(0, self.sdrSource.props) + stack.addLayer(1, Config.get()) + self.props = props = stack.collect( "samp_rate", "fft_size", "fft_fps", @@ -23,7 +27,7 @@ class SpectrumThread(csdr.output): "csdr_print_bufsizes", "csdr_through", "temporary_directory", - ).defaults(Config.get()) + ) self.dsp = dsp = csdr.dsp(self) dsp.nc_port = self.sdrSource.getPort() @@ -42,11 +46,11 @@ class SpectrumThread(csdr.output): ) self.subscriptions = [ - props.getProperty("samp_rate").wire(dsp.set_samp_rate), - props.getProperty("fft_size").wire(dsp.set_fft_size), - props.getProperty("fft_fps").wire(dsp.set_fft_fps), - props.getProperty("fft_compression").wire(dsp.set_fft_compression), - props.getProperty("temporary_directory").wire(dsp.set_temporary_directory), + props.wireProperty("samp_rate", dsp.set_samp_rate), + 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.collect("samp_rate", "fft_size", "fft_fps", "fft_voverlap_factor").wire(set_fft_averages), ] diff --git a/owrx/property/__init__.py b/owrx/property/__init__.py index 655cd42..8489fec 100644 --- a/owrx/property/__init__.py +++ b/owrx/property/__init__.py @@ -1,13 +1,18 @@ +from abc import ABC, abstractmethod import logging logger = logging.getLogger(__name__) class Subscription(object): - def __init__(self, subscriptee, subscriber): + def __init__(self, subscriptee, name, subscriber): self.subscriptee = subscriptee + self.name = name self.subscriber = subscriber + def getName(self): + return self.name + def call(self, *args, **kwargs): self.subscriber(*args, **kwargs) @@ -15,30 +20,39 @@ class Subscription(object): self.subscriptee.unwire(self) -class Property(object): - def __init__(self, value=None): - self.value = value +class PropertyManager(ABC): + def __init__(self): self.subscribers = [] - def getValue(self): - return self.value + @abstractmethod + def __getitem__(self, item): + pass - def setValue(self, value): - if self.value == value: - return self - self.value = value - for c in self.subscribers: - try: - c.call(self.value) - except Exception as e: - logger.exception(e) - return self + @abstractmethod + def __setitem__(self, key, value): + pass + + @abstractmethod + def __contains__(self, item): + pass + + @abstractmethod + def __dict__(self): + pass + + def collect(self, *props): + return PropertyFilter(self, *props) def wire(self, callback): - sub = Subscription(self, callback) + sub = Subscription(self, None, callback) self.subscribers.append(sub) - if self.value is not None: - sub.call(self.value) + return sub + + def wireProperty(self, name, callback): + sub = Subscription(self, name, callback) + self.subscribers.append(sub) + if name in self: + sub.call(self[name]) return sub def unwire(self, sub): @@ -49,80 +63,79 @@ class Property(object): pass return self + def _fireCallbacks(self, name, value): + for c in self.subscribers: + try: + if c.getName() is None: + c.call(name, value) + elif c.getName() == name: + c.call(value) + except Exception as e: + logger.exception(e) -class PropertyManager(object): - def collect(self, *props): - return PropertyManager( - {name: self.getProperty(name) if self.hasProperty(name) else Property() for name in props} - ) +class PropertyLayer(PropertyManager): def __init__(self, properties=None): + super().__init__() self.properties = {} - self.subscribers = [] if properties is not None: for (name, prop) in properties.items(): - self.add(name, prop) + self._add(name, prop) - def add(self, name, prop): + def _add(self, name, prop): self.properties[name] = prop - - def fireCallbacks(value): - for c in self.subscribers: - try: - c.call(name, value) - except Exception as e: - logger.exception(e) - - prop.wire(fireCallbacks) + self._fireCallbacks(name, prop.getValue()) return self def __contains__(self, name): - return self.hasProperty(name) - - def __getitem__(self, name): - return self.getPropertyValue(name) - - def __setitem__(self, name, value): - if not self.hasProperty(name): - self.add(name, Property()) - self.getProperty(name).setValue(value) - - def __dict__(self): - return {k: v.getValue() for k, v in self.properties.items()} - - def hasProperty(self, name): return name in self.properties - def getProperty(self, name): - if not self.hasProperty(name): - self.add(name, Property()) + def __getitem__(self, name): return self.properties[name] - def getPropertyValue(self, name): - return self.getProperty(name).getValue() + def __setitem__(self, name, value): + logger.debug("property change: %s => %s", name, value) + self.properties[name] = value + self._fireCallbacks(name, value) - def wire(self, callback): - sub = Subscription(self, callback) - self.subscribers.append(sub) - return sub - - def unwire(self, sub): - try: - self.subscribers.remove(sub) - except ValueError: - # happens when already removed before - pass - return self - - def defaults(self, other_pm): - for (key, p) in self.properties.items(): - if p.getValue() is None: - p.setValue(other_pm[key]) - return self + def __dict__(self): + return {k: v for k, v in self.properties.items()} -class PropertyLayers(object): +class PropertyFilter(PropertyManager): + def __init__(self, pm: PropertyManager, *props: str): + super().__init__() + self.pm = pm + self.props = props + self.pm.wire(self.receiveEvent) + + def receiveEvent(self, name, value): + if name not in self.props: + return + self._fireCallbacks(name, value) + + def __getitem__(self, item): + if item not in self.props: + raise KeyError(item) + return self.pm.__getitem__(item) + + def __setitem__(self, key, value): + if key not in self.props: + raise KeyError(key) + return self.pm.__setitem__(key, value) + + def __contains__(self, item): + if item not in self.props: + return False + return self.pm.__contains__(item) + + def __dict__(self): + return {k: v for k, v in self.pm.__dict__().items() if k in self.props} + + +class PropertyStack(PropertyManager): def __init__(self): + super().__init__() self.layers = [] def addLayer(self, priority: int, pm: PropertyManager): @@ -136,8 +149,26 @@ class PropertyLayers(object): if layer["props"] == pm: self.layers.remove(layer) - def __getitem__(self, item): + def _getLayer(self, item): layers = [la["props"] for la in sorted(self.layers, key=lambda l: l["priority"])] for m in layers: if item in m: - return m[item] \ No newline at end of file + return m + # return top layer by default + return layers[0] + + def __getitem__(self, item): + layer = self._getLayer(item) + return layer.__getitem__(item) + + def __setitem__(self, key, value): + layer = self._getLayer(key) + return layer.__setitem__(key, value) + + def __contains__(self, item): + layer = self._getLayer(item) + return layer.__contains__(item) + + def __dict__(self): + keys = [key for l in self.layers for key in l["props"].__dict__().keys()] + return {k: self.__getitem__(k) for k in keys} diff --git a/owrx/sdr.py b/owrx/sdr.py index 19c34c3..69aa261 100644 --- a/owrx/sdr.py +++ b/owrx/sdr.py @@ -1,5 +1,5 @@ from owrx.config import Config -from owrx.property import PropertyManager +from owrx.property import PropertyLayer from owrx.feature import FeatureDetector, UnknownFeatureException import logging @@ -19,7 +19,7 @@ class SdrService(object): featureDetector = FeatureDetector() def loadIntoPropertyManager(dict: dict): - propertyManager = PropertyManager() + propertyManager = PropertyLayer() for (name, value) in dict.items(): propertyManager[name] = value return propertyManager diff --git a/owrx/service/__init__.py b/owrx/service/__init__.py index efda39d..7bda36b 100644 --- a/owrx/service/__init__.py +++ b/owrx/service/__init__.py @@ -8,7 +8,7 @@ from owrx.aprs import AprsParser from owrx.config import Config from owrx.source.resampler import Resampler from owrx.feature import FeatureDetector -from owrx.property import PropertyManager +from owrx.property import PropertyLayer from abc import ABCMeta, abstractmethod from .schedule import ServiceScheduler @@ -164,7 +164,7 @@ class ServiceHandler(object): cf = (min + max) / 2 bw = max - min logger.debug("group center frequency: {0}, bandwidth: {1}".format(cf, bw)) - resampler_props = PropertyManager() + resampler_props = PropertyLayer() resampler_props["center_freq"] = cf # TODO the + 24000 is a temporary fix since the resampling optimizer does not account for required bandwidths resampler_props["samp_rate"] = bw + 24000 diff --git a/owrx/source/__init__.py b/owrx/source/__init__.py index abc35d2..1518f0b 100644 --- a/owrx/source/__init__.py +++ b/owrx/source/__init__.py @@ -9,6 +9,7 @@ import signal from abc import ABC, abstractmethod from owrx.command import CommandMapper from owrx.socket import getAvailablePort +from owrx.property import PropertyStack import logging @@ -35,7 +36,10 @@ class SdrSource(ABC): self.props = props self.profile_id = None self.activateProfile() - self.rtlProps = self.props.collect(*self.getEventNames()).defaults(Config.get()) + stack = PropertyStack() + stack.addLayer(0, self.props) + stack.addLayer(1, Config.get()) + self.rtlProps = stack.collect(*self.getEventNames()) self.wireEvents() self.commandMapper = None diff --git a/test/property/test_property.py b/test/property/test_property.py deleted file mode 100644 index 26634c9..0000000 --- a/test/property/test_property.py +++ /dev/null @@ -1,28 +0,0 @@ -import unittest -from unittest.mock import Mock -from owrx.property import Property - - -class PropertyTest(unittest.TestCase): - def testValue(self): - prop = Property("testvalue") - self.assertEqual(prop.getValue(), "testvalue") - - def testChangeValue(self): - prop = Property("before") - prop.setValue("after") - self.assertEqual(prop.getValue(), "after") - - def testInitialValueOnCallback(self): - prop = Property("before") - m = Mock() - prop.wire(m.method) - m.method.assert_called_once_with("before") - - def testChangedValueOnCallback(self): - prop = Property("before") - m = Mock() - prop.wire(m.method) - m.reset_mock() - prop.setValue("after") - m.method.assert_called_with("after") diff --git a/test/property/test_property_filter.py b/test/property/test_property_filter.py new file mode 100644 index 0000000..66eff3b --- /dev/null +++ b/test/property/test_property_filter.py @@ -0,0 +1,51 @@ +from unittest import TestCase +from unittest.mock import Mock +from owrx.property import PropertyLayer, PropertyFilter + + +class PropertyFilterTest(TestCase): + + def testPassesProperty(self): + pm = PropertyLayer() + pm["testkey"] = "testvalue" + pf = PropertyFilter(pm, "testkey") + self.assertEqual(pf["testkey"], "testvalue") + + def testMissesPropert(self): + pm = PropertyLayer() + pm["testkey"] = "testvalue" + pf = PropertyFilter(pm, "other_key") + self.assertFalse("testkey" in pf) + with self.assertRaises(KeyError): + x = pf["testkey"] + + def testForwardsEvent(self): + pm = PropertyLayer() + pf = PropertyFilter(pm, "testkey") + mock = Mock() + pf.wire(mock.method) + pm["testkey"] = "testvalue" + mock.method.assert_called_once_with("testkey", "testvalue") + + def testForwardsPropertyEvent(self): + pm = PropertyLayer() + pf = PropertyFilter(pm, "testkey") + mock = Mock() + pf.wireProperty("testkey", mock.method) + pm["testkey"] = "testvalue" + mock.method.assert_called_once_with("testvalue") + + def testForwardsWrite(self): + pm = PropertyLayer() + pf = PropertyFilter(pm, "testkey") + pf["testkey"] = "testvalue" + self.assertTrue("testkey" in pm) + self.assertEqual(pm["testkey"], "testvalue") + + def testOverwrite(self): + pm = PropertyLayer() + pm["testkey"] = "old value" + pf = PropertyFilter(pm, "testkey") + pf["testkey"] = "new value" + self.assertEqual(pm["testkey"], "new value") + self.assertEqual(pf["testkey"], "new value") diff --git a/test/property/test_property_layer.py b/test/property/test_property_layer.py new file mode 100644 index 0000000..b307a39 --- /dev/null +++ b/test/property/test_property_layer.py @@ -0,0 +1,52 @@ +from owrx.property import PropertyLayer +from unittest import TestCase +from unittest.mock import Mock + + +class PropertyLayerTest(TestCase): + def testKeyIsset(self): + pm = PropertyLayer() + self.assertFalse("some_key" in pm) + + def testKeyError(self): + pm = PropertyLayer() + with self.assertRaises(KeyError): + x = pm["some_key"] + + def testSubscription(self): + pm = PropertyLayer() + pm["testkey"] = "before" + mock = Mock() + pm.wire(mock.method) + pm["testkey"] = "after" + mock.method.assert_called_once_with("testkey", "after") + + def testUnsubscribe(self): + pm = PropertyLayer() + pm["testkey"] = "before" + mock = Mock() + sub = pm.wire(mock.method) + pm["testkey"] = "between" + mock.method.assert_called_once_with("testkey", "between") + + mock.reset_mock() + pm.unwire(sub) + pm["testkey"] = "after" + mock.method.assert_not_called() + + def testContains(self): + pm = PropertyLayer() + pm["testkey"] = "value" + self.assertTrue("testkey" in pm) + + def testDoesNotContain(self): + pm = PropertyLayer() + self.assertFalse("testkey" in pm) + + def testSubscribeBeforeSet(self): + pm = PropertyLayer() + mock = Mock() + pm.wireProperty("testkey", mock.method) + mock.method.assert_not_called() + pm["testkey"] = "newvalue" + mock.method.assert_called_once_with("newvalue") diff --git a/test/property/test_property_layers.py b/test/property/test_property_stack.py similarity index 67% rename from test/property/test_property_layers.py rename to test/property/test_property_stack.py index 7bd0336..2102a2c 100644 --- a/test/property/test_property_layers.py +++ b/test/property/test_property_stack.py @@ -1,20 +1,20 @@ from unittest import TestCase from unittest.mock import Mock -from owrx.property import PropertyManager, PropertyLayers +from owrx.property import PropertyLayer, PropertyStack -class TestPropertyLayers(TestCase): +class PropertyStackTest(TestCase): def testLayer(self): - om = PropertyLayers() - pm = PropertyManager() + om = PropertyStack() + pm = PropertyLayer() pm["testkey"] = "testvalue" om.addLayer(1, pm) self.assertEqual(om["testkey"], "testvalue") def testHighPriority(self): - om = PropertyLayers() - low_pm = PropertyManager() - high_pm = PropertyManager() + om = PropertyStack() + low_pm = PropertyLayer() + high_pm = PropertyLayer() low_pm["testkey"] = "low value" high_pm["testkey"] = "high value" om.addLayer(1, low_pm) @@ -22,18 +22,18 @@ class TestPropertyLayers(TestCase): self.assertEqual(om["testkey"], "high value") def testPriorityFallback(self): - om = PropertyLayers() - low_pm = PropertyManager() - high_pm = PropertyManager() + om = PropertyStack() + low_pm = PropertyLayer() + high_pm = PropertyLayer() low_pm["testkey"] = "low value" om.addLayer(1, low_pm) om.addLayer(0, high_pm) self.assertEqual(om["testkey"], "low value") def testLayerRemoval(self): - om = PropertyLayers() - low_pm = PropertyManager() - high_pm = PropertyManager() + om = PropertyStack() + low_pm = PropertyLayer() + high_pm = PropertyLayer() low_pm["testkey"] = "low value" high_pm["testkey"] = "high value" om.addLayer(1, low_pm)