diff --git a/meshchat.py b/meshchat.py index 3ed2e6a..f63d06a 100644 --- a/meshchat.py +++ b/meshchat.py @@ -26,6 +26,7 @@ from src.backend.announce_handler import AnnounceHandler from src.backend.async_utils import AsyncUtils from src.backend.colour_utils import ColourUtils from src.backend.interface_config_parser import InterfaceConfigParser +from src.backend.interface_editor import InterfaceEditor from src.backend.lxmf_message_fields import LxmfImageField, LxmfFileAttachmentsField, LxmfFileAttachment, LxmfAudioField from src.backend.audio_call_manager import AudioCall, AudioCallManager from src.backend.sideband_commands import SidebandCommands @@ -329,8 +330,29 @@ class ReticulumMeshChat: if "interfaces" in self.reticulum.config: interfaces = self.reticulum.config["interfaces"] + processed_interfaces = {} + for interface_name, interface in interfaces.items(): + interface_data = interface.copy() + + # handle sub-interfaces for RNodeMultiInterface + if interface_data.get("type") == "RNodeMultiInterface": + sub_interfaces = [] + for sub_name, sub_config in interface_data.items(): + if sub_name not in {"type", "port", "interface_enabled", "selected_interface_mode", "configured_bitrate"}: + if isinstance(sub_config, dict): + sub_config["name"] = sub_name + sub_interfaces.append(sub_config) + + # add sub-interfaces to the main interface data + interface_data["sub_interfaces"] = sub_interfaces + + for sub in sub_interfaces: + del interface_data[sub["name"]] + + processed_interfaces[interface_name] = interface_data + return web.json_response({ - "interfaces": interfaces, + "interfaces": processed_interfaces, }) # enable reticulum interface @@ -443,94 +465,178 @@ class ReticulumMeshChat: if "enabled" not in interface_details and "interface_enabled" not in interface_details: interface_details["interface_enabled"] = "true" - # handle tcp client interface + # handle AutoInterface + if interface_type == "AutoInterface": + + # set optional AutoInterface options + InterfaceEditor.update_value(interface_details, data, "group_id") + InterfaceEditor.update_value(interface_details, data, "multicast_address_type") + InterfaceEditor.update_value(interface_details, data, "devices") + InterfaceEditor.update_value(interface_details, data, "ignored_devices") + InterfaceEditor.update_value(interface_details, data, "discovery_scope") + InterfaceEditor.update_value(interface_details, data, "discovery_port") + InterfaceEditor.update_value(interface_details, data, "data_port") + + # handle TCPClientInterface if interface_type == "TCPClientInterface": - interface_target_host = data.get('target_host') - interface_target_port = data.get('target_port') - # ensure target host provided + interface_target_host = data.get('target_host') if interface_target_host is None or interface_target_host == "": return web.json_response({ "message": "Target Host is required", }, status=422) # ensure target port provided + interface_target_port = data.get('target_port') if interface_target_port is None or interface_target_port == "": return web.json_response({ "message": "Target Port is required", }, status=422) - interface_details["target_host"] = data.get('target_host') - interface_details["target_port"] = data.get('target_port') + # set required TCPClientInterface options + interface_details["target_host"] = interface_target_host + interface_details["target_port"] = interface_target_port + + # set optional TCPClientInterface options + InterfaceEditor.update_value(interface_details, data, "kiss_framing") + InterfaceEditor.update_value(interface_details, data, "i2p_tunneled") + + # handle I2P interface + if interface_type == "I2PInterface": + interface_details['connectable'] = "True" + interface_details["peers"] = data.get('peers') # handle tcp server interface if interface_type == "TCPServerInterface": - interface_listen_ip = data.get('listen_ip') - interface_listen_port = data.get('listen_port') - # ensure listen ip provided + interface_listen_ip = data.get('listen_ip') if interface_listen_ip is None or interface_listen_ip == "": return web.json_response({ "message": "Listen IP is required", }, status=422) # ensure listen port provided + interface_listen_port = data.get('listen_port') if interface_listen_port is None or interface_listen_port == "": return web.json_response({ "message": "Listen Port is required", }, status=422) - interface_details["listen_ip"] = data.get('listen_ip') - interface_details["listen_port"] = data.get('listen_port') + # set required TCPServerInterface options + interface_details["listen_ip"] = interface_listen_ip + interface_details["listen_port"] = interface_listen_port + + # set optional TCPServerInterface options + InterfaceEditor.update_value(interface_details, data, "device") + InterfaceEditor.update_value(interface_details, data, "prefer_ipv6") # handle udp interface if interface_type == "UDPInterface": - interface_listen_ip = data.get('listen_ip') - interface_listen_port = data.get('listen_port') - interface_forward_ip = data.get('forward_ip') - interface_forward_port = data.get('forward_port') - # ensure listen ip provided + interface_listen_ip = data.get('listen_ip') if interface_listen_ip is None or interface_listen_ip == "": return web.json_response({ "message": "Listen IP is required", }, status=422) # ensure listen port provided + interface_listen_port = data.get('listen_port') if interface_listen_port is None or interface_listen_port == "": return web.json_response({ "message": "Listen Port is required", }, status=422) # ensure forward ip provided + interface_forward_ip = data.get('forward_ip') if interface_forward_ip is None or interface_forward_ip == "": return web.json_response({ "message": "Forward IP is required", }, status=422) # ensure forward port provided + interface_forward_port = data.get('forward_port') if interface_forward_port is None or interface_forward_port == "": return web.json_response({ "message": "Forward Port is required", }, status=422) - interface_details["listen_ip"] = data.get('listen_ip') - interface_details["listen_port"] = data.get('listen_port') - interface_details["forward_ip"] = data.get('forward_ip') - interface_details["forward_port"] = data.get('forward_port') + # set required UDPInterface options + interface_details["listen_ip"] = interface_listen_ip + interface_details["listen_port"] = interface_listen_port + interface_details["forward_ip"] = interface_forward_ip + interface_details["forward_port"] = interface_forward_port - # handle rnode interface + # set optional UDPInterface options + InterfaceEditor.update_value(interface_details, data, "device") + + # handle RNodeInterface if interface_type == "RNodeInterface": + # ensure port provided interface_port = data.get('port') + if interface_port is None or interface_port == "": + return web.json_response({ + "message": "Port is required", + }, status=422) + + # ensure frequency provided interface_frequency = data.get('frequency') + if interface_frequency is None or interface_frequency == "": + return web.json_response({ + "message": "Frequency is required", + }, status=422) + + # ensure bandwidth provided interface_bandwidth = data.get('bandwidth') + if interface_bandwidth is None or interface_bandwidth == "": + return web.json_response({ + "message": "Bandwidth is required", + }, status=422) + + # ensure txpower provided interface_txpower = data.get('txpower') + if interface_txpower is None or interface_txpower == "": + return web.json_response({ + "message": "TX power is required", + }, status=422) + + # ensure spreading factor provided interface_spreadingfactor = data.get('spreadingfactor') + if interface_spreadingfactor is None or interface_spreadingfactor == "": + return web.json_response({ + "message": "Spreading Factor is required", + }, status=422) + + # ensure coding rate provided interface_codingrate = data.get('codingrate') + if interface_codingrate is None or interface_codingrate == "": + return web.json_response({ + "message": "Coding Rate is required", + }, status=422) + + # set required RNodeInterface options + interface_details["port"] = interface_port + interface_details["frequency"] = interface_frequency + interface_details["bandwidth"] = interface_bandwidth + interface_details["txpower"] = interface_txpower + interface_details["spreadingfactor"] = interface_spreadingfactor + interface_details["codingrate"] = interface_codingrate + + # set optional RNodeInterface options + InterfaceEditor.update_value(interface_details, data, "callsign") + InterfaceEditor.update_value(interface_details, data, "id_interval") + InterfaceEditor.update_value(interface_details, data, "airtime_limit_long") + InterfaceEditor.update_value(interface_details, data, "airtime_limit_short") + + # handle RNodeMultiInterface + if interface_type == "RNodeMultiInterface": + + # required settings + interface_port = data.get("port") + sub_interfaces = data.get("sub_interfaces", []) # ensure port provided if interface_port is None or interface_port == "": @@ -538,42 +644,114 @@ class ReticulumMeshChat: "message": "Port is required", }, status=422) - # ensure frequency provided - if interface_frequency is None or interface_frequency == "": + # ensure sub interfaces provided + if not isinstance(sub_interfaces, list) or not sub_interfaces: return web.json_response({ - "message": "Frequency is required", - }, status=422) - - # ensure bandwidth provided - if interface_bandwidth is None or interface_bandwidth == "": - return web.json_response({ - "message": "Bandwidth is required", - }, status=422) - - # ensure txpower provided - if interface_txpower is None or interface_txpower == "": - return web.json_response({ - "message": "TX power is required", - }, status=422) - - # ensure spreading factor provided - if interface_spreadingfactor is None or interface_spreadingfactor == "": - return web.json_response({ - "message": "Spreading Factor is required", - }, status=422) - - # ensure coding rate provided - if interface_codingrate is None or interface_codingrate == "": - return web.json_response({ - "message": "Coding Rate is required", + "message": "At least one sub-interface is required", }, status=422) + # set required RNodeMultiInterface options interface_details["port"] = interface_port - interface_details["frequency"] = interface_frequency - interface_details["bandwidth"] = interface_bandwidth - interface_details["txpower"] = interface_txpower - interface_details["spreadingfactor"] = interface_spreadingfactor - interface_details["codingrate"] = interface_codingrate + + # remove any existing sub interfaces, which can be found by finding keys that contain a dict value + # this allows us to replace all sub interfaces with the ones we are about to add, while also ensuring + # that we do not remove any existing config values from the main interface config + for key in interface_details: + value = interface_details[key] + if isinstance(value, dict): + del interface_details[key] + + # process each provided sub interface + for idx, sub_interface in enumerate(sub_interfaces): + + # ensure required fields for sub-interface provided + missing_fields = [] + required_subinterface_fields = ["name", "frequency", "bandwidth", "txpower", "spreadingfactor", "codingrate", "vport"] + for field in required_subinterface_fields: + if field not in sub_interface or sub_interface.get(field) is None or sub_interface.get(field) == "": + missing_fields.append(field) + if missing_fields: + return web.json_response({ + "message": f"Sub-interface {idx + 1} is missing required field(s): {', '.join(missing_fields)}" + }, status=422) + + sub_interface_name = sub_interface.get("name") + interface_details[sub_interface_name] = { + "interface_enabled": "true", + "frequency": int(sub_interface["frequency"]), + "bandwidth": int(sub_interface["bandwidth"]), + "txpower": int(sub_interface["txpower"]), + "spreadingfactor": int(sub_interface["spreadingfactor"]), + "codingrate": int(sub_interface["codingrate"]), + "vport": int(sub_interface["vport"]), + } + + interfaces[interface_name] = interface_details + + # handle SerialInterface, KISSInterface, and AX25KISSInterface + if interface_type == "SerialInterface" or interface_type == "KISSInterface" or interface_type == "AX25KISSInterface": + + # ensure port provided + interface_port = data.get('port') + if interface_port is None or interface_port == "": + return web.json_response({ + "message": "Port is required", + }, status=422) + + # set required options + interface_details["port"] = interface_port + + # set optional options + InterfaceEditor.update_value(interface_details, data, "speed") + InterfaceEditor.update_value(interface_details, data, "databits") + InterfaceEditor.update_value(interface_details, data, "parity") + InterfaceEditor.update_value(interface_details, data, "stopbits") + + # Handle KISS and AX25KISS specific options + if interface_type == "KISSInterface" or interface_type == "AX25KISSInterface": + + # set optional options + InterfaceEditor.update_value(interface_details, data, "preamble") + InterfaceEditor.update_value(interface_details, data, "txtail") + InterfaceEditor.update_value(interface_details, data, "persistence") + InterfaceEditor.update_value(interface_details, data, "slottime") + InterfaceEditor.update_value(interface_details, data, "callsign") + InterfaceEditor.update_value(interface_details, data, "ssid") + + # FIXME: move to own sections + # RNode Airtime limits and station ID + InterfaceEditor.update_value(interface_details, data, "callsign") + InterfaceEditor.update_value(interface_details, data, "id_interval") + InterfaceEditor.update_value(interface_details, data, "airtime_limit_long") + InterfaceEditor.update_value(interface_details, data, "airtime_limit_short") + + # handle Pipe Interface + if interface_type == "PipeInterface": + + # ensure command provided + interface_command = data.get('command') + if interface_command is None or interface_command == "": + return web.json_response({ + "message": "Command is required", + }, status=422) + + # ensure command provided + interface_respawn_delay = data.get('respawn_delay') + if interface_respawn_delay is None or interface_respawn_delay == "": + return web.json_response({ + "message": "Respawn delay is required", + }, status=422) + + # set required options + interface_details["command"] = interface_command + interface_details["respawn_delay"] = interface_respawn_delay + + # set common interface options + InterfaceEditor.update_value(interface_details, data, "bitrate") + InterfaceEditor.update_value(interface_details, data, "mode") + InterfaceEditor.update_value(interface_details, data, "network_name") + InterfaceEditor.update_value(interface_details, data, "passphrase") + InterfaceEditor.update_value(interface_details, data, "ifac_size") # merge new interface into existing interfaces interfaces[interface_name] = interface_details @@ -617,9 +795,22 @@ class ReticulumMeshChat: # add interface to output output.append(f"[[{interface_name}]]") for key, value in interface.items(): - output.append(f" {key} = {value}") + if not isinstance(value, dict): + output.append(f" {key} = {value}") output.append("") - + + # Handle sub-interfaces for RNodeMultiInterface + if interface.get("type") == "RNodeMultiInterface": + for sub_name, sub_config in interface.items(): + if sub_name in {"type", "port", "interface_enabled"}: + continue + if isinstance(sub_config, dict): + output.append(f" [[[{sub_name}]]]") + for sub_key, sub_value in sub_config.items(): + output.append(f" {sub_key} = {sub_value}") + output.append("") + + return web.Response( text="\n".join(output), content_type="text/plain", diff --git a/src/backend/interface_config_parser.py b/src/backend/interface_config_parser.py index 75ab0b2..94bf823 100644 --- a/src/backend/interface_config_parser.py +++ b/src/backend/interface_config_parser.py @@ -1,50 +1,31 @@ +import RNS.vendor.configobj + + class InterfaceConfigParser: @staticmethod def parse(text): - # process config + # get lines from provided text + lines = text.splitlines() + + # ensure [interfaces] section exists + if "[interfaces]" not in lines: + lines.insert(0, "[interfaces]") + + # parse lines as rns config object + config = RNS.vendor.configobj.ConfigObj(lines) + + # get interfaces from config + config_interfaces = config.get("interfaces") + + # process interfaces interfaces = [] - current_interface = None - for line in text.splitlines(): + for interface_name in config_interfaces: - # strip whitespace from either side of string - line = line.strip() - - # skip empty lines and commented out lines - if not line or line.startswith("#"): - continue - - # check if we found a line containing an interface name - if line.startswith("[[") and line.endswith("]]"): - - # we found a new interface, so add the previously parsed one to the interfaces list - if current_interface: - interfaces.append(current_interface) - - # get interface name by removing the "[[" and "]]" - interface_name = line[2:-2] - current_interface = { - "name": interface_name, - } - - # we found a key=value line, so we will set these values on the interface - elif current_interface is not None and "=" in line: - - # parse key and value - line_parts = line.split("=", 1) - key = line_parts[0].strip() - value = line_parts[1].strip().strip('"').strip("'") - - # skip empty values or values equal to "None" - if value is None or value == "None": - continue - - # set key/value on interface - current_interface[key] = value - - # add the final interface to the list - if current_interface: - interfaces.append(current_interface) + # ensure interface has a name + interface_config = config_interfaces[interface_name] + interface_config["name"] = interface_name + interfaces.append(interface_config) return interfaces diff --git a/src/backend/interface_editor.py b/src/backend/interface_editor.py new file mode 100644 index 0000000..e836eb9 --- /dev/null +++ b/src/backend/interface_editor.py @@ -0,0 +1,14 @@ +class InterfaceEditor: + + @staticmethod + def update_value(interface_details: dict, data: dict, key: str): + + # update value if provided and not empty + value = data.get(key) + if value is not None and value != "": + interface_details[key] = value + return + + # otherwise remove existing value + if key in interface_details: + del interface_details[key] diff --git a/src/frontend/components/forms/FormLabel.vue b/src/frontend/components/forms/FormLabel.vue new file mode 100644 index 0000000..367ece7 --- /dev/null +++ b/src/frontend/components/forms/FormLabel.vue @@ -0,0 +1,10 @@ + + \ No newline at end of file diff --git a/src/frontend/components/forms/FormSubLabel.vue b/src/frontend/components/forms/FormSubLabel.vue new file mode 100644 index 0000000..84741e6 --- /dev/null +++ b/src/frontend/components/forms/FormSubLabel.vue @@ -0,0 +1,10 @@ + + \ No newline at end of file diff --git a/src/frontend/components/interfaces/AddInterfacePage.vue b/src/frontend/components/interfaces/AddInterfacePage.vue index 1ffafc2..593e608 100644 --- a/src/frontend/components/interfaces/AddInterfacePage.vue +++ b/src/frontend/components/interfaces/AddInterfacePage.vue @@ -7,12 +7,12 @@
Community Interfaces
-
These TCP interfaces serve as a quick way to test Reticulum. We suggest running your own as these may not always be available.
+
These TCP interfaces serve as a quick way to test Reticulum. We suggest running your own as these may not always be available.
@@ -25,7 +25,10 @@
amsterdam.connect.reticulum.network:4965
-
@@ -37,7 +40,10 @@
reticulum.betweentheborders.com:4242
-
@@ -47,90 +53,162 @@ -
+
Edit Interface Add Interface
+
- - -
Interface names must be unique.
+ Name + + Interface names must be unique.
- + Type - +
+
- - + Target Host +
- - + Target Port +
+
- - + Listen IP +
- - + Listen Port +
+
- - + Forward IP +
- - + Forward Port +
+ + +
+
ⓘ To use the I2P interface, you must have an I2P router running on your system. When the I2P Interface is added for the first time Reticulum will generate a new I2P address for the interface and begin listening for inbound traffic.
+ Peers +
+
+ + +
+ +
+
+ +
- + Port + +
Reload Ports
+
- +
- - -
{{ formatFrequency(newInterfaceFrequency) }}
+ + Frequency: {{ formattedFrequency }} + +
+
+ + GHz +
+
+ + MHz +
+
+ + kHz +
+
- + Bandwidth @@ -138,35 +216,583 @@
- + Transmit Power (dBm)
- -
- - +
+ + +
+ Spreading Factor + +
+ + +
+ Coding Rate + +
+
- -
- - + + +
Reload Ports
+
- - + +
+ Sub-Interfaces +
+
+ + + +
+
+ Frequency (Hz) + +
+
+ Bandwidth + +
+
+ +
+
+ Spreading Factor + +
+
+ Coding Rate + +
+
+ +
+
+ TX Power (dBm) + +
+
+ Virtual Port + +
+
+ + + +
+ +
+
+ + +
+ +
+ Port + + +
Reload Ports
+
+
+ +
+ Serial connection baud rate (bps) + +
+ +
+ Databits + +
+ +
+ Parity + +
+ +
+ Stopbits + +
+ +
+ + +
+ +
+ + Enable AX.25 Framing +
+ +
+
+ Preamble (milliseconds) + +
+
+ TX Tail (milliseconds) + +
+
+ CDMA Persistence (milliseconds) + +
+
+ CDMA Slot Time (milliseconds) + +
+
+ +
+
+ SSID + +
+
+ Callsign + +
+
+ Callsign ID Interval + +
+
+ +
+ + +
+ +
ⓘ Using this interface, Reticulum can use any program as an interface via stdin and stdout. This can be usedto easily create virtual interfaces, or to interface with custom hardware or other systems.
+ +
+ Command + +
+ +
+ Respawn Delay (seconds) + +
+ +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ +
+
@@ -174,13 +800,22 @@ diff --git a/src/frontend/components/interfaces/ExpandingSection.vue b/src/frontend/components/interfaces/ExpandingSection.vue new file mode 100644 index 0000000..9d23a17 --- /dev/null +++ b/src/frontend/components/interfaces/ExpandingSection.vue @@ -0,0 +1,35 @@ + + \ No newline at end of file diff --git a/src/frontend/components/interfaces/Interface.vue b/src/frontend/components/interfaces/Interface.vue index 5aff14a..5eafded 100644 --- a/src/frontend/components/interfaces/Interface.vue +++ b/src/frontend/components/interfaces/Interface.vue @@ -36,6 +36,14 @@ + + + + + + + + @@ -186,6 +194,10 @@
• Bitrate: {{ formatBitsPerSecond(iface._stats?.bitrate ?? 0) }}
• TX: {{ formatBytes(iface._stats?.txb ?? 0) }}
• RX: {{ formatBytes(iface._stats?.rxb ?? 0) }}
+
• Noise Floor: {{ + iface._stats?.noise_floor + }} dBm +
• Clients: {{ iface._stats?.clients }}
diff --git a/src/frontend/components/nomadnetwork/NomadNetworkPage.vue b/src/frontend/components/nomadnetwork/NomadNetworkPage.vue index c806642..9f4feeb 100644 --- a/src/frontend/components/nomadnetwork/NomadNetworkPage.vue +++ b/src/frontend/components/nomadnetwork/NomadNetworkPage.vue @@ -69,7 +69,7 @@
Loading {{ nodePageProgress }}%
-

+            

         
 
         
diff --git a/src/frontend/js/Utils.js b/src/frontend/js/Utils.js
index f63c9f3..9362eca 100644
--- a/src/frontend/js/Utils.js
+++ b/src/frontend/js/Utils.js
@@ -143,7 +143,7 @@ class Utils {
 
     static isInterfaceEnabled(iface) {
         const rawValue = iface.enabled ?? iface.interface_enabled;
-        const value = rawValue?.toLowerCase();
+        const value = rawValue?.toString()?.toLowerCase();
         return value === "on" || value === "yes" || value === "true";
     }