Placing rtl433 and multimon integrations into a common "toolbox" module.

This commit is contained in:
Marat Fayzullin 2023-05-29 20:59:43 -04:00
parent 67789c07ef
commit 008189dc22
10 changed files with 104 additions and 265 deletions

View File

@ -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)

View File

@ -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):

View File

@ -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

View File

@ -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

View File

@ -285,7 +285,7 @@ PageMessagePanel.prototype.render = function() {
$(this.el).append($(
'<table>' +
'<thead><tr>' +
'<th>Paging</th>' +
'<th>Pager&nbsp;Messages</th>' +
'</tr></thead>' +
'<tbody></tbody>' +
'</table>'
@ -347,7 +347,7 @@ IsmMessagePanel.prototype.render = function() {
$(this.el).append($(
'<table>' +
'<thead><tr>' +
'<th>Devices</th>' +
'<th>Device&nbsp;Messages</th>' +
'</tr></thead>' +
'<tbody></tbody>' +
'</table>'

View File

@ -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)

View File

@ -156,7 +156,7 @@ class Modes(object):
# ),
DigitalMode(
"page",
"Paging",
"Page",
underlying=["nfm"],
bandpass=Bandpass(-6000, 6000),
requirements=["page"],

View File

@ -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

View File

@ -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))

View File

@ -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<baud>: Address: <num> Function: <hex> (Certainty: <num> )?(Numeric|Alpha|Skyper): <message>
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|<baud>/<value>/C/C|NN.NNN|NNNNNNNNN|<type>|<message>
@ -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