From d43fdb2afb783ebff2e59dbeee21753bba6b37bc Mon Sep 17 00:00:00 2001 From: Marat Fayzullin Date: Sat, 27 May 2023 23:54:44 -0400 Subject: [PATCH] Trying to integrate with rtl_433 for ISM signal decoding. --- csdr/chain/rtl433.py | 28 ++++++++ csdr/module/rtl433.py | 22 ++++++ htdocs/css/openwebrx.css | 1 + owrx/dsp.py | 3 + owrx/feature.py | 9 +++ owrx/modes.py | 8 +++ owrx/rtl433.py | 145 +++++++++++++++++++++++++++++++++++++++ 7 files changed, 216 insertions(+) create mode 100644 csdr/chain/rtl433.py create mode 100644 csdr/module/rtl433.py create mode 100644 owrx/rtl433.py diff --git a/csdr/chain/rtl433.py b/csdr/chain/rtl433.py new file mode 100644 index 00000000..bd66c6f7 --- /dev/null +++ b/csdr/chain/rtl433.py @@ -0,0 +1,28 @@ +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): + self.sampleRate = 24000 + self.parser = Rtl433Parser() + workers = [ + Agc(Format.COMPLEX_FLOAT), + Convert(Format.COMPLEX_FLOAT, Format.COMPLEX_SHORT), + Rtl433Module(self.sampleRate), + 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/module/rtl433.py b/csdr/module/rtl433.py new file mode 100644 index 00000000..e78b4adb --- /dev/null +++ b/csdr/module/rtl433.py @@ -0,0 +1,22 @@ +from pycsdr.types import Format +from csdr.module import PopenModule + + +class Rtl433Module(PopenModule): + def __init__(self, sampleRate: int = 24000): + self.sampleRate = sampleRate + super().__init__() + + def getCommand(self): + return [ + "rtl_433", "-r", "cs16:-", "-f", "0", "-s", str(self.sampleRate), + "-R", "-80", "-R", "-149", "-R", "-154", "-R", "-160", + "-R", "-161", "-R", "-167", "-R", "-178", + ] + + def getInputFormat(self) -> Format: + return Format.COMPLEX_SHORT + + def getOutputFormat(self) -> Format: + return Format.CHAR + diff --git a/htdocs/css/openwebrx.css b/htdocs/css/openwebrx.css index 4cb616c8..aab8424c 100644 --- a/htdocs/css/openwebrx.css +++ b/htdocs/css/openwebrx.css @@ -1390,6 +1390,7 @@ img.openwebrx-mirror-img #openwebrx-panel-digimodes[data-mode="sstv"] #openwebrx-digimode-select-channel, #openwebrx-panel-digimodes[data-mode="fax"] #openwebrx-digimode-select-channel, #openwebrx-panel-digimodes[data-mode="selcall"] #openwebrx-digimode-select-channel +#openwebrx-panel-digimodes[data-mode="ism"] #openwebrx-digimode-select-channel { display: none; } diff --git a/owrx/dsp.py b/owrx/dsp.py index da881a3b..15a431fc 100644 --- a/owrx/dsp.py +++ b/owrx/dsp.py @@ -643,6 +643,9 @@ class DspManager(SdrSourceEventClient, ClientDemodulatorSecondaryDspEventClient) elif mod == "fax": from csdr.chain.digimodes import FaxDemodulator return FaxDemodulator() + elif mod == "ism": + from csdr.chain.rtl433 import Rtl433Demodulator + return Rtl433Demodulator() def setSecondaryDemodulator(self, mod): demodulator = self._getSecondaryDemodulator(mod) diff --git a/owrx/feature.py b/owrx/feature.py index dde025b2..d645131e 100644 --- a/owrx/feature.py +++ b/owrx/feature.py @@ -88,6 +88,7 @@ class FeatureDetector(object): "js8call": ["js8", "js8py"], "drm": ["dream"], "png": ["imagemagick"], + "ism": ["rtl433"], } def feature_availability(self): @@ -587,3 +588,11 @@ class FeatureDetector(object): """ return self.command_is_runnable("multimon-ng --help") + def has_rtl433(self): + """ + OpenWebRX uses the [rtl_433](https://github.com/merbanan/rtl_433) decoder suite to decode various + instrumentation signals. Rtl_433 is available from the package manager on many + distributions, or you can compile it from source. + """ + return self.command_is_runnable("rtl_433 --help") + diff --git a/owrx/modes.py b/owrx/modes.py index 39bfab68..769a1de5 100644 --- a/owrx/modes.py +++ b/owrx/modes.py @@ -187,6 +187,14 @@ class Modes(object): requirements=["selcall"], squelch=True ), + DigitalMode( + "ism", + "ISM", + underlying=["nfm"], + bandpass=Bandpass(-6250, 6250), + requirements=["ism"], + squelch=False + ), ] @staticmethod diff --git a/owrx/rtl433.py b/owrx/rtl433.py new file mode 100644 index 00000000..ddbbf8c4 --- /dev/null +++ b/owrx/rtl433.py @@ -0,0 +1,145 @@ +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 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 + 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 parse(self, msg: str): + # By default, do not parse, just return the string + return msg + + 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 +