Compare commits

..

No commits in common. "master" and "v1.19.0" have entirely different histories.

37 changed files with 662 additions and 3048 deletions

View file

@ -45,10 +45,7 @@ jobs:
artifacts: "dist/*-win-installer.exe,dist/*-win-portable.exe"
build_mac:
strategy:
matrix:
os: [ macos-15, macos-15-intel ]
runs-on: ${{ matrix.os }}
runs-on: macos-13
permissions:
contents: write
steps:
@ -83,7 +80,7 @@ jobs:
replacesArtifacts: true
omitDraftDuringUpdate: true
omitNameDuringUpdate: true
artifacts: "dist/*-mac-*.dmg"
artifacts: "dist/*-mac.dmg"
build_linux:
runs-on: ubuntu-latest

View file

@ -283,6 +283,20 @@ I build the vite app everytime without hot reload, since MeshChat expects everyt
## TODO
- [ ] button to forget announces
- [ ] support for managing Reticulum interfaces via the web ui
- [x] AutoInterface
- [x] RNodeInterface
- [x] TCPClientInterface
- [x] TCPServerInterface
- [x] UDPInterface
- [ ] I2PInterface
- [ ] SerialInterface
- [ ] PipeInterface
- [ ] KISSInterface
- [ ] AX25KISSInterface
- [ ] Other Options
- [ ] network_name
- [ ] passphrase
# Notes

View file

@ -95,21 +95,6 @@ class CustomDestinationDisplayName(BaseModel):
table_name = "custom_destination_display_names"
class FavouriteDestination(BaseModel):
id = BigAutoField()
destination_hash = CharField(unique=True) # unique destination hash
display_name = CharField() # custom display name for the destination hash
aspect = CharField() # e.g: nomadnetwork.node
created_at = DateTimeField(default=lambda: datetime.now(timezone.utc))
updated_at = DateTimeField(default=lambda: datetime.now(timezone.utc))
# define table name
class Meta:
table_name = "favourite_destinations"
class LxmfMessage(BaseModel):
id = BigAutoField()

View file

