mirror of
https://github.com/liamcottle/reticulum-meshchat.git
synced 2026-10-26 02:48:47 -04:00
Compare commits
138 commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a9f925975e | ||
|
|
3190f3e5a6 | ||
|
|
df5aea94ea | ||
|
|
7e88277abd | ||
|
|
45f89a8fca | ||
|
|
ecb1efc5a4 | ||
|
|
f9d930ed87 | ||
|
|
db26474273 | ||
|
|
b36dcddfcd | ||
|
|
3baa5cd19a | ||
|
|
6b45c3239d | ||
|
|
e49df24360 | ||
|
|
97be61e36c | ||
|
|
946d113e69 | ||
|
|
42db0fdb23 | ||
|
|
2c6d316801 | ||
|
|
4ef57593e9 | ||
|
|
011876bec5 | ||
|
|
e3dae8aea9 | ||
|
|
c2ee9be39a | ||
|
|
002360399c | ||
|
|
c9f4ef64c1 | ||
|
|
ffe2cb884d | ||
|
|
d6847d262a | ||
|
|
65df111b87 | ||
|
|
747236ae8b | ||
|
|
4e55006084 | ||
|
|
dcaffe2594 | ||
|
|
094f6cb5ec | ||
|
|
0c0f059ec4 | ||
|
|
9031c1a3d7 | ||
|
|
64adad27f8 | ||
|
|
4734e62468 | ||
|
|
37cc6aa158 | ||
|
|
f3bf0abd84 | ||
|
|
90445467e1 | ||
|
|
51bdd35f01 | ||
|
|
817d5b5e59 | ||
|
|
a094a741a8 | ||
|
|
24acbaf223 | ||
|
|
0bb171a81b | ||
|
|
b5a54dd120 | ||
|
|
86cfddce52 | ||
|
|
97071c7edb | ||
|
|
a58f73357a | ||
|
|
6b3639dcd2 | ||
|
|
47a84fc110 | ||
|
|
588780d632 | ||
|
|
5b783399f8 | ||
|
|
df533fb1bf | ||
|
|
e757a2f022 | ||
|
|
ce56c205c6 | ||
|
|
66b619c398 | ||
|
|
458a387517 | ||
|
|
e97352713d | ||
|
|
07a41215be | ||
|
|
e9a9e9f831 | ||
|
|
b8d388fa56 | ||
|
|
d7080c8ca1 | ||
|
|
7c20529d62 | ||
|
|
c6eeab97e6 | ||
|
|
10c85cdba0 | ||
|
|
9ea98eb0f0 | ||
|
|
2662f96c8b | ||
|
|
59deac6d07 | ||
|
|
9d60707515 | ||
|
|
6f321741d7 | ||
|
|
eaf1b75c54 | ||
|
|
c59ed015ce | ||
|
|
d13b395a2c | ||
|
|
59c185354b | ||
|
|
9e7d0cdfeb | ||
|
|
e6ff5097c0 | ||
|
|
ee08a5619c | ||
|
|
c0bb0763a1 | ||
|
|
b6e41b3027 | ||
|
|
030a1e64a9 | ||
|
|
5802671e0d | ||
|
|
03d7b669ae | ||
|
|
a81c6787c7 | ||
|
|
a500b58d05 | ||
|
|
94179f9779 | ||
|
|
93b6104aef | ||
|
|
10bef61a90 | ||
|
|
0f31c9f8c0 | ||
|
|
2c518d1b31 | ||
|
|
176aed98ff | ||
|
|
e1ae122297 | ||
|
|
f6b1c65faa | ||
|
|
4f497620c8 | ||
|
|
4d816ae87c | ||
|
|
df8e98366b | ||
|
|
54b1d56107 | ||
|
|
ba118f7a9c | ||
|
|
e48c26042c | ||
|
|
d95878c659 | ||
|
|
734eaeed1b | ||
|
|
33e4888737 | ||
|
|
408a62dffe | ||
|
|
43a5a907c0 | ||
|
|
620c147dbd | ||
|
|
4555de5836 | ||
|
|
842dbeb0b4 | ||
|
|
9d2f3eebc8 | ||
|
|
b21e3fc026 | ||
|
|
abd70ae606 | ||
|
|
1e2d4387e7 | ||
|
|
d4b5b99045 | ||
|
|
ce52532522 | ||
|
|
6c43c2cc4f | ||
|
|
c5e4776dc1 | ||
|
|
dabd6c4a37 | ||
|
|
dacd2ea3f2 | ||
|
|
9741cdcd60 | ||
|
|
f87a360d5c | ||
|
|
9b62f60e18 | ||
|
|
019ba93d80 | ||
|
|
01562aff75 | ||
|
|
e2b844f2c2 | ||
|
|
c555d8f15b | ||
|
|
0dc3dc955f | ||
|
|
812ff6b887 | ||
|
|
3a13442bb9 | ||
|
|
d7375081f3 | ||
|
|
68ebe4a1c9 | ||
|
|
8b2520f3fa | ||
|
|
5e068b7341 | ||
|
|
d796722772 | ||
|
|
adad97e917 | ||
|
|
59eba2ff64 | ||
|
|
1bad77553c | ||
|
|
b215c4ac31 | ||
|
|
6af4e53de4 | ||
|
|
558e4c8b3d | ||
|
|
7d1681fbf1 | ||
|
|
580c907138 | ||
|
|
4ae83ca980 | ||
|
|
29c062d701 |
37 changed files with 3069 additions and 683 deletions
7
.github/workflows/build.yml
vendored
7
.github/workflows/build.yml
vendored
|
|
@ -45,7 +45,10 @@ jobs:
|
|||
artifacts: "dist/*-win-installer.exe,dist/*-win-portable.exe"
|
||||
|
||||
build_mac:
|
||||
runs-on: macos-13
|
||||
strategy:
|
||||
matrix:
|
||||
os: [ macos-15, macos-15-intel ]
|
||||
runs-on: ${{ matrix.os }}
|
||||
permissions:
|
||||
contents: write
|
||||
steps:
|
||||
|
|
@ -80,7 +83,7 @@ jobs:
|
|||
replacesArtifacts: true
|
||||
omitDraftDuringUpdate: true
|
||||
omitNameDuringUpdate: true
|
||||
artifacts: "dist/*-mac.dmg"
|
||||
artifacts: "dist/*-mac-*.dmg"
|
||||
|
||||
build_linux:
|
||||
runs-on: ubuntu-latest
|
||||
|
|
|
|||
14
README.md
14
README.md
|
|
@ -283,20 +283,6 @@ 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
|
||||
|
||||
|
|
|
|||
15
database.py
15
database.py
|
|
@ -95,6 +95,21 @@ 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()
|
||||
|
|
|
|||
119
electron/main.js
119
electron/main.js
|
|
@ -22,6 +22,27 @@ 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({
|
||||
|
|
@ -48,6 +69,9 @@ 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;
|
||||
|
|
@ -58,9 +82,6 @@ function log(message) {
|
|||
return;
|
||||
}
|
||||
|
||||
// log to electron console
|
||||
console.log(message);
|
||||
|
||||
// log to web console
|
||||
mainWindow.webContents.send('log', message);
|
||||
|
||||
|
|
@ -98,50 +119,64 @@ function getDefaultReticulumConfigDir() {
|
|||
|
||||
app.whenReady().then(async () => {
|
||||
|
||||
// create browser window
|
||||
mainWindow = new BrowserWindow({
|
||||
width: 1500,
|
||||
height: 800,
|
||||
webPreferences: {
|
||||
// used to inject logging over ipc
|
||||
preload: path.join(__dirname, 'preload.js'),
|
||||
},
|
||||
});
|
||||
// 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");
|
||||
|
||||
// open external links in default web browser instead of electron
|
||||
mainWindow.webContents.setWindowOpenHandler(({ url }) => {
|
||||
if(!shouldLaunchHeadless){
|
||||
|
||||
var shouldShowInNewElectronWindow = false;
|
||||
// create browser window
|
||||
mainWindow = new BrowserWindow({
|
||||
width: 1500,
|
||||
height: 800,
|
||||
webPreferences: {
|
||||
// used to inject logging over ipc
|
||||
preload: path.join(__dirname, 'preload.js'),
|
||||
},
|
||||
});
|
||||
|
||||
// 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;
|
||||
}
|
||||
// open external links in default web browser instead of electron
|
||||
mainWindow.webContents.setWindowOpenHandler(({ url }) => {
|
||||
|
||||
// we want to open blob urls in a new electron window
|
||||
else if(url.startsWith("blob:")) {
|
||||
shouldShowInNewElectronWindow = true;
|
||||
}
|
||||
var shouldShowInNewElectronWindow = false;
|
||||
|
||||
// open in new electron window
|
||||
if(shouldShowInNewElectronWindow){
|
||||
// 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: "allow",
|
||||
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');
|
||||
}
|
||||
|
||||
// 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";
|
||||
|
|
@ -152,16 +187,8 @@ 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
|
||||
|
|
|
|||
|
|
@ -15,6 +15,11 @@ 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);
|
||||
|
|
|
|||
586
meshchat.py
586
meshchat.py
|
|
@ -1,8 +1,10 @@
|
|||
#!/usr/bin/env python
|
||||
|
||||
import argparse
|
||||
import io
|
||||
import json
|
||||
import os
|
||||
import platform
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
|
|
@ -26,6 +28,7 @@ from src.backend.announce_handler import AnnounceHandler
|
|||
from src.backend.async_utils import AsyncUtils
|
||||
from src.backend.colour_utils import ColourUtils
|
||||
from src.backend.interface_config_parser import InterfaceConfigParser
|
||||
from src.backend.interface_editor import InterfaceEditor
|
||||
from src.backend.lxmf_message_fields import LxmfImageField, LxmfFileAttachmentsField, LxmfFileAttachment, LxmfAudioField
|
||||
from src.backend.audio_call_manager import AudioCall, AudioCallManager
|
||||
from src.backend.sideband_commands import SidebandCommands
|
||||
|
|
@ -76,6 +79,7 @@ class ReticulumMeshChat:
|
|||
database.Config,
|
||||
database.Announce,
|
||||
database.CustomDestinationDisplayName,
|
||||
database.FavouriteDestination,
|
||||
database.LxmfMessage,
|
||||
database.LxmfConversationReadState,
|
||||
database.LxmfUserIcon,
|
||||
|
|
@ -269,7 +273,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()))
|
||||
asyncio.run(self.websocket_broadcast(json.dumps({
|
||||
AsyncUtils.run_async(self.websocket_broadcast(json.dumps({
|
||||
"type": "incoming_audio_call",
|
||||
})))
|
||||
|
||||
|
|
@ -329,8 +333,29 @@ class ReticulumMeshChat:
|
|||
if "interfaces" in self.reticulum.config:
|
||||
interfaces = self.reticulum.config["interfaces"]
|
||||
|
||||
processed_interfaces = {}
|
||||
for interface_name, interface in interfaces.items():
|
||||
interface_data = interface.copy()
|
||||
|
||||
# handle sub-interfaces for RNodeMultiInterface
|
||||
if interface_data.get("type") == "RNodeMultiInterface":
|
||||
sub_interfaces = []
|
||||
for sub_name, sub_config in interface_data.items():
|
||||
if sub_name not in {"type", "port", "interface_enabled", "selected_interface_mode", "configured_bitrate"}:
|
||||
if isinstance(sub_config, dict):
|
||||
sub_config["name"] = sub_name
|
||||
sub_interfaces.append(sub_config)
|
||||
|
||||
# add sub-interfaces to the main interface data
|
||||
interface_data["sub_interfaces"] = sub_interfaces
|
||||
|
||||
for sub in sub_interfaces:
|
||||
del interface_data[sub["name"]]
|
||||
|
||||
processed_interfaces[interface_name] = interface_data
|
||||
|
||||
return web.json_response({
|
||||
"interfaces": interfaces,
|
||||
"interfaces": processed_interfaces,
|
||||
})
|
||||
|
||||
# enable reticulum interface
|
||||
|
|
@ -443,94 +468,178 @@ class ReticulumMeshChat:
|
|||
if "enabled" not in interface_details and "interface_enabled" not in interface_details:
|
||||
interface_details["interface_enabled"] = "true"
|
||||
|
||||
# handle tcp client interface
|
||||
# handle AutoInterface
|
||||
if interface_type == "AutoInterface":
|
||||
|
||||
# set optional AutoInterface options
|
||||
InterfaceEditor.update_value(interface_details, data, "group_id")
|
||||
InterfaceEditor.update_value(interface_details, data, "multicast_address_type")
|
||||
InterfaceEditor.update_value(interface_details, data, "devices")
|
||||
InterfaceEditor.update_value(interface_details, data, "ignored_devices")
|
||||
InterfaceEditor.update_value(interface_details, data, "discovery_scope")
|
||||
InterfaceEditor.update_value(interface_details, data, "discovery_port")
|
||||
InterfaceEditor.update_value(interface_details, data, "data_port")
|
||||
|
||||
# handle TCPClientInterface
|
||||
if interface_type == "TCPClientInterface":
|
||||
|
||||
interface_target_host = data.get('target_host')
|
||||
interface_target_port = data.get('target_port')
|
||||
|
||||
# ensure target host provided
|
||||
interface_target_host = data.get('target_host')
|
||||
if interface_target_host is None or interface_target_host == "":
|
||||
return web.json_response({
|
||||
"message": "Target Host is required",
|
||||
}, status=422)
|
||||
|
||||
# ensure target port provided
|
||||
interface_target_port = data.get('target_port')
|
||||
if interface_target_port is None or interface_target_port == "":
|
||||
return web.json_response({
|
||||
"message": "Target Port is required",
|
||||
}, status=422)
|
||||
|
||||
interface_details["target_host"] = data.get('target_host')
|
||||
interface_details["target_port"] = data.get('target_port')
|
||||
# set required TCPClientInterface options
|
||||
interface_details["target_host"] = interface_target_host
|
||||
interface_details["target_port"] = interface_target_port
|
||||
|
||||
# set optional TCPClientInterface options
|
||||
InterfaceEditor.update_value(interface_details, data, "kiss_framing")
|
||||
InterfaceEditor.update_value(interface_details, data, "i2p_tunneled")
|
||||
|
||||
# handle I2P interface
|
||||
if interface_type == "I2PInterface":
|
||||
interface_details['connectable'] = "True"
|
||||
interface_details["peers"] = data.get('peers')
|
||||
|
||||
# handle tcp server interface
|
||||
if interface_type == "TCPServerInterface":
|
||||
|
||||
interface_listen_ip = data.get('listen_ip')
|
||||
interface_listen_port = data.get('listen_port')
|
||||
|
||||
# ensure listen ip provided
|
||||
interface_listen_ip = data.get('listen_ip')
|
||||
if interface_listen_ip is None or interface_listen_ip == "":
|
||||
return web.json_response({
|
||||
"message": "Listen IP is required",
|
||||
}, status=422)
|
||||
|
||||
# ensure listen port provided
|
||||
interface_listen_port = data.get('listen_port')
|
||||
if interface_listen_port is None or interface_listen_port == "":
|
||||
return web.json_response({
|
||||
"message": "Listen Port is required",
|
||||
}, status=422)
|
||||
|
||||
interface_details["listen_ip"] = data.get('listen_ip')
|
||||
interface_details["listen_port"] = data.get('listen_port')
|
||||
# set required TCPServerInterface options
|
||||
interface_details["listen_ip"] = interface_listen_ip
|
||||
interface_details["listen_port"] = interface_listen_port
|
||||
|
||||
# set optional TCPServerInterface options
|
||||
InterfaceEditor.update_value(interface_details, data, "device")
|
||||
InterfaceEditor.update_value(interface_details, data, "prefer_ipv6")
|
||||
|
||||
# handle udp interface
|
||||
if interface_type == "UDPInterface":
|
||||
|
||||
interface_listen_ip = data.get('listen_ip')
|
||||
interface_listen_port = data.get('listen_port')
|
||||
interface_forward_ip = data.get('forward_ip')
|
||||
interface_forward_port = data.get('forward_port')
|
||||
|
||||
# ensure listen ip provided
|
||||
interface_listen_ip = data.get('listen_ip')
|
||||
if interface_listen_ip is None or interface_listen_ip == "":
|
||||
return web.json_response({
|
||||
"message": "Listen IP is required",
|
||||
}, status=422)
|
||||
|
||||
# ensure listen port provided
|
||||
interface_listen_port = data.get('listen_port')
|
||||
if interface_listen_port is None or interface_listen_port == "":
|
||||
return web.json_response({
|
||||
"message": "Listen Port is required",
|
||||
}, status=422)
|
||||
|
||||
# ensure forward ip provided
|
||||
interface_forward_ip = data.get('forward_ip')
|
||||
if interface_forward_ip is None or interface_forward_ip == "":
|
||||
return web.json_response({
|
||||
"message": "Forward IP is required",
|
||||
}, status=422)
|
||||
|
||||
# ensure forward port provided
|
||||
interface_forward_port = data.get('forward_port')
|
||||
if interface_forward_port is None or interface_forward_port == "":
|
||||
return web.json_response({
|
||||
"message": "Forward Port is required",
|
||||
}, status=422)
|
||||
|
||||
interface_details["listen_ip"] = data.get('listen_ip')
|
||||
interface_details["listen_port"] = data.get('listen_port')
|
||||
interface_details["forward_ip"] = data.get('forward_ip')
|
||||
interface_details["forward_port"] = data.get('forward_port')
|
||||
# set required UDPInterface options
|
||||
interface_details["listen_ip"] = interface_listen_ip
|
||||
interface_details["listen_port"] = interface_listen_port
|
||||
interface_details["forward_ip"] = interface_forward_ip
|
||||
interface_details["forward_port"] = interface_forward_port
|
||||
|
||||
# handle rnode interface
|
||||
# set optional UDPInterface options
|
||||
InterfaceEditor.update_value(interface_details, data, "device")
|
||||
|
||||
# handle RNodeInterface
|
||||
if interface_type == "RNodeInterface":
|
||||
|
||||
# ensure port provided
|
||||
interface_port = data.get('port')
|
||||
if interface_port is None or interface_port == "":
|
||||
return web.json_response({
|
||||
"message": "Port is required",
|
||||
}, status=422)
|
||||
|
||||
# ensure frequency provided
|
||||
interface_frequency = data.get('frequency')
|
||||
if interface_frequency is None or interface_frequency == "":
|
||||
return web.json_response({
|
||||
"message": "Frequency is required",
|
||||
}, status=422)
|
||||
|
||||
# ensure bandwidth provided
|
||||
interface_bandwidth = data.get('bandwidth')
|
||||
if interface_bandwidth is None or interface_bandwidth == "":
|
||||
return web.json_response({
|
||||
"message": "Bandwidth is required",
|
||||
}, status=422)
|
||||
|
||||
# ensure txpower provided
|
||||
interface_txpower = data.get('txpower')
|
||||
if interface_txpower is None or interface_txpower == "":
|
||||
return web.json_response({
|
||||
"message": "TX power is required",
|
||||
}, status=422)
|
||||
|
||||
# ensure spreading factor provided
|
||||
interface_spreadingfactor = data.get('spreadingfactor')
|
||||
if interface_spreadingfactor is None or interface_spreadingfactor == "":
|
||||
return web.json_response({
|
||||
"message": "Spreading Factor is required",
|
||||
}, status=422)
|
||||
|
||||
# ensure coding rate provided
|
||||
interface_codingrate = data.get('codingrate')
|
||||
if interface_codingrate is None or interface_codingrate == "":
|
||||
return web.json_response({
|
||||
"message": "Coding Rate is required",
|
||||
}, status=422)
|
||||
|
||||
# set required RNodeInterface options
|
||||
interface_details["port"] = interface_port
|
||||
interface_details["frequency"] = interface_frequency
|
||||
interface_details["bandwidth"] = interface_bandwidth
|
||||
interface_details["txpower"] = interface_txpower
|
||||
interface_details["spreadingfactor"] = interface_spreadingfactor
|
||||
interface_details["codingrate"] = interface_codingrate
|
||||
|
||||
# set optional RNodeInterface options
|
||||
InterfaceEditor.update_value(interface_details, data, "callsign")
|
||||
InterfaceEditor.update_value(interface_details, data, "id_interval")
|
||||
InterfaceEditor.update_value(interface_details, data, "airtime_limit_long")
|
||||
InterfaceEditor.update_value(interface_details, data, "airtime_limit_short")
|
||||
|
||||
# handle RNodeMultiInterface
|
||||
if interface_type == "RNodeMultiInterface":
|
||||
|
||||
# required settings
|
||||
interface_port = data.get("port")
|
||||
sub_interfaces = data.get("sub_interfaces", [])
|
||||
|
||||
# ensure port provided
|
||||
if interface_port is None or interface_port == "":
|
||||
|
|
@ -538,42 +647,114 @@ class ReticulumMeshChat:
|
|||
"message": "Port is required",
|
||||
}, status=422)
|
||||
|
||||
# ensure frequency provided
|
||||
if interface_frequency is None or interface_frequency == "":
|
||||
# ensure sub interfaces provided
|
||||
if not isinstance(sub_interfaces, list) or not sub_interfaces:
|
||||
return web.json_response({
|
||||
"message": "Frequency is required",
|
||||
}, status=422)
|
||||
|
||||
# ensure bandwidth provided
|
||||
if interface_bandwidth is None or interface_bandwidth == "":
|
||||
return web.json_response({
|
||||
"message": "Bandwidth is required",
|
||||
}, status=422)
|
||||
|
||||
# ensure txpower provided
|
||||
if interface_txpower is None or interface_txpower == "":
|
||||
return web.json_response({
|
||||
"message": "TX power is required",
|
||||
}, status=422)
|
||||
|
||||
# ensure spreading factor provided
|
||||
if interface_spreadingfactor is None or interface_spreadingfactor == "":
|
||||
return web.json_response({
|
||||
"message": "Spreading Factor is required",
|
||||
}, status=422)
|
||||
|
||||
# ensure coding rate provided
|
||||
if interface_codingrate is None or interface_codingrate == "":
|
||||
return web.json_response({
|
||||
"message": "Coding Rate is required",
|
||||
"message": "At least one sub-interface is required",
|
||||
}, status=422)
|
||||
|
||||
# set required RNodeMultiInterface options
|
||||
interface_details["port"] = interface_port
|
||||
interface_details["frequency"] = interface_frequency
|
||||
interface_details["bandwidth"] = interface_bandwidth
|
||||
interface_details["txpower"] = interface_txpower
|
||||
interface_details["spreadingfactor"] = interface_spreadingfactor
|
||||
interface_details["codingrate"] = interface_codingrate
|
||||
|
||||
# remove any existing sub interfaces, which can be found by finding keys that contain a dict value
|
||||
# this allows us to replace all sub interfaces with the ones we are about to add, while also ensuring
|
||||
# that we do not remove any existing config values from the main interface config
|
||||
for key in interface_details:
|
||||
value = interface_details[key]
|
||||
if isinstance(value, dict):
|
||||
del interface_details[key]
|
||||
|
||||
# process each provided sub interface
|
||||
for idx, sub_interface in enumerate(sub_interfaces):
|
||||
|
||||
# ensure required fields for sub-interface provided
|
||||
missing_fields = []
|
||||
required_subinterface_fields = ["name", "frequency", "bandwidth", "txpower", "spreadingfactor", "codingrate", "vport"]
|
||||
for field in required_subinterface_fields:
|
||||
if field not in sub_interface or sub_interface.get(field) is None or sub_interface.get(field) == "":
|
||||
missing_fields.append(field)
|
||||
if missing_fields:
|
||||
return web.json_response({
|
||||
"message": f"Sub-interface {idx + 1} is missing required field(s): {', '.join(missing_fields)}"
|
||||
}, status=422)
|
||||
|
||||
sub_interface_name = sub_interface.get("name")
|
||||
interface_details[sub_interface_name] = {
|
||||
"interface_enabled": "true",
|
||||
"frequency": int(sub_interface["frequency"]),
|
||||
"bandwidth": int(sub_interface["bandwidth"]),
|
||||
"txpower": int(sub_interface["txpower"]),
|
||||
"spreadingfactor": int(sub_interface["spreadingfactor"]),
|
||||
"codingrate": int(sub_interface["codingrate"]),
|
||||
"vport": int(sub_interface["vport"]),
|
||||
}
|
||||
|
||||
interfaces[interface_name] = interface_details
|
||||
|
||||
# handle SerialInterface, KISSInterface, and AX25KISSInterface
|
||||
if interface_type == "SerialInterface" or interface_type == "KISSInterface" or interface_type == "AX25KISSInterface":
|
||||
|
||||
# ensure port provided
|
||||
interface_port = data.get('port')
|
||||
if interface_port is None or interface_port == "":
|
||||
return web.json_response({
|
||||
"message": "Port is required",
|
||||
}, status=422)
|
||||
|
||||
# set required options
|
||||
interface_details["port"] = interface_port
|
||||
|
||||
# set optional options
|
||||
InterfaceEditor.update_value(interface_details, data, "speed")
|
||||
InterfaceEditor.update_value(interface_details, data, "databits")
|
||||
InterfaceEditor.update_value(interface_details, data, "parity")
|
||||
InterfaceEditor.update_value(interface_details, data, "stopbits")
|
||||
|
||||
# Handle KISS and AX25KISS specific options
|
||||
if interface_type == "KISSInterface" or interface_type == "AX25KISSInterface":
|
||||
|
||||
# set optional options
|
||||
InterfaceEditor.update_value(interface_details, data, "preamble")
|
||||
InterfaceEditor.update_value(interface_details, data, "txtail")
|
||||
InterfaceEditor.update_value(interface_details, data, "persistence")
|
||||
InterfaceEditor.update_value(interface_details, data, "slottime")
|
||||
InterfaceEditor.update_value(interface_details, data, "callsign")
|
||||
InterfaceEditor.update_value(interface_details, data, "ssid")
|
||||
|
||||
# FIXME: move to own sections
|
||||
# RNode Airtime limits and station ID
|
||||
InterfaceEditor.update_value(interface_details, data, "callsign")
|
||||
InterfaceEditor.update_value(interface_details, data, "id_interval")
|
||||
InterfaceEditor.update_value(interface_details, data, "airtime_limit_long")
|
||||
InterfaceEditor.update_value(interface_details, data, "airtime_limit_short")
|
||||
|
||||
# handle Pipe Interface
|
||||
if interface_type == "PipeInterface":
|
||||
|
||||
# ensure command provided
|
||||
interface_command = data.get('command')
|
||||
if interface_command is None or interface_command == "":
|
||||
return web.json_response({
|
||||
"message": "Command is required",
|
||||
}, status=422)
|
||||
|
||||
# ensure command provided
|
||||
interface_respawn_delay = data.get('respawn_delay')
|
||||
if interface_respawn_delay is None or interface_respawn_delay == "":
|
||||
return web.json_response({
|
||||
"message": "Respawn delay is required",
|
||||
}, status=422)
|
||||
|
||||
# set required options
|
||||
interface_details["command"] = interface_command
|
||||
interface_details["respawn_delay"] = interface_respawn_delay
|
||||
|
||||
# set common interface options
|
||||
InterfaceEditor.update_value(interface_details, data, "bitrate")
|
||||
InterfaceEditor.update_value(interface_details, data, "mode")
|
||||
InterfaceEditor.update_value(interface_details, data, "network_name")
|
||||
InterfaceEditor.update_value(interface_details, data, "passphrase")
|
||||
InterfaceEditor.update_value(interface_details, data, "ifac_size")
|
||||
|
||||
# merge new interface into existing interfaces
|
||||
interfaces[interface_name] = interface_details
|
||||
|
|
@ -617,9 +798,22 @@ class ReticulumMeshChat:
|
|||
# add interface to output
|
||||
output.append(f"[[{interface_name}]]")
|
||||
for key, value in interface.items():
|
||||
output.append(f" {key} = {value}")
|
||||
if not isinstance(value, dict):
|
||||
output.append(f" {key} = {value}")
|
||||
output.append("")
|
||||
|
||||
|
||||
# Handle sub-interfaces for RNodeMultiInterface
|
||||
if interface.get("type") == "RNodeMultiInterface":
|
||||
for sub_name, sub_config in interface.items():
|
||||
if sub_name in {"type", "port", "interface_enabled"}:
|
||||
continue
|
||||
if isinstance(sub_config, dict):
|
||||
output.append(f" [[[{sub_name}]]]")
|
||||
for sub_key, sub_value in sub_config.items():
|
||||
output.append(f" {sub_key} = {sub_value}")
|
||||
output.append("")
|
||||
|
||||
|
||||
return web.Response(
|
||||
text="\n".join(output),
|
||||
content_type="text/plain",
|
||||
|
|
@ -751,6 +945,7 @@ 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),
|
||||
|
|
@ -785,6 +980,10 @@ 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()
|
||||
|
|
@ -797,6 +996,10 @@ 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()
|
||||
|
|
@ -924,7 +1127,7 @@ class ReticulumMeshChat:
|
|||
def on_audio_packet(data):
|
||||
if websocket_response.closed is False:
|
||||
try:
|
||||
asyncio.run(websocket_response.send_bytes(data))
|
||||
AsyncUtils.run_async(websocket_response.send_bytes(data))
|
||||
except:
|
||||
# ignore errors sending audio packets to websocket
|
||||
pass
|
||||
|
|
@ -933,7 +1136,7 @@ class ReticulumMeshChat:
|
|||
def on_hangup():
|
||||
if websocket_response.closed is False:
|
||||
try:
|
||||
asyncio.run(websocket_response.close(code=WSCloseCode.GOING_AWAY))
|
||||
AsyncUtils.run_async(websocket_response.close(code=WSCloseCode.GOING_AWAY))
|
||||
except:
|
||||
# ignore errors closing websocket
|
||||
pass
|
||||
|
|
@ -1007,6 +1210,7 @@ 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
|
||||
|
|
@ -1020,6 +1224,10 @@ 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)
|
||||
|
|
@ -1036,6 +1244,98 @@ 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):
|
||||
|
|
@ -1433,6 +1733,10 @@ 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()
|
||||
|
|
@ -1561,6 +1865,30 @@ 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):
|
||||
|
|
@ -1724,6 +2052,9 @@ 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:
|
||||
|
|
@ -1863,6 +2194,7 @@ 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 "{}":
|
||||
|
|
@ -1878,17 +2210,20 @@ 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
|
||||
if _type == "config.set":
|
||||
elif _type == "config.set":
|
||||
|
||||
# get config from websocket
|
||||
config = data["config"]
|
||||
|
|
@ -1908,7 +2243,7 @@ class ReticulumMeshChat:
|
|||
|
||||
# handle successful file download
|
||||
def on_file_download_success(file_name, file_bytes):
|
||||
asyncio.run(client.send_str(json.dumps({
|
||||
AsyncUtils.run_async(client.send_str(json.dumps({
|
||||
"type": "nomadnet.file.download",
|
||||
"nomadnet_file_download": {
|
||||
"status": "success",
|
||||
|
|
@ -1921,7 +2256,7 @@ class ReticulumMeshChat:
|
|||
|
||||
# handle file download failure
|
||||
def on_file_download_failure(failure_reason):
|
||||
asyncio.create_task(client.send_str(json.dumps({
|
||||
AsyncUtils.run_async(client.send_str(json.dumps({
|
||||
"type": "nomadnet.file.download",
|
||||
"nomadnet_file_download": {
|
||||
"status": "failure",
|
||||
|
|
@ -1933,7 +2268,7 @@ class ReticulumMeshChat:
|
|||
|
||||
# handle file download progress
|
||||
def on_file_download_progress(progress):
|
||||
asyncio.run(client.send_str(json.dumps({
|
||||
AsyncUtils.run_async(client.send_str(json.dumps({
|
||||
"type": "nomadnet.file.download",
|
||||
"nomadnet_file_download": {
|
||||
"status": "progress",
|
||||
|
|
@ -1943,11 +2278,9 @@ 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)
|
||||
await downloader.download()
|
||||
AsyncUtils.run_async(downloader.download())
|
||||
|
||||
# handle downloading a page from a nomadnet node
|
||||
elif _type == "nomadnet.page.download":
|
||||
|
|
@ -1983,7 +2316,7 @@ class ReticulumMeshChat:
|
|||
|
||||
# handle successful page download
|
||||
def on_page_download_success(page_content):
|
||||
asyncio.run(client.send_str(json.dumps({
|
||||
AsyncUtils.run_async(client.send_str(json.dumps({
|
||||
"type": "nomadnet.page.download",
|
||||
"nomadnet_page_download": {
|
||||
"status": "success",
|
||||
|
|
@ -1995,7 +2328,7 @@ class ReticulumMeshChat:
|
|||
|
||||
# handle page download failure
|
||||
def on_page_download_failure(failure_reason):
|
||||
asyncio.create_task(client.send_str(json.dumps({
|
||||
AsyncUtils.run_async(client.send_str(json.dumps({
|
||||
"type": "nomadnet.page.download",
|
||||
"nomadnet_page_download": {
|
||||
"status": "failure",
|
||||
|
|
@ -2007,7 +2340,7 @@ class ReticulumMeshChat:
|
|||
|
||||
# handle page download progress
|
||||
def on_page_download_progress(progress):
|
||||
asyncio.run(client.send_str(json.dumps({
|
||||
AsyncUtils.run_async(client.send_str(json.dumps({
|
||||
"type": "nomadnet.page.download",
|
||||
"nomadnet_page_download": {
|
||||
"status": "progress",
|
||||
|
|
@ -2017,11 +2350,9 @@ 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)
|
||||
await downloader.download()
|
||||
AsyncUtils.run_async(downloader.download())
|
||||
|
||||
# unhandled type
|
||||
else:
|
||||
|
|
@ -2307,6 +2638,17 @@ 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):
|
||||
|
||||
|
|
@ -2393,7 +2735,7 @@ class ReticulumMeshChat:
|
|||
return
|
||||
|
||||
# send received lxmf message data to all websocket clients
|
||||
asyncio.run(self.websocket_broadcast(json.dumps({
|
||||
AsyncUtils.run_async(self.websocket_broadcast(json.dumps({
|
||||
"type": "lxmf.delivery",
|
||||
"lxmf_message": self.convert_db_lxmf_message_to_dict(db_lxmf_message),
|
||||
})))
|
||||
|
|
@ -2517,6 +2859,22 @@ 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):
|
||||
|
||||
|
|
@ -2653,7 +3011,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
|
||||
asyncio.create_task(self.handle_lxmf_message_progress(lxmf_message))
|
||||
AsyncUtils.run_async(self.handle_lxmf_message_progress(lxmf_message))
|
||||
|
||||
return lxmf_message
|
||||
|
||||
|
|
@ -2681,9 +3039,11 @@ 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:
|
||||
if has_delivered or has_propagated or has_failed or has_rejected or is_cancelled:
|
||||
should_update_message = False
|
||||
|
||||
# handle an announce received from reticulum, for an audio call address
|
||||
|
|
@ -2702,7 +3062,7 @@ class ReticulumMeshChat:
|
|||
return
|
||||
|
||||
# send database announce to all websocket clients
|
||||
asyncio.run(self.websocket_broadcast(json.dumps({
|
||||
AsyncUtils.run_async(self.websocket_broadcast(json.dumps({
|
||||
"type": "announce",
|
||||
"announce": self.convert_db_announce_to_dict(announce),
|
||||
})))
|
||||
|
|
@ -2723,14 +3083,14 @@ class ReticulumMeshChat:
|
|||
return
|
||||
|
||||
# send database announce to all websocket clients
|
||||
asyncio.run(self.websocket_broadcast(json.dumps({
|
||||
AsyncUtils.run_async(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():
|
||||
asyncio.run(self.resend_failed_messages_for_destination(destination_hash.hex()))
|
||||
AsyncUtils.run_async(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
|
||||
|
|
@ -2748,7 +3108,7 @@ class ReticulumMeshChat:
|
|||
return
|
||||
|
||||
# send database announce to all websocket clients
|
||||
asyncio.run(self.websocket_broadcast(json.dumps({
|
||||
AsyncUtils.run_async(self.websocket_broadcast(json.dumps({
|
||||
"type": "announce",
|
||||
"announce": self.convert_db_announce_to_dict(announce),
|
||||
})))
|
||||
|
|
@ -2832,7 +3192,7 @@ class ReticulumMeshChat:
|
|||
return
|
||||
|
||||
# send database announce to all websocket clients
|
||||
asyncio.run(self.websocket_broadcast(json.dumps({
|
||||
AsyncUtils.run_async(self.websocket_broadcast(json.dumps({
|
||||
"type": "announce",
|
||||
"announce": self.convert_db_announce_to_dict(announce),
|
||||
})))
|
||||
|
|
@ -3070,7 +3430,7 @@ class Config:
|
|||
nomadnet_cached_links = {}
|
||||
class NomadnetDownloader:
|
||||
|
||||
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):
|
||||
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):
|
||||
self.app_name = "nomadnetwork"
|
||||
self.aspects = "node"
|
||||
self.destination_hash = destination_hash
|
||||
|
|
@ -3152,8 +3512,8 @@ class NomadnetDownloader:
|
|||
)
|
||||
|
||||
# handle successful download
|
||||
def on_response(self, request_receipt):
|
||||
self.on_download_success(request_receipt.response)
|
||||
def on_response(self, request_receipt: RNS.RequestReceipt):
|
||||
self.on_download_success(request_receipt)
|
||||
|
||||
# handle failure
|
||||
def on_failed(self, request_receipt=None):
|
||||
|
|
@ -3172,8 +3532,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, response_bytes):
|
||||
micron_markup_response = response_bytes.decode("utf-8")
|
||||
def on_download_success(self, request_receipt: RNS.RequestReceipt):
|
||||
micron_markup_response = request_receipt.response.decode("utf-8")
|
||||
self.on_page_download_success(micron_markup_response)
|
||||
|
||||
# page download failed, send error to provided callback
|
||||
|
|
@ -3189,10 +3549,52 @@ 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, response):
|
||||
file_name: str = response[0]
|
||||
file_data: bytes = response[1]
|
||||
self.on_file_download_success(file_name, file_data)
|
||||
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")
|
||||
|
||||
# page download failed, send error to provided callback
|
||||
def on_download_failure(self, failure_reason):
|
||||
|
|
|
|||
35
package-lock.json
generated
35
package-lock.json
generated
|
|
@ -1,22 +1,23 @@
|
|||
{
|
||||
"name": "reticulum-meshchat",
|
||||
"version": "1.19.0",
|
||||
"version": "2.4.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "reticulum-meshchat",
|
||||
"version": "1.19.0",
|
||||
"version": "2.4.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.7.9",
|
||||
"axios": "^1.10.0",
|
||||
"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",
|
||||
|
|
@ -1405,6 +1406,12 @@
|
|||
"@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",
|
||||
|
|
@ -1908,9 +1915,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/axios": {
|
||||
"version": "1.7.9",
|
||||
"resolved": "https://registry.npmjs.org/axios/-/axios-1.7.9.tgz",
|
||||
"integrity": "sha512-LhLcE7Hbiryz8oMDdDptSrWowmB4Bl6RCt6sIJKpRB4XtVf0iEgewX3au/pJqm+Py1kCASkb/FFKjxQaLtxJvw==",
|
||||
"version": "1.10.0",
|
||||
"resolved": "https://registry.npmjs.org/axios/-/axios-1.10.0.tgz",
|
||||
"integrity": "sha512-/1xYAC4MP/HEG+3duIhFr4ZQXR4sQXOIe+o6sdqzeykGLx6Upp/1p8MHqhINOvGeP7xyNHe7tsiJByc4SSVUxw==",
|
||||
"dependencies": {
|
||||
"follow-redirects": "^1.15.6",
|
||||
"form-data": "^4.0.0",
|
||||
|
|
@ -2785,6 +2792,14 @@
|
|||
"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",
|
||||
|
|
@ -4168,6 +4183,14 @@
|
|||
"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",
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "reticulum-meshchat",
|
||||
"version": "1.19.0",
|
||||
"version": "2.4.0",
|
||||
"description": "",
|
||||
"main": "electron/main.js",
|
||||
"scripts": {
|
||||
|
|
@ -33,7 +33,7 @@
|
|||
"mac": {
|
||||
"target": "dmg",
|
||||
"identity": null,
|
||||
"artifactName": "ReticulumMeshChat-v${version}-${os}.${ext}",
|
||||
"artifactName": "ReticulumMeshChat-v${version}-${os}-${arch}.${ext}",
|
||||
"extendInfo": {
|
||||
"NSMicrophoneUsageDescription": "Microphone access is only needed for Audio Calls",
|
||||
"com.apple.security.device.audio-input": true
|
||||
|
|
@ -98,10 +98,11 @@
|
|||
"@tailwindcss/forms": "^0.5.9",
|
||||
"@vitejs/plugin-vue": "^5.2.1",
|
||||
"autoprefixer": "^10.4.20",
|
||||
"axios": "^1.7.9",
|
||||
"axios": "^1.10.0",
|
||||
"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",
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
aiohttp>=3.9.5
|
||||
aiohttp>=3.13.3
|
||||
cx_freeze>=7.0.0
|
||||
lxmf>=0.6.0
|
||||
peewee>=3.17.3
|
||||
rns>=0.9.1
|
||||
websockets>=12.0
|
||||
lxmf>=1.0.1
|
||||
peewee>=3.18.1,<4.0.0
|
||||
rns>=1.3.7
|
||||
websockets>=16.0
|
||||
|
|
|
|||
|
|
@ -1,25 +1,25 @@
|
|||
import asyncio
|
||||
from typing import Coroutine
|
||||
|
||||
|
||||
class AsyncUtils:
|
||||
|
||||
# this method allows running the provided async coroutine from within a sync function
|
||||
# it will run the async function on the existing event loop if available, otherwise it will start a new event loop
|
||||
# remember main loop
|
||||
main_loop: asyncio.AbstractEventLoop | None = None
|
||||
|
||||
@staticmethod
|
||||
def run_async(coroutine):
|
||||
def set_main_loop(loop: asyncio.AbstractEventLoop):
|
||||
AsyncUtils.main_loop = 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
|
||||
# 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
|
||||
@staticmethod
|
||||
def run_async(coroutine: Coroutine):
|
||||
|
||||
# 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)
|
||||
# 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)
|
||||
return
|
||||
|
||||
# otherwise start a new event loop to run the coroutine
|
||||
asyncio.run(coroutine)
|
||||
# main event loop not running...
|
||||
print("WARNING: Main event loop not available. Could not schedule task.")
|
||||
|
|
|
|||
|
|
@ -1,50 +1,31 @@
|
|||
import RNS.vendor.configobj
|
||||
|
||||
|
||||
class InterfaceConfigParser:
|
||||
|
||||
@staticmethod
|
||||
def parse(text):
|
||||
|
||||
# process config
|
||||
# get lines from provided text
|
||||
lines = text.splitlines()
|
||||
|
||||
# ensure [interfaces] section exists
|
||||
if "[interfaces]" not in lines:
|
||||
lines.insert(0, "[interfaces]")
|
||||
|
||||
# parse lines as rns config object
|
||||
config = RNS.vendor.configobj.ConfigObj(lines)
|
||||
|
||||
# get interfaces from config
|
||||
config_interfaces = config.get("interfaces")
|
||||
|
||||
# process interfaces
|
||||
interfaces = []
|
||||
current_interface = None
|
||||
for line in text.splitlines():
|
||||
for interface_name in config_interfaces:
|
||||
|
||||
# strip whitespace from either side of string
|
||||
line = line.strip()
|
||||
|
||||
# skip empty lines and commented out lines
|
||||
if not line or line.startswith("#"):
|
||||
continue
|
||||
|
||||
# check if we found a line containing an interface name
|
||||
if line.startswith("[[") and line.endswith("]]"):
|
||||
|
||||
# we found a new interface, so add the previously parsed one to the interfaces list
|
||||
if current_interface:
|
||||
interfaces.append(current_interface)
|
||||
|
||||
# get interface name by removing the "[[" and "]]"
|
||||
interface_name = line[2:-2]
|
||||
current_interface = {
|
||||
"name": interface_name,
|
||||
}
|
||||
|
||||
# we found a key=value line, so we will set these values on the interface
|
||||
elif current_interface is not None and "=" in line:
|
||||
|
||||
# parse key and value
|
||||
line_parts = line.split("=", 1)
|
||||
key = line_parts[0].strip()
|
||||
value = line_parts[1].strip().strip('"').strip("'")
|
||||
|
||||
# skip empty values or values equal to "None"
|
||||
if value is None or value == "None":
|
||||
continue
|
||||
|
||||
# set key/value on interface
|
||||
current_interface[key] = value
|
||||
|
||||
# add the final interface to the list
|
||||
if current_interface:
|
||||
interfaces.append(current_interface)
|
||||
# ensure interface has a name
|
||||
interface_config = config_interfaces[interface_name]
|
||||
interface_config["name"] = interface_name
|
||||
interfaces.append(interface_config)
|
||||
|
||||
return interfaces
|
||||
|
|
|
|||
14
src/backend/interface_editor.py
Normal file
14
src/backend/interface_editor.py
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
class InterfaceEditor:
|
||||
|
||||
@staticmethod
|
||||
def update_value(interface_details: dict, data: dict, key: str):
|
||||
|
||||
# update value if provided and not empty
|
||||
value = data.get(key)
|
||||
if value is not None and value != "":
|
||||
interface_details[key] = value
|
||||
return
|
||||
|
||||
# otherwise remove existing value
|
||||
if key in interface_details:
|
||||
del interface_details[key]
|
||||
134
src/backend/interfaces/WebsocketClientInterface.py
Normal file
134
src/backend/interfaces/WebsocketClientInterface.py
Normal file
|
|
@ -0,0 +1,134 @@
|
|||
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
|
||||
156
src/backend/interfaces/WebsocketServerInterface.py
Normal file
156
src/backend/interfaces/WebsocketServerInterface.py
Normal file
|
|
@ -0,0 +1,156 @@
|
|||
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
|
||||
|
|
@ -448,7 +448,7 @@ export default {
|
|||
|
||||
// ask to stop syncing if already syncing
|
||||
if(this.isSyncingPropagationNode){
|
||||
if(confirm("Are you sure you want to stop syncing?")){
|
||||
if(await DialogUtils.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(!confirm("Are you sure you want to hang up all incoming and outgoing calls?")){
|
||||
if(!await DialogUtils.confirm("Are you sure you want to hang up all incoming and outgoing calls?")){
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,11 +1,11 @@
|
|||
<template>
|
||||
<RouterLink :to="to" v-slot="{ href, route, navigate, isActive, isExactActive }" custom>
|
||||
<RouterLink :to="to" v-slot="{ href, route, navigate, isActive }" custom>
|
||||
<a
|
||||
:href="href"
|
||||
@click="handleNavigate($event, navigate)"
|
||||
type="button"
|
||||
:class="[
|
||||
isExactActive
|
||||
isActive
|
||||
? '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'
|
||||
]"
|
||||
|
|
|
|||
|
|
@ -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 }}
|
||||
MeshChat v{{ appInfo.version }} • RNS v{{ appInfo.rns_version }} • LXMF v{{ appInfo.lxmf_version }} • Python v{{ appInfo.python_version }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="hidden sm:block mx-2 my-auto">
|
||||
|
|
|
|||
|
|
@ -259,6 +259,7 @@
|
|||
|
||||
<script>
|
||||
import protobuf from "protobufjs";
|
||||
import DialogUtils from "../../js/DialogUtils";
|
||||
export default {
|
||||
name: 'CallPage',
|
||||
data() {
|
||||
|
|
@ -488,7 +489,7 @@ export default {
|
|||
async hangupCall(callHash) {
|
||||
|
||||
// confirm user wants to hang up call
|
||||
if(!confirm("Are you sure you want to hang up this call?")){
|
||||
if(!await DialogUtils.confirm("Are you sure you want to hang up this call?")){
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -681,7 +682,7 @@ export default {
|
|||
async deleteCall(callHash) {
|
||||
|
||||
// confirm user wants to delete call
|
||||
if(!confirm("Are you sure you want to delete this call?")){
|
||||
if(!await DialogUtils.confirm("Are you sure you want to delete this call?")){
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -701,7 +702,7 @@ export default {
|
|||
async clearCallHistory() {
|
||||
|
||||
// confirm user wants to clear call history
|
||||
if(!confirm("Are you sure you want to clear your call history?")){
|
||||
if(!await DialogUtils.confirm("Are you sure you want to clear your call history?")){
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
|
|||
10
src/frontend/components/forms/FormLabel.vue
Normal file
10
src/frontend/components/forms/FormLabel.vue
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
<template>
|
||||
<label class="block text-sm font-medium text-gray-900 dark:text-zinc-100">
|
||||
<slot/>
|
||||
</label>
|
||||
</template>
|
||||
<script>
|
||||
export default {
|
||||
name: 'FormLabel',
|
||||
}
|
||||
</script>
|
||||
10
src/frontend/components/forms/FormSubLabel.vue
Normal file
10
src/frontend/components/forms/FormSubLabel.vue
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
<template>
|
||||
<div class="text-xs text-gray-600 dark:text-zinc-300">
|
||||
<slot/>
|
||||
</div>
|
||||
</template>
|
||||
<script>
|
||||
export default {
|
||||
name: 'FormSubLabel',
|
||||
}
|
||||
</script>
|
||||
File diff suppressed because it is too large
Load diff
35
src/frontend/components/interfaces/ExpandingSection.vue
Normal file
35
src/frontend/components/interfaces/ExpandingSection.vue
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
<template>
|
||||
<div class="bg-white rounded shadow divide-y divide-gray-300 dark:divide-zinc-700 dark:bg-zinc-900 overflow-hidden">
|
||||
<div @click="isExpanded = !isExpanded" class="flex p-2 justify-between cursor-pointer hover:bg-gray-50 dark:hover:bg-zinc-800">
|
||||
<div class="my-auto mr-auto">
|
||||
<div class="font-bold dark:text-white">
|
||||
<slot name="title"/>
|
||||
</div>
|
||||
<div class="text-sm text-gray-600 dark:text-gray-300">
|
||||
<slot name="subtitle"/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="my-auto ml-2">
|
||||
<div class="w-5 h-5 text-gray-600 dark:text-gray-300 transform transition-transform duration-200" :class="{ 'rotate-90': isExpanded }">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" class="size-5">
|
||||
<rect width="256" height="256" fill="none"/>
|
||||
<path d="M181.66,122.34l-80-80A8,8,0,0,0,88,48V208a8,8,0,0,0,13.66,5.66l80-80A8,8,0,0,0,181.66,122.34Z" fill="currentColor"/>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="isExpanded" class="divide-y divide-gray-200 dark:text-white">
|
||||
<slot name="content"/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<script>
|
||||
export default {
|
||||
name: 'ExpandingSection',
|
||||
data() {
|
||||
return {
|
||||
isExpanded: false,
|
||||
};
|
||||
},
|
||||
}
|
||||
</script>
|
||||
|
|
@ -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> with sig <span @click="onIFACSignatureClick(iface._stats.ifac_signature)" class="cursor-pointer"><{{ iface._stats.ifac_signature.slice(0, 6) }}...{{ iface._stats.ifac_signature.slice(-6) }}></span>
|
||||
<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"><{{ iface._stats.ifac_signature.slice(0, 6) }}...{{ iface._stats.ifac_signature.slice(-6) }}></span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -36,6 +36,14 @@
|
|||
<path d="M252.44,121.34l-48-32A8,8,0,0,0,192,96v24H72V72h33a32,32,0,1,0,0-16H72A16,16,0,0,0,56,72v48H8a8,8,0,0,0,0,16H56v48a16,16,0,0,0,16,16h32v8a16,16,0,0,0,16,16h32a16,16,0,0,0,16-16V176a16,16,0,0,0-16-16H120a16,16,0,0,0-16,16v8H72V136H192v24a8,8,0,0,0,12.44,6.66l48-32a8,8,0,0,0,0-13.32ZM136,48a16,16,0,1,1-16,16A16,16,0,0,1,136,48ZM120,176h32v32H120Zm88-30.95V111l25.58,17Z"></path>
|
||||
</svg>
|
||||
|
||||
<svg v-else-if="iface.type === 'RNodeMultiInterface'" xmlns="http://www.w3.org/2000/svg" fill="currentColor" viewBox="0 0 256 256" class="size-6 dark:text-white"><path d="M128,88a40,40,0,1,0,40,40A40,40,0,0,0,128,88Zm0,64a24,24,0,1,1,24-24A24,24,0,0,1,128,152Zm73.71,7.14a80,80,0,0,1-14.08,22.2,8,8,0,0,1-11.92-10.67,63.95,63.95,0,0,0,0-85.33,8,8,0,1,1,11.92-10.67,80.08,80.08,0,0,1,14.08,84.47ZM69,103.09a64,64,0,0,0,11.26,67.58,8,8,0,0,1-11.92,10.67,79.93,79.93,0,0,1,0-106.67A8,8,0,1,1,80.29,85.34,63.77,63.77,0,0,0,69,103.09ZM248,128a119.58,119.58,0,0,1-34.29,84,8,8,0,1,1-11.42-11.2,103.9,103.9,0,0,0,0-145.56A8,8,0,1,1,213.71,44,119.58,119.58,0,0,1,248,128ZM53.71,200.78A8,8,0,1,1,42.29,212a119.87,119.87,0,0,1,0-168,8,8,0,1,1,11.42,11.2,103.9,103.9,0,0,0,0,145.56Z"></path></svg>
|
||||
|
||||
<svg v-else-if="iface.type === 'I2PInterface'" xmlns="http://www.w3.org/2000/svg" fill="currentColor" viewBox="0 0 256 256" class="size-6 dark:text-white"><path d="M72,92A12,12,0,1,1,60,80,12,12,0,0,1,72,92Zm56-12a12,12,0,1,0,12,12A12,12,0,0,0,128,80Zm68,24a12,12,0,1,0-12-12A12,12,0,0,0,196,104ZM60,152a12,12,0,1,0,12,12A12,12,0,0,0,60,152Zm68,0a12,12,0,1,0,12,12A12,12,0,0,0,128,152Zm68,0a12,12,0,1,0,12,12A12,12,0,0,0,196,152Z"></path></svg>
|
||||
|
||||
<svg v-else-if="iface.type === 'KISSInterface' || iface.type === 'AX25KISSInterface'" xmlns="http://www.w3.org/2000/svg" fill="currentColor" viewBox="0 0 256 256" class="size-6 dark:text-white"><path d="M104,168a8,8,0,0,1-8,8H64a8,8,0,0,1,0-16H96A8,8,0,0,1,104,168Zm-8-40H64a8,8,0,0,0,0,16H96a8,8,0,0,0,0-16Zm0-32H64a8,8,0,0,0,0,16H96a8,8,0,0,0,0-16ZM232,80V192a16,16,0,0,1-16,16H40a16,16,0,0,1-16-16V72a8,8,0,0,1,5.7-7.66l160-48a8,8,0,0,1,4.6,15.33L86.51,64H216A16,16,0,0,1,232,80ZM216,192V80H40V192H216Zm-16-56a40,40,0,1,1-40-40A40,40,0,0,1,200,136Zm-16,0a24,24,0,1,0-24,24A24,24,0,0,0,184,136Z"></path></svg>
|
||||
|
||||
<svg v-else-if="iface.type === 'PipeInterface'" xmlns="http://www.w3.org/2000/svg" fill="currentColor" viewBox="0 0 256 256" class="size-6 dark:text-white"><path d="M128,128a8,8,0,0,1-3,6.25l-40,32a8,8,0,1,1-10-12.5L107.19,128,75,102.25a8,8,0,1,1,10-12.5l40,32A8,8,0,0,1,128,128Zm48,24H136a8,8,0,0,0,0,16h40a8,8,0,0,0,0-16Zm56-96V200a16,16,0,0,1-16,16H40a16,16,0,0,1-16-16V56A16,16,0,0,1,40,40H216A16,16,0,0,1,232,56ZM216,200V56H40V200H216Z"></path></svg>
|
||||
|
||||
<svg v-else xmlns="http://www.w3.org/2000/svg" fill="currentColor" viewBox="0 0 256 256" class="size-6 dark:text-white">
|
||||
<path d="M140,180a12,12,0,1,1-12-12A12,12,0,0,1,140,180ZM128,72c-22.06,0-40,16.15-40,36v4a8,8,0,0,0,16,0v-4c0-11,10.77-20,24-20s24,9,24,20-10.77,20-24,20a8,8,0,0,0-8,8v8a8,8,0,0,0,16,0v-.72c18.24-3.35,32-17.9,32-35.28C168,88.15,150.06,72,128,72Zm104,56A104,104,0,1,1,128,24,104.11,104.11,0,0,1,232,128Zm-16,0a88,88,0,1,0-88,88A88.1,88.1,0,0,0,216,128Z"></path>
|
||||
</svg>
|
||||
|
|
@ -186,7 +194,11 @@
|
|||
<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._stats?.clients">• Clients: {{ iface._stats?.clients }}</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>
|
||||
|
||||
|
|
|
|||
|
|
@ -141,57 +141,13 @@ export default {
|
|||
// update data
|
||||
const interfaces = response.data.interface_stats?.interfaces ?? [];
|
||||
for(const iface of interfaces){
|
||||
this.interfaceStats[iface.name] = iface;
|
||||
this.interfaceStats[iface.short_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
|
||||
|
|
@ -235,7 +191,7 @@ export default {
|
|||
async deleteInterface(interfaceName) {
|
||||
|
||||
// ask user to confirm deleting conversation history
|
||||
if(!confirm("Are you sure you want to delete this interface? This can not be undone!")){
|
||||
if(!await DialogUtils.confirm("Are you sure you want to delete this interface? This can not be undone!")){
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -301,7 +257,7 @@ export default {
|
|||
const results = [];
|
||||
for(const [interfaceName, iface] of Object.entries(this.interfaces)){
|
||||
iface._name = interfaceName;
|
||||
iface._stats = this.findInterfaceStats(interfaceName);
|
||||
iface._stats = this.interfaceStats[interfaceName];
|
||||
results.push(iface);
|
||||
}
|
||||
return results;
|
||||
|
|
|
|||
|
|
@ -73,7 +73,7 @@ export default {
|
|||
async onDeleteMessageHistory() {
|
||||
|
||||
// ask user to confirm deleting conversation history
|
||||
if(!confirm("Are you sure you want to delete all messages in this conversation? This can not be undone!")){
|
||||
if(!await DialogUtils.confirm("Are you sure you want to delete all messages in this conversation? This can not be undone!")){
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -316,6 +316,7 @@
|
|||
|
||||
<!-- text input -->
|
||||
<textarea
|
||||
ref="message-input"
|
||||
id="message-input"
|
||||
:readonly="isSendingMessage"
|
||||
v-model="newMessageText"
|
||||
|
|
@ -996,7 +997,7 @@ export default {
|
|||
try {
|
||||
|
||||
// ask user to confirm deleting message
|
||||
if(shouldConfirm && !confirm("Are you sure you want to delete this message? This can not be undone!")){
|
||||
if(shouldConfirm && !await DialogUtils.confirm("Are you sure you want to delete this message? This can not be undone!")){
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -1055,7 +1056,11 @@ export default {
|
|||
if(this.newMessageImage){
|
||||
imageTotalSize = this.newMessageImage.size;
|
||||
fields["image"] = {
|
||||
// Reticulum sends image type as "jpg" or "png" and not "image/jpg" or "image/png"
|
||||
// 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
|
||||
"image_type": this.newMessageImage.type.replace("image/", ""),
|
||||
"image_bytes": Utils.arrayBufferToBase64(await this.newMessageImage.arrayBuffer()),
|
||||
};
|
||||
|
|
@ -1077,7 +1082,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(!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(!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?`)){
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
|
@ -1208,10 +1213,10 @@ export default {
|
|||
clearFileInput: function() {
|
||||
this.$refs["file-input"].value = null;
|
||||
},
|
||||
removeImageAttachment: function() {
|
||||
async removeImageAttachment() {
|
||||
|
||||
// ask user to confirm removing image attachment
|
||||
if(!confirm("Are you sure you want to remove this image attachment?")){
|
||||
if(!await DialogUtils.confirm("Are you sure you want to remove this image attachment?")){
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -1243,7 +1248,7 @@ export default {
|
|||
}
|
||||
|
||||
// ask user to confirm recording new audio attachment, if an existing audio attachment exists
|
||||
if(this.newMessageAudio && !confirm("An audio recording is already attached. A new recording will replace it. Do you want to continue?")){
|
||||
if(this.newMessageAudio && !await DialogUtils.confirm("An audio recording is already attached. A new recording will replace it. Do you want to continue?")){
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -1385,10 +1390,10 @@ export default {
|
|||
}
|
||||
|
||||
},
|
||||
removeAudioAttachment: function() {
|
||||
async removeAudioAttachment() {
|
||||
|
||||
// ask user to confirm removing audio attachment
|
||||
if(!confirm("Are you sure you want to remove this audio attachment?")){
|
||||
if(!await DialogUtils.confirm("Are you sure you want to remove this audio attachment?")){
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -1402,7 +1407,22 @@ export default {
|
|||
});
|
||||
},
|
||||
addNewLine: function() {
|
||||
this.newMessageText += "\n";
|
||||
|
||||
// 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;
|
||||
});
|
||||
|
||||
},
|
||||
onEnterPressed: function() {
|
||||
|
||||
|
|
|
|||
|
|
@ -15,7 +15,7 @@
|
|||
:my-lxmf-address-hash="config?.lxmf_address_hash"
|
||||
:selected-peer="selectedPeer"
|
||||
:conversations="conversations"
|
||||
@close="selectedPeer = null"
|
||||
@close="onCloseConversationViewer"
|
||||
@reload-conversations="getConversations"/>
|
||||
|
||||
</div>
|
||||
|
|
@ -38,6 +38,9 @@ export default {
|
|||
ConversationViewer,
|
||||
MessagesSidebar,
|
||||
},
|
||||
props: {
|
||||
destinationHash: String,
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
|
||||
|
|
@ -76,6 +79,11 @@ 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) {
|
||||
|
|
@ -93,6 +101,9 @@ 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){
|
||||
|
|
@ -165,6 +176,28 @@ 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`);
|
||||
|
|
@ -178,7 +211,18 @@ 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) {
|
||||
|
||||
|
|
@ -189,6 +233,17 @@ 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() {
|
||||
|
|
|
|||
|
|
@ -165,6 +165,60 @@ 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();
|
||||
|
||||
|
|
@ -358,6 +412,9 @@ export default {
|
|||
|
||||
}
|
||||
|
||||
// attach announce to this node
|
||||
node._announce = announce;
|
||||
|
||||
// add node
|
||||
nodes.push(node);
|
||||
|
||||
|
|
|
|||
|
|
@ -3,23 +3,62 @@
|
|||
<!-- nomadnetwork sidebar -->
|
||||
<NomadNetworkSidebar
|
||||
:nodes="nodes"
|
||||
:favourites="favourites"
|
||||
:selected-destination-hash="selectedNode?.destination_hash"
|
||||
@node-click="onNodeClick"/>
|
||||
@node-click="onNodeClick"
|
||||
@rename-favourite="onRenameFavourite"
|
||||
@remove-favourite="onRemoveFavourite"/>
|
||||
|
||||
<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>
|
||||
|
||||
<!-- close button -->
|
||||
<!-- identify button -->
|
||||
<div class="my-auto ml-auto mr-2">
|
||||
<div @click="selectedNode = null" class="cursor-pointer">
|
||||
<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="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">
|
||||
|
|
@ -33,7 +72,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, '/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">
|
||||
<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">
|
||||
<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>
|
||||
|
|
@ -43,6 +82,11 @@
|
|||
<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" />
|
||||
|
|
@ -69,7 +113,7 @@
|
|||
</div>
|
||||
<div class="my-auto">Loading {{ nodePageProgress }}%</div>
|
||||
</div>
|
||||
<pre v-else v-html="nodePageContent" class="h-full text-wrap"></pre>
|
||||
<pre v-else v-html="renderedNodePageContent()" class="h-full break-words whitespace-pre-wrap"></pre>
|
||||
</div>
|
||||
|
||||
<!-- file download bottom bar -->
|
||||
|
|
@ -130,7 +174,7 @@ pre a:hover {
|
|||
|
||||
<script>
|
||||
|
||||
import MicronParser from "../../js/MicronParser";
|
||||
import MicronParser from "micron-parser";
|
||||
import DialogUtils from "../../js/DialogUtils";
|
||||
import WebSocketConnection from "../../js/WebSocketConnection";
|
||||
import NomadNetworkSidebar from "./NomadNetworkSidebar.vue";
|
||||
|
|
@ -141,14 +185,23 @@ 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,
|
||||
|
|
@ -157,7 +210,6 @@ export default {
|
|||
nodePagePathHistory: [],
|
||||
nodePageCache: {},
|
||||
|
||||
|
||||
isDownloadingNodeFile: false,
|
||||
nodeFilePath: null,
|
||||
nodeFileProgress: 0,
|
||||
|
|
@ -168,23 +220,59 @@ 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);
|
||||
|
||||
// fixme: this is called by the micron-parser.js
|
||||
window.onNodePageUrlClick = (url, options = null) => {
|
||||
this.onNodePageUrlClick(url, options);
|
||||
};
|
||||
// listen for element clicks
|
||||
window.document.addEventListener('click', this.onElementClick);
|
||||
|
||||
// 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){
|
||||
|
|
@ -272,6 +360,54 @@ 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 {
|
||||
|
||||
|
|
@ -279,6 +415,7 @@ export default {
|
|||
const response = await window.axios.get(`/api/v1/announces`, {
|
||||
params: {
|
||||
aspect: "nomadnetwork.node",
|
||||
limit: 500, // limit ui to showing 500 latest announces
|
||||
},
|
||||
});
|
||||
|
||||
|
|
@ -293,6 +430,28 @@ 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;
|
||||
},
|
||||
|
|
@ -310,6 +469,14 @@ 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;
|
||||
|
||||
|
|
@ -343,6 +510,7 @@ 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;
|
||||
}
|
||||
|
|
@ -351,31 +519,21 @@ 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;
|
||||
}
|
||||
|
||||
// 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 page content
|
||||
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
|
||||
|
|
@ -409,6 +567,35 @@ 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, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """)
|
||||
.replace(/'/g, "'");
|
||||
|
||||
},
|
||||
toggleNodePageSource() {
|
||||
this.isShowingNodePageSource = !this.isShowingNodePageSource;
|
||||
},
|
||||
async reloadNodePage() {
|
||||
|
||||
// reload current node page without adding to history and without using cache
|
||||
|
|
@ -435,9 +622,9 @@ export default {
|
|||
// remove leading ":"
|
||||
var path = url.substring(1);
|
||||
|
||||
// if page path is empty we should load "/page/index.mu"
|
||||
// if page path is empty we should load default page path
|
||||
if(path === ""){
|
||||
path = "/page/index.mu";
|
||||
path = this.defaultNodePagePath;
|
||||
}
|
||||
|
||||
return {
|
||||
|
|
@ -467,7 +654,7 @@ export default {
|
|||
if(url.length === 32){
|
||||
return {
|
||||
destination_hash: url,
|
||||
path: "/page/index.mu",
|
||||
path: this.defaultNodePagePath,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -539,8 +726,12 @@ export default {
|
|||
if(url.startsWith("lxmf@")){
|
||||
const destinationHash = url.replace("lxmf@", "");
|
||||
if(destinationHash.length === 32){
|
||||
await this.$router.push({ name: "messages" });
|
||||
GlobalEmitter.emit("compose-new-message", destinationHash);
|
||||
await this.$router.push({
|
||||
name: "messages",
|
||||
params: {
|
||||
destinationHash: destinationHash,
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
|
@ -639,8 +830,58 @@ export default {
|
|||
|
||||
},
|
||||
onNodeClick: function(node) {
|
||||
|
||||
// update selected node
|
||||
this.selectedNode = node;
|
||||
this.loadNodePage(node.destination_hash, "/page/index.mu");
|
||||
|
||||
// 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",
|
||||
});
|
||||
|
||||
},
|
||||
getNomadnetPageDownloadCallbackKey: function(destinationHash, pagePath) {
|
||||
return `${destinationHash}:${pagePath}`;
|
||||
|
|
@ -666,6 +907,24 @@ 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 {
|
||||
|
||||
|
|
@ -713,6 +972,9 @@ export default {
|
|||
console.error(e);
|
||||
}
|
||||
},
|
||||
renderedNodePageContent() {
|
||||
return this.renderPageContent(this.nodePagePath, this.nodePageContent);
|
||||
},
|
||||
},
|
||||
}
|
||||
</script>
|
||||
|
|
|
|||
|
|
@ -1,6 +1,99 @@
|
|||
<template>
|
||||
<div class="flex flex-col w-80 min-w-80">
|
||||
<div class="flex-1 flex flex-col bg-white dark:bg-zinc-950 border-r dark:border-zinc-800 overflow-hidden">
|
||||
|
||||
<!-- 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">
|
||||
<!-- search -->
|
||||
<div v-if="nodesCount > 0" class="p-1 border-b border-gray-300 dark:border-zinc-800">
|
||||
<input
|
||||
|
|
@ -58,6 +151,7 @@
|
|||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
|
@ -65,16 +159,22 @@
|
|||
|
||||
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: {MaterialDesignIcon},
|
||||
components: {DropDownMenuItem, IconButton, DropDownMenu, MaterialDesignIcon},
|
||||
props: {
|
||||
nodes: Object,
|
||||
favourites: Array,
|
||||
selectedDestinationHash: String,
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
tab: "favourites",
|
||||
favouritesSearchTerm: "",
|
||||
nodesSearchTerm: "",
|
||||
};
|
||||
},
|
||||
|
|
@ -82,9 +182,21 @@ 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() {
|
||||
|
|
@ -107,6 +219,15 @@ 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>
|
||||
|
|
|
|||
|
|
@ -145,7 +145,7 @@ export default {
|
|||
}
|
||||
|
||||
// confirm user wants to update their icon
|
||||
if(!confirm("Are you sure you want to set this as your profile icon?")){
|
||||
if(!await DialogUtils.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(!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(!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.")){
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -10,6 +10,16 @@ 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
|
||||
|
|
|
|||
|
|
@ -2,6 +2,13 @@ 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){
|
||||
|
|
@ -143,7 +150,7 @@ class Utils {
|
|||
|
||||
static isInterfaceEnabled(iface) {
|
||||
const rawValue = iface.enabled ?? iface.interface_enabled;
|
||||
const value = rawValue?.toLowerCase();
|
||||
const value = rawValue?.toString()?.toLowerCase();
|
||||
return value === "on" || value === "yes" || value === "true";
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -3,8 +3,18 @@ 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
|
||||
|
|
@ -47,6 +57,16 @@ class WebSocketConnection {
|
|||
}
|
||||
}
|
||||
|
||||
ping() {
|
||||
try {
|
||||
this.send(JSON.stringify({
|
||||
"type": "ping",
|
||||
}));
|
||||
} catch(e) {
|
||||
// ignore error
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
export default new WebSocketConnection();
|
||||
|
|
|
|||
|
|
@ -46,7 +46,8 @@ const router = createRouter({
|
|||
},
|
||||
{
|
||||
name: "messages",
|
||||
path: '/messages',
|
||||
path: '/messages/:destinationHash?',
|
||||
props: true,
|
||||
component: defineAsyncComponent(() => import("./components/messages/MessagesPage.vue")),
|
||||
},
|
||||
{
|
||||
|
|
@ -56,7 +57,8 @@ const router = createRouter({
|
|||
},
|
||||
{
|
||||
name: "nomadnetwork",
|
||||
path: '/nomadnetwork',
|
||||
path: '/nomadnetwork/:destinationHash?',
|
||||
props: true,
|
||||
component: defineAsyncComponent(() => import("./components/nomadnetwork/NomadNetworkPage.vue")),
|
||||
},
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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">: {{ recommendedFirmwareFilename }}</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>
|
||||
</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,6 +312,48 @@
|
|||
|
||||
</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 -->
|
||||
|
|
@ -334,6 +376,8 @@
|
|||
data() {
|
||||
return {
|
||||
|
||||
rnode: null,
|
||||
|
||||
isFlashing: false,
|
||||
flashingProgress: 0,
|
||||
|
||||
|
|
@ -397,6 +441,22 @@
|
|||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
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,
|
||||
|
|
@ -587,6 +647,21 @@
|
|||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
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",
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
|
|
@ -714,11 +789,11 @@
|
|||
platform: ROM.PLATFORM_NRF52,
|
||||
models: [
|
||||
{
|
||||
id: ROM.MODEL_T4,
|
||||
id: ROM.MODEL_16,
|
||||
name: "433 MHz",
|
||||
},
|
||||
{
|
||||
id: ROM.MODEL_T9,
|
||||
id: ROM.MODEL_17,
|
||||
name: "868 MHz / 915 MHz / 923 MHz",
|
||||
},
|
||||
],
|
||||
|
|
@ -858,11 +933,37 @@
|
|||
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() {
|
||||
|
||||
|
|
@ -1069,17 +1170,9 @@
|
|||
},
|
||||
async detect() {
|
||||
|
||||
// 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!");
|
||||
// ask for rnode
|
||||
const rnode = await this.askForRNode();
|
||||
if(!rnode){
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -1122,17 +1215,9 @@
|
|||
},
|
||||
async reboot() {
|
||||
|
||||
// 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!");
|
||||
// ask for rnode
|
||||
const rnode = await this.askForRNode();
|
||||
if(!rnode){
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -1210,17 +1295,9 @@
|
|||
},
|
||||
async readDisplay() {
|
||||
|
||||
// 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!");
|
||||
// ask for rnode
|
||||
const rnode = await this.askForRNode();
|
||||
if(!rnode){
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -1236,17 +1313,9 @@
|
|||
},
|
||||
async dumpEeprom() {
|
||||
|
||||
// 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!");
|
||||
// ask for rnode
|
||||
const rnode = await this.askForRNode();
|
||||
if(!rnode){
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -1264,22 +1333,15 @@
|
|||
},
|
||||
async wipeEeprom() {
|
||||
|
||||
// ask for serial port
|
||||
const serialPort = await this.askForSerialPort();
|
||||
if(!serialPort){
|
||||
// ask for rnode
|
||||
const rnode = await this.askForRNode();
|
||||
if(!rnode){
|
||||
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.")){
|
||||
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;
|
||||
}
|
||||
|
||||
|
|
@ -1298,18 +1360,9 @@
|
|||
},
|
||||
async provision() {
|
||||
|
||||
// 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();
|
||||
// ask for rnode
|
||||
const rnode = await this.askForRNode();
|
||||
if(!rnode){
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -1489,17 +1542,9 @@
|
|||
},
|
||||
async setFirmwareHash() {
|
||||
|
||||
// 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!");
|
||||
// ask for rnode
|
||||
const rnode = await this.askForRNode();
|
||||
if(!rnode){
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -1546,17 +1591,9 @@
|
|||
},
|
||||
async enableTncMode() {
|
||||
|
||||
// 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!");
|
||||
// ask for rnode
|
||||
const rnode = await this.askForRNode();
|
||||
if(!rnode){
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -1602,17 +1639,9 @@
|
|||
},
|
||||
async disableTncMode() {
|
||||
|
||||
// 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!");
|
||||
// ask for rnode
|
||||
const rnode = await this.askForRNode();
|
||||
if(!rnode){
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -1643,17 +1672,9 @@
|
|||
},
|
||||
async enableBluetooth() {
|
||||
|
||||
// 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!");
|
||||
// ask for rnode
|
||||
const rnode = await this.askForRNode();
|
||||
if(!rnode){
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -1671,26 +1692,18 @@
|
|||
await rnode.enableBluetooth();
|
||||
console.log("enabling bluetooth: done");
|
||||
|
||||
await Utils.sleepMillis(1000);
|
||||
alert("Bluetooth has been enabled!");
|
||||
|
||||
// done
|
||||
await Utils.sleepMillis(1000);
|
||||
await rnode.close();
|
||||
alert("Bluetooth has been enabled!");
|
||||
|
||||
},
|
||||
async disableBluetooth() {
|
||||
|
||||
// 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!");
|
||||
// ask for rnode
|
||||
const rnode = await this.askForRNode();
|
||||
if(!rnode){
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -1707,27 +1720,18 @@
|
|||
console.log("disabling bluetooth");
|
||||
await rnode.disableBluetooth();
|
||||
console.log("disabling bluetooth: done");
|
||||
|
||||
await Utils.sleepMillis(1000);
|
||||
alert("Bluetooth has been disabled!");
|
||||
|
||||
// done
|
||||
await Utils.sleepMillis(1000);
|
||||
await rnode.close();
|
||||
alert("Bluetooth has been disabled!");
|
||||
|
||||
},
|
||||
async startBluetoothPairing() {
|
||||
|
||||
// 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!");
|
||||
// ask for rnode
|
||||
const rnode = await this.askForRNode();
|
||||
if(!rnode){
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -1743,13 +1747,72 @@
|
|||
// start bluetooth pairing
|
||||
try {
|
||||
console.log("start bluetooth pairing");
|
||||
const pin = await rnode.startBluetoothPairing();
|
||||
await rnode.startBluetoothPairing(async (pin) => {
|
||||
alert("Bluetooth Pairing Pin: " + pin);
|
||||
await rnode.close();
|
||||
});
|
||||
console.log("start bluetooth pairing: done");
|
||||
} catch(error) {
|
||||
alert(error);
|
||||
}
|
||||
|
||||
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.");
|
||||
// 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");
|
||||
|
||||
// done
|
||||
await rnode.close();
|
||||
|
|
|
|||
|
|
@ -79,6 +79,8 @@ 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;
|
||||
|
|
@ -121,8 +123,10 @@ class RNode {
|
|||
|
||||
constructor(serialPort) {
|
||||
this.serialPort = serialPort;
|
||||
this.readable = serialPort.readable;
|
||||
this.reader = serialPort.readable.getReader();
|
||||
this.writable = serialPort.writable;
|
||||
this.callbacks = {};
|
||||
this.readLoop();
|
||||
}
|
||||
|
||||
static async fromSerialPort(serialPort) {
|
||||
|
|
@ -137,11 +141,21 @@ 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) {
|
||||
|
|
@ -153,79 +167,100 @@ class RNode {
|
|||
}
|
||||
}
|
||||
|
||||
async readFromSerialPort(timeoutMillis) {
|
||||
return new Promise(async (resolve, reject) => {
|
||||
async readLoop() {
|
||||
try {
|
||||
let buffer = [];
|
||||
let inFrame = false;
|
||||
while(true){
|
||||
|
||||
// create reader
|
||||
const reader = this.readable.getReader();
|
||||
// read kiss frames until reader indicates it's done
|
||||
const { value, done } = await this.reader.read();
|
||||
if(done){
|
||||
break;
|
||||
}
|
||||
|
||||
// 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
|
||||
// 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.");
|
||||
}
|
||||
buffer = [];
|
||||
}
|
||||
inFrame = !inFrame;
|
||||
} else if(inFrame) {
|
||||
buffer.push(byte);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error reading from serial port: ', error);
|
||||
} finally {
|
||||
reader.releaseLock();
|
||||
|
||||
}
|
||||
} catch(error) {
|
||||
|
||||
// ignore error if reader was released
|
||||
if(error instanceof TypeError){
|
||||
return;
|
||||
}
|
||||
|
||||
});
|
||||
console.error('Error reading from serial port: ', error);
|
||||
|
||||
} finally {
|
||||
this.reader.releaseLock();
|
||||
}
|
||||
}
|
||||
|
||||
handleKISSFrame(frame) {
|
||||
onCommandReceived(data) {
|
||||
try {
|
||||
|
||||
let data = [];
|
||||
// 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 escaping = false;
|
||||
|
||||
// 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) {
|
||||
for(const byte of frame){
|
||||
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 {
|
||||
if (byte === this.KISS_FESC) {
|
||||
escaping = true;
|
||||
} else if (byte === this.KISS_FEND) {
|
||||
// Ignore the end frame delimiter
|
||||
break;
|
||||
} else {
|
||||
data.push(byte);
|
||||
}
|
||||
data.push(byte);
|
||||
}
|
||||
}
|
||||
|
||||
//console.log('Received KISS frame data:', new Uint8Array(data));
|
||||
return data;
|
||||
// return null if incomplete escape at end
|
||||
return escaping ? null : data;
|
||||
|
||||
}
|
||||
|
||||
|
|
@ -248,6 +283,28 @@ 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,
|
||||
|
|
@ -256,30 +313,42 @@ class RNode {
|
|||
}
|
||||
|
||||
async detect() {
|
||||
return new Promise(async (resolve) => {
|
||||
try {
|
||||
|
||||
// ask if device is rnode
|
||||
await this.sendKissCommand([
|
||||
this.CMD_DETECT,
|
||||
this.DETECT_REQ,
|
||||
]);
|
||||
// timeout after provided millis
|
||||
const timeout = setTimeout(() => {
|
||||
resolve(false);
|
||||
}, 2000);
|
||||
|
||||
// read response from device
|
||||
const [ command, responseByte ] = await this.readFromSerialPort();
|
||||
// detect rnode
|
||||
const response = await this.sendCommand(this.CMD_DETECT, [
|
||||
this.DETECT_REQ,
|
||||
]);
|
||||
|
||||
// device is an rnode if response is as expected
|
||||
return command === this.CMD_DETECT && responseByte === this.DETECT_RESP;
|
||||
// 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);
|
||||
}
|
||||
});
|
||||
|
||||
}
|
||||
|
||||
async getFirmwareVersion() {
|
||||
|
||||
await this.sendKissCommand([
|
||||
this.CMD_FW_VERSION,
|
||||
const response = await this.sendCommand(this.CMD_FW_VERSION, [
|
||||
0x00,
|
||||
]);
|
||||
|
||||
// read response from device
|
||||
var [ command, majorVersion, minorVersion ] = await this.readFromSerialPort();
|
||||
var [ majorVersion, minorVersion ] = response;
|
||||
if(minorVersion.length === 1){
|
||||
minorVersion = "0" + minorVersion;
|
||||
}
|
||||
|
|
@ -291,99 +360,91 @@ class RNode {
|
|||
|
||||
async getPlatform() {
|
||||
|
||||
await this.sendKissCommand([
|
||||
this.CMD_PLATFORM,
|
||||
const response = await this.sendCommand(this.CMD_PLATFORM, [
|
||||
0x00,
|
||||
]);
|
||||
|
||||
// read response from device
|
||||
const [ command, platformByte ] = await this.readFromSerialPort();
|
||||
const [ platformByte ] = response;
|
||||
return platformByte;
|
||||
|
||||
}
|
||||
|
||||
async getMcu() {
|
||||
|
||||
await this.sendKissCommand([
|
||||
this.CMD_MCU,
|
||||
const response = await this.sendCommand(this.CMD_MCU, [
|
||||
0x00,
|
||||
]);
|
||||
|
||||
// read response from device
|
||||
const [ command, mcuByte ] = await this.readFromSerialPort();
|
||||
const [ mcuByte ] = response;
|
||||
return mcuByte;
|
||||
|
||||
}
|
||||
|
||||
async getBoard() {
|
||||
|
||||
await this.sendKissCommand([
|
||||
this.CMD_BOARD,
|
||||
const response = await this.sendCommand(this.CMD_BOARD, [
|
||||
0x00,
|
||||
]);
|
||||
|
||||
// read response from device
|
||||
const [ command, boardByte ] = await this.readFromSerialPort();
|
||||
const [ boardByte ] = response;
|
||||
return boardByte;
|
||||
|
||||
}
|
||||
|
||||
async getDeviceHash() {
|
||||
|
||||
await this.sendKissCommand([
|
||||
this.CMD_DEV_HASH,
|
||||
const response = await this.sendCommand(this.CMD_DEV_HASH, [
|
||||
0x01, // anything != 0x00
|
||||
]);
|
||||
|
||||
// read response from device
|
||||
const [ command, ...deviceHash ] = await this.readFromSerialPort();
|
||||
const [ ...deviceHash ] = response;
|
||||
return deviceHash;
|
||||
|
||||
}
|
||||
|
||||
async getTargetFirmwareHash() {
|
||||
|
||||
await this.sendKissCommand([
|
||||
this.CMD_HASHES,
|
||||
const response = await this.sendCommand(this.CMD_HASHES, [
|
||||
this.HASH_TYPE_TARGET_FIRMWARE,
|
||||
]);
|
||||
|
||||
// read response from device
|
||||
const [ command, hashType, ...targetFirmwareHash ] = await this.readFromSerialPort();
|
||||
const [ hashType, ...targetFirmwareHash ] = response;
|
||||
return targetFirmwareHash;
|
||||
|
||||
}
|
||||
|
||||
async getFirmwareHash() {
|
||||
|
||||
await this.sendKissCommand([
|
||||
this.CMD_HASHES,
|
||||
const response = await this.sendCommand(this.CMD_HASHES, [
|
||||
this.HASH_TYPE_FIRMWARE,
|
||||
]);
|
||||
|
||||
// read response from device
|
||||
const [ command, hashType, ...firmwareHash ] = await this.readFromSerialPort();
|
||||
const [ hashType, ...firmwareHash ] = response;
|
||||
return firmwareHash;
|
||||
|
||||
}
|
||||
|
||||
async getRom() {
|
||||
|
||||
await this.sendKissCommand([
|
||||
this.CMD_ROM_READ,
|
||||
const response = await this.sendCommand(this.CMD_ROM_READ, [
|
||||
0x00,
|
||||
]);
|
||||
|
||||
// read response from device
|
||||
const [ command, ...eepromBytes ] = await this.readFromSerialPort();
|
||||
const [ ...eepromBytes ] = response;
|
||||
return eepromBytes;
|
||||
|
||||
}
|
||||
|
||||
async getFrequency() {
|
||||
|
||||
await this.sendKissCommand([
|
||||
this.CMD_FREQUENCY,
|
||||
const response = await this.sendCommand(this.CMD_FREQUENCY, [
|
||||
// request frequency by sending zero as 4 bytes
|
||||
0x00,
|
||||
0x00,
|
||||
|
|
@ -392,7 +453,7 @@ class RNode {
|
|||
]);
|
||||
|
||||
// read response from device
|
||||
const [ command, ...frequencyBytes ] = await this.readFromSerialPort();
|
||||
const [ ...frequencyBytes ] = response;
|
||||
|
||||
// convert 4 bytes to 32bit integer representing frequency in hertz
|
||||
const frequencyInHz = frequencyBytes[0] << 24 | frequencyBytes[1] << 16 | frequencyBytes[2] << 8 | frequencyBytes[3];
|
||||
|
|
@ -402,8 +463,7 @@ class RNode {
|
|||
|
||||
async getBandwidth() {
|
||||
|
||||
await this.sendKissCommand([
|
||||
this.CMD_BANDWIDTH,
|
||||
const response = await this.sendCommand(this.CMD_BANDWIDTH, [
|
||||
// request bandwidth by sending zero as 4 bytes
|
||||
0x00,
|
||||
0x00,
|
||||
|
|
@ -412,7 +472,7 @@ class RNode {
|
|||
]);
|
||||
|
||||
// read response from device
|
||||
const [ command, ...bandwidthBytes ] = await this.readFromSerialPort();
|
||||
const [ ...bandwidthBytes ] = response;
|
||||
|
||||
// convert 4 bytes to 32bit integer representing bandwidth in hertz
|
||||
const bandwidthInHz = bandwidthBytes[0] << 24 | bandwidthBytes[1] << 16 | bandwidthBytes[2] << 8 | bandwidthBytes[3];
|
||||
|
|
@ -422,69 +482,60 @@ class RNode {
|
|||
|
||||
async getTxPower() {
|
||||
|
||||
await this.sendKissCommand([
|
||||
this.CMD_TXPOWER,
|
||||
const response = await this.sendCommand(this.CMD_TXPOWER, [
|
||||
0xFF, // request tx power
|
||||
]);
|
||||
|
||||
// read response from device
|
||||
const [ command, txPower ] = await this.readFromSerialPort();
|
||||
|
||||
const [ txPower ] = response;
|
||||
return txPower;
|
||||
|
||||
}
|
||||
|
||||
async getSpreadingFactor() {
|
||||
|
||||
await this.sendKissCommand([
|
||||
this.CMD_SF,
|
||||
const response = await this.sendCommand(this.CMD_SF, [
|
||||
0xFF, // request spreading factor
|
||||
]);
|
||||
|
||||
// read response from device
|
||||
const [ command, spreadingFactor ] = await this.readFromSerialPort();
|
||||
|
||||
const [ spreadingFactor ] = response;
|
||||
return spreadingFactor;
|
||||
|
||||
}
|
||||
|
||||
async getCodingRate() {
|
||||
|
||||
await this.sendKissCommand([
|
||||
this.CMD_CR,
|
||||
const response = await this.sendCommand(this.CMD_CR, [
|
||||
0xFF, // request coding rate
|
||||
]);
|
||||
|
||||
// read response from device
|
||||
const [ command, codingRate ] = await this.readFromSerialPort();
|
||||
|
||||
const [ codingRate ] = response;
|
||||
return codingRate;
|
||||
|
||||
}
|
||||
|
||||
async getRadioState() {
|
||||
|
||||
await this.sendKissCommand([
|
||||
this.CMD_RADIO_STATE,
|
||||
const response = await this.sendCommand(this.CMD_RADIO_STATE, [
|
||||
0xFF, // request radio state
|
||||
]);
|
||||
|
||||
// read response from device
|
||||
const [ command, radioState ] = await this.readFromSerialPort();
|
||||
|
||||
const [ radioState ] = response;
|
||||
return radioState;
|
||||
|
||||
}
|
||||
|
||||
async getRxStat() {
|
||||
|
||||
await this.sendKissCommand([
|
||||
this.CMD_STAT_RX,
|
||||
const response = await this.sendCommand(this.CMD_STAT_RX, [
|
||||
0x00,
|
||||
]);
|
||||
|
||||
// read response from device
|
||||
const [ command, ...statBytes ] = await this.readFromSerialPort();
|
||||
const [ ...statBytes ] = response;
|
||||
|
||||
// convert 4 bytes to 32bit integer
|
||||
const stat = statBytes[0] << 24 | statBytes[1] << 16 | statBytes[2] << 8 | statBytes[3];
|
||||
|
|
@ -494,13 +545,12 @@ class RNode {
|
|||
|
||||
async getTxStat() {
|
||||
|
||||
await this.sendKissCommand([
|
||||
this.CMD_STAT_TX,
|
||||
const response = await this.sendCommand(this.CMD_STAT_TX, [
|
||||
0x00,
|
||||
]);
|
||||
|
||||
// read response from device
|
||||
const [ command, ...statBytes ] = await this.readFromSerialPort();
|
||||
const [ ...statBytes ] = response;
|
||||
|
||||
// convert 4 bytes to 32bit integer
|
||||
const stat = statBytes[0] << 24 | statBytes[1] << 16 | statBytes[2] << 8 | statBytes[3];
|
||||
|
|
@ -510,14 +560,12 @@ class RNode {
|
|||
|
||||
async getRssiStat() {
|
||||
|
||||
await this.sendKissCommand([
|
||||
this.CMD_STAT_RSSI,
|
||||
const response = await this.sendCommand(this.CMD_STAT_RSSI, [
|
||||
0x00,
|
||||
]);
|
||||
|
||||
// read response from device
|
||||
const [ command, rssi ] = await this.readFromSerialPort();
|
||||
|
||||
const [ rssi ] = response;
|
||||
return rssi;
|
||||
|
||||
}
|
||||
|
|
@ -536,7 +584,23 @@ class RNode {
|
|||
]);
|
||||
}
|
||||
|
||||
async startBluetoothPairing() {
|
||||
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);
|
||||
|
||||
};
|
||||
|
||||
// enable pairing
|
||||
await this.sendKissCommand([
|
||||
|
|
@ -544,43 +608,16 @@ 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() {
|
||||
|
||||
await this.sendKissCommand([
|
||||
this.CMD_DISP_READ,
|
||||
const response = await this.sendCommand(this.CMD_DISP_READ, [
|
||||
0x01,
|
||||
]);
|
||||
|
||||
// read response from device
|
||||
const [ command, ...displayBuffer ] = await this.readFromSerialPort();
|
||||
|
||||
const [ ...displayBuffer ] = response;
|
||||
return displayBuffer;
|
||||
|
||||
}
|
||||
|
|
@ -715,6 +752,20 @@ 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 {
|
||||
|
|
@ -743,6 +794,7 @@ 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
|
||||
|
|
@ -766,6 +818,10 @@ 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
|
||||
|
|
@ -781,8 +837,8 @@ class ROM {
|
|||
static MODEL_D9 = 0xD9;
|
||||
|
||||
static PRODUCT_TECHO = 0x15;
|
||||
static MODEL_T4 = 0x16;
|
||||
static MODEL_T9 = 0x17;
|
||||
static MODEL_16 = 0x16;
|
||||
static MODEL_17 = 0x17;
|
||||
|
||||
static PRODUCT_HMBRW = 0xF0
|
||||
static MODEL_FF = 0xFF
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue