From e825b0b8ffe7e954a9fdb3d76c5bcee958ab575d Mon Sep 17 00:00:00 2001 From: Mark Qvist Date: Sat, 14 May 2022 20:19:46 +0200 Subject: [PATCH] Added Pipe Interface --- README.md | 4 +- RNS/Interfaces/PipeInterface.py | 187 ++++++++++++++++++++++++++++++ RNS/Interfaces/SerialInterface.py | 2 +- RNS/Reticulum.py | 33 +++++- 4 files changed, 222 insertions(+), 4 deletions(-) create mode 100644 RNS/Interfaces/PipeInterface.py diff --git a/README.md b/README.md index e12580d..0fa6552 100755 --- a/README.md +++ b/README.md @@ -91,14 +91,16 @@ Currently, the following interfaces are supported: - Any device with a serial port - TCP over IP networks - UDP over IP networks +- External programs via stdio or pipes +- Custom hardware via stdio or pipes ## Development Roadmap - Version 0.3.6 - Improving [the manual](https://markqvist.github.io/Reticulum/manual/) with sections specifically for beginners +- Version 0.3.7 - Support for radio and modem interfaces on Android - GUI interface configuration tool - Easy way to share interface configurations, see [#19](https://github.com/markqvist/Reticulum/discussions/19) -- Version 0.3.7 - More interface types for even broader compatibility - Plain ESP32 devices (ESP-Now, WiFi, Bluetooth, etc.) - More LoRa transceivers diff --git a/RNS/Interfaces/PipeInterface.py b/RNS/Interfaces/PipeInterface.py new file mode 100644 index 0000000..58738fc --- /dev/null +++ b/RNS/Interfaces/PipeInterface.py @@ -0,0 +1,187 @@ +# MIT License +# +# Copyright (c) 2016-2022 Mark Qvist / unsigned.io +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in all +# copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. + +from .Interface import Interface +from time import sleep +import sys +import threading +import time +import RNS + +import subprocess +import shlex + +class HDLC(): + # The Pipe Interface packetizes data using + # simplified HDLC framing, similar to PPP + FLAG = 0x7E + ESC = 0x7D + ESC_MASK = 0x20 + + @staticmethod + def escape(data): + data = data.replace(bytes([HDLC.ESC]), bytes([HDLC.ESC, HDLC.ESC^HDLC.ESC_MASK])) + data = data.replace(bytes([HDLC.FLAG]), bytes([HDLC.ESC, HDLC.FLAG^HDLC.ESC_MASK])) + return data + +class PipeInterface(Interface): + MAX_CHUNK = 32768 + BITRATE_GUESS = 1*1000*1000 + + owner = None + command = None + + def __init__(self, owner, name, command, respawn_delay): + if respawn_delay == None: + respawn_delay = 5 + + self.rxb = 0 + self.txb = 0 + + self.owner = owner + self.name = name + self.command = command + self.process = None + self.timeout = 100 + self.online = False + self.pipe_is_open = False + self.bitrate = PipeInterface.BITRATE_GUESS + self.respawn_delay = respawn_delay + + try: + self.open_pipe() + + except Exception as e: + RNS.log("Could connect pipe for interface "+str(self), RNS.LOG_ERROR) + raise e + + if self.pipe_is_open: + self.configure_pipe() + else: + raise IOError("Could not connect pipe") + + + def open_pipe(self): + RNS.log("Connecting subprocess pipe for "+str(self)+"...", RNS.LOG_VERBOSE) + + try: + self.process = subprocess.Popen(shlex.split(self.command), stdin=subprocess.PIPE, stdout=subprocess.PIPE) + self.pipe_is_open = True + except Exception as e: + raise e + self.pipe_is_open = False + + + def configure_pipe(self): + sleep(0.01) + thread = threading.Thread(target=self.readLoop) + thread.setDaemon(True) + thread.start() + self.online = True + RNS.log("Subprocess pipe for "+str(self)+" is now connected", RNS.LOG_VERBOSE) + + + def processIncoming(self, data): + self.rxb += len(data) + self.owner.inbound(data, self) + + + def processOutgoing(self,data): + if self.online: + data = bytes([HDLC.FLAG])+HDLC.escape(data)+bytes([HDLC.FLAG]) + written = self.process.stdin.write(data) + self.process.stdin.flush() + self.txb += len(data) + if written != len(data): + raise IOError("Pipe interface only wrote "+str(written)+" bytes of "+str(len(data))) + + + def readLoop(self): + try: + in_frame = False + escape = False + data_buffer = b"" + last_read_ms = int(time.time()*1000) + + while True: + process_output = self.process.stdout.read(1) + if len(process_output) == 0 and self.process.poll() is not None: + break + + else: + byte = ord(process_output) + last_read_ms = int(time.time()*1000) + + if (in_frame and byte == HDLC.FLAG): + in_frame = False + self.processIncoming(data_buffer) + elif (byte == HDLC.FLAG): + in_frame = True + data_buffer = b"" + elif (in_frame and len(data_buffer) < RNS.Reticulum.MTU): + if (byte == HDLC.ESC): + escape = True + else: + if (escape): + if (byte == HDLC.FLAG ^ HDLC.ESC_MASK): + byte = HDLC.FLAG + if (byte == HDLC.ESC ^ HDLC.ESC_MASK): + byte = HDLC.ESC + escape = False + data_buffer = data_buffer+bytes([byte]) + + RNS.log("Subprocess terminated on "+str(self)) + self.process.kill() + + except Exception as e: + self.online = False + try: + self.process.kill() + except Exception as e: + pass + + RNS.log("A pipe error occurred, the contained exception was: "+str(e), RNS.LOG_ERROR) + RNS.log("The interface "+str(self)+" experienced an unrecoverable error and is now offline.", RNS.LOG_ERROR) + + if RNS.Reticulum.panic_on_interface_error: + RNS.panic() + + RNS.log("Reticulum will attempt to reconnect the interface periodically.", RNS.LOG_ERROR) + + self.online = False + self.reconnect_pipe() + + def reconnect_pipe(self): + while not self.online: + try: + time.sleep(self.respawn_delay) + RNS.log("Attempting to respawn subprocess for "+str(self)+"...", RNS.LOG_VERBOSE) + self.open_pipe() + if self.pipe_is_open: + self.configure_pipe() + except Exception as e: + RNS.log("Error while spawning subprocess, the contained exception was: "+str(e), RNS.LOG_ERROR) + + RNS.log("Reconnected pipe for "+str(self)) + + def __str__(self): + return "PipeInterface["+self.name+"]" diff --git a/RNS/Interfaces/SerialInterface.py b/RNS/Interfaces/SerialInterface.py index e77b20b..4cc8161 100755 --- a/RNS/Interfaces/SerialInterface.py +++ b/RNS/Interfaces/SerialInterface.py @@ -117,7 +117,7 @@ class SerialInterface(Interface): thread.setDaemon(True) thread.start() self.online = True - RNS.log("Serial port "+self.port+" is now open") + RNS.log("Serial port "+self.port+" is now open", RNS.LOG_VERBOSE) def processIncoming(self, data): diff --git a/RNS/Reticulum.py b/RNS/Reticulum.py index 100b671..4114349 100755 --- a/RNS/Reticulum.py +++ b/RNS/Reticulum.py @@ -328,7 +328,7 @@ class Reticulum: self.__start_local_interface() if self.is_shared_instance or self.is_standalone_instance: - RNS.log("Bringing up system interfaces...", RNS.LOG_DEBUG) + RNS.log("Bringing up system interfaces...", RNS.LOG_VERBOSE) interface_names = [] for name in self.config["interfaces"]: if not name in interface_names: @@ -642,6 +642,35 @@ class Reticulum: else: interface.ifac_size = 8 + if c["type"] == "PipeInterface": + command = c["command"] if "command" in c else None + respawn_delay = c.as_float("respawn_delay") if "respawn_delay" in c else None + + if command == None: + raise ValueError("No command specified for PipeInterface") + + interface = PipeInterface.PipeInterface( + RNS.Transport, + name, + command, + respawn_delay, + ) + + if "outgoing" in c and c.as_bool("outgoing") == False: + interface.OUT = False + else: + interface.OUT = True + + interface.mode = interface_mode + + interface.announce_cap = announce_cap + if configured_bitrate: + interface.bitrate = configured_bitrate + if ifac_size != None: + interface.ifac_size = ifac_size + else: + interface.ifac_size = 8 + if c["type"] == "KISSInterface": preamble = int(c["preamble"]) if "preamble" in c else None txtail = int(c["txtail"]) if "txtail" in c else None @@ -827,7 +856,7 @@ class Reticulum: RNS.log("The interface name \""+name+"\" was already used. Check your configuration file for errors!", RNS.LOG_ERROR) RNS.panic() - RNS.log("System interfaces are ready", RNS.LOG_DEBUG) + RNS.log("System interfaces are ready", RNS.LOG_VERBOSE)