Merge pull request #60 from RFnexus/interfaces-update

Add additional interfaces and interface options
This commit is contained in:
Liam Cottle
2025-02-03 12:49:44 +13:00
committed by GitHub
10 changed files with 1354 additions and 159 deletions

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,10 @@
<template>
<label class="block text-sm font-medium text-gray-900 dark:text-zinc-100">
<slot/>
</label>
</template>
<script>
export default {
name: 'FormLabel',
}
</script>

View File

@@ -0,0 +1,10 @@
<template>
<div class="text-xs text-gray-600 dark:text-zinc-300">
<slot/>
</div>
</template>
<script>
export default {
name: 'FormSubLabel',
}
</script>

View File

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,35 @@
<template>
<div class="bg-white rounded shadow divide-y divide-gray-300 dark:divide-zinc-700 dark:bg-zinc-900 overflow-hidden">
<div @click="isExpanded = !isExpanded" class="flex p-2 justify-between cursor-pointer hover:bg-gray-50 dark:hover:bg-zinc-800">
<div class="my-auto mr-auto">
<div class="font-bold dark:text-white">
<slot name="title"/>
</div>
<div class="text-sm text-gray-600 dark:text-gray-300">
<slot name="subtitle"/>
</div>
</div>
<div class="my-auto ml-2">
<div class="w-5 h-5 text-gray-600 dark:text-gray-300 transform transition-transform duration-200" :class="{ 'rotate-90': isExpanded }">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" class="size-5">
<rect width="256" height="256" fill="none"/>
<path d="M181.66,122.34l-80-80A8,8,0,0,0,88,48V208a8,8,0,0,0,13.66,5.66l80-80A8,8,0,0,0,181.66,122.34Z" fill="currentColor"/>
</svg>
</div>
</div>
</div>
<div v-if="isExpanded" class="divide-y divide-gray-200 dark:text-white">
<slot name="content"/>
</div>
</div>
</template>
<script>
export default {
name: 'ExpandingSection',
data() {
return {
isExpanded: false,
};
},
}
</script>

View File

@@ -36,6 +36,14 @@
<path d="M252.44,121.34l-48-32A8,8,0,0,0,192,96v24H72V72h33a32,32,0,1,0,0-16H72A16,16,0,0,0,56,72v48H8a8,8,0,0,0,0,16H56v48a16,16,0,0,0,16,16h32v8a16,16,0,0,0,16,16h32a16,16,0,0,0,16-16V176a16,16,0,0,0-16-16H120a16,16,0,0,0-16,16v8H72V136H192v24a8,8,0,0,0,12.44,6.66l48-32a8,8,0,0,0,0-13.32ZM136,48a16,16,0,1,1-16,16A16,16,0,0,1,136,48ZM120,176h32v32H120Zm88-30.95V111l25.58,17Z"></path>
</svg>
<svg v-else-if="iface.type === 'RNodeMultiInterface'" xmlns="http://www.w3.org/2000/svg" fill="currentColor" viewBox="0 0 256 256" class="size-6 dark:text-white"><path d="M128,88a40,40,0,1,0,40,40A40,40,0,0,0,128,88Zm0,64a24,24,0,1,1,24-24A24,24,0,0,1,128,152Zm73.71,7.14a80,80,0,0,1-14.08,22.2,8,8,0,0,1-11.92-10.67,63.95,63.95,0,0,0,0-85.33,8,8,0,1,1,11.92-10.67,80.08,80.08,0,0,1,14.08,84.47ZM69,103.09a64,64,0,0,0,11.26,67.58,8,8,0,0,1-11.92,10.67,79.93,79.93,0,0,1,0-106.67A8,8,0,1,1,80.29,85.34,63.77,63.77,0,0,0,69,103.09ZM248,128a119.58,119.58,0,0,1-34.29,84,8,8,0,1,1-11.42-11.2,103.9,103.9,0,0,0,0-145.56A8,8,0,1,1,213.71,44,119.58,119.58,0,0,1,248,128ZM53.71,200.78A8,8,0,1,1,42.29,212a119.87,119.87,0,0,1,0-168,8,8,0,1,1,11.42,11.2,103.9,103.9,0,0,0,0,145.56Z"></path></svg>
<svg v-else-if="iface.type === 'I2PInterface'" xmlns="http://www.w3.org/2000/svg" fill="currentColor" viewBox="0 0 256 256" class="size-6 dark:text-white"><path d="M72,92A12,12,0,1,1,60,80,12,12,0,0,1,72,92Zm56-12a12,12,0,1,0,12,12A12,12,0,0,0,128,80Zm68,24a12,12,0,1,0-12-12A12,12,0,0,0,196,104ZM60,152a12,12,0,1,0,12,12A12,12,0,0,0,60,152Zm68,0a12,12,0,1,0,12,12A12,12,0,0,0,128,152Zm68,0a12,12,0,1,0,12,12A12,12,0,0,0,196,152Z"></path></svg>
<svg v-else-if="iface.type === 'KISSInterface' || iface.type === 'AX25KISSInterface'" xmlns="http://www.w3.org/2000/svg" fill="currentColor" viewBox="0 0 256 256" class="size-6 dark:text-white"><path d="M104,168a8,8,0,0,1-8,8H64a8,8,0,0,1,0-16H96A8,8,0,0,1,104,168Zm-8-40H64a8,8,0,0,0,0,16H96a8,8,0,0,0,0-16Zm0-32H64a8,8,0,0,0,0,16H96a8,8,0,0,0,0-16ZM232,80V192a16,16,0,0,1-16,16H40a16,16,0,0,1-16-16V72a8,8,0,0,1,5.7-7.66l160-48a8,8,0,0,1,4.6,15.33L86.51,64H216A16,16,0,0,1,232,80ZM216,192V80H40V192H216Zm-16-56a40,40,0,1,1-40-40A40,40,0,0,1,200,136Zm-16,0a24,24,0,1,0-24,24A24,24,0,0,0,184,136Z"></path></svg>
<svg v-else-if="iface.type === 'PipeInterface'" xmlns="http://www.w3.org/2000/svg" fill="currentColor" viewBox="0 0 256 256" class="size-6 dark:text-white"><path d="M128,128a8,8,0,0,1-3,6.25l-40,32a8,8,0,1,1-10-12.5L107.19,128,75,102.25a8,8,0,1,1,10-12.5l40,32A8,8,0,0,1,128,128Zm48,24H136a8,8,0,0,0,0,16h40a8,8,0,0,0,0-16Zm56-96V200a16,16,0,0,1-16,16H40a16,16,0,0,1-16-16V56A16,16,0,0,1,40,40H216A16,16,0,0,1,232,56ZM216,200V56H40V200H216Z"></path></svg>
<svg v-else xmlns="http://www.w3.org/2000/svg" fill="currentColor" viewBox="0 0 256 256" class="size-6 dark:text-white">
<path d="M140,180a12,12,0,1,1-12-12A12,12,0,0,1,140,180ZM128,72c-22.06,0-40,16.15-40,36v4a8,8,0,0,0,16,0v-4c0-11,10.77-20,24-20s24,9,24,20-10.77,20-24,20a8,8,0,0,0-8,8v8a8,8,0,0,0,16,0v-.72c18.24-3.35,32-17.9,32-35.28C168,88.15,150.06,72,128,72Zm104,56A104,104,0,1,1,128,24,104.11,104.11,0,0,1,232,128Zm-16,0a88,88,0,1,0-88,88A88.1,88.1,0,0,0,216,128Z"></path>
</svg>
@@ -186,6 +194,10 @@
<div> Bitrate: {{ formatBitsPerSecond(iface._stats?.bitrate ?? 0) }}</div>
<div> TX: {{ formatBytes(iface._stats?.txb ?? 0) }}</div>
<div> RX: {{ formatBytes(iface._stats?.rxb ?? 0) }}</div>
<div v-if="iface.type === 'RNodeInterface'"> Noise Floor: {{
iface._stats?.noise_floor
}} dBm
</div>
<div v-if="iface._stats?.clients"> Clients: {{ iface._stats?.clients }}</div>
</div>

View File

@@ -69,7 +69,7 @@
</div>
<div class="my-auto">Loading {{ nodePageProgress }}%</div>
</div>
<pre v-else v-html="nodePageContent" class="h-full text-wrap"></pre>
<pre v-else v-html="nodePageContent" class="h-full break-words whitespace-pre-wrap"></pre>
</div>
<!-- file download bottom bar -->

View File

@@ -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";
}