diff --git a/csdr/chain/rtl433.py b/csdr/chain/rtl433.py deleted file mode 100644 index e34c20cf..00000000 --- a/csdr/chain/rtl433.py +++ /dev/null @@ -1,29 +0,0 @@ -from csdr.chain.demodulator import ServiceDemodulator, DialFrequencyReceiver -from csdr.module.rtl433 import Rtl433Module -from pycsdr.modules import Convert, Agc -from pycsdr.types import Format -from owrx.rtl433 import Rtl433Parser - - -class Rtl433Demodulator(ServiceDemodulator, DialFrequencyReceiver): - def __init__(self, service: bool = False): - self.sampleRate = 48000 - self.parser = Rtl433Parser(service=service) - workers = [ - Agc(Format.COMPLEX_FLOAT), - Convert(Format.COMPLEX_FLOAT, Format.COMPLEX_SHORT), - Rtl433Module(self.sampleRate, jsonOutput = not service), - self.parser, - ] - # Connect all the workers - super().__init__(workers) - - def getFixedAudioRate(self) -> int: - return self.sampleRate - - def supportsSquelch(self) -> bool: - return False - - def setDialFrequency(self, frequency: int) -> None: - self.parser.setDialFrequency(frequency) - diff --git a/csdr/chain/multimon.py b/csdr/chain/toolbox.py similarity index 69% rename from csdr/chain/multimon.py rename to csdr/chain/toolbox.py index 5029c763..099f1b5a 100644 --- a/csdr/chain/multimon.py +++ b/csdr/chain/toolbox.py @@ -1,8 +1,31 @@ from csdr.chain.demodulator import ServiceDemodulator, DialFrequencyReceiver -from csdr.module.multimon import MultimonModule -from pycsdr.modules import FmDemod, AudioResampler, Convert, Squelch +from csdr.module.toolbox import Rtl433Module, MultimonModule +from pycsdr.modules import FmDemod, AudioResampler, Convert, Agc, Squelch from pycsdr.types import Format -from owrx.multimon import MultimonParser, PageParser, SelCallParser +from owrx.toolbox import TextParser, PageParser, SelCallParser, IsmParser + + +class IsmDemodulator(ServiceDemodulator, DialFrequencyReceiver): + def __init__(self, service: bool = False): + self.sampleRate = 48000 + self.parser = IsmParser(service=service) + workers = [ + Agc(Format.COMPLEX_FLOAT), + Convert(Format.COMPLEX_FLOAT, Format.COMPLEX_SHORT), + Rtl433Module(self.sampleRate, jsonOutput = not service), + self.parser, + ] + # Connect all the workers + super().__init__(workers) + + def getFixedAudioRate(self) -> int: + return self.sampleRate + + def supportsSquelch(self) -> bool: + return False + + def setDialFrequency(self, frequency: int) -> None: + self.parser.setDialFrequency(frequency) class MultimonDemodulator(ServiceDemodulator, DialFrequencyReceiver): @@ -55,7 +78,7 @@ class PageDemodulator(MultimonDemodulator): class EasDemodulator(MultimonDemodulator): def __init__(self, service: bool = False): - super().__init__(["EAS"], MultimonParser(service=service)) + super().__init__(["EAS"], TextParser(service=service)) class SelCallDemodulator(MultimonDemodulator): diff --git a/csdr/module/multimon.py b/csdr/module/multimon.py deleted file mode 100644 index d1635cd0..00000000 --- a/csdr/module/multimon.py +++ /dev/null @@ -1,21 +0,0 @@ -from pycsdr.types import Format -from csdr.module import PopenModule - - -class MultimonModule(PopenModule): - def __init__(self, decoders: list[str]): - self.decoders = decoders - super().__init__() - - def getCommand(self): - cmd = ["multimon-ng", "-", "-v0", "-c"] - for x in self.decoders: - cmd += ["-a", x] - return cmd - - def getInputFormat(self) -> Format: - return Format.SHORT - - def getOutputFormat(self) -> Format: - return Format.CHAR - diff --git a/csdr/module/rtl433.py b/csdr/module/toolbox.py similarity index 66% rename from csdr/module/rtl433.py rename to csdr/module/toolbox.py index 1ac53570..2884e0ef 100644 --- a/csdr/module/rtl433.py +++ b/csdr/module/toolbox.py @@ -8,10 +8,10 @@ class Rtl433Module(PopenModule): self.jsonOutput = jsonOutput super().__init__() - def getCommandTEST(self): + def getCommand(self): return ["dummy433"] - def getCommand(self): + def getCommandOK(self): return [ "rtl_433", "-r", "cs16:-", "-s", str(self.sampleRate), "-M", "time:utc", "-F", "json" if self.jsonOutput else "kv", @@ -30,3 +30,21 @@ class Rtl433Module(PopenModule): def getOutputFormat(self) -> Format: return Format.CHAR + +class MultimonModule(PopenModule): + def __init__(self, decoders: list[str]): + self.decoders = decoders + super().__init__() + + def getCommand(self): + cmd = ["multimon-ng", "-", "-v0", "-c"] + for x in self.decoders: + cmd += ["-a", x] + return cmd + + def getInputFormat(self) -> Format: + return Format.SHORT + + def getOutputFormat(self) -> Format: + return Format.CHAR + diff --git a/htdocs/lib/MessagePanel.js b/htdocs/lib/MessagePanel.js index 621e3a36..e0441a28 100644 --- a/htdocs/lib/MessagePanel.js +++ b/htdocs/lib/MessagePanel.js @@ -285,7 +285,7 @@ PageMessagePanel.prototype.render = function() { $(this.el).append($( '' + '' + - '' + + '' + '' + '' + '
PagingPager Messages
' @@ -347,7 +347,7 @@ IsmMessagePanel.prototype.render = function() { $(this.el).append($( '' + '' + - '' + + '' + '' + '' + '
DevicesDevice Messages
' diff --git a/owrx/dsp.py b/owrx/dsp.py index 15a431fc..f5d92356 100644 --- a/owrx/dsp.py +++ b/owrx/dsp.py @@ -617,10 +617,10 @@ class DspManager(SdrSourceEventClient, ClientDemodulatorSecondaryDspEventClient) from csdr.chain.digiham import PocsagDemodulator return PocsagDemodulator() elif mod == "page": - from csdr.chain.multimon import PageDemodulator + from csdr.chain.toolbox import PageDemodulator return PageDemodulator() elif mod == "selcall": - from csdr.chain.multimon import SelCallDemodulator + from csdr.chain.toolbox import SelCallDemodulator return SelCallDemodulator() elif mod == "bpsk31": from csdr.chain.digimodes import PskDemodulator @@ -644,8 +644,8 @@ class DspManager(SdrSourceEventClient, ClientDemodulatorSecondaryDspEventClient) from csdr.chain.digimodes import FaxDemodulator return FaxDemodulator() elif mod == "ism": - from csdr.chain.rtl433 import Rtl433Demodulator - return Rtl433Demodulator() + from csdr.chain.toolbox import IsmDemodulator + return IsmDemodulator() def setSecondaryDemodulator(self, mod): demodulator = self._getSecondaryDemodulator(mod) diff --git a/owrx/modes.py b/owrx/modes.py index aac3793f..ea26f120 100644 --- a/owrx/modes.py +++ b/owrx/modes.py @@ -156,7 +156,7 @@ class Modes(object): # ), DigitalMode( "page", - "Paging", + "Page", underlying=["nfm"], bandpass=Bandpass(-6000, 6000), requirements=["page"], diff --git a/owrx/rtl433.py b/owrx/rtl433.py deleted file mode 100644 index 7de643e2..00000000 --- a/owrx/rtl433.py +++ /dev/null @@ -1,172 +0,0 @@ -from owrx.storage import Storage -from csdr.module import ThreadModule -from pycsdr.types import Format -from datetime import datetime -import pickle -import os -import re -import json - -import logging - -logger = logging.getLogger(__name__) - -class Rtl433Parser(ThreadModule): - def __init__(self, filePrefix: str = "ISM", service: bool = False): - self.service = service - self.frequency = 0 - self.data = bytearray(b'') - self.filePfx = filePrefix - self.file = None - self.maxLines = 10000 - self.cntLines = 0 - self.colorBuf = {} - # Use these colors to mark devices by ID - self.colors = [ - "#FFFFFF", "#999999", "#FF9999", "#FFCC99", "#FFFF99", "#CCFF99", - "#99FF99", "#99FFCC", "#99FFFF", "#99CCFF", "#9999FF", "#CC99FF", - "#FF99FF", "#FF99CC", - ] - super().__init__() - - def __del__(self): - # Close currently open file, if any - self.closeFile() - - def closeFile(self): - if self.file is not None: - try: - logger.debug("Closing log file '%s'." % self.fileName) - self.file.close() - self.file = None - # Delete excessive files from storage - logger.debug("Performing storage cleanup...") - Storage().cleanStoredFiles() - - except Exception as exptn: - logger.debug("Exception closing file: %s" % str(exptn)) - self.file = None - - def newFile(self, fileName): - self.closeFile() - try: - self.fileName = Storage().getFilePath(fileName + ".txt") - logger.debug("Opening log file '%s'..." % self.fileName) - self.file = open(self.fileName, "wb") - self.cntLines = 0 - - except Exception as exptn: - logger.debug("Exception opening file: %s" % str(exptn)) - self.file = None - - def writeFile(self, data): - # If no file open, create and open a new file - if self.file is None: - self.newFile(Storage().makeFileName(self.filePfx+"-{0}", self.frequency)) - # If file open now... - if self.file is not None: - # Write new line into the file - try: - self.file.write(data) - except Exception: - pass - # No more than maxLines per file - self.cntLines = self.cntLines + 1 - if self.cntLines >= self.maxLines: - self.closeFile() - - def getInputFormat(self) -> Format: - return Format.CHAR - - def getOutputFormat(self) -> Format: - return Format.CHAR - - def setDialFrequency(self, frequency: int) -> None: - self.frequency = frequency - - def myName(self): - return "%s%s" % ( - "Service" if self.service else "Client", - " at %dkHz" % (self.frequency // 1000) if self.frequency>0 else "" - ) - - def getColor(self, id: str) -> str: - if id in self.colorBuf: - # Sort entries in order of freshness - color = self.colorBuf.pop(id) - elif len(self.colorBuf) < len(self.colors): - # Assign each initial entry color based on its order - color = self.colors[len(self.colorBuf)] - else: - # If we run out of colors, reuse the oldest entry - color = self.colorBuf.pop(next(iter(self.colorBuf))) - # Done - self.colorBuf[id] = color - return color - - def parse(self, msg: str): - # Expect JSON data in text form - out = json.loads(msg) - out.update({ - "mode": "ISM", - "color": self.getColor(out["id"]) - }) - return out - - def run(self): - logger.debug("%s starting..." % self.myName()) - # Run while there is input data - while self.doRun: - # Read input data - inp = self.reader.read() - # Terminate if no input data - if inp is None: - logger.debug("%s exiting..." % self.myName()) - self.doRun = False - break - # Add read data to the buffer - self.data = self.data + inp.tobytes() - # Process buffer contents - out = self.process() - # Keep processing while there is input to parse - while out is not None: - if len(out)>0: - if isinstance(out, bytes): - self.writer.write(out) - elif isinstance(out, str): - self.writer.write(bytes(out, 'utf-8')) - else: - self.writer.write(pickle.dumps(out)) - out = self.process() - - def process(self): - # No result yet - out = None - - # Search for end-of-line - eol = self.data.find(b'\n') - - # If found end-of-line... - if eol>=0: - try: - msg = self.data[0:eol].decode(encoding="utf-8", errors="replace") - logger.debug("%s: %s" % (self.myName(), msg)) - # If running as a service... - if self.service: - # Write message into open log file, including end-of-line - self.writeFile(self.data[0:eol+1]) - # Empty result - out = {} - else: - # Let parse() function do its thing - out = self.parse(msg) - - except Exception as exptn: - logger.debug("%s: Exception parsing: %s" % (self.myName(), str(exptn))) - - # Remove parsed message from input, including end-of-line - del self.data[0:eol+1] - - # Return parsed result or None if no result yet - return out - diff --git a/owrx/service/__init__.py b/owrx/service/__init__.py index 8d188b14..204e3fb2 100644 --- a/owrx/service/__init__.py +++ b/owrx/service/__init__.py @@ -319,11 +319,11 @@ class ServiceHandler(SdrSourceEventClient): from csdr.chain.digimodes import FaxDemodulator return FaxDemodulator(service=True) elif mod == "page": - from csdr.chain.multimon import PageDemodulator + from csdr.chain.toolbox import PageDemodulator return PageDemodulator(service=True) elif mod == "ism": - from csdr.chain.rtl433 import Rtl433Demodulator - return Rtl433Demodulator(service=True) + from csdr.chain.toolbox import IsmDemodulator + return IsmDemodulator(service=True) raise ValueError("unsupported service modulation: {}".format(mod)) diff --git a/owrx/multimon.py b/owrx/toolbox.py similarity index 91% rename from owrx/multimon.py rename to owrx/toolbox.py index c9c34707..b06474e3 100644 --- a/owrx/multimon.py +++ b/owrx/toolbox.py @@ -6,13 +6,21 @@ from datetime import datetime import pickle import os import re +import json import logging logger = logging.getLogger(__name__) -class MultimonParser(ThreadModule): - def __init__(self, filePrefix: str = "MON", service: bool = False): + +class TextParser(ThreadModule): + def __init__(self, filePrefix: str = "LOG", service: bool = False): + # Use these colors to label messages by address + self.colors = [ + "#FFFFFF", "#999999", "#FF9999", "#FFCC99", "#FFFF99", "#CCFF99", + "#99FF99", "#99FFCC", "#99FFFF", "#99CCFF", "#9999FF", "#CC99FF", + "#FF99FF", "#FF99CC", + ] self.service = service self.frequency = 0 self.data = bytearray(b'') @@ -20,6 +28,7 @@ class MultimonParser(ThreadModule): self.file = None self.maxLines = 10000 self.cntLines = 0 + self.colorBuf = {} super().__init__() def __del__(self): @@ -77,12 +86,29 @@ class MultimonParser(ThreadModule): def setDialFrequency(self, frequency: int) -> None: self.frequency = frequency + # Compose name of this decoder, made of client/service and frequency def myName(self): return "%s%s" % ( "Service" if self.service else "Client", " at %dkHz" % (self.frequency // 1000) if self.frequency>0 else "" ) + # Get a unique color for a given ID, reusing colors as we go + def getColor(self, id: str) -> str: + if id in self.colorBuf: + # Sort entries in order of freshness + color = self.colorBuf.pop(id) + elif len(self.colorBuf) < len(self.colors): + # Assign each initial entry color based on its order + color = self.colors[len(self.colorBuf)] + else: + # If we run out of colors, reuse the oldest entry + color = self.colorBuf.pop(next(iter(self.colorBuf))) + # Done + self.colorBuf[id] = color + return color + + # DERIVED CLASSES SHOULD IMPLEMENT THIS FUNCTION! def parse(self, msg: str): # By default, do not parse, just return the string return msg @@ -145,17 +171,26 @@ class MultimonParser(ThreadModule): return out -class PageParser(MultimonParser): +class IsmParser(TextParser): + def __init__(self, service: bool = False): + super().__init__(filePrefix="ISM", service=service) + + def parse(self, msg: str): + # Expect JSON data in text form + out = json.loads(msg) + # Add mode name and a color to identify the sender + out.update({ + "mode": "ISM", + "color": self.getColor(out["id"]) + }) + return out + + +class PageParser(TextParser): def __init__(self, service: bool = False): # When true, try filtering out unreadable messages pm = Config.get() self.filtering = "paging_filter" in pm and pm["paging_filter"] - # Use these colors to mark messages by address - self.colors = [ - "#FFFFFF", "#999999", "#FF9999", "#FFCC99", "#FFFF99", "#CCFF99", - "#99FF99", "#99FFCC", "#99FFFF", "#99CCFF", "#9999FF", "#CC99FF", - "#FF99FF", "#FF99CC", - ] # POCSAG: Address: Function: (Certainty: )?(Numeric|Alpha|Skyper): self.rePocsag = re.compile(r"POCSAG(\d+):\s*Address:\s*(\S+)\s+Function:\s*(\S+)(\s+Certainty:.*(\d+))?(\s+(\S+):\s*(.*))?") # FLEX|NNNN-NN-NN NN:NN:NN|//C/C|NN.NNN|NNNNNNNNN|| @@ -170,8 +205,6 @@ class PageParser(MultimonParser): self.reSpaces = re.compile(r"[\000-\037\s]+") # Fragmented messages will be assembled here self.flexBuf = {} - # Color assignments will be maintained here - self.colorBuf = {} # Construct parent object super().__init__(filePrefix="PAGE", service=service) @@ -195,20 +228,6 @@ class PageParser(MultimonParser): letters = len(msg) - spaces return (letters > 0) and (letters / (spaces+1) < 40) - def getColor(self, capcode: str) -> str: - if capcode in self.colorBuf: - # Sort entries in order of freshness - color = self.colorBuf.pop(capcode) - elif len(self.colorBuf) < len(self.colors): - # Assign each initial entry color based on its order - color = self.colors[len(self.colorBuf)] - else: - # If we run out of colors, reuse the oldest entry - color = self.colorBuf.pop(next(iter(self.colorBuf))) - # Done - self.colorBuf[capcode] = color - return color - def parsePocsag(self, msg: str): # No result yet out = {} @@ -305,12 +324,13 @@ class PageParser(MultimonParser): return out -class SelCallParser(MultimonParser): +class SelCallParser(TextParser): def __init__(self, service: bool = False): self.reSplit = re.compile(r"(ZVEI1|ZVEI2|ZVEI3|DZVEI|PZVEI|DTMF|EEA|EIA|CCIR):\s+") self.reMatch = re.compile(r"ZVEI1|ZVEI2|ZVEI3|DZVEI|PZVEI|DTMF|EEA|EIA|CCIR") self.mode = "" - super().__init__(service) + # Construct parent object + super().__init__(filePrefix="SELCALL", service=service) def parse(self, msg: str): # Parse SELCALL messages