@ -22,27 +22,6 @@ ipcMain.handle('alert', async(event, message) => {
});
});
// add support for showing a confirm window via ipc
ipcMain.handle('confirm', async(event, message) => {
// show confirm dialog
const result = await dialog.showMessageBox(mainWindow, {
type: "question",
title: "Confirm",
message: message,
cancelId: 0, // esc key should press cancel button
defaultId: 1, // enter key should press ok button
buttons: [
"Cancel", // 0
"OK", // 1
],
});
// check if user clicked OK
return result.response === 1;
});
// add support for showing a prompt window via ipc
ipcMain.handle('prompt', async(event, message) => {
return await electronPrompt({
@ -69,9 +48,6 @@ ipcMain.handle('showPathInFolder', (event, path) => {
function log(message) {
// log to stdout of this process
console.log(message);
// make sure main window exists
if(!mainWindow){
return;
@ -82,6 +58,9 @@ function log(message) {
return;
}
// log to electron console
console.log(message);
// log to web console
mainWindow.webContents.send('log', message);
@ -119,64 +98,50 @@ function getDefaultReticulumConfigDir() {
app.whenReady().then(async () => {
// get arguments passed to application, and remove the provided application path
const ignoredArguments = ["--no-sandbox", "--ozone-platform-hint=auto"];
const userProvidedArguments = process.argv.slice(1).filter((arg) => !ignoredArguments.includes(arg));
const shouldLaunchHeadless = userProvidedArguments.includes("--headless");
// create browser window
mainWindow = new BrowserWindow({
width: 1500,
height: 800,
webPreferences: {
// used to inject logging over ipc
preload: path.join(__dirname, 'preload.js'),
},
});
if(!shouldLaunchHeadless){
// open external links in default web browser instead of electron
mainWindow.webContents.setWindowOpenHandler(({ url }) => {
// create browser window
mainWindow = new BrowserWindow({
width: 1500,
height: 800,
webPreferences: {
// used to inject logging over ipc
preload: path.join(__dirname, 'preload.js'),
},
});
var shouldShowInNewElectronWindow = false;
// open external links in default web browser instead of electron
mainWindow.webContents.setWindowOpenHandler(({ url }) => {
var shouldShowInNewElectronWindow = false;
// we want to open call.html in a new electron window
// but all other target="_blank" links should open in the system web browser
// we don't want /rnode-flasher/index.html to open in electron, otherwise user can't select usb devices...
if(url.startsWith("http://localhost") && url.includes("/call.html")){
shouldShowInNewElectronWindow = true;
}
// we want to open blob urls in a new electron window
else if(url.startsWith("blob:")) {
shouldShowInNewElectronWindow = true;
}
// open in new electron window
if(shouldShowInNewElectronWindow){
return {
action: "allow",
};
}
// fallback to opening any other url in external browser
shell.openExternal(url);
return {
action: "deny",
};
});
// navigate to loading page
await mainWindow.loadFile(path.join(__dirname, 'loading.html'));
// ask mac users for microphone access for audio calls to work
if(process.platform === "darwin"){
await systemPreferences.askForMediaAccess('microphone');
// we want to open call.html in a new electron window
// but all other target="_blank" links should open in the system web browser
// we don't want /rnode-flasher/index.html to open in electron, otherwise user can't select usb devices...
if(url.startsWith("http://localhost") && url.includes("/call.html")){
shouldShowInNewElectronWindow = true;
}
}
// we want to open blob urls in a new electron window
else if(url.startsWith("blob:")) {
shouldShowInNewElectronWindow = true;
}
// open in new electron window
if(shouldShowInNewElectronWindow){
return {
action: "allow",
};
}
// fallback to opening any other url in external browser
shell.openExternal(url);
return {
action: "deny",
};
});
// navigate to loading page
await mainWindow.loadFile(path.join(__dirname, 'loading.html'));
// find path to python/cxfreeze reticulum meshchat executable
const exeName = process.platform === "win32" ? "ReticulumMeshChat.exe" : "ReticulumMeshChat";
@ -187,8 +152,16 @@ app.whenReady().then(async () => {
exe = path.join(__dirname, '..', `build/exe/${exeName}`);
}
// ask mac users for microphone access for audio calls to work
if(process.platform === "darwin"){
await systemPreferences.askForMediaAccess('microphone');
}
try {
// get arguments passed to application, and remove the provided application path
const userProvidedArguments = process.argv.slice(1);
// arguments we always want to pass in
const requiredArguments = [
'--headless', // reticulum meshchat usually launches default web browser, we don't want this when using electron

View file

@ -15,11 +15,6 @@ contextBridge.exposeInMainWorld('electron', {
return await ipcRenderer.invoke('alert', message);
},
// show a confirm dialog in electron browser window, this fixes a bug where confirm breaks input fields on windows
confirm: async function(message) {
return await ipcRenderer.invoke('confirm', message);
},
// add support for using "prompt" in electron browser window
prompt: async function(message) {
return await ipcRenderer.invoke('prompt', message);

View file

@ -1,10 +1,8 @@
#!/usr/bin/env python
import argparse
import io
import json
import os
import platform
import sys
import threading
import time
@ -28,7 +26,6 @@ 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
@ -79,7 +76,6 @@ class ReticulumMeshChat:
database.Config,
database.Announce,
database.CustomDestinationDisplayName,
database.FavouriteDestination,
database.LxmfMessage,
database.LxmfConversationReadState,
database.LxmfUserIcon,
@ -273,7 +269,7 @@ class ReticulumMeshChat:
# handle receiving a new audio call
def on_incoming_audio_call(self, audio_call: AudioCall):
print("on_incoming_audio_call: {}".format(audio_call.link.hash.hex()))
AsyncUtils.run_async(self.websocket_broadcast(json.dumps({
asyncio.run(self.websocket_broadcast(json.dumps({
"type": "incoming_audio_call",
})))
@ -333,29 +329,8 @@ 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": processed_interfaces,
"interfaces": interfaces,
})
# enable reticulum interface
@ -468,159 +443,131 @@ class ReticulumMeshChat:
if "enabled" not in interface_details and "interface_enabled" not in interface_details:
interface_details["interface_enabled"] = "true"
# 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
# handle tcp client interface
if interface_type == "TCPClientInterface":
# ensure target host provided
interface_target_host = data.get('target_host')
interface_target_port = data.get('target_port')
# ensure target host provided
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)
# 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')
interface_details["target_host"] = data.get('target_host')
interface_details["target_port"] = data.get('target_port')
# handle tcp server interface
if interface_type == "TCPServerInterface":
# ensure listen ip provided
interface_listen_ip = data.get('listen_ip')
interface_listen_port = data.get('listen_port')
# ensure listen ip provided
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)
# 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")
interface_details["listen_ip"] = data.get('listen_ip')
interface_details["listen_port"] = data.get('listen_port')
# handle udp interface
if interface_type == "UDPInterface":
# ensure listen ip provided
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
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)
# 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
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 optional UDPInterface options
InterfaceEditor.update_value(interface_details, data, "device")
# handle RNodeInterface
# handle rnode interface
if interface_type == "RNodeInterface":
# ensure port provided
interface_port = data.get('port')
interface_frequency = data.get('frequency')
interface_bandwidth = data.get('bandwidth')
interface_txpower = data.get('txpower')
interface_spreadingfactor = data.get('spreadingfactor')
interface_codingrate = data.get('codingrate')
# ensure port provided
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
@ -628,134 +575,6 @@ class ReticulumMeshChat:
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 == "":
return web.json_response({
"message": "Port is required",
}, status=422)
# ensure sub interfaces provided
if not isinstance(sub_interfaces, list) or not sub_interfaces:
return web.json_response({
"message": "At least one sub-interface is required",
}, status=422)
# set required RNodeMultiInterface options
interface_details["port"] = interface_port
# 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
self.reticulum.config["interfaces"] = interfaces
@ -798,22 +617,9 @@ class ReticulumMeshChat:
# add interface to output
output.append(f"[[{interface_name}]]")
for key, value in interface.items():
if not isinstance(value, dict):
output.append(f" {key} = {value}")
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",
@ -945,7 +751,6 @@ class ReticulumMeshChat:
"version": self.get_app_version(),
"lxmf_version": LXMF.__version__,
"rns_version": RNS.__version__,
"python_version": platform.python_version(),
"storage_path": self.storage_path,
"database_path": self.database_path,
"database_file_size": os.path.getsize(self.database_path),
@ -980,10 +785,6 @@ class ReticulumMeshChat:
@routes.post("/api/v1/reticulum/enable-transport")
async def index(request):
# ensure reticulum section exists
if "reticulum" not in self.reticulum.config:
self.reticulum.config["reticulum"] = {}
# enable transport mode
self.reticulum.config["reticulum"]["enable_transport"] = True
self.reticulum.config.write()
@ -996,10 +797,6 @@ class ReticulumMeshChat:
@routes.post("/api/v1/reticulum/disable-transport")
async def index(request):
# ensure reticulum section exists
if "reticulum" not in self.reticulum.config:
self.reticulum.config["reticulum"] = {}
# disable transport mode
self.reticulum.config["reticulum"]["enable_transport"] = False
self.reticulum.config.write()
@ -1127,7 +924,7 @@ class ReticulumMeshChat:
def on_audio_packet(data):
if websocket_response.closed is False:
try:
AsyncUtils.run_async(websocket_response.send_bytes(data))
asyncio.run(websocket_response.send_bytes(data))
except:
# ignore errors sending audio packets to websocket
pass
@ -1136,7 +933,7 @@ class ReticulumMeshChat:
def on_hangup():
if websocket_response.closed is False:
try:
AsyncUtils.run_async(websocket_response.close(code=WSCloseCode.GOING_AWAY))
asyncio.run(websocket_response.close(code=WSCloseCode.GOING_AWAY))
except:
# ignore errors closing websocket
pass
@ -1210,7 +1007,6 @@ class ReticulumMeshChat:
# get query params
aspect = request.query.get("aspect", None)
identity_hash = request.query.get("identity_hash", None)
destination_hash = request.query.get("destination_hash", None)
limit = request.query.get("limit", None)
# build announces database query
@ -1224,10 +1020,6 @@ class ReticulumMeshChat:
if identity_hash is not None:
query = query.where(database.Announce.identity_hash == identity_hash)
# filter by provided destination hash
if destination_hash is not None:
query = query.where(database.Announce.destination_hash == destination_hash)
# limit results
if limit is not None:
query = query.limit(limit)
@ -1244,98 +1036,6 @@ class ReticulumMeshChat:
"announces": announces,
})
# serve favourites
@routes.get("/api/v1/favourites")
async def index(request):
# get query params
aspect = request.query.get("aspect", None)
# build favourites database query
query = database.FavouriteDestination.select()
# filter by provided aspect
if aspect is not None:
query = query.where(database.FavouriteDestination.aspect == aspect)
# order favourites alphabetically
query_results = query.order_by(database.FavouriteDestination.display_name.asc())
# process favourites
favourites = []
for favourite in query_results:
favourites.append(self.convert_db_favourite_to_dict(favourite))
return web.json_response({
"favourites": favourites,
})
# add favourite
@routes.post("/api/v1/favourites/add")
async def index(request):
# get request data
data = await request.json()
destination_hash = data.get("destination_hash", None)
display_name = data.get("display_name", None)
aspect = data.get("aspect", None)
# destination hash is required
if destination_hash is None:
return web.json_response({
"message": "destination_hash is required",
}, status=422)
# display name is required
if display_name is None:
return web.json_response({
"message": "display_name is required",
}, status=422)
# aspect is required
if aspect is None:
return web.json_response({
"message": "aspect is required",
}, status=422)
# upsert favourite
self.db_upsert_favourite(destination_hash, display_name, aspect)
return web.json_response({
"message": "Favourite has been added!",
})
# rename favourite
@routes.post("/api/v1/favourites/{destination_hash}/rename")
async def index(request):
# get path params
destination_hash = request.match_info.get("destination_hash", "")
# get request data
data = await request.json()
display_name = data.get("display_name")
# update display name if provided
if len(display_name) > 0:
database.FavouriteDestination.update(display_name=display_name).where(database.FavouriteDestination.destination_hash == destination_hash).execute()
return web.json_response({
"message": "Favourite has been renamed",
})
# delete favourite
@routes.delete("/api/v1/favourites/{destination_hash}")
async def index(request):
# get path params
destination_hash = request.match_info.get("destination_hash", "")
# delete favourite
database.FavouriteDestination.delete().where(database.FavouriteDestination.destination_hash == destination_hash).execute()
return web.json_response({
"message": "Favourite has been added!",
})
# propagation node status
@routes.get("/api/v1/lxmf/propagation-node/status")
async def index(request):
@ -1733,10 +1433,6 @@ class ReticulumMeshChat:
if "transport_id" in interface_stats:
interface_stats["transport_id"] = interface_stats["transport_id"].hex()
# ensure network_id is hex as json_response can't serialize bytes
if "network_id" in interface_stats and interface_stats["network_id"] is not None:
interface_stats["network_id"] = interface_stats["network_id"].hex()
# ensure probe_responder is hex as json_response can't serialize bytes
if "probe_responder" in interface_stats and interface_stats["probe_responder"] is not None:
interface_stats["probe_responder"] = interface_stats["probe_responder"].hex()
@ -1865,30 +1561,6 @@ class ReticulumMeshChat:
"lxmf_message": lxmf_message,
})
# identify self on existing nomadnetwork link
@routes.post("/api/v1/nomadnetwork/{destination_hash}/identify")
async def index(request):
# get path params
destination_hash = request.match_info.get("destination_hash", "")
# convert destination hash to bytes
destination_hash = bytes.fromhex(destination_hash)
# identify to existing active link
if destination_hash in nomadnet_cached_links:
link = nomadnet_cached_links[destination_hash]
if link.status is RNS.Link.ACTIVE:
link.identify(self.identity)
return web.json_response({
"message": "Identity has been sent!",
})
# failed to identify
return web.json_response({
"message": "Failed to identify. No active link to destination.",
}, status=500)
# delete lxmf message
@routes.delete("/api/v1/lxmf-messages/{hash}")
async def index(request):
@ -2052,9 +1724,6 @@ class ReticulumMeshChat:
# called when web app has started
async def on_startup(app):
# remember main event loop
AsyncUtils.set_main_loop(asyncio.get_event_loop())
# auto launch web browser
if launch_browser:
try:
@ -2194,7 +1863,6 @@ class ReticulumMeshChat:
else:
print(f"unhandled field: {field}")
return data
def convert_nomadnet_field_data_to_map(self, field_data):
data = {}
if field_data is not None or "{}":
@ -2210,20 +1878,17 @@ class ReticulumMeshChat:
return data
# handle data received from websocket client
async def on_websocket_data_received(self, client, data):
# get type from client data
_type = data["type"]
# handle ping
if _type == "ping":
AsyncUtils.run_async(client.send_str(json.dumps({
"type": "pong",
})))
# handle updating config
elif _type == "config.set":
if _type == "config.set":
# get config from websocket
config = data["config"]
@ -2243,7 +1908,7 @@ class ReticulumMeshChat:
# handle successful file download
def on_file_download_success(file_name, file_bytes):
AsyncUtils.run_async(client.send_str(json.dumps({
asyncio.run(client.send_str(json.dumps({
"type": "nomadnet.file.download",
"nomadnet_file_download": {
"status": "success",
@ -2256,7 +1921,7 @@ class ReticulumMeshChat:
# handle file download failure
def on_file_download_failure(failure_reason):
AsyncUtils.run_async(client.send_str(json.dumps({
asyncio.create_task(client.send_str(json.dumps({
"type": "nomadnet.file.download",
"nomadnet_file_download": {
"status": "failure",
@ -2268,7 +1933,7 @@ class ReticulumMeshChat:
# handle file download progress
def on_file_download_progress(progress):
AsyncUtils.run_async(client.send_str(json.dumps({
asyncio.run(client.send_str(json.dumps({
"type": "nomadnet.file.download",
"nomadnet_file_download": {
"status": "progress",
@ -2278,9 +1943,11 @@ class ReticulumMeshChat:
},
})))
# todo: handle file download progress
# download the file
downloader = NomadnetFileDownloader(destination_hash, file_path, on_file_download_success, on_file_download_failure, on_file_download_progress)
AsyncUtils.run_async(downloader.download())
await downloader.download()
# handle downloading a page from a nomadnet node
elif _type == "nomadnet.page.download":
@ -2316,7 +1983,7 @@ class ReticulumMeshChat:
# handle successful page download
def on_page_download_success(page_content):
AsyncUtils.run_async(client.send_str(json.dumps({
asyncio.run(client.send_str(json.dumps({
"type": "nomadnet.page.download",
"nomadnet_page_download": {
"status": "success",
@ -2328,7 +1995,7 @@ class ReticulumMeshChat:
# handle page download failure
def on_page_download_failure(failure_reason):
AsyncUtils.run_async(client.send_str(json.dumps({
asyncio.create_task(client.send_str(json.dumps({
"type": "nomadnet.page.download",
"nomadnet_page_download": {
"status": "failure",
@ -2340,7 +2007,7 @@ class ReticulumMeshChat:
# handle page download progress
def on_page_download_progress(progress):
AsyncUtils.run_async(client.send_str(json.dumps({
asyncio.run(client.send_str(json.dumps({
"type": "nomadnet.page.download",
"nomadnet_page_download": {
"status": "progress",
@ -2350,9 +2017,11 @@ class ReticulumMeshChat:
},
})))
# todo: handle page download progress
# download the page
downloader = NomadnetPageDownloader(destination_hash, page_path_to_download, combined_data, on_page_download_success, on_page_download_failure, on_page_download_progress)
AsyncUtils.run_async(downloader.download())
await downloader.download()
# unhandled type
else:
@ -2638,17 +2307,6 @@ class ReticulumMeshChat:
"updated_at": announce.updated_at,
}
# convert database favourite to a dictionary
def convert_db_favourite_to_dict(self, favourite: database.FavouriteDestination):
return {
"id": favourite.id,
"destination_hash": favourite.destination_hash,
"display_name": favourite.display_name,
"aspect": favourite.aspect,
"created_at": favourite.created_at,
"updated_at": favourite.updated_at,
}
# convert database lxmf message to a dictionary
def convert_db_lxmf_message_to_dict(self, db_lxmf_message: database.LxmfMessage):
@ -2735,7 +2393,7 @@ class ReticulumMeshChat:
return
# send received lxmf message data to all websocket clients
AsyncUtils.run_async(self.websocket_broadcast(json.dumps({
asyncio.run(self.websocket_broadcast(json.dumps({
"type": "lxmf.delivery",
"lxmf_message": self.convert_db_lxmf_message_to_dict(db_lxmf_message),
})))
@ -2859,22 +2517,6 @@ class ReticulumMeshChat:
query = query.on_conflict(conflict_target=[database.CustomDestinationDisplayName.destination_hash], update=data)
query.execute()
# upserts a custom destination display name to the database
def db_upsert_favourite(self, destination_hash: str, display_name: str, aspect: str):
# prepare data to insert or update
data = {
"destination_hash": destination_hash,
"display_name": display_name,
"aspect": aspect,
"updated_at": datetime.now(timezone.utc),
}
# upsert to database
query = database.FavouriteDestination.insert(data)
query = query.on_conflict(conflict_target=[database.FavouriteDestination.destination_hash], update=data)
query.execute()
# upserts lxmf conversation read state to the database
def db_mark_lxmf_conversation_as_read(self, destination_hash: str):
@ -3011,7 +2653,7 @@ class ReticulumMeshChat:
# handle lxmf message progress loop without blocking or awaiting
# otherwise other incoming websocket packets will not be processed until sending is complete
# which results in the next message not showing up until the first message is finished
AsyncUtils.run_async(self.handle_lxmf_message_progress(lxmf_message))
asyncio.create_task(self.handle_lxmf_message_progress(lxmf_message))
return lxmf_message
@ -3039,11 +2681,9 @@ class ReticulumMeshChat:
has_delivered = lxmf_message.state == LXMF.LXMessage.DELIVERED
has_propagated = lxmf_message.state == LXMF.LXMessage.SENT and lxmf_message.method == LXMF.LXMessage.PROPAGATED
has_failed = lxmf_message.state == LXMF.LXMessage.FAILED
has_rejected = lxmf_message.state == LXMF.LXMessage.REJECTED
is_cancelled = lxmf_message.state == LXMF.LXMessage.CANCELLED
# check if we should stop updating
if has_delivered or has_propagated or has_failed or has_rejected or is_cancelled:
if has_delivered or has_propagated or has_failed:
should_update_message = False
# handle an announce received from reticulum, for an audio call address
@ -3062,7 +2702,7 @@ class ReticulumMeshChat:
return
# send database announce to all websocket clients
AsyncUtils.run_async(self.websocket_broadcast(json.dumps({
asyncio.run(self.websocket_broadcast(json.dumps({
"type": "announce",
"announce": self.convert_db_announce_to_dict(announce),
})))
@ -3083,14 +2723,14 @@ class ReticulumMeshChat:
return
# send database announce to all websocket clients
AsyncUtils.run_async(self.websocket_broadcast(json.dumps({
asyncio.run(self.websocket_broadcast(json.dumps({
"type": "announce",
"announce": self.convert_db_announce_to_dict(announce),
})))
# resend all failed messages that were intended for this destination
if self.config.auto_resend_failed_messages_when_announce_received.get():
AsyncUtils.run_async(self.resend_failed_messages_for_destination(destination_hash.hex()))
asyncio.run(self.resend_failed_messages_for_destination(destination_hash.hex()))
# handle an announce received from reticulum, for an lxmf propagation node address
# NOTE: cant be async, as Reticulum doesn't await it
@ -3108,7 +2748,7 @@ class ReticulumMeshChat:
return
# send database announce to all websocket clients
AsyncUtils.run_async(self.websocket_broadcast(json.dumps({
asyncio.run(self.websocket_broadcast(json.dumps({
"type": "announce",
"announce": self.convert_db_announce_to_dict(announce),
})))
@ -3192,7 +2832,7 @@ class ReticulumMeshChat:
return
# send database announce to all websocket clients
AsyncUtils.run_async(self.websocket_broadcast(json.dumps({
asyncio.run(self.websocket_broadcast(json.dumps({
"type": "announce",
"announce": self.convert_db_announce_to_dict(announce),
})))
@ -3430,7 +3070,7 @@ class Config:
nomadnet_cached_links = {}
class NomadnetDownloader:
def __init__(self, destination_hash: bytes, path: str, data: str|None, on_download_success: Callable[[RNS.RequestReceipt], None], on_download_failure: Callable[[str], None], on_progress_update: Callable[[float], None], timeout: int|None = None):
def __init__(self, destination_hash: bytes, path: str, data: str|None, on_download_success: Callable[[bytes], None], on_download_failure: Callable[[str], None], on_progress_update: Callable[[float], None], timeout: int|None = None):
self.app_name = "nomadnetwork"
self.aspects = "node"
self.destination_hash = destination_hash
@ -3512,8 +3152,8 @@ class NomadnetDownloader:
)
# handle successful download
def on_response(self, request_receipt: RNS.RequestReceipt):
self.on_download_success(request_receipt)
def on_response(self, request_receipt):
self.on_download_success(request_receipt.response)
# handle failure
def on_failed(self, request_receipt=None):
@ -3532,8 +3172,8 @@ class NomadnetPageDownloader(NomadnetDownloader):
super().__init__(destination_hash, page_path, data, self.on_download_success, self.on_download_failure, on_progress_update, timeout)
# page download was successful, decode the response and send to provided callback
def on_download_success(self, request_receipt: RNS.RequestReceipt):
micron_markup_response = request_receipt.response.decode("utf-8")
def on_download_success(self, response_bytes):
micron_markup_response = response_bytes.decode("utf-8")
self.on_page_download_success(micron_markup_response)
# page download failed, send error to provided callback
@ -3549,52 +3189,10 @@ class NomadnetFileDownloader(NomadnetDownloader):
super().__init__(destination_hash, page_path, None, self.on_download_success, self.on_download_failure, on_progress_update, timeout)
# file download was successful, decode the response and send to provided callback
def on_download_success(self, request_receipt: RNS.RequestReceipt):
# get response
response = request_receipt.response
# handle buffered reader response
if isinstance(response, io.BufferedReader):
# get file name from metadata
file_name = "downloaded_file"
metadata = request_receipt.metadata
if metadata is not None and "name" in metadata:
file_path = metadata["name"].decode("utf-8")
file_name = os.path.basename(file_path)
# get file data
file_data: bytes = response.read()
self.on_file_download_success(file_name, file_data)
return
# check for list response with bytes in position 0, and metadata dict in position 1
# e.g: [file_bytes, {name: "filename.ext"}]
if isinstance(response, list) and isinstance(response[1], dict):
file_data: bytes = response[0]
metadata: dict = response[1]
# get file name from metadata
file_name = "downloaded_file"
if metadata is not None and "name" in metadata:
file_path = metadata["name"].decode("utf-8")
file_name = os.path.basename(file_path)
self.on_file_download_success(file_name, file_data)
return
# try using original response format
# unsure if this is actually used anymore now that a buffered reader is provided
# have left here just in case...
try:
file_name: str = response[0]
file_data: bytes = response[1]
self.on_file_download_success(file_name, file_data)
except:
self.on_download_failure("unsupported_response")
def on_download_success(self, response):
file_name: str = response[0]
file_data: bytes = response[1]
self.on_file_download_success(file_name, file_data)
# page download failed, send error to provided callback
def on_download_failure(self, failure_reason):

35
package-lock.json generated
View file

@ -1,23 +1,22 @@
{
"name": "reticulum-meshchat",
"version": "2.4.0",
"version": "1.19.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "reticulum-meshchat",
"version": "2.4.0",
"version": "1.19.0",
"license": "MIT",
"dependencies": {
"@mdi/js": "^7.4.47",
"@tailwindcss/forms": "^0.5.9",
"@vitejs/plugin-vue": "^5.2.1",
"autoprefixer": "^10.4.20",
"axios": "^1.10.0",
"axios": "^1.7.9",
"click-outside-vue3": "^4.0.1",
"compressorjs": "^1.2.1",
"electron-prompt": "^1.7.0",
"micron-parser": "^1.0.2",
"mitt": "^3.0.1",
"moment": "^2.30.1",
"postcss": "^8.4.49",
@ -1406,12 +1405,6 @@
"@types/node": "*"
}
},
"node_modules/@types/trusted-types": {
"version": "2.0.7",
"resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz",
"integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==",
"optional": true
},
"node_modules/@types/verror": {
"version": "1.10.10",
"resolved": "https://registry.npmjs.org/@types/verror/-/verror-1.10.10.tgz",
@ -1915,9 +1908,9 @@
}
},
"node_modules/axios": {
"version": "1.10.0",
"resolved": "https://registry.npmjs.org/axios/-/axios-1.10.0.tgz",
"integrity": "sha512-/1xYAC4MP/HEG+3duIhFr4ZQXR4sQXOIe+o6sdqzeykGLx6Upp/1p8MHqhINOvGeP7xyNHe7tsiJByc4SSVUxw==",
"version": "1.7.9",
"resolved": "https://registry.npmjs.org/axios/-/axios-1.7.9.tgz",
"integrity": "sha512-LhLcE7Hbiryz8oMDdDptSrWowmB4Bl6RCt6sIJKpRB4XtVf0iEgewX3au/pJqm+Py1kCASkb/FFKjxQaLtxJvw==",
"dependencies": {
"follow-redirects": "^1.15.6",
"form-data": "^4.0.0",
@ -2792,14 +2785,6 @@
"node": ">=8"
}
},
"node_modules/dompurify": {
"version": "3.2.4",
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.2.4.tgz",
"integrity": "sha512-ysFSFEDVduQpyhzAob/kkuJjf5zWkZD8/A9ywSp1byueyuCfHamrCBa14/Oc2iiB0e51B+NpxSl5gmzn+Ms/mg==",
"optionalDependencies": {
"@types/trusted-types": "^2.0.7"
}
},
"node_modules/dotenv": {
"version": "9.0.2",
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-9.0.2.tgz",
@ -4183,14 +4168,6 @@
"node": ">=8.6"
}
},
"node_modules/micron-parser": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/micron-parser/-/micron-parser-1.0.2.tgz",
"integrity": "sha512-lYrEolylOUXeSISYrPRW/ZZAH1dpZRyTJ0VzQIA4cWJy0yNCXUUs+ujuAwV2OYlAPH8tCE1Z22+zg04Ilp/JWg==",
"dependencies": {
"dompurify": "*"
}
},
"node_modules/mime": {
"version": "2.6.0",
"resolved": "https://registry.npmjs.org/mime/-/mime-2.6.0.tgz",

View file

@ -1,6 +1,6 @@
{
"name": "reticulum-meshchat",
"version": "2.4.0",
"version": "1.19.0",
"description": "",
"main": "electron/main.js",
"scripts": {
@ -33,7 +33,7 @@
"mac": {
"target": "dmg",
"identity": null,
"artifactName": "ReticulumMeshChat-v${version}-${os}-${arch}.${ext}",
"artifactName": "ReticulumMeshChat-v${version}-${os}.${ext}",
"extendInfo": {
"NSMicrophoneUsageDescription": "Microphone access is only needed for Audio Calls",
"com.apple.security.device.audio-input": true
@ -98,11 +98,10 @@
"@tailwindcss/forms": "^0.5.9",
"@vitejs/plugin-vue": "^5.2.1",
"autoprefixer": "^10.4.20",
"axios": "^1.10.0",
"axios": "^1.7.9",
"click-outside-vue3": "^4.0.1",
"compressorjs": "^1.2.1",
"electron-prompt": "^1.7.0",
"micron-parser": "^1.0.2",
"mitt": "^3.0.1",
"moment": "^2.30.1",
"postcss": "^8.4.49",

View file

@ -1,6 +1,6 @@
aiohttp>=3.13.3
aiohttp>=3.9.5
cx_freeze>=7.0.0
lxmf>=1.0.1
peewee>=3.18.1,<4.0.0
rns>=1.3.7
websockets>=16.0
lxmf>=0.6.0
peewee>=3.17.3
rns>=0.9.1
websockets>=12.0

View file

@ -1,25 +1,25 @@
import asyncio
from typing import Coroutine
class AsyncUtils:
# remember main loop
main_loop: asyncio.AbstractEventLoop | None = None
@staticmethod
def set_main_loop(loop: asyncio.AbstractEventLoop):
AsyncUtils.main_loop = loop
# this method allows running the provided async coroutine from within a sync function
# it will run the async function on the main event loop if possible, otherwise it logs a warning
# it will run the async function on the existing event loop if available, otherwise it will start a new event loop
@staticmethod
def run_async(coroutine: Coroutine):
def run_async(coroutine):
# run provided coroutine on main event loop, ensuring thread safety
if AsyncUtils.main_loop and AsyncUtils.main_loop.is_running():
asyncio.run_coroutine_threadsafe(coroutine, AsyncUtils.main_loop)
# attempt to get existing event loop
existing_event_loop = None
try:
existing_event_loop = asyncio.get_running_loop()
except RuntimeError:
# 'RuntimeError: no running event loop'
pass
# if there is an existing event loop running, submit the coroutine to that loop
if existing_event_loop and existing_event_loop.is_running():
existing_event_loop.create_task(coroutine)
return
# main event loop not running...
print("WARNING: Main event loop not available. Could not schedule task.")
# otherwise start a new event loop to run the coroutine
asyncio.run(coroutine)

View file

@ -1,31 +1,50 @@
import RNS.vendor.configobj
class InterfaceConfigParser:
@staticmethod
def parse(text):
# 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
# process config
interfaces = []
for interface_name in config_interfaces:
current_interface = None
for line in text.splitlines():
# ensure interface has a name
interface_config = config_interfaces[interface_name]
interface_config["name"] = interface_name
interfaces.append(interface_config)
# 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)
return interfaces

View file

@ -1,14 +0,0 @@
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

@ -1,134 +0,0 @@
import threading
import time
import RNS
from RNS.Interfaces.Interface import Interface
from websockets.sync.client import connect
from websockets.sync.connection import Connection
class WebsocketClientInterface(Interface):
# TODO: required?
DEFAULT_IFAC_SIZE = 16
RECONNECT_DELAY_SECONDS = 5
def __str__(self):
return f"WebsocketClientInterface[{self.name}/{self.target_url}]"
def __init__(self, owner, configuration, websocket: Connection = None):
super().__init__()
self.owner = owner
self.parent_interface = None
self.IN = True
self.OUT = False
self.HW_MTU = 262144 # 256KiB
self.bitrate = 1_000_000_000 # 1Gbps
self.mode = RNS.Interfaces.Interface.Interface.MODE_FULL
# parse config
ifconf = Interface.get_config_obj(configuration)
self.name = ifconf.get("name")
self.target_url = ifconf.get("target_url", None)
# ensure target url is provided
if self.target_url is None:
raise SystemError(f"target_url is required for interface '{self.name}'")
# connect to websocket server if an existing connection was not provided
self.websocket = websocket
if self.websocket is None:
thread = threading.Thread(target=self.connect)
thread.daemon = True
thread.start()
# called when a full packet has been received over the websocket
def process_incoming(self, data):
# do nothing if offline or detached
if not self.online or self.detached:
return
# update received bytes counter
self.rxb += len(data)
# update received bytes counter for parent interface
if self.parent_interface is not None:
self.parent_interface.rxb += len(data)
# send received data to transport instance
self.owner.inbound(data, self)
# the running reticulum transport instance will call this method whenever the interface must transmit a packet
def process_outgoing(self, data):
# do nothing if offline or detached
if not self.online or self.detached:
return
# send to websocket server
try:
self.websocket.send(data)
except Exception as e:
RNS.log(f"Exception occurred while transmitting via {str(self)}", RNS.LOG_ERROR)
RNS.log(f"The contained exception was: {str(e)}", RNS.LOG_ERROR)
return
# update sent bytes counter
self.txb += len(data)
# update received bytes counter for parent interface
if self.parent_interface is not None:
self.parent_interface.txb += len(data)
# connect to the configured websocket server
def connect(self):
# do nothing if interface is detached
if self.detached:
return
# connect to websocket server
try:
RNS.log(f"Connecting to Websocket for {str(self)}...", RNS.LOG_DEBUG)
self.websocket = connect(f"{self.target_url}", max_size=None, compression=None)
RNS.log(f"Connected to Websocket for {str(self)}", RNS.LOG_DEBUG)
self.read_loop()
except Exception as e:
RNS.log(f"{self} failed with error: {e}", RNS.LOG_ERROR)
# auto reconnect after delay
RNS.log(f"Websocket disconnected for {str(self)}...", RNS.LOG_DEBUG)
time.sleep(self.RECONNECT_DELAY_SECONDS)
self.connect()
def read_loop(self):
self.online = True
try:
for message in self.websocket:
self.process_incoming(message)
except Exception as e:
RNS.log(f"{self} read loop error: {e}", RNS.LOG_ERROR)
self.online = False
def detach(self):
# mark as offline
self.online = False
# close websocket
if self.websocket is not None:
self.websocket.close()
# mark as detached
self.detached = True
# set interface class RNS should use when importing this external interface
interface_class = WebsocketClientInterface

View file

@ -1,156 +0,0 @@
import threading
import time
import RNS
from RNS.Interfaces.Interface import Interface
from websockets.sync.server import Server
from websockets.sync.server import serve
from websockets.sync.server import ServerConnection
from src.backend.interfaces.WebsocketClientInterface import WebsocketClientInterface
class WebsocketServerInterface(Interface):
# TODO: required?
DEFAULT_IFAC_SIZE = 16
RESTART_DELAY_SECONDS = 5
def __str__(self):
return f"WebsocketServerInterface[{self.name}/{self.listen_ip}:{self.listen_port}]"
def __init__(self, owner, configuration):
super().__init__()
self.owner = owner
self.IN = True
self.OUT = False
self.HW_MTU = 262144 # 256KiB
self.bitrate = 1_000_000_000 # 1Gbps
self.mode = RNS.Interfaces.Interface.Interface.MODE_FULL
self.server: Server | None = None
self.spawned_interfaces: [WebsocketClientInterface] = []
# parse config
ifconf = Interface.get_config_obj(configuration)
self.name = ifconf.get("name")
self.listen_ip = ifconf.get("listen_ip", None)
self.listen_port = ifconf.get("listen_port", None)
# ensure listen ip is provided
if self.listen_ip is None:
raise SystemError(f"listen_ip is required for interface '{self.name}'")
# ensure listen port is provided
if self.listen_port is None:
raise SystemError(f"listen_port is required for interface '{self.name}'")
# convert listen port to int
self.listen_port = int(self.listen_port)
# run websocket server
thread = threading.Thread(target=self.serve)
thread.daemon = True
thread.start()
@property
def clients(self):
return len(self.spawned_interfaces)
# todo docs
def received_announce(self, from_spawned=False):
if from_spawned:
self.ia_freq_deque.append(time.time())
# todo docs
def sent_announce(self, from_spawned=False):
if from_spawned:
self.oa_freq_deque.append(time.time())
# do nothing as the spawned child interface will take care of rx/tx
def process_incoming(self, data):
pass
# do nothing as the spawned child interface will take care of rx/tx
def process_outgoing(self, data):
pass
def serve(self):
# handle new websocket client connections
def on_websocket_client_connected(websocket: ServerConnection):
# create new child interface
RNS.log("Accepting incoming WebSocket connection", RNS.LOG_VERBOSE)
spawned_interface = WebsocketClientInterface(self.owner, {
"name": f"Client on {self.name}",
"target_host": websocket.remote_address[0],
"target_port": str(websocket.remote_address[1]),
}, websocket=websocket)
# configure child interface
spawned_interface.IN = self.IN
spawned_interface.OUT = self.OUT
spawned_interface.HW_MTU = self.HW_MTU
spawned_interface.bitrate = self.bitrate
spawned_interface.mode = self.mode
spawned_interface.parent_interface = self
spawned_interface.online = True
# todo implement?
spawned_interface.announce_rate_target = None
spawned_interface.announce_rate_grace = None
spawned_interface.announce_rate_penalty = None
# todo ifac?
# todo announce rates?
# activate child interface
RNS.log(f"Spawned new WebsocketClientInterface: {spawned_interface}", RNS.LOG_VERBOSE)
RNS.Transport.interfaces.append(spawned_interface)
# associate child interface with this interface
while spawned_interface in self.spawned_interfaces:
self.spawned_interfaces.remove(spawned_interface)
self.spawned_interfaces.append(spawned_interface)
# run read loop
spawned_interface.read_loop()
# client must have disconnected as the read loop finished, so forget the spawned interface
self.spawned_interfaces.remove(spawned_interface)
# run websocket server
try:
RNS.log(f"Starting Websocket server for {str(self)}...", RNS.LOG_DEBUG)
with serve(on_websocket_client_connected, self.listen_ip, self.listen_port, compression=None) as server:
self.online = True
self.server = server
server.serve_forever()
except Exception as e:
RNS.log(f"{self} failed with error: {e}", RNS.LOG_ERROR)
# websocket server is no longer running, let's restart it
self.online = False
RNS.log(f"Websocket server stopped for {str(self)}...", RNS.LOG_DEBUG)
time.sleep(self.RESTART_DELAY_SECONDS)
self.serve()
def detach(self):
# mark as offline
self.online = False
# stop websocket server
if self.server is not None:
self.server.shutdown()
# mark as detached
self.detached = True
# set interface class RNS should use when importing this external interface
interface_class = WebsocketServerInterface

View file

@ -448,7 +448,7 @@ export default {
// ask to stop syncing if already syncing
if(this.isSyncingPropagationNode){
if(await DialogUtils.confirm("Are you sure you want to stop syncing?")){
if(confirm("Are you sure you want to stop syncing?")){
await this.stopSyncingPropagationNode();
}
return;
@ -529,7 +529,7 @@ export default {
async hangupAllCalls() {
// confirm user wants to hang up calls
if(!await DialogUtils.confirm("Are you sure you want to hang up all incoming and outgoing calls?")){
if(!confirm("Are you sure you want to hang up all incoming and outgoing calls?")){
return;
}

View file

@ -1,11 +1,11 @@
<template>
<RouterLink :to="to" v-slot="{ href, route, navigate, isActive }" custom>
<RouterLink :to="to" v-slot="{ href, route, navigate, isActive, isExactActive }" custom>
<a
:href="href"
@click="handleNavigate($event, navigate)"
type="button"
:class="[
isActive
isExactActive
? 'bg-blue-100 text-blue-800 group:text-blue-800 dark:bg-zinc-800 dark:text-blue-300'
: 'hover:bg-gray-100 dark:hover:bg-zinc-700'
]"

View file

@ -12,7 +12,7 @@
<div class="mr-auto">
<div>Versions</div>
<div class="text-sm text-gray-700 dark:text-zinc-400">
MeshChat v{{ appInfo.version }} • RNS v{{ appInfo.rns_version }} • LXMF v{{ appInfo.lxmf_version }} • Python v{{ appInfo.python_version }}
MeshChat v{{ appInfo.version }} • RNS v{{ appInfo.rns_version }} • LXMF v{{ appInfo.lxmf_version }}
</div>
</div>
<div class="hidden sm:block mx-2 my-auto">

View file

@ -259,7 +259,6 @@
<script>
import protobuf from "protobufjs";
import DialogUtils from "../../js/DialogUtils";
export default {
name: 'CallPage',
data() {
@ -489,7 +488,7 @@ export default {
async hangupCall(callHash) {
// confirm user wants to hang up call
if(!await DialogUtils.confirm("Are you sure you want to hang up this call?")){
if(!confirm("Are you sure you want to hang up this call?")){
return;
}
@ -682,7 +681,7 @@ export default {
async deleteCall(callHash) {
// confirm user wants to delete call
if(!await DialogUtils.confirm("Are you sure you want to delete this call?")){
if(!confirm("Are you sure you want to delete this call?")){
return;
}
@ -702,7 +701,7 @@ export default {
async clearCallHistory() {
// confirm user wants to clear call history
if(!await DialogUtils.confirm("Are you sure you want to clear your call history?")){
if(!confirm("Are you sure you want to clear your call history?")){
return;
}

View file

@ -1,10 +0,0 @@
<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

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

File diff suppressed because it is too large Load diff

View file

@ -1,35 +0,0 @@
<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

@ -10,7 +10,7 @@
</svg>
</div>
<span class="ml-1 my-auto">
<span class="text-green-500">{{ iface._stats.ifac_size * 8 }}-bit IFAC</span> <span v-if="iface._stats?.ifac_netname != null">• Network Name: <span class="text-purple-500">{{ iface._stats.ifac_netname }}</span></span> • Signature <span @click="onIFACSignatureClick(iface._stats.ifac_signature)" class="cursor-pointer">&lt;{{ iface._stats.ifac_signature.slice(0, 6) }}...{{ iface._stats.ifac_signature.slice(-6) }}&gt;</span>
<span class="text-green-500">{{ iface._stats.ifac_size * 8 }}-bit IFAC</span> with sig <span @click="onIFACSignatureClick(iface._stats.ifac_signature)" class="cursor-pointer">&lt;{{ iface._stats.ifac_signature.slice(0, 6) }}...{{ iface._stats.ifac_signature.slice(-6) }}&gt;</span>
</span>
</div>
</div>
@ -36,14 +36,6 @@
<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>
@ -194,11 +186,7 @@
<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 != null">• Clients: {{ iface._stats?.clients }}</div>
<div v-if="iface._stats?.clients">• Clients: {{ iface._stats?.clients }}</div>
</div>

View file

@ -141,13 +141,57 @@ export default {
// update data
const interfaces = response.data.interface_stats?.interfaces ?? [];
for(const iface of interfaces){
this.interfaceStats[iface.short_name] = iface;
this.interfaceStats[iface.name] = iface;
}
} catch(e) {
// do nothing if failed to load interfaces
}
},
findInterfaceStats(interfaceName) {
const interfaceDescription = this.getInterfaceDescription(interfaceName);
return this.interfaceStats[interfaceDescription];
},
getInterfaceDescription(interfaceName) {
// the interface-stats api returns interface names like the following;
//
// "AutoInterface[Default Interface]"
// "RNodeInterface[RNode LoRa Interface Fast]"
// "TCPInterface[RNS Testnet Amsterdam/amsterdam.connect.reticulum.network:4965]"
//
// however, the interfaces api just returns;
// "Default Interface"
// "RNode LoRa Interface Fast"
// "RNS Testnet Amsterdam"
//
// so we need to map the basic interface name to the former, so we can lookup stats for the interface
const iface = this.interfaces[interfaceName];
if(iface){
switch(iface.type){
case "TCPClientInterface": {
// yes, this is meant to be passed as TCPInterface, even though the interface type includes client...
// example: "TCPInterface[RNS Testnet Amsterdam/amsterdam.connect.reticulum.network:4965]";
return `TCPInterface[${interfaceName}/${iface.target_host}:${iface.target_port}]`;
}
case "TCPServerInterface": {
// example: "TCPServerInterface[TCP Server Interface/0.0.0.0:4242]";
return `TCPServerInterface[${interfaceName}/${iface.listen_ip}:${iface.listen_port}]`;
}
case "UDPInterface": {
// example: "UDPInterface[UDP Interface/0.0.0.0:1234]";
return `UDPInterface[${interfaceName}/${iface.listen_ip}:${iface.listen_port}]`;
}
default: {
// example: "RNodeInterface[RNode LoRa Interface Fast]",
return `${iface.type}[${interfaceName}]`;
}
}
}
return null;
},
async enableInterface(interfaceName) {
// enable interface
@ -191,7 +235,7 @@ export default {
async deleteInterface(interfaceName) {
// ask user to confirm deleting conversation history
if(!await DialogUtils.confirm("Are you sure you want to delete this interface? This can not be undone!")){
if(!confirm("Are you sure you want to delete this interface? This can not be undone!")){
return;
}
@ -257,7 +301,7 @@ export default {
const results = [];
for(const [interfaceName, iface] of Object.entries(this.interfaces)){
iface._name = interfaceName;
iface._stats = this.interfaceStats[interfaceName];
iface._stats = this.findInterfaceStats(interfaceName);
results.push(iface);
}
return results;

View file

@ -73,7 +73,7 @@ export default {
async onDeleteMessageHistory() {
// ask user to confirm deleting conversation history
if(!await DialogUtils.confirm("Are you sure you want to delete all messages in this conversation? This can not be undone!")){
if(!confirm("Are you sure you want to delete all messages in this conversation? This can not be undone!")){
return;
}

View file

@ -316,7 +316,6 @@
<!-- text input -->
<textarea
ref="message-input"
id="message-input"
:readonly="isSendingMessage"
v-model="newMessageText"
@ -997,7 +996,7 @@ export default {
try {
// ask user to confirm deleting message
if(shouldConfirm && !await DialogUtils.confirm("Are you sure you want to delete this message? This can not be undone!")){
if(shouldConfirm && !confirm("Are you sure you want to delete this message? This can not be undone!")){
return;
}
@ -1056,11 +1055,7 @@ export default {
if(this.newMessageImage){
imageTotalSize = this.newMessageImage.size;
fields["image"] = {
// Reticulum sends image type as "jpg", "png", "webp" etc and not "image/jpg" or "image/png"
// From memory, Sideband would not display images if the image type has the "image/" prefix
// https://github.com/markqvist/Sideband/blob/354fb08297835eab04ac69d15081a18baf0583ac/docs/example_plugins/view.py#L78
// https://github.com/markqvist/Sideband/blob/354fb08297835eab04ac69d15081a18baf0583ac/sbapp/main.py#L1900
// https://github.com/markqvist/Sideband/blob/354fb08297835eab04ac69d15081a18baf0583ac/sbapp/ui/messages.py#L783
// Reticulum sends image type as "jpg" or "png" and not "image/jpg" or "image/png"
"image_type": this.newMessageImage.type.replace("image/", ""),
"image_bytes": Utils.arrayBufferToBase64(await this.newMessageImage.arrayBuffer()),
};
@ -1082,7 +1077,7 @@ export default {
// ask user if they still want to send message if it may be rejected by sender
if(totalMessageSize > 1000 * 900){ // actual limit in LXMF Router is 1mb
if(!await DialogUtils.confirm(`Your message exceeds 900KB (It's ${this.formatBytes(totalMessageSize)}). It may be rejected by the recipient unless they have increased their delivery limit. Do you want to try sending anyway?`)){
if(!confirm(`Your message exceeds 900KB (It's ${this.formatBytes(totalMessageSize)}). It may be rejected by the recipient unless they have increased their delivery limit. Do you want to try sending anyway?`)){
return;
}
}
@ -1213,10 +1208,10 @@ export default {
clearFileInput: function() {
this.$refs["file-input"].value = null;
},
async removeImageAttachment() {
removeImageAttachment: function() {
// ask user to confirm removing image attachment
if(!await DialogUtils.confirm("Are you sure you want to remove this image attachment?")){
if(!confirm("Are you sure you want to remove this image attachment?")){
return;
}
@ -1248,7 +1243,7 @@ export default {
}
// ask user to confirm recording new audio attachment, if an existing audio attachment exists
if(this.newMessageAudio && !await DialogUtils.confirm("An audio recording is already attached. A new recording will replace it. Do you want to continue?")){
if(this.newMessageAudio && !confirm("An audio recording is already attached. A new recording will replace it. Do you want to continue?")){
return;
}
@ -1390,10 +1385,10 @@ export default {
}
},
async removeAudioAttachment() {
removeAudioAttachment: function() {
// ask user to confirm removing audio attachment
if(!await DialogUtils.confirm("Are you sure you want to remove this audio attachment?")){
if(!confirm("Are you sure you want to remove this audio attachment?")){
return;
}
@ -1407,22 +1402,7 @@ export default {
});
},
addNewLine: function() {
// get cursor position for message input
const input = this.$refs["message-input"];
const cursorPosition = input.selectionStart;
// insert a newline character after the cursor position
const text = this.newMessageText;
this.newMessageText = text.slice(0, cursorPosition) + '\n' + text.slice(cursorPosition);
// move cursor to the position after the added newline
const newCursorPosition = cursorPosition + 1;
this.$nextTick(() => {
input.selectionStart = newCursorPosition;
input.selectionEnd = newCursorPosition;
});
this.newMessageText += "\n";
},
onEnterPressed: function() {

View file

@ -15,7 +15,7 @@
:my-lxmf-address-hash="config?.lxmf_address_hash"
:selected-peer="selectedPeer"
:conversations="conversations"
@close="onCloseConversationViewer"
@close="selectedPeer = null"
@reload-conversations="getConversations"/>
</div>
@ -38,9 +38,6 @@ export default {
ConversationViewer,
MessagesSidebar,
},
props: {
destinationHash: String,
},
data() {
return {
@ -79,11 +76,6 @@ export default {
this.getConversations();
}, 5000);
// compose message if a destination hash was provided on page load
if(this.destinationHash){
this.onComposeNewMessage(this.destinationHash);
}
},
methods: {
async onComposeNewMessage(destinationHash) {
@ -101,9 +93,6 @@ export default {
destinationHash = destinationHash.replace("lxmf@", "");
}
// fetch updated announce as we might be composing new message before we loaded the announces list
await this.getLxmfDeliveryAnnounce(destinationHash);
// attempt to find existing peer so we can show their name
const existingPeer = this.peers[destinationHash];
if(existingPeer){
@ -176,28 +165,6 @@ export default {
console.log(e);
}
},
async getLxmfDeliveryAnnounce(destinationHash) {
try {
// fetch announce for destination hash
const response = await window.axios.get(`/api/v1/announces`, {
params: {
destination_hash: destinationHash,
limit: 1,
},
});
// update ui
const lxmfDeliveryAnnounces = response.data.announces;
for(const lxmfDeliveryAnnounce of lxmfDeliveryAnnounces){
this.updatePeerFromAnnounce(lxmfDeliveryAnnounce);
}
} catch(e) {
// do nothing if failed to load announce
console.log(e);
}
},
async getConversations() {
try {
const response = await window.axios.get(`/api/v1/lxmf/conversations`);
@ -211,18 +178,7 @@ export default {
this.peers[announce.destination_hash] = announce;
},
onPeerClick: function(peer) {
// update selected peer
this.selectedPeer = peer;
// update current route
this.$router.replace({
name: "messages",
params: {
destinationHash: peer.destination_hash,
},
});
},
onConversationClick: function(conversation) {
@ -233,17 +189,6 @@ export default {
this.$refs["conversation-viewer"].markConversationAsRead(conversation);
},
onCloseConversationViewer: function() {
// clear selected peer
this.selectedPeer = null;
// update current route
this.$router.replace({
name: "messages",
});
},
},
watch: {
conversations() {

View file

@ -165,60 +165,6 @@ export default {
},
});
// handle double click on a node
this.network.on("doubleClick", (params) => {
// get clicked node id
const clickedNodeId = params.nodes[0];
if(!clickedNodeId){
return;
}
// find node by id
const node = this.network.body.nodes[clickedNodeId];
if(!node){
return;
}
// handle double click on an announce node
if(node.options.group === "announce"){
// get announce
const announce = node.options._announce;
if(!announce) {
return;
}
// handle double click on lxmf.delivery node
if(announce.aspect === "lxmf.delivery"){
// go to messages page for this destination hash
this.$router.push({
name: "messages",
params: {
destinationHash: announce.destination_hash,
},
});
}
// handle double click on nomadnetwork.node node
if(announce.aspect === "nomadnetwork.node"){
// go to nomadnetwork page for this destination hash
this.$router.push({
name: "nomadnetwork",
params: {
destinationHash: announce.destination_hash,
},
});
}
}
});
// update network
await this.update();
@ -412,9 +358,6 @@ export default {
}
// attach announce to this node
node._announce = announce;
// add node
nodes.push(node);

View file

@ -3,62 +3,23 @@
<!-- nomadnetwork sidebar -->
<NomadNetworkSidebar
:nodes="nodes"
:favourites="favourites"
:selected-destination-hash="selectedNode?.destination_hash"
@node-click="onNodeClick"
@rename-favourite="onRenameFavourite"
@remove-favourite="onRemoveFavourite"/>
@node-click="onNodeClick"/>
<div class="flex flex-col flex-1 overflow-hidden min-w-full sm:min-w-[500px] dark:bg-zinc-950">
<!-- node -->
<div v-if="selectedNode" class="flex flex-col h-full bg-white dark:bg-zinc-950 overflow-hidden sm:m-2 sm:border dark:border-zinc-800 sm:rounded-xl sm:shadow dark:shadow-zinc-900">
<!-- header -->
<div class="flex p-2 border-b border-gray-300 dark:border-zinc-800">
<!-- favourite button -->
<div class="my-auto mr-2">
<div v-if="isFavourite(selectedNode.destination_hash)" @click="removeFavourite(selectedNode)" class="cursor-pointer">
<div class="flex text-yellow-500 dark:text-yellow-300 bg-gray-100 dark:bg-zinc-800 hover:bg-gray-200 dark:hover:bg-zinc-700 p-1 rounded-full">
<div>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" class="size-5">
<path fill-rule="evenodd" d="M10.788 3.21c.448-1.077 1.976-1.077 2.424 0l2.082 5.006 5.404.434c1.164.093 1.636 1.545.749 2.305l-4.117 3.527 1.257 5.273c.271 1.136-.964 2.033-1.96 1.425L12 18.354 7.373 21.18c-.996.608-2.231-.29-1.96-1.425l1.257-5.273-4.117-3.527c-.887-.76-.415-2.212.749-2.305l5.404-.434 2.082-5.005Z" clip-rule="evenodd" />
</svg>
</div>
</div>
</div>
<div v-else @click="addFavourite(selectedNode)" class="cursor-pointer">
<div class="flex text-gray-700 dark:text-gray-300 bg-gray-100 dark:bg-zinc-800 hover:bg-gray-200 dark:hover:bg-zinc-700 p-1 rounded-full">
<div>
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="size-5">
<path stroke-linecap="round" stroke-linejoin="round" d="M11.48 3.499a.562.562 0 0 1 1.04 0l2.125 5.111a.563.563 0 0 0 .475.345l5.518.442c.499.04.701.663.321.988l-4.204 3.602a.563.563 0 0 0-.182.557l1.285 5.385a.562.562 0 0 1-.84.61l-4.725-2.885a.562.562 0 0 0-.586 0L6.982 20.54a.562.562 0 0 1-.84-.61l1.285-5.386a.562.562 0 0 0-.182-.557l-4.204-3.602a.562.562 0 0 1 .321-.988l5.518-.442a.563.563 0 0 0 .475-.345L11.48 3.5Z" />
</svg>
</div>
</div>
</div>
</div>
<!-- node info -->
<div class="my-auto dark:text-gray-100">
<span class="font-semibold">{{ selectedNode.display_name }}</span>
<span v-if="selectedNodePath" @click="onDestinationPathClick(selectedNodePath)" class="text-sm cursor-pointer"> - {{ selectedNodePath.hops }} {{ selectedNodePath.hops === 1 ? 'hop' : 'hops' }} away</span>
</div>
<!-- identify button -->
<div class="my-auto ml-auto mr-2">
<div @click="identify(selectedNode.destination_hash)" class="cursor-pointer">
<div class="flex text-gray-700 dark:text-gray-300 bg-gray-100 dark:bg-zinc-800 hover:bg-gray-200 dark:hover:bg-zinc-700 p-1 rounded-full">
<div>
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="size-5">
<path stroke-linecap="round" stroke-linejoin="round" d="M7.864 4.243A7.5 7.5 0 0 1 19.5 10.5c0 2.92-.556 5.709-1.568 8.268M5.742 6.364A7.465 7.465 0 0 0 4.5 10.5a7.464 7.464 0 0 1-1.15 3.993m1.989 3.559A11.209 11.209 0 0 0 8.25 10.5a3.75 3.75 0 1 1 7.5 0c0 .527-.021 1.049-.064 1.565M12 10.5a14.94 14.94 0 0 1-3.6 9.75m6.633-4.596a18.666 18.666 0 0 1-2.485 5.33" />
</svg>
</div>
</div>
</div>
</div>
<!-- close button -->
<div class="my-auto mr-2">
<div @click="onCloseNodeViewer" class="cursor-pointer">
<div class="my-auto ml-auto mr-2">
<div @click="selectedNode = null" class="cursor-pointer">
<div class="flex text-gray-700 dark:text-gray-300 bg-gray-100 dark:bg-zinc-800 hover:bg-gray-200 dark:hover:bg-zinc-700 p-1 rounded-full">
<div>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" class="w-5 h-5">
@ -72,7 +33,7 @@
<!-- browser navigation -->
<div class="flex w-full border-gray-300 dark:border-zinc-800 border-b p-2">
<button @click="loadNodePage(selectedNode.destination_hash, defaultNodePagePath)" type="button" class="my-auto text-gray-500 dark:text-gray-300 bg-gray-200 dark:bg-zinc-800 hover:bg-gray-300 dark:hover:bg-zinc-700 rounded p-1 cursor-pointer">
<button @click="loadNodePage(selectedNode.destination_hash, '/page/index.mu')" type="button" class="my-auto text-gray-500 dark:text-gray-300 bg-gray-200 dark:bg-zinc-800 hover:bg-gray-300 dark:hover:bg-zinc-700 rounded p-1 cursor-pointer">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" class="w-5 h-5">
<path fill-rule="evenodd" d="M9.293 2.293a1 1 0 0 1 1.414 0l7 7A1 1 0 0 1 17 11h-1v6a1 1 0 0 1-1 1h-2a1 1 0 0 1-1-1v-3a1 1 0 0 0-1-1H9a1 1 0 0 0-1 1v3a1 1 0 0 1-1 1H5a1 1 0 0 1-1-1v-6H3a1 1 0 0 1-.707-1.707l7-7Z" clip-rule="evenodd" />
</svg>
@ -82,11 +43,6 @@
<path fill-rule="evenodd" d="M15.312 11.424a5.5 5.5 0 0 1-9.201 2.466l-.312-.311h2.433a.75.75 0 0 0 0-1.5H3.989a.75.75 0 0 0-.75.75v4.242a.75.75 0 0 0 1.5 0v-2.43l.31.31a7 7 0 0 0 11.712-3.138.75.75 0 0 0-1.449-.39Zm1.23-3.723a.75.75 0 0 0 .219-.53V2.929a.75.75 0 0 0-1.5 0V5.36l-.31-.31A7 7 0 0 0 3.239 8.188a.75.75 0 1 0 1.448.389A5.5 5.5 0 0 1 13.89 6.11l.311.31h-2.432a.75.75 0 0 0 0 1.5h4.243a.75.75 0 0 0 .53-.219Z" clip-rule="evenodd" />
</svg>
</button>
<button @click="toggleNodePageSource" type="button" title="Toggle Source Code" class="ml-1 my-auto text-gray-500 dark:text-gray-300 rounded p-1 cursor-pointer" :class="[ isShowingNodePageSource ? 'bg-green-500 hover:bg-green-600 text-white' : 'bg-gray-200 dark:bg-zinc-800 hover:bg-gray-300 dark:hover:bg-zinc-700' ]">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="size-5">
<path stroke-linecap="round" stroke-linejoin="round" d="M17.25 6.75 22.5 12l-5.25 5.25m-10.5 0L1.5 12l5.25-5.25m7.5-3-4.5 16.5" />
</svg>
</button>
<button @click="loadPreviousNodePage" type="button" :disabled="nodePagePathHistory.length === 0" :class="[ nodePagePathHistory.length > 0 ? 'text-gray-500 dark:text-gray-300 bg-gray-200 dark:bg-zinc-800 hover:bg-gray-300 dark:hover:bg-zinc-700' : 'text-gray-400 dark:text-gray-500 bg-gray-100 dark:bg-zinc-900']" class="ml-1 my-auto rounded p-1 cursor-pointer">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" class="w-5 h-5">
<path fill-rule="evenodd" d="M17 10a.75.75 0 0 1-.75.75H5.612l4.158 3.96a.75.75 0 1 1-1.04 1.08l-5.5-5.25a.75.75 0 0 1 0-1.08l5.5-5.25a.75.75 0 1 1 1.04 1.08L5.612 9.25H16.25A.75.75 0 0 1 17 10Z" clip-rule="evenodd" />
@ -113,7 +69,7 @@
</div>
<div class="my-auto">Loading {{ nodePageProgress }}%</div>
</div>
<pre v-else v-html="renderedNodePageContent()" class="h-full break-words whitespace-pre-wrap"></pre>
<pre v-else v-html="nodePageContent" class="h-full text-wrap"></pre>
</div>
<!-- file download bottom bar -->
@ -174,7 +130,7 @@ pre a:hover {
<script>
import MicronParser from "micron-parser";
import MicronParser from "../../js/MicronParser";
import DialogUtils from "../../js/DialogUtils";
import WebSocketConnection from "../../js/WebSocketConnection";
import NomadNetworkSidebar from "./NomadNetworkSidebar.vue";
@ -185,23 +141,14 @@ export default {
components: {
NomadNetworkSidebar,
},
props: {
destinationHash: String,
},
data() {
return {
reloadInterval: null,
nodes: {},
selectedNode: null,
selectedNodePath: null,
favourites: [],
isLoadingNodePage: false,
isShowingNodePageSource: false,
defaultNodePagePath: "/page/index.mu",
nodePageRequestSequence: 0,
nodePagePath: null,
nodePagePathUrlInput: null,
@ -210,6 +157,7 @@ export default {
nodePagePathHistory: [],
nodePageCache: {},
isDownloadingNodeFile: false,
nodeFilePath: null,
nodeFileProgress: 0,
@ -220,59 +168,23 @@ export default {
};
},
beforeUnmount() {
clearInterval(this.reloadInterval);
// stop listening for websocket messages
WebSocketConnection.off("message", this.onWebsocketMessage);
// stop listening for element clicks
window.document.removeEventListener('click', this.onElementClick);
},
mounted() {
// listen for websocket messages
WebSocketConnection.on("message", this.onWebsocketMessage);
// listen for element clicks
window.document.addEventListener('click', this.onElementClick);
// fixme: this is called by the micron-parser.js
window.onNodePageUrlClick = (url, options = null) => {
this.onNodePageUrlClick(url, options);
};
// load nomadnetwork node if a destination hash was provided on page load
if(this.destinationHash){
(async () => {
// fetch updated announce as we are probably loading node page before we loaded the announces list
await this.getNomadnetworkNodeAnnounce(this.destinationHash);
await this.onNodePageUrlClick(`${this.destinationHash}:${this.defaultNodePagePath}`);
})();
}
this.getFavourites();
this.getNomadnetworkNodeAnnounces();
// update info every few seconds
this.reloadInterval = setInterval(() => {
this.getFavourites();
}, 5000);
},
methods: {
onElementClick(event) {
// find the closest ancestor (or the clicked element itself) with data-action="openNode"
const element = event.target.closest('[data-action="openNode"]');
if(!element){
return;
}
// get the destination and fields
const destination = element.getAttribute("data-destination");
const fields = element.getAttribute("data-fields");
// navigate to destination
this.onNodePageUrlClick(destination, fields);
},
async onWebsocketMessage(message) {
const json = JSON.parse(message.data);
switch(json.type){
@ -360,54 +272,6 @@ export default {
onDestinationPathClick: function(path) {
DialogUtils.alert(`${path.hops} ${ path.hops === 1 ? 'hop' : 'hops' } away via ${path.next_hop_interface}`);
},
async getFavourites() {
try {
const response = await window.axios.get("/api/v1/favourites", {
params: {
aspect: "nomadnetwork.node",
},
});
this.favourites = response.data.favourites;
} catch(e) {
// do nothing if failed to load favourites
console.log(e);
}
},
isFavourite(destinationHash) {
return this.favourites.find((favourite) => {
return favourite.destination_hash === destinationHash;
}) != null;
},
async addFavourite(node) {
// add to favourites
try {
await window.axios.post("/api/v1/favourites/add", {
destination_hash: node.destination_hash,
display_name: node.display_name,
aspect: "nomadnetwork.node",
});
} catch(e) {
console.log(e);
}
// update favourites
this.getFavourites();
},
async removeFavourite(node) {
// remove from favourites
try {
await window.axios.delete(`/api/v1/favourites/${node.destination_hash}`);
} catch(e) {
console.log(e);
}
// update favourites
this.getFavourites();
},
async getNomadnetworkNodeAnnounces() {
try {
@ -415,7 +279,6 @@ export default {
const response = await window.axios.get(`/api/v1/announces`, {
params: {
aspect: "nomadnetwork.node",
limit: 500, // limit ui to showing 500 latest announces
},
});
@ -430,28 +293,6 @@ export default {
console.log(e);
}
},
async getNomadnetworkNodeAnnounce(destinationHash) {
try {
// fetch announces for "nomadnetwork.node" aspect
const response = await window.axios.get(`/api/v1/announces`, {
params: {
destination_hash: destinationHash,
limit: 1,
},
});
// update ui
const nodeAnnounces = response.data.announces;
for(const nodeAnnounce of nodeAnnounces){
this.updateNodeFromAnnounce(nodeAnnounce);
}
} catch(e) {
// do nothing if failed to load announce
console.log(e);
}
},
updateNodeFromAnnounce: function(announce) {
this.nodes[announce.destination_hash] = announce;
},
@ -469,14 +310,6 @@ export default {
},
async loadNodePage(destinationHash, pagePath, fieldData = null, addToHistory = true, loadFromCache = true) {
// update current route
this.$router.replace({
name: "nomadnetwork",
params: {
destinationHash: destinationHash,
},
});
// get new sequence for this page load
const seq = ++this.nodePageRequestSequence;
@ -510,7 +343,6 @@ export default {
// if page is cache, we can just return it now
if(cachedNodePageContent != null){
this.nodePageContent = cachedNodePageContent;
this.renderPageContent(pagePath, cachedNodePageContent);
this.isLoadingNodePage = false;
return;
}
@ -519,21 +351,31 @@ export default {
this.downloadNomadNetPage(destinationHash, pagePath, fieldData, (pageContent) => {
const muParser = new MicronParser();
// do nothing if callback is for a previous request
if(seq !== this.nodePageRequestSequence){
console.log("ignoring page content callback for previous page request")
return;
}
// update page content
this.nodePageContent = pageContent;
// check if page url ends with .mu but remove page data first
// address:/page/index.mu`Data=123
const [ pagePathWithoutData, pageData ] = pagePath.split("`");
// convert micron to html if page ends with .mu extension
// otherwise, we will just serve the content as is
if(pagePathWithoutData.endsWith(".mu")){
this.nodePageContent = muParser.convertMicronToHtml(pageContent);
} else {
this.nodePageContent = pageContent;
}
// update cache
const nodePagePathCacheKey = `${destinationHash}:${pagePath}`;
this.nodePageCache[nodePagePathCacheKey] = this.nodePageContent;
// update page content
this.renderPageContent(pagePath, pageContent);
this.isLoadingNodePage = false;
// update node path
@ -567,35 +409,6 @@ export default {
});
},
renderPageContent(path, content) {
// render page content if we aren't viewing source
if(!this.isShowingNodePageSource){
// check if page url ends with .mu but remove page data first
// address:/page/index.mu`Data=123
const [ pagePathWithoutData ] = path.split("`");
// convert micron to html if page ends with .mu extension
if(pagePathWithoutData.endsWith(".mu")){
const muParser = new MicronParser();
return muParser.convertMicronToHtml(content);
}
}
// otherwise, we will just serve the raw content, making sure to prevent injecting html
return content
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#039;");
},
toggleNodePageSource() {
this.isShowingNodePageSource = !this.isShowingNodePageSource;
},
async reloadNodePage() {
// reload current node page without adding to history and without using cache
@ -622,9 +435,9 @@ export default {
// remove leading ":"
var path = url.substring(1);
// if page path is empty we should load default page path
// if page path is empty we should load "/page/index.mu"
if(path === ""){
path = this.defaultNodePagePath;
path = "/page/index.mu";
}
return {
@ -654,7 +467,7 @@ export default {
if(url.length === 32){
return {
destination_hash: url,
path: this.defaultNodePagePath,
path: "/page/index.mu",
};
}
@ -726,12 +539,8 @@ export default {
if(url.startsWith("lxmf@")){
const destinationHash = url.replace("lxmf@", "");
if(destinationHash.length === 32){
await this.$router.push({
name: "messages",
params: {
destinationHash: destinationHash,
},
});
await this.$router.push({ name: "messages" });
GlobalEmitter.emit("compose-new-message", destinationHash);
return;
}
}
@ -830,58 +639,8 @@ export default {
},
onNodeClick: function(node) {
// update selected node
this.selectedNode = node;
// load default node page
this.loadNodePage(node.destination_hash, this.defaultNodePagePath);
},
async onRenameFavourite(favourite) {
// ask user for new display name
const displayName = await DialogUtils.prompt("Rename this favourite");
if(displayName == null){
return;
}
try {
// rename on server
await axios.post(`/api/v1/favourites/${favourite.destination_hash}/rename`, {
display_name: displayName,
});
// reload favourites
await this.getFavourites();
} catch(e) {
console.log(e);
DialogUtils.alert("Failed to rename favourite");
}
},
async onRemoveFavourite(favourite) {
// ask user to confirm
if(!await DialogUtils.confirm("Are you sure you want to remove this favourite?")){
return;
}
this.removeFavourite(favourite);
},
onCloseNodeViewer: function() {
// clear selected node
this.selectedNode = null;
// update current route
this.$router.replace({
name: "nomadnetwork",
});
this.loadNodePage(node.destination_hash, "/page/index.mu");
},
getNomadnetPageDownloadCallbackKey: function(destinationHash, pagePath) {
return `${destinationHash}:${pagePath}`;
@ -907,24 +666,6 @@ export default {
}
},
async identify(destinationHash) {
try {
// ask user to confirm
if(!await DialogUtils.confirm("Are you sure you want to identify yourself to this NomadNetwork Node? The page will reload after your identity has been sent.")){
return;
}
// identify self to nomadnetwork node
await window.axios.post(`/api/v1/nomadnetwork/${destinationHash}/identify`);
// reload page
this.reloadNodePage();
} catch(e) {
DialogUtils.alert(e.response?.data?.message ?? "Failed to identify!");
}
},
downloadNomadNetFile(destinationHash, filePath, onSuccessCallback, onFailureCallback, onProgressCallback) {
try {
@ -972,9 +713,6 @@ export default {
console.error(e);
}
},
renderedNodePageContent() {
return this.renderPageContent(this.nodePagePath, this.nodePageContent);
},
},
}
</script>

View file

@ -1,99 +1,6 @@
<template>
<div class="flex flex-col w-80 min-w-80">
<!-- tabs -->
<div class="bg-white dark:bg-zinc-950 border-b border-r border-gray-200 dark:border-zinc-700">
<div class="-mb-px flex">
<div @click="tab = 'favourites'" class="w-full border-b-2 py-3 px-1 text-center text-sm font-medium cursor-pointer" :class="[ tab === 'favourites' ? 'border-blue-500 text-blue-600 dark:border-blue-400 dark:text-blue-400' : 'border-transparent text-gray-500 dark:text-gray-400 hover:border-gray-300 dark:hover:border-zinc-600 hover:text-gray-700 dark:hover:text-gray-300']">Favourites</div>
<div @click="tab = 'announces'" class="w-full border-b-2 py-3 px-1 text-center text-sm font-medium cursor-pointer" :class="[ tab === 'announces' ? 'border-blue-500 text-blue-600 dark:border-blue-400 dark:text-blue-400' : 'border-transparent text-gray-500 dark:text-gray-400 hover:border-gray-300 dark:hover:border-zinc-600 hover:text-gray-700 dark:hover:text-gray-300']">Announces</div>
</div>
</div>
<!-- favourites -->
<div v-if="tab === 'favourites'" class="flex-1 flex flex-col bg-white dark:bg-zinc-950 border-r border-gray-200 dark:border-zinc-700 overflow-hidden">
<!-- search -->
<div v-if="favourites.length > 0" class="p-1 border-b border-gray-300 dark:border-zinc-700">
<input v-model="favouritesSearchTerm" type="text" :placeholder="`Search ${favourites.length} Favourites...`" class="bg-gray-50 dark:bg-zinc-700 border border-gray-300 dark:border-zinc-600 text-gray-900 dark:text-gray-100 text-sm rounded-lg focus:ring-blue-500 dark:focus:ring-blue-600 focus:border-blue-500 dark:focus:border-blue-600 block w-full p-2.5">
</div>
<!-- peers -->
<div class="flex h-full overflow-y-auto">
<div v-if="searchedFavourites.length > 0" class="w-full">
<div @click="onFavouriteClick(favourite)" v-for="favourite of searchedFavourites" class="flex cursor-pointer p-2 border-l-2" :class="[ favourite.destination_hash === selectedDestinationHash ? 'bg-gray-100 dark:bg-zinc-700 border-blue-500 dark:border-blue-400' : 'bg-white dark:bg-zinc-950 border-transparent hover:bg-gray-50 dark:hover:bg-zinc-700 hover:border-gray-200 dark:hover:border-zinc-600' ]">
<div class="my-auto mr-2">
<div class="bg-gray-200 dark:bg-zinc-800 text-gray-500 dark:text-gray-400 p-2 rounded">
<MaterialDesignIcon icon-name="server-network-outline" class="w-6 h-6"/>
</div>
</div>
<div>
<div class="text-gray-900 dark:text-gray-100">{{ favourite.display_name }}</div>
<div class="text-gray-500 dark:text-gray-400 text-sm">{{ formatDestinationHash(favourite.destination_hash) }}</div>
</div>
<div class="ml-auto my-auto">
<DropDownMenu>
<template v-slot:button>
<IconButton class="bg-transparent dark:bg-transparent">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="size-5">
<path stroke-linecap="round" stroke-linejoin="round" d="M12 6.75a.75.75 0 1 1 0-1.5.75.75 0 0 1 0 1.5ZM12 12.75a.75.75 0 1 1 0-1.5.75.75 0 0 1 0 1.5ZM12 18.75a.75.75 0 1 1 0-1.5.75.75 0 0 1 0 1.5Z" />
</svg>
</IconButton>
</template>
<template v-slot:items>
<!-- rename button -->
<DropDownMenuItem @click="onRenameFavourite(favourite)">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" class="size-5">
<path fill-rule="evenodd" d="M5.25 2.25a3 3 0 0 0-3 3v4.318a3 3 0 0 0 .879 2.121l9.58 9.581c.92.92 2.39 1.186 3.548.428a18.849 18.849 0 0 0 5.441-5.44c.758-1.16.492-2.629-.428-3.548l-9.58-9.581a3 3 0 0 0-2.122-.879H5.25ZM6.375 7.5a1.125 1.125 0 1 0 0-2.25 1.125 1.125 0 0 0 0 2.25Z" clip-rule="evenodd" />
</svg>
<span>Rename Favourite</span>
</DropDownMenuItem>
<!-- remove favourite button -->
<div>
<DropDownMenuItem @click="onRemoveFavourite(favourite)">
<svg class="size-5 text-red-500" viewBox="0 0 20 20" fill="currentColor">
<path fill-rule="evenodd" d="M8.75 1A2.75 2.75 0 0 0 6 3.75v.443c-.795.077-1.584.176-2.365.298a.75.75 0 1 0 .23 1.482l.149-.022.841 10.518A2.75 2.75 0 0 0 7.596 19h4.807a2.75 2.75 0 0 0 2.742-2.53l.841-10.52.149.023a.75.75 0 0 0 .23-1.482A41.03 41.03 0 0 0 14 4.193V3.75A2.75 2.75 0 0 0 11.25 1h-2.5ZM10 4c.84 0 1.673.025 2.5.075V3.75c0-.69-.56-1.25-1.25-1.25h-2.5c-.69 0-1.25.56-1.25 1.25v.325C8.327 4.025 9.16 4 10 4ZM8.58 7.72a.75.75 0 0 0-1.5.06l.3 7.5a.75.75 0 1 0 1.5-.06l-.3-7.5Zm4.34.06a.75.75 0 1 0-1.5-.06l-.3 7.5a.75.75 0 1 0 1.5.06l.3-7.5Z" clip-rule="evenodd" />
</svg>
<span class="text-red-500">Remove Favourite</span>
</DropDownMenuItem>
</div>
</template>
</DropDownMenu>
</div>
</div>
</div>
<div v-else class="mx-auto my-auto text-center leading-5">
<!-- no favourites at all -->
<div v-if="favourites.length === 0" class="flex flex-col text-gray-900 dark:text-gray-100">
<div class="mx-auto mb-1">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="size-6">
<path stroke-linecap="round" stroke-linejoin="round" d="M11.48 3.499a.562.562 0 0 1 1.04 0l2.125 5.111a.563.563 0 0 0 .475.345l5.518.442c.499.04.701.663.321.988l-4.204 3.602a.563.563 0 0 0-.182.557l1.285 5.385a.562.562 0 0 1-.84.61l-4.725-2.885a.562.562 0 0 0-.586 0L6.982 20.54a.562.562 0 0 1-.84-.61l1.285-5.386a.562.562 0 0 0-.182-.557l-4.204-3.602a.562.562 0 0 1 .321-.988l5.518-.442a.563.563 0 0 0 .475-.345L11.48 3.5Z" />
</svg>
</div>
<div class="font-semibold">No Favourites</div>
<div>Discover nodes on the Announces tab.</div>
</div>
<!-- is searching, but no results -->
<div v-if="favouritesSearchTerm !== '' && favourites.length > 0" class="flex flex-col text-gray-900 dark:text-gray-100">
<div class="mx-auto mb-1">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-6 h-6">
<path stroke-linecap="round" stroke-linejoin="round" d="m21 21-5.197-5.197m0 0A7.5 7.5 0 1 0 5.196 5.196a7.5 7.5 0 0 0 10.607 10.607Z" />
</svg>
</div>
<div class="font-semibold">No Search Results</div>
<div>Your search didn't match any Favourites!</div>
</div>
</div>
</div>
</div>
<!-- announces -->
<div v-if="tab === 'announces'" class="flex-1 flex flex-col bg-white dark:bg-zinc-950 border-r dark:border-zinc-800 overflow-hidden">
<div class="flex-1 flex flex-col bg-white dark:bg-zinc-950 border-r dark:border-zinc-800 overflow-hidden">
<!-- search -->
<div v-if="nodesCount > 0" class="p-1 border-b border-gray-300 dark:border-zinc-800">
<input
@ -151,7 +58,6 @@
</div>
</div>
</div>
</div>
</template>
@ -159,22 +65,16 @@
import Utils from "../../js/Utils";
import MaterialDesignIcon from "../MaterialDesignIcon.vue";
import DropDownMenu from "../DropDownMenu.vue";
import IconButton from "../IconButton.vue";
import DropDownMenuItem from "../DropDownMenuItem.vue";
export default {
name: 'NomadNetworkSidebar',
components: {DropDownMenuItem, IconButton, DropDownMenu, MaterialDesignIcon},
components: {MaterialDesignIcon},
props: {
nodes: Object,
favourites: Array,
selectedDestinationHash: String,
},
data() {
return {
tab: "favourites",
favouritesSearchTerm: "",
nodesSearchTerm: "",
};
},
@ -182,21 +82,9 @@ export default {
onNodeClick(node) {
this.$emit("node-click", node);
},
onFavouriteClick(favourite) {
this.onNodeClick(favourite);
},
onRenameFavourite(favourite) {
this.$emit("rename-favourite", favourite);
},
onRemoveFavourite(favourite) {
this.$emit("remove-favourite", favourite);
},
formatTimeAgo: function(datetimeString) {
return Utils.formatTimeAgo(datetimeString);
},
formatDestinationHash: function(destinationHash) {
return Utils.formatDestinationHash(destinationHash);
},
},
computed: {
nodesCount() {
@ -219,15 +107,6 @@ export default {
return matchesDisplayName || matchesDestinationHash;
});
},
searchedFavourites() {
return this.favourites.filter((favourite) => {
const search = this.favouritesSearchTerm.toLowerCase();
const matchesDisplayName = favourite.display_name.toLowerCase().includes(search);
const matchesCustomDisplayName = favourite.custom_display_name?.toLowerCase()?.includes(search) === true;
const matchesDestinationHash = favourite.destination_hash.toLowerCase().includes(search);
return matchesDisplayName || matchesCustomDisplayName || matchesDestinationHash;
});
},
},
}
</script>

View file

@ -145,7 +145,7 @@ export default {
}
// confirm user wants to update their icon
if(!await DialogUtils.confirm("Are you sure you want to set this as your profile icon?")){
if(!confirm("Are you sure you want to set this as your profile icon?")){
return;
}
@ -160,7 +160,7 @@ export default {
async removeProfileIcon() {
// confirm user wants to remove their icon
if(!await DialogUtils.confirm("Are you sure you want to remove your profile icon? Anyone that has already received it will continue to see it until you send them a new icon.")){
if(!confirm("Are you sure you want to remove your profile icon? Anyone that has already received it will continue to see it until you send them a new icon.")){
return;
}

View file

@ -10,16 +10,6 @@ class DialogUtils {
}
}
static confirm(message) {
if(window.electron){
// running inside electron, use ipc confirm
return window.electron.confirm(message);
} else {
// running inside normal browser, use browser alert
return window.confirm(message);
}
}
static async prompt(message) {
if(window.electron){
// running inside electron, use ipc prompt

View file

@ -2,13 +2,6 @@ import moment from "moment";
class Utils {
static formatDestinationHash(destinationHashHex) {
const bytesPerSide = 4;
const leftSide = destinationHashHex.substring(0, bytesPerSide * 2);
const rightSide = destinationHashHex.substring(destinationHashHex.length - bytesPerSide * 2);
return `<${leftSide}...${rightSide}>`
}
static formatBytes(bytes) {
if(bytes === 0){
@ -150,7 +143,7 @@ class Utils {
static isInterfaceEnabled(iface) {
const rawValue = iface.enabled ?? iface.interface_enabled;
const value = rawValue?.toString()?.toLowerCase();
const value = rawValue?.toLowerCase();
return value === "on" || value === "yes" || value === "true";
}

View file

@ -3,18 +3,8 @@ import mitt from 'mitt';
class WebSocketConnection {
constructor() {
this.emitter = mitt();
this.reconnect();
/**
* ping websocket server every 30 seconds
* this helps to prevent the underlying tcp connection from going stale when there's no traffic for a long time
*/
setInterval(() => {
this.ping();
}, 30000);
}
// add event listener
@ -57,16 +47,6 @@ class WebSocketConnection {
}
}
ping() {
try {
this.send(JSON.stringify({
"type": "ping",
}));
} catch(e) {
// ignore error
}
}
}
export default new WebSocketConnection();

View file

@ -46,8 +46,7 @@ const router = createRouter({
},
{
name: "messages",
path: '/messages/:destinationHash?',
props: true,
path: '/messages',
component: defineAsyncComponent(() => import("./components/messages/MessagesPage.vue")),
},
{
@ -57,8 +56,7 @@ const router = createRouter({
},
{
name: "nomadnetwork",
path: '/nomadnetwork/:destinationHash?',
props: true,
path: '/nomadnetwork',
component: defineAsyncComponent(() => import("./components/nomadnetwork/NomadNetworkPage.vue")),
},
{

View file

@ -110,7 +110,7 @@
<div class="border-t px-2 py-1 text-sm">
<div>
<span>Download Firmware</span>
<span v-if="selectedProduct && selectedModel && recommendedFirmwareFilename">: <a target="_blank" :href="`https://github.com/markqvist/RNode_Firmware/releases/latest/download/${recommendedFirmwareFilename}`" class="text-blue-500 hover:underline">{{ recommendedFirmwareFilename }}</a></span>
<span v-if="selectedProduct && selectedModel && recommendedFirmwareFilename">: {{ recommendedFirmwareFilename }}</span>
</div>
<div class="space-x-1">
<a target="_blank" href="https://github.com/markqvist/RNode_Firmware/releases" class="text-blue-500 hover:underline">Official Firmware</a>
@ -312,48 +312,6 @@
</div>
<div class="border bg-gray-50 rounded shadow">
<div class="border-b px-2 py-1">
Configure Display (optional)
</div>
<div class="p-3 space-y-2">
<div class="flex space-x-1">
<div class="my-auto">Rotation</div>
<button @click="setDisplayRotation(0)" class="border border-gray-500 px-2 bg-gray-100 hover:bg-gray-200 rounded">
0
</button>
<button @click="setDisplayRotation(1)" class="border border-gray-500 px-2 bg-gray-100 hover:bg-gray-200 rounded">
1
</button>
<button @click="setDisplayRotation(2)" class="border border-gray-500 px-2 bg-gray-100 hover:bg-gray-200 rounded">
2
</button>
<button @click="setDisplayRotation(3)" class="border border-gray-500 px-2 bg-gray-100 hover:bg-gray-200 rounded">
3
</button>
</div>
<div class="flex space-x-1">
<div class="my-auto">Reconditioning</div>
<button @click="startDisplayReconditioning" class="border border-gray-500 px-2 bg-gray-100 hover:bg-gray-200 rounded">
Start
</button>
<button @click="reboot" class="border border-gray-500 px-2 bg-gray-100 hover:bg-gray-200 rounded">
Stop
</button>
</div>
</div>
<div class="border-t px-2 py-1 text-sm">
<div>Setting display rotation requires firmware v1.80+</div>
</div>
</div>
</div>
<!-- setup web-serial-polyfill -->
@ -376,8 +334,6 @@
data() {
return {
rnode: null,
isFlashing: false,
flashingProgress: 0,
@ -441,22 +397,6 @@
},
},
},
{
name: "Heltec T114",
id: ROM.PRODUCT_HELTEC_T114,
platform: ROM.PLATFORM_NRF52,
models: [
{
id: ROM.MODEL_C6,
name: "470-510 MHz (HT-n5262-LF)",
},
{
id: ROM.MODEL_C7,
name: "863-928 MHz (HT-n5262-HF)",
},
],
firmware_filename: "rnode_firmware_heltec_t114.zip",
},
{
name: "LilyGO LoRa32 v1.0",
id: ROM.PRODUCT_T32_10,
@ -647,21 +587,6 @@
},
},
},
{
id: ROM.MODEL_AC,
name: "2.4 GHz (with SX1280 chip)",
firmware_filename: "rnode_firmware_t3s3_sx1280_pa.zip",
flash_config: {
flash_size: "4MB",
flash_files: {
"0xe000": "rnode_firmware_t3s3_sx1280_pa.boot_app0",
"0x0": "rnode_firmware_t3s3_sx1280_pa.bootloader",
"0x10000": "rnode_firmware_t3s3_sx1280_pa.bin",
"0x210000": "console_image.bin",
"0x8000": "rnode_firmware_t3s3_sx1280_pa.partitions",
},
},
},
],
},
{
@ -789,11 +714,11 @@
platform: ROM.PLATFORM_NRF52,
models: [
{
id: ROM.MODEL_16,
id: ROM.MODEL_T4,
name: "433 MHz",
},
{
id: ROM.MODEL_17,
id: ROM.MODEL_T9,
name: "868 MHz / 915 MHz / 923 MHz",
},
],
@ -933,37 +858,11 @@
return null;
}
// close any existing rnode connection
if(this.rnode){
await this.rnode.close();
this.rnode = null;
}
// ask user to select device
return await navigator.serial.requestPort({
filters: [],
});
},
async askForRNode() {
// ask for serial port
const serialPort = await this.askForSerialPort();
if(!serialPort){
return false;
}
// check if device is an rnode
this.rnode = await RNode.fromSerialPort(serialPort);
const isRNode = await this.rnode.detect();
if(!isRNode){
await this.rnode.close();
alert("Selected device is not an RNode!");
return false;
}
return this.rnode;
},
async enterDfuMode() {
@ -1170,9 +1069,17 @@
},
async detect() {
// ask for rnode
const rnode = await this.askForRNode();
if(!rnode){
// ask for serial port
const serialPort = await this.askForSerialPort();
if(!serialPort){
return;
}
// check if device is an rnode
const rnode = await RNode.fromSerialPort(serialPort);
const isRNode = await rnode.detect();
if(!isRNode){
alert("Selected device is not an RNode!");
return;
}
@ -1215,9 +1122,17 @@
},
async reboot() {
// ask for rnode
const rnode = await this.askForRNode();
if(!rnode){
// ask for serial port
const serialPort = await this.askForSerialPort();
if(!serialPort){
return;
}
// check if device is an rnode
const rnode = await RNode.fromSerialPort(serialPort);
const isRNode = await rnode.detect();
if(!isRNode){
alert("Selected device is not an RNode!");
return;
}
@ -1295,9 +1210,17 @@
},
async readDisplay() {
// ask for rnode
const rnode = await this.askForRNode();
if(!rnode){
// ask for serial port
const serialPort = await this.askForSerialPort();
if(!serialPort){
return;
}
// check if device is an rnode
const rnode = await RNode.fromSerialPort(serialPort);
const isRNode = await rnode.detect();
if(!isRNode){
alert("Selected device is not an RNode!");
return;
}
@ -1313,9 +1236,17 @@
},
async dumpEeprom() {
// ask for rnode
const rnode = await this.askForRNode();
if(!rnode){
// ask for serial port
const serialPort = await this.askForSerialPort();
if(!serialPort){
return;
}
// check if device is an rnode
const rnode = await RNode.fromSerialPort(serialPort);
const isRNode = await rnode.detect();
if(!isRNode){
alert("Selected device is not an RNode!");
return;
}
@ -1333,15 +1264,22 @@
},
async wipeEeprom() {
// ask for rnode
const rnode = await this.askForRNode();
if(!rnode){
// ask for serial port
const serialPort = await this.askForSerialPort();
if(!serialPort){
return;
}
// ask user to confirm
if(!confirm("Are you sure you want to wipe the eeprom on this device? This will take about 30 seconds. An alert will show when the eeprom wipe has finished.")){
await rnode.close();
return;
}
// check if device is an rnode
const rnode = await RNode.fromSerialPort(serialPort);
const isRNode = await rnode.detect();
if(!isRNode){
alert("Selected device is not an RNode!");
return;
}
@ -1360,9 +1298,18 @@
},
async provision() {
// ask for rnode
const rnode = await this.askForRNode();
if(!rnode){
// ask for serial port
const serialPort = await this.askForSerialPort();
if(!serialPort){
return;
}
// check if device is an rnode
const rnode = await RNode.fromSerialPort(serialPort);
const isRNode = await rnode.detect();
if(!isRNode){
alert("Selected device is not an RNode!");
await rnode.close();
return;
}
@ -1542,9 +1489,17 @@
},
async setFirmwareHash() {
// ask for rnode
const rnode = await this.askForRNode();
if(!rnode){
// ask for serial port
const serialPort = await this.askForSerialPort();
if(!serialPort){
return;
}
// check if device is an rnode
const rnode = await RNode.fromSerialPort(serialPort);
const isRNode = await rnode.detect();
if(!isRNode){
alert("Selected device is not an RNode!");
return;
}
@ -1591,9 +1546,17 @@
},
async enableTncMode() {
// ask for rnode
const rnode = await this.askForRNode();
if(!rnode){
// ask for serial port
const serialPort = await this.askForSerialPort();
if(!serialPort){
return;
}
// check if device is an rnode
const rnode = await RNode.fromSerialPort(serialPort);
const isRNode = await rnode.detect();
if(!isRNode){
alert("Selected device is not an RNode!");
return;
}
@ -1639,9 +1602,17 @@
},
async disableTncMode() {
// ask for rnode
const rnode = await this.askForRNode();
if(!rnode){
// ask for serial port
const serialPort = await this.askForSerialPort();
if(!serialPort){
return;
}
// check if device is an rnode
const rnode = await RNode.fromSerialPort(serialPort);
const isRNode = await rnode.detect();
if(!isRNode){
alert("Selected device is not an RNode!");
return;
}
@ -1672,9 +1643,17 @@
},
async enableBluetooth() {
// ask for rnode
const rnode = await this.askForRNode();
if(!rnode){
// ask for serial port
const serialPort = await this.askForSerialPort();
if(!serialPort){
return;
}
// check if device is an rnode
const rnode = await RNode.fromSerialPort(serialPort);
const isRNode = await rnode.detect();
if(!isRNode){
alert("Selected device is not an RNode!");
return;
}
@ -1692,18 +1671,26 @@
await rnode.enableBluetooth();
console.log("enabling bluetooth: done");
alert("Bluetooth has been enabled!");
await Utils.sleepMillis(1000);
// done
await Utils.sleepMillis(1000);
await rnode.close();
alert("Bluetooth has been enabled!");
},
async disableBluetooth() {
// ask for rnode
const rnode = await this.askForRNode();
if(!rnode){
// ask for serial port
const serialPort = await this.askForSerialPort();
if(!serialPort){
return;
}
// check if device is an rnode
const rnode = await RNode.fromSerialPort(serialPort);
const isRNode = await rnode.detect();
if(!isRNode){
alert("Selected device is not an RNode!");
return;
}
@ -1720,18 +1707,27 @@
console.log("disabling bluetooth");
await rnode.disableBluetooth();
console.log("disabling bluetooth: done");
alert("Bluetooth has been disabled!");
await Utils.sleepMillis(1000);
// done
await Utils.sleepMillis(1000);
await rnode.close();
alert("Bluetooth has been disabled!");
},
async startBluetoothPairing() {
// ask for rnode
const rnode = await this.askForRNode();
if(!rnode){
// ask for serial port
const serialPort = await this.askForSerialPort();
if(!serialPort){
return;
}
// check if device is an rnode
const rnode = await RNode.fromSerialPort(serialPort);
const isRNode = await rnode.detect();
if(!isRNode){
alert("Selected device is not an RNode!");
return;
}
@ -1747,72 +1743,13 @@
// start bluetooth pairing
try {
console.log("start bluetooth pairing");
await rnode.startBluetoothPairing(async (pin) => {
alert("Bluetooth Pairing Pin: " + pin);
await rnode.close();
});
const pin = await rnode.startBluetoothPairing();
console.log("start bluetooth pairing: done");
} catch(error) {
alert(error);
}
// tell user device is in pairing mode, and how to pair
alert([
"- RNode is in Bluetooth Pairing Mode for 30 seconds.",
"- Close this alert before performing the next steps.",
"- Open bluetooth settings on your Android device.",
"- Click pair on the RNode device that shows up.",
"- Bluetooth pin will shown on your RNode screen and on this page.",
].join("\n"));
},
async setDisplayRotation(rotation) {
// ask for rnode
const rnode = await this.askForRNode();
if(!rnode){
return;
}
// check if device has been provisioned
const rom = await rnode.getRomAsObject();
const details = rom.parse();
if(!details || !details.is_provisioned){
alert("Eeprom is not provisioned. You must do this first!");
await rnode.close();
return;
}
// configure
console.log("setting display rotation");
await rnode.setDisplayRotation(rotation);
console.log("setting display rotation: done");
// done
await rnode.close();
},
async startDisplayReconditioning() {
// ask for rnode
const rnode = await this.askForRNode();
if(!rnode){
return;
}
// check if device has been provisioned
const rom = await rnode.getRomAsObject();
const details = rom.parse();
if(!details || !details.is_provisioned){
alert("Eeprom is not provisioned. You must do this first!");
await rnode.close();
return;
}
// configure
console.log("starting display reconditioning");
await rnode.startDisplayReconditioning();
console.log("starting display reconditioning: done");
alert("RNode should now be in Bluetooth Pairing mode. A pin will be shown on the screen when you pair with it from Android bluetooth settings.");
// done
await rnode.close();

View file

@ -79,8 +79,6 @@ class RNode {
ROM_UNLOCK_BYTE = 0xF8;
CMD_HASHES = 0x60;
CMD_FW_UPD = 0x61;
CMD_DISP_ROT = 0x67;
CMD_DISP_RCND = 0x68;
CMD_BT_CTRL = 0x46;
CMD_BT_PIN = 0x62;
@ -123,10 +121,8 @@ class RNode {
constructor(serialPort) {
this.serialPort = serialPort;
this.reader = serialPort.readable.getReader();
this.readable = serialPort.readable;
this.writable = serialPort.writable;
this.callbacks = {};
this.readLoop();
}
static async fromSerialPort(serialPort) {
@ -141,21 +137,11 @@ class RNode {
}
async close() {
// release reader lock
try {
this.reader.releaseLock();
} catch(e) {
//console.log("failed to release lock on serial port readable, ignoring...", e);
}
// close serial port
try {
await this.serialPort.close();
} catch(e) {
//console.log("failed to close serial port, ignoring...", e);
console.log("failed to close serial port, ignoring...", e);
}
}
async write(bytes) {
@ -167,100 +153,79 @@ class RNode {
}
}
async readLoop() {
try {
let buffer = [];
let inFrame = false;
while(true){
async readFromSerialPort(timeoutMillis) {
return new Promise(async (resolve, reject) => {
// read kiss frames until reader indicates it's done
const { value, done } = await this.reader.read();
if(done){
break;
}
// create reader
const reader = this.readable.getReader();
// read kiss frames
for(const byte of value){
if(byte === this.KISS_FEND){
if(inFrame){
// End of frame
const decodedFrame = this.decodeKissFrame(buffer);
if(decodedFrame){
this.onCommandReceived(decodedFrame);
} else {
console.warn("Invalid frame ignored.");
// timeout after provided millis
if(timeoutMillis != null){
setTimeout(() => {
reader.releaseLock();
reject("timeout");
}, timeoutMillis);
}
// attempt to read kiss frame
try {
let buffer = [];
while(true){
const { value, done } = await reader.read();
if(done){
break;
}
if(value){
for(let byte of value){
buffer.push(byte);
if(byte === this.KISS_FEND){
if(buffer.length > 1){
resolve(this.handleKISSFrame(buffer));
return;
}
buffer = [this.KISS_FEND]; // Start new frame
}
buffer = [];
}
inFrame = !inFrame;
} else if(inFrame) {
buffer.push(byte);
}
}
}
} catch(error) {
// ignore error if reader was released
if(error instanceof TypeError){
return;
} catch (error) {
console.error('Error reading from serial port: ', error);
} finally {
reader.releaseLock();
}
console.error('Error reading from serial port: ', error);
} finally {
this.reader.releaseLock();
}
});
}
onCommandReceived(data) {
try {
handleKISSFrame(frame) {
// get received command and bytes from data
const [ command, ...bytes ] = data;
console.log("onCommandReceived", "0x" + command.toString(16), bytes);
// find callback for received command
const callback = this.callbacks[command];
if(!callback){
return;
}
// fire callback
callback(bytes);
// forget callback
delete this.callbacks[command];
} catch(e) {
console.log("failed to handle received command", data, e);
}
}
decodeKissFrame(frame) {
const data = [];
let data = [];
let escaping = false;
for(const byte of frame){
if(escaping){
if(byte === this.KISS_TFEND){
// Skip the initial 0xC0 and process the rest
for(let i = 1; i < frame.length; i++){
let byte = frame[i];
if (escaping) {
if (byte === this.KISS_TFEND) {
data.push(this.KISS_FEND);
} else if(byte === this.KISS_TFESC) {
} else if (byte === this.KISS_TFESC) {
data.push(this.KISS_FESC);
} else {
return null; // Invalid escape sequence
}
escaping = false;
} else if(byte === this.KISS_FESC) {
escaping = true;
} else {
data.push(byte);
if (byte === this.KISS_FESC) {
escaping = true;
} else if (byte === this.KISS_FEND) {
// Ignore the end frame delimiter
break;
} else {
data.push(byte);
}
}
}
// return null if incomplete escape at end
return escaping ? null : data;
//console.log('Received KISS frame data:', new Uint8Array(data));
return data;
}
@ -283,28 +248,6 @@ class RNode {
await this.write(this.createKissFrame(data));
}
// sends a command to the rnode, and resolves the promise with the result
async sendCommand(command, data) {
return new Promise(async (resolve, reject) => {
try {
// listen for response
this.callbacks[command] = (response) => {
resolve(response);
};
// send command
await this.sendKissCommand([
command,
...data,
]);
} catch(e) {
reject(e);
}
});
}
async reset() {
await this.sendKissCommand([
this.CMD_RESET,
@ -313,42 +256,30 @@ class RNode {
}
async detect() {
return new Promise(async (resolve) => {
try {
// timeout after provided millis
const timeout = setTimeout(() => {
resolve(false);
}, 2000);
// ask if device is rnode
await this.sendKissCommand([
this.CMD_DETECT,
this.DETECT_REQ,
]);
// detect rnode
const response = await this.sendCommand(this.CMD_DETECT, [
this.DETECT_REQ,
]);
// read response from device
const [ command, responseByte ] = await this.readFromSerialPort();
// we no longer want to timeout
clearTimeout(timeout);
// device is an rnode if response is as expected
const [ responseByte ] = response;
const isRnode = responseByte === this.DETECT_RESP;
resolve(isRnode);
} catch(e) {
resolve(false);
}
});
// device is an rnode if response is as expected
return command === this.CMD_DETECT && responseByte === this.DETECT_RESP;
}
async getFirmwareVersion() {
const response = await this.sendCommand(this.CMD_FW_VERSION, [
await this.sendKissCommand([
this.CMD_FW_VERSION,
0x00,
]);
// read response from device
var [ majorVersion, minorVersion ] = response;
var [ command, majorVersion, minorVersion ] = await this.readFromSerialPort();
if(minorVersion.length === 1){
minorVersion = "0" + minorVersion;
}
@ -360,91 +291,99 @@ class RNode {
async getPlatform() {
const response = await this.sendCommand(this.CMD_PLATFORM, [
await this.sendKissCommand([
this.CMD_PLATFORM,
0x00,
]);
// read response from device
const [ platformByte ] = response;
const [ command, platformByte ] = await this.readFromSerialPort();
return platformByte;
}
async getMcu() {
const response = await this.sendCommand(this.CMD_MCU, [
await this.sendKissCommand([
this.CMD_MCU,
0x00,
]);
// read response from device
const [ mcuByte ] = response;
const [ command, mcuByte ] = await this.readFromSerialPort();
return mcuByte;
}
async getBoard() {
const response = await this.sendCommand(this.CMD_BOARD, [
await this.sendKissCommand([
this.CMD_BOARD,
0x00,
]);
// read response from device
const [ boardByte ] = response;
const [ command, boardByte ] = await this.readFromSerialPort();
return boardByte;
}
async getDeviceHash() {
const response = await this.sendCommand(this.CMD_DEV_HASH, [
await this.sendKissCommand([
this.CMD_DEV_HASH,
0x01, // anything != 0x00
]);
// read response from device
const [ ...deviceHash ] = response;
const [ command, ...deviceHash ] = await this.readFromSerialPort();
return deviceHash;
}
async getTargetFirmwareHash() {
const response = await this.sendCommand(this.CMD_HASHES, [
await this.sendKissCommand([
this.CMD_HASHES,
this.HASH_TYPE_TARGET_FIRMWARE,
]);
// read response from device
const [ hashType, ...targetFirmwareHash ] = response;
const [ command, hashType, ...targetFirmwareHash ] = await this.readFromSerialPort();
return targetFirmwareHash;
}
async getFirmwareHash() {
const response = await this.sendCommand(this.CMD_HASHES, [
await this.sendKissCommand([
this.CMD_HASHES,
this.HASH_TYPE_FIRMWARE,
]);
// read response from device
const [ hashType, ...firmwareHash ] = response;
const [ command, hashType, ...firmwareHash ] = await this.readFromSerialPort();
return firmwareHash;
}
async getRom() {
const response = await this.sendCommand(this.CMD_ROM_READ, [
await this.sendKissCommand([
this.CMD_ROM_READ,
0x00,
]);
// read response from device
const [ ...eepromBytes ] = response;
const [ command, ...eepromBytes ] = await this.readFromSerialPort();
return eepromBytes;
}
async getFrequency() {
const response = await this.sendCommand(this.CMD_FREQUENCY, [
await this.sendKissCommand([
this.CMD_FREQUENCY,
// request frequency by sending zero as 4 bytes
0x00,
0x00,
@ -453,7 +392,7 @@ class RNode {
]);
// read response from device
const [ ...frequencyBytes ] = response;
const [ command, ...frequencyBytes ] = await this.readFromSerialPort();
// convert 4 bytes to 32bit integer representing frequency in hertz
const frequencyInHz = frequencyBytes[0] << 24 | frequencyBytes[1] << 16 | frequencyBytes[2] << 8 | frequencyBytes[3];
@ -463,7 +402,8 @@ class RNode {
async getBandwidth() {
const response = await this.sendCommand(this.CMD_BANDWIDTH, [
await this.sendKissCommand([
this.CMD_BANDWIDTH,
// request bandwidth by sending zero as 4 bytes
0x00,
0x00,
@ -472,7 +412,7 @@ class RNode {
]);
// read response from device
const [ ...bandwidthBytes ] = response;
const [ command, ...bandwidthBytes ] = await this.readFromSerialPort();
// convert 4 bytes to 32bit integer representing bandwidth in hertz
const bandwidthInHz = bandwidthBytes[0] << 24 | bandwidthBytes[1] << 16 | bandwidthBytes[2] << 8 | bandwidthBytes[3];
@ -482,60 +422,69 @@ class RNode {
async getTxPower() {
const response = await this.sendCommand(this.CMD_TXPOWER, [
await this.sendKissCommand([
this.CMD_TXPOWER,
0xFF, // request tx power
]);
// read response from device
const [ txPower ] = response;
const [ command, txPower ] = await this.readFromSerialPort();
return txPower;
}
async getSpreadingFactor() {
const response = await this.sendCommand(this.CMD_SF, [
await this.sendKissCommand([
this.CMD_SF,
0xFF, // request spreading factor
]);
// read response from device
const [ spreadingFactor ] = response;
const [ command, spreadingFactor ] = await this.readFromSerialPort();
return spreadingFactor;
}
async getCodingRate() {
const response = await this.sendCommand(this.CMD_CR, [
await this.sendKissCommand([
this.CMD_CR,
0xFF, // request coding rate
]);
// read response from device
const [ codingRate ] = response;
const [ command, codingRate ] = await this.readFromSerialPort();
return codingRate;
}
async getRadioState() {
const response = await this.sendCommand(this.CMD_RADIO_STATE, [
await this.sendKissCommand([
this.CMD_RADIO_STATE,
0xFF, // request radio state
]);
// read response from device
const [ radioState ] = response;
const [ command, radioState ] = await this.readFromSerialPort();
return radioState;
}
async getRxStat() {
const response = await this.sendCommand(this.CMD_STAT_RX, [
await this.sendKissCommand([
this.CMD_STAT_RX,
0x00,
]);
// read response from device
const [ ...statBytes ] = response;
const [ command, ...statBytes ] = await this.readFromSerialPort();
// convert 4 bytes to 32bit integer
const stat = statBytes[0] << 24 | statBytes[1] << 16 | statBytes[2] << 8 | statBytes[3];
@ -545,12 +494,13 @@ class RNode {
async getTxStat() {
const response = await this.sendCommand(this.CMD_STAT_TX, [
await this.sendKissCommand([
this.CMD_STAT_TX,
0x00,
]);
// read response from device
const [ ...statBytes ] = response;
const [ command, ...statBytes ] = await this.readFromSerialPort();
// convert 4 bytes to 32bit integer
const stat = statBytes[0] << 24 | statBytes[1] << 16 | statBytes[2] << 8 | statBytes[3];
@ -560,12 +510,14 @@ class RNode {
async getRssiStat() {
const response = await this.sendCommand(this.CMD_STAT_RSSI, [
await this.sendKissCommand([
this.CMD_STAT_RSSI,
0x00,
]);
// read response from device
const [ rssi ] = response;
const [ command, rssi ] = await this.readFromSerialPort();
return rssi;
}
@ -584,23 +536,7 @@ class RNode {
]);
}
async startBluetoothPairing(pinCallback) {
// listen for bluetooth pin
// pin will be available once the user has initiated pairing from an Android device
this.callbacks[this.CMD_BT_PIN] = (response) => {
// read response from device
const [ ...pinBytes ] = response;
// convert 4 bytes to 32bit integer
const pin = pinBytes[0] << 24 | pinBytes[1] << 16 | pinBytes[2] << 8 | pinBytes[3];
// tell user what the bluetooth pin is
console.log("Bluetooth Pairing Pin: " + pin);
pinCallback(pin);
};
async startBluetoothPairing() {
// enable pairing
await this.sendKissCommand([
@ -608,16 +544,43 @@ class RNode {
0x02, // enable pairing
]);
// todo: listen for packets, pin will be available once user has initiated pairing from Android device
// // attempt to get bluetooth pairing pin
// try {
//
// // read response from device
// const [ command, ...pinBytes ] = await this.readFromSerialPort(5000);
// if(command !== this.CMD_BT_PIN){
// throw `unexpected command response: ${command}`;
// }
//
// // convert 4 bytes to 32bit integer
// const pin = pinBytes[0] << 24 | pinBytes[1] << 16 | pinBytes[2] << 8 | pinBytes[3];
//
// // todo: remove logs
// console.log(pinBytes);
// console.log(pin);
//
// // todo: convert to string
// return pin;
//
// } catch(error) {
// throw `failed to get bluetooth pin: ${error}`;
// }
}
async readDisplay() {
const response = await this.sendCommand(this.CMD_DISP_READ, [
await this.sendKissCommand([
this.CMD_DISP_READ,
0x01,
]);
// read response from device
const [ ...displayBuffer ] = response;
const [ command, ...displayBuffer ] = await this.readFromSerialPort();
return displayBuffer;
}
@ -752,20 +715,6 @@ class RNode {
return new ROM(rom);
}
async setDisplayRotation(rotation) {
await this.sendKissCommand([
this.CMD_DISP_ROT,
rotation & 0xFF,
]);
}
async startDisplayReconditioning() {
await this.sendKissCommand([
this.CMD_DISP_RCND,
0x01,
]);
}
}
class ROM {
@ -794,7 +743,6 @@ class ROM {
static MODEL_A7 = 0xA7
static MODEL_A5 = 0xA5;
static MODEL_AA = 0xAA;
static MODEL_AC = 0xAC;
static PRODUCT_T32_10 = 0xB2
static MODEL_BA = 0xBA
@ -818,10 +766,6 @@ class ROM {
static MODEL_C5 = 0xC5
static MODEL_CA = 0xCA
static PRODUCT_HELTEC_T114 = 0xC2
static MODEL_C6 = 0xC6
static MODEL_C7 = 0xC7
static PRODUCT_TBEAM = 0xE0
static MODEL_E4 = 0xE4
static MODEL_E9 = 0xE9
@ -837,8 +781,8 @@ class ROM {
static MODEL_D9 = 0xD9;
static PRODUCT_TECHO = 0x15;
static MODEL_16 = 0x16;
static MODEL_17 = 0x17;
static MODEL_T4 = 0x16;
static MODEL_T9 = 0x17;
static PRODUCT_HMBRW = 0xF0
static MODEL_FF = 0xFF