From cb856fa02ff699e3dfd4e8335c810638dd5606ad Mon Sep 17 00:00:00 2001 From: Marat Fayzullin Date: Fri, 8 Sep 2023 13:22:23 -0400 Subject: [PATCH] Adding ACARS decoding via AcarsDec tool. --- bookmarks.txt | 21 ++++++++++++ csdr/chain/toolbox.py | 27 +++++++++++++-- csdr/module/toolbox.py | 47 +++++++++++++++++++++++++++ htdocs/css/openwebrx.css | 3 ++ htdocs/lib/DemodulatorPanel.js | 2 +- htdocs/lib/MapMarkers.js | 7 ++-- htdocs/lib/MessagePanel.js | 3 +- owrx/aircraft.py | 36 ++++++++++++++++++-- owrx/config/defaults.py | 1 + owrx/controllers/settings/decoding.py | 6 ++++ owrx/dsp.py | 3 ++ owrx/feature.py | 8 +++++ owrx/modes.py | 9 +++++ owrx/service/__init__.py | 3 ++ owrx/toolbox.py | 2 +- 15 files changed, 168 insertions(+), 10 deletions(-) diff --git a/bookmarks.txt b/bookmarks.txt index 359839c5..fb31d0dd 100644 --- a/bookmarks.txt +++ b/bookmarks.txt @@ -57,6 +57,27 @@ NOAA-5|||162500|NFM NOAA-6|||162525|NFM NOAA-7|||162550|NFM +# +# ACARS frequencies +# +USA and Canada Additional|||129125|ACARS +USA and Canada Secondary|||130025|ACARS +USA Additional|||130425|ACARS +USA and Canada Additional|||130450|ACARS +USA Additional|||131125|ACARS +Japan Primary|||131450|ACARS +Air Canada Company Channel|||131475|ACARS +European Secondary|||131525|ACARS +World Primary|||131550|ACARS +Europe Primary|||131725|ACARS +New European Channel|||131.850|ACARS +USA Additional|||136700|ACARS +USA Additional|||136750|ACARS +USA Additional|||136800|ACARS +SITA|North America||136850|ACARS +SITA|Australia & NZ||131550|ACARS +ARINC|Australia & NZ||131450|ACARS + # # VDL2 frequencies # diff --git a/csdr/chain/toolbox.py b/csdr/chain/toolbox.py index 02bd4ede..d6d57f29 100644 --- a/csdr/chain/toolbox.py +++ b/csdr/chain/toolbox.py @@ -1,9 +1,9 @@ from csdr.chain.demodulator import ServiceDemodulator, DialFrequencyReceiver, FixedIfSampleRateChain -from csdr.module.toolbox import Rtl433Module, MultimonModule, DumpHfdlModule, DumpVdl2Module, Dump1090Module +from csdr.module.toolbox import Rtl433Module, MultimonModule, DumpHfdlModule, DumpVdl2Module, Dump1090Module, AcarsDecModule from pycsdr.modules import FmDemod, AudioResampler, Convert, Agc, Squelch from pycsdr.types import Format from owrx.toolbox import TextParser, PageParser, SelCallParser, IsmParser -from owrx.aircraft import HfdlParser, Vdl2Parser, AdsbParser +from owrx.aircraft import HfdlParser, Vdl2Parser, AdsbParser, AcarsParser class IsmDemodulator(ServiceDemodulator, DialFrequencyReceiver): @@ -143,7 +143,6 @@ class Vdl2Demodulator(ServiceDemodulator, DialFrequencyReceiver): def setDialFrequency(self, frequency: int) -> None: self.parser.setDialFrequency(frequency) - pass class AdsbDemodulator(ServiceDemodulator, DialFrequencyReceiver): @@ -166,3 +165,25 @@ class AdsbDemodulator(ServiceDemodulator, DialFrequencyReceiver): def setDialFrequency(self, frequency: int) -> None: self.parser.setDialFrequency(frequency) + + +class AcarsDemodulator(ServiceDemodulator, DialFrequencyReceiver): + def __init__(self, service: bool = False): + self.sampleRate = 12500 + self.parser = AcarsParser(service=service) + workers = [ + Convert(Format.FLOAT, Format.SHORT), + AcarsDecModule(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/module/toolbox.py b/csdr/module/toolbox.py index b388bfe5..4d05e168 100644 --- a/csdr/module/toolbox.py +++ b/csdr/module/toolbox.py @@ -97,3 +97,50 @@ class Dump1090Module(ExecModule): if rawOutput: cmd += [ "--raw" ] super().__init__(Format.COMPLEX_SHORT, Format.CHAR, cmd) + + +class AcarsDecModule(PopenModule): + def __init__(self, sampleRate: int = 12500, jsonOutput: bool = False): + self.sampleRate = sampleRate + self.jsonOutput = jsonOutput + super().__init__() + + def getCommand(self): + return [ + "acarsdec", "-f", "/dev/stdin", + "-o", str(4 if self.jsonOutput else 1) + ] + + def getInputFormat(self) -> Format: + return Format.SHORT + + def getOutputFormat(self) -> Format: + return Format.CHAR + + def start(self): + # Create process and pumps + super().start() + # Created simulated .WAV file header + byteRate = (self.sampleRate * 16 * 1) >> 3 + header = bytearray(44) + header[0:3] = b"RIFF" + header[4:7] = bytes([36, 0xFF, 0xFF, 0xFF]) + header[8:11] = b"WAVE" + header[12:15] = b"fmt " + header[16] = 16 # Chunk size + header[20] = 1 # Format (PCM) + header[22] = 1 # Number of channels (1) + header[24] = self.sampleRate & 0xFF + header[25] = (self.sampleRate >> 8) & 0xFF + header[26] = (self.sampleRate >> 16) & 0xFF + header[27] = (self.sampleRate >> 24) & 0xFF + header[28] = byteRate & 0xFF + header[29] = (byteRate >> 8) & 0xFF + header[30] = (byteRate >> 16) & 0xFF + header[31] = (byteRate >> 24) & 0xFF + header[32] = 2 # Block alignment (2 bytes) + header[34] = 16 # Bits per sample (16) + header[36:39] = b"data" + header[40:43] = bytes([0, 0xFF, 0xFF, 0xFF]) + # Send .WAV file header to the process + self.process.stdin.write(header) diff --git a/htdocs/css/openwebrx.css b/htdocs/css/openwebrx.css index db4b8e7f..54f29672 100644 --- a/htdocs/css/openwebrx.css +++ b/htdocs/css/openwebrx.css @@ -1445,6 +1445,7 @@ img.openwebrx-mirror-img #openwebrx-panel-digimodes[data-mode="hfdl"] #openwebrx-digimode-content-container, #openwebrx-panel-digimodes[data-mode="vdl2"] #openwebrx-digimode-content-container, #openwebrx-panel-digimodes[data-mode="adsb"] #openwebrx-digimode-content-container, +#openwebrx-panel-digimodes[data-mode="acars"] #openwebrx-digimode-content-container, #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, @@ -1465,6 +1466,7 @@ img.openwebrx-mirror-img #openwebrx-panel-digimodes[data-mode="hfdl"] #openwebrx-digimode-select-channel, #openwebrx-panel-digimodes[data-mode="vdl2"] #openwebrx-digimode-select-channel, #openwebrx-panel-digimodes[data-mode="adsb"] #openwebrx-digimode-select-channel, +#openwebrx-panel-digimodes[data-mode="acars"] #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, @@ -1491,6 +1493,7 @@ img.openwebrx-mirror-img #openwebrx-panel-digimodes[data-mode="hfdl"] #openwebrx-digimode-canvas-container, #openwebrx-panel-digimodes[data-mode="vdl2"] #openwebrx-digimode-canvas-container, #openwebrx-panel-digimodes[data-mode="adsb"] #openwebrx-digimode-canvas-container, +#openwebrx-panel-digimodes[data-mode="acars"] #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, diff --git a/htdocs/lib/DemodulatorPanel.js b/htdocs/lib/DemodulatorPanel.js index b5eb7010..fd4a9add 100644 --- a/htdocs/lib/DemodulatorPanel.js +++ b/htdocs/lib/DemodulatorPanel.js @@ -167,7 +167,7 @@ DemodulatorPanel.prototype.updatePanels = function() { toggle_panel("openwebrx-panel-packet-message", ["packet", "ais"].indexOf(modulation) >= 0); toggle_panel("openwebrx-panel-pocsag-message", modulation === "pocsag"); toggle_panel("openwebrx-panel-page-message", modulation === "page"); - toggle_panel("openwebrx-panel-hfdl-message", ["hfdl", "vdl2", "adsb"].indexOf(modulation) >= 0); + toggle_panel("openwebrx-panel-hfdl-message", ["hfdl", "vdl2", "adsb", "acars"].indexOf(modulation) >= 0); toggle_panel("openwebrx-panel-sstv-message", modulation === "sstv"); toggle_panel("openwebrx-panel-fax-message", modulation === "fax"); toggle_panel("openwebrx-panel-ism-message", modulation === "ism"); diff --git a/htdocs/lib/MapMarkers.js b/htdocs/lib/MapMarkers.js index ea4d3467..98946db8 100644 --- a/htdocs/lib/MapMarkers.js +++ b/htdocs/lib/MapMarkers.js @@ -16,7 +16,8 @@ function MarkerManager() { 'OpenWebRX' : '#004000', 'HFDL' : '#004000', 'VDL2' : '#000080', - 'ADSB' : '#800000' + 'ADSB' : '#800000', + 'ACARS' : '#000000' }; // Symbols used for marker types @@ -30,7 +31,8 @@ function MarkerManager() { 'AIS' : '⩯', 'HFDL' : '✈', 'VDL2' : '✈', - 'ADSB' : '✈' + 'ADSB' : '✈', + 'ACARS' : '✈' }; // Marker type shown/hidden status @@ -647,6 +649,7 @@ AprsMarker.prototype.getInfoHTML = function(name, receiverMarker = null) { case 'HFDL': case 'VDL2': case 'ADSB': + case 'ACARS': if (this.flight) { name = this.flight; url = flight_url; diff --git a/htdocs/lib/MessagePanel.js b/htdocs/lib/MessagePanel.js index 701fe665..a7476fcd 100644 --- a/htdocs/lib/MessagePanel.js +++ b/htdocs/lib/MessagePanel.js @@ -335,6 +335,7 @@ $.fn.pageMessagePanel = function() { HfdlMessagePanel = function(el) { MessagePanel.call(this, el); this.initClearTimer(); + this.modes = ['HFDL', 'VDL2', 'ADSB', 'ACARS']; this.flight_url = null; this.modes_url = null; } @@ -342,7 +343,7 @@ HfdlMessagePanel = function(el) { HfdlMessagePanel.prototype = new MessagePanel(); HfdlMessagePanel.prototype.supportsMessage = function(message) { - return (message['mode'] === 'HFDL') || (message['mode'] === 'VDL2') || (message['mode'] === 'ADSB'); + return this.modes.indexOf(message['mode']) >= 0; }; HfdlMessagePanel.prototype.setFlightUrl = function(url) { diff --git a/owrx/aircraft.py b/owrx/aircraft.py index 680c4599..c805f1b7 100644 --- a/owrx/aircraft.py +++ b/owrx/aircraft.py @@ -148,7 +148,7 @@ class AircraftManager(object): # If both positions exist, compute course if "course" not in data and pos0 and pos1 and pos1 != pos0: item["course"] = round(self.bearing(pos0, pos1)) - logger.debug("Updated %s course to %d degrees" % (id, item["course"])) + #logger.debug("Updated %s course to %d degrees" % (id, item["course"])) # Update timme-to-live, if missing, assume HFDL longevity if "ts" not in data: pm = Config.get() @@ -411,7 +411,7 @@ class AdsbParser(AircraftParser): if msg.startswith("*") and msg.endswith(";") and len(msg) in [16, 30]: # Parse Mode-S message out = self.smode_parser.process(bytes.fromhex(msg[1:-1])) - logger.debug("@@@ PARSE OUT: {0}".format(out)) + #logger.debug("@@@ PARSE OUT: {0}".format(out)) # Only consider position and identification reports for now if "identification" in out or "groundspeed" in out or ("lat" in out and "lon" in out): # Add fields for compatibility with other aircraft parsers @@ -456,3 +456,35 @@ class AdsbParser(AircraftParser): # No data parsed return {} + + +# +# Parser for ACARS messages coming from AcarsDec in JSON format. +# +class AcarsParser(AircraftParser): + def __init__(self, service: bool = False): + super().__init__(filePrefix="ACARS", service=service) + + def parse(self, msg: str): + # Expect JSON data in text form + data = json.loads(msg) + pm = Config.get() + ts = data["timestamp"] + logger.debug("@@@ ACARS: {0}".format(data)) + # Collect basic data first + out = { + "mode" : "ACARS", + "time" : datetime.utcfromtimestamp(ts).strftime("%H:%M:%S"), + "ts" : ts, + "ttl" : ts + pm["acars_ttl"] + } + # Fetch other data + if "tail" in data: + out["aircraft"] = data["tail"] + if "flight" in data: + out["flight"] = data["flight"] + if "text" in data: + out["message"] = data["text"] + # Update aircraft database with the new data + AircraftManager.getSharedInstance().update(out) + return out diff --git a/owrx/config/defaults.py b/owrx/config/defaults.py index 4e337c96..74098891 100644 --- a/owrx/config/defaults.py +++ b/owrx/config/defaults.py @@ -205,6 +205,7 @@ defaultConfig = PropertyLayer( adsb_ttl=900, vdl2_ttl=1800, hfdl_ttl=1800, + acars_ttl=1800, fax_postprocess=True, fax_color=False, fax_am=False diff --git a/owrx/controllers/settings/decoding.py b/owrx/controllers/settings/decoding.py index 1cadd2a7..140aa8df 100644 --- a/owrx/controllers/settings/decoding.py +++ b/owrx/controllers/settings/decoding.py @@ -85,6 +85,12 @@ class DecodingSettingsController(SettingsFormController): validator=RangeValidator(30, 3600), append="s", ), + NumberInput( + "acars_ttl", + "ACARS reports expiration time", + validator=RangeValidator(30, 3600), + append="s", + ), ), Section( "Fax transmissions", diff --git a/owrx/dsp.py b/owrx/dsp.py index 761899b0..3bd4e490 100644 --- a/owrx/dsp.py +++ b/owrx/dsp.py @@ -666,6 +666,9 @@ class DspManager(SdrSourceEventClient, ClientDemodulatorSecondaryDspEventClient) elif mod == "vdl2": from csdr.chain.toolbox import Vdl2Demodulator return Vdl2Demodulator() + elif mod == "acars": + from csdr.chain.toolbox import AcarsDemodulator + return AcarsDemodulator() elif mod == "adsb": from csdr.chain.toolbox import AdsbDemodulator return AdsbDemodulator() diff --git a/owrx/feature.py b/owrx/feature.py index ad330c3a..41f5e77a 100644 --- a/owrx/feature.py +++ b/owrx/feature.py @@ -89,6 +89,7 @@ class FeatureDetector(object): "hfdl": ["dumphfdl"], "vdl2": ["dumpvdl2"], "adsb": ["dump1090"], + "acars": ["acarsdec"], "js8call": ["js8", "js8py"], "drm": ["dream"], "png": ["imagemagick"], @@ -623,3 +624,10 @@ class FeatureDetector(object): """ return self.command_is_runnable("dump1090 --help") + def has_acarsdec(self): + """ + OpenWebRX uses the [acarsdec](https://github.com/TLeconte/acarsdec) tool to decode ACARS + traffic. You will have to compile it from source. + """ + return self.command_is_runnable("acarsdec --help") + diff --git a/owrx/modes.py b/owrx/modes.py index 419f865f..29a49276 100644 --- a/owrx/modes.py +++ b/owrx/modes.py @@ -221,6 +221,15 @@ class Modes(object): service=True, squelch=False ), + DigitalMode( + "acars", + "ACARS", + underlying=["am"], + bandpass=Bandpass(-6250, 6250), + requirements=["acars"], + service=True, + squelch=False + ), DigitalMode( "adsb", "ADSB", diff --git a/owrx/service/__init__.py b/owrx/service/__init__.py index 3dd97f5d..34273b7e 100644 --- a/owrx/service/__init__.py +++ b/owrx/service/__init__.py @@ -330,6 +330,9 @@ class ServiceHandler(SdrSourceEventClient): elif mod == "vdl2": from csdr.chain.toolbox import Vdl2Demodulator return Vdl2Demodulator(service=True) + elif mod == "acars": + from csdr.chain.toolbox import AcarsDemodulator + return AcarsDemodulator(service=True) elif mod == "adsb": from csdr.chain.toolbox import AdsbDemodulator return AdsbDemodulator(service=True) diff --git a/owrx/toolbox.py b/owrx/toolbox.py index f44feefb..7e84cd0c 100644 --- a/owrx/toolbox.py +++ b/owrx/toolbox.py @@ -169,7 +169,7 @@ class TextParser(ThreadModule): if eol>=0: try: msg = self.data[0:eol].decode(encoding="utf-8", errors="replace") - logger.debug("%s: %s" % (self.myName(), msg)) + #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