diff --git a/README.md b/README.md index 84b5582..9656150 100644 --- a/README.md +++ b/README.md @@ -41,6 +41,7 @@ A simple mesh network communications app powered by the [Reticulum Network Stack - Supports auto resending undelivered messages when an announce is received from the recipient. - Supports sending messages to and syncing messages from [LXMF Propagation Nodes](https://github.com/markqvist/lxmf?tab=readme-ov-file#propagation-nodes). - Supports running a local LXMF Propagation Node so other users can use your device for message storage and retrieval. +- Support for browsing pages, and downloading files hosted on Nomad Network Nodes. ## Beta Features @@ -49,9 +50,6 @@ A simple mesh network communications app powered by the [Reticulum Network Stack - Using a microphone requires using the web ui over localhost or https, due to [AudioWorklet](https://developer.mozilla.org/en-US/docs/Web/API/AudioWorklet) secure context. - I have tested two-way audio calls over LoRa with a single hop. It works well when a [reasonable bitrate](https://unsigned.io/understanding-lora-parameters/) is configured on the RNode. - Some browsers such as FireFox don't work as expected. Try using a Chromium based browser if running via the command line. -- Support for browsing pages, and downloading files hosted on Nomad Network Nodes. - -> NOTE: micron format parsing is still in development, some pages may not render or work correctly at all. ## Download @@ -374,7 +372,6 @@ I build the vite app everytime without hot reload, since MeshChat expects everyt - [ ] button to forget announces - [ ] optimise ui to work nicely on a mobile device, such as Android/iOS - [ ] will probably write a new app for mobile devices once [microReticulum](https://github.com/attermann/microReticulum) supports Links -- [ ] support for micron input fields, to allow interacting with pages like Retipedia - [ ] support for managing Reticulum interfaces via the web ui - [x] AutoInterface - [x] RNodeInterface @@ -395,7 +392,7 @@ I build the vite app everytime without hot reload, since MeshChat expects everyt **LXMF Router** - By default, the LXMF router rejects inbound messages larger than 1mb. -- LXMF clients are likely to have [this default limit](https://github.com/markqvist/LXMF/blob/master/LXMF/LXMRouter.py#L35), and your messages will [fail to send](https://github.com/markqvist/LXMF/blob/master/LXMF/LXMRouter.py#L1026). +- LXMF clients are likely to have [this default limit](https://github.com/markqvist/LXMF/blob/c426c93cc5d63a3dae18ad2264b1299a7ad9e46c/LXMF/LXMRouter.py#L38), and your messages will [fail to send](https://github.com/markqvist/LXMF/blob/c426c93cc5d63a3dae18ad2264b1299a7ad9e46c/LXMF/LXMRouter.py#L1428). - MeshChat has increased the receive limit to 10mb to allow for larger attachments. ## License diff --git a/database.py b/database.py index 7ab9369..11a7427 100644 --- a/database.py +++ b/database.py @@ -3,7 +3,7 @@ from datetime import datetime, timezone from peewee import * from playhouse.migrate import migrate as migrate_database, SqliteMigrator -latest_version = 4 # increment each time new database migrations are added +latest_version = 5 # increment each time new database migrations are added database = DatabaseProxy() # use a proxy object, as we will init real db client inside meshchat.py migrator = SqliteMigrator(database) @@ -32,6 +32,14 @@ def migrate(current_version): migrator.add_column("lxmf_messages", 'method', LxmfMessage.method), ) + # migrate to version 5 + if current_version < 5: + migrate_database( + migrator.add_column("announces", 'rssi', Announce.rssi), + migrator.add_column("announces", 'snr', Announce.snr), + migrator.add_column("announces", 'quality', Announce.quality), + ) + return latest_version @@ -61,6 +69,9 @@ class Announce(BaseModel): identity_hash = CharField(index=True) # identity hash that announced the destination identity_public_key = CharField() # base64 encoded public key, incase we want to recreate the identity manually app_data = TextField(null=True) # base64 encoded app data bytes + rssi = IntegerField(null=True) + snr = FloatField(null=True) + quality = FloatField(null=True) created_at = DateTimeField(default=lambda: datetime.now(timezone.utc)) updated_at = DateTimeField(default=lambda: datetime.now(timezone.utc)) diff --git a/electron/main.js b/electron/main.js index cb7c7b5..3084f39 100644 --- a/electron/main.js +++ b/electron/main.js @@ -108,6 +108,26 @@ app.whenReady().then(async () => { }, }); + // open external links in default web browser instead of electron + mainWindow.webContents.setWindowOpenHandler(({ url }) => { + + // 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")){ + return { + action: "allow", + }; + } + + // fallback to opening any other url in external browser + shell.openExternal(url); + return { + action: "deny", + }; + + }); + // navigate to loading page await mainWindow.loadFile(path.join(__dirname, 'loading.html')); diff --git a/meshchat.py b/meshchat.py index 3ac195e..718dcd9 100644 --- a/meshchat.py +++ b/meshchat.py @@ -1047,6 +1047,83 @@ class ReticulumMeshChat: }, }) + # drop path to destination + @routes.post("/api/v1/destination/{destination_hash}/drop-path") + 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) + + # drop path + self.reticulum.drop_path(destination_hash) + + return web.json_response({ + "message": "Path has been dropped", + }) + + # get signal metrics for a destination by checking the latest announce or lxmf message received from them + @routes.get("/api/v1/destination/{destination_hash}/signal-metrics") + async def index(request): + + # get path params + destination_hash = request.match_info.get("destination_hash", "") + + # signal metrics to return + snr = None + rssi = None + quality = None + updated_at = None + + # get latest announce from database for the provided destination hash + latest_announce = (database.Announce.select() + .where(database.Announce.destination_hash == destination_hash) + .get_or_none()) + + # get latest lxmf message from database sent to us from the provided destination hash + latest_lxmf_message = (database.LxmfMessage.select() + .where(database.LxmfMessage.destination_hash == self.local_lxmf_destination.hexhash) + .where(database.LxmfMessage.source_hash == destination_hash) + .order_by(database.LxmfMessage.id.desc()) + .get_or_none()) + + # determine when latest announce was received + latest_announce_at = None + if latest_announce is not None: + latest_announce_at = datetime.fromisoformat(latest_announce.updated_at) + + # determine when latest lxmf message was received + latest_lxmf_message_at = None + if latest_lxmf_message is not None: + latest_lxmf_message_at = datetime.fromisoformat(latest_lxmf_message.created_at) + + # get signal metrics from latest announce + if latest_announce is not None: + snr = latest_announce.snr + rssi = latest_announce.rssi + quality = latest_announce.quality + # using updated_at from announce because this is when the latest announce was received + updated_at = latest_announce.updated_at + + # get signal metrics from latest lxmf message if it's more recent than the announce + if latest_lxmf_message is not None and latest_lxmf_message_at > latest_announce_at: + snr = latest_lxmf_message.snr + rssi = latest_lxmf_message.rssi + quality = latest_lxmf_message.quality + # using created_at from lxmf message because this is when the message was received + updated_at = latest_lxmf_message.created_at + + return web.json_response({ + "signal_metrics": { + "snr": snr, + "rssi": rssi, + "quality": quality, + "updated_at": updated_at, + }, + }) + # pings an lxmf.delivery destination by sending empty data and waiting for the recipient to send a proof back # the lxmf router proves all received packets, then drops them if they can't be decoded as lxmf messages # this allows us to ping/probe any active lxmf.delivery destination and get rtt/snr/rssi data on demand @@ -1103,8 +1180,9 @@ class ReticulumMeshChat: "message": f"Ping failed. Timed out after {timeout_seconds} seconds.", }, status=503) - # get number of hops to destination - hops = RNS.Transport.hops_to(destination_hash) + # get number of hops to destination and back from destination + hops_there = RNS.Transport.hops_to(destination_hash) + hops_back = receipt.proof_packet.hops # get rssi rssi = receipt.proof_packet.rssi @@ -1127,13 +1205,15 @@ class ReticulumMeshChat: rtt_duration_string = f"{rtt_milliseconds} ms" return web.json_response({ - "message": f"Valid reply from {receipt.destination.hash.hex()}: hops={hops} time={rtt_duration_string}", + "message": f"Valid reply from {receipt.destination.hash.hex()}\nDuration: {rtt_duration_string}\nHops There: {hops_there}\nHops Back: {hops_back}", "ping_result": { "rtt": rtt, - "hops": hops, + "hops_there": hops_there, + "hops_back": hops_back, "rssi": rssi, "snr": snr, "quality": quality, + "receiving_interface": str(receipt.proof_packet.receiving_interface), }, }) @@ -2009,6 +2089,19 @@ class ReticulumMeshChat: elif announce.aspect == "nomadnetwork.node": display_name = self.parse_nomadnetwork_node_display_name(announce.app_data) + # find lxmf user icon from database + lxmf_user_icon = None + db_lxmf_user_icon = database.LxmfUserIcon.get_or_none(database.LxmfUserIcon.destination_hash == announce.destination_hash) + if db_lxmf_user_icon is not None: + lxmf_user_icon = { + "icon_name": db_lxmf_user_icon.icon_name, + "foreground_colour": db_lxmf_user_icon.foreground_colour, + "background_colour": db_lxmf_user_icon.background_colour, + } + + # get current hops away + hops = RNS.Transport.hops_to(bytes.fromhex(announce.destination_hash)) + return { "id": announce.id, "destination_hash": announce.destination_hash, @@ -2016,8 +2109,13 @@ class ReticulumMeshChat: "identity_hash": announce.identity_hash, "identity_public_key": announce.identity_public_key, "app_data": announce.app_data, + "hops": hops, + "rssi": announce.rssi, + "snr": announce.snr, + "quality": announce.quality, "display_name": display_name, "custom_display_name": self.get_custom_destination_display_name(announce.destination_hash), + "lxmf_user_icon": lxmf_user_icon, "created_at": announce.created_at, "updated_at": announce.updated_at, } @@ -2175,7 +2273,12 @@ class ReticulumMeshChat: query.execute() # upserts the provided announce to the database - def db_upsert_announce(self, identity: RNS.Identity, destination_hash: bytes, aspect: str, app_data: bytes): + def db_upsert_announce(self, identity: RNS.Identity, destination_hash: bytes, aspect: str, app_data: bytes, announce_packet_hash: bytes): + + # get rssi, snr and signal quality if available + rssi = self.reticulum.get_packet_rssi(announce_packet_hash) + snr = self.reticulum.get_packet_snr(announce_packet_hash) + quality = self.reticulum.get_packet_q(announce_packet_hash) # prepare data to insert or update data = { @@ -2183,6 +2286,9 @@ class ReticulumMeshChat: "aspect": aspect, "identity_hash": identity.hash.hex(), "identity_public_key": base64.b64encode(identity.get_public_key()).decode("utf-8"), + "rssi": rssi, + "snr": snr, + "quality": quality, "updated_at": datetime.now(timezone.utc), } @@ -2366,13 +2472,13 @@ class ReticulumMeshChat: # handle an announce received from reticulum, for an audio call address # NOTE: cant be async, as Reticulum doesn't await it - def on_audio_call_announce_received(self, aspect, destination_hash, announced_identity, app_data): + def on_audio_call_announce_received(self, aspect, destination_hash, announced_identity, app_data, announce_packet_hash): # log received announce print("Received an announce from " + RNS.prettyhexrep(destination_hash) + " for [call.audio]") # upsert announce to database - self.db_upsert_announce(announced_identity, destination_hash, aspect, app_data) + self.db_upsert_announce(announced_identity, destination_hash, aspect, app_data, announce_packet_hash) # find announce from database announce = database.Announce.get_or_none(database.Announce.destination_hash == destination_hash.hex()) @@ -2387,13 +2493,13 @@ class ReticulumMeshChat: # handle an announce received from reticulum, for an lxmf address # NOTE: cant be async, as Reticulum doesn't await it - def on_lxmf_announce_received(self, aspect, destination_hash, announced_identity, app_data): + def on_lxmf_announce_received(self, aspect, destination_hash, announced_identity, app_data, announce_packet_hash): # log received announce print("Received an announce from " + RNS.prettyhexrep(destination_hash) + " for [lxmf.delivery]") # upsert announce to database - self.db_upsert_announce(announced_identity, destination_hash, aspect, app_data) + self.db_upsert_announce(announced_identity, destination_hash, aspect, app_data, announce_packet_hash) # find announce from database announce = database.Announce.get_or_none(database.Announce.destination_hash == destination_hash.hex()) @@ -2412,13 +2518,13 @@ class ReticulumMeshChat: # handle an announce received from reticulum, for an lxmf propagation node address # NOTE: cant be async, as Reticulum doesn't await it - def on_lxmf_propagation_announce_received(self, aspect, destination_hash, announced_identity, app_data): + def on_lxmf_propagation_announce_received(self, aspect, destination_hash, announced_identity, app_data, announce_packet_hash): # log received announce print("Received an announce from " + RNS.prettyhexrep(destination_hash) + " for [lxmf.propagation]") # upsert announce to database - self.db_upsert_announce(announced_identity, destination_hash, aspect, app_data) + self.db_upsert_announce(announced_identity, destination_hash, aspect, app_data, announce_packet_hash) # find announce from database announce = database.Announce.get_or_none(database.Announce.destination_hash == destination_hash.hex()) @@ -2496,13 +2602,13 @@ class ReticulumMeshChat: # handle an announce received from reticulum, for a nomadnet node # NOTE: cant be async, as Reticulum doesn't await it - def on_nomadnet_node_announce_received(self, aspect, destination_hash, announced_identity, app_data): + def on_nomadnet_node_announce_received(self, aspect, destination_hash, announced_identity, app_data, announce_packet_hash): # log received announce print("Received an announce from " + RNS.prettyhexrep(destination_hash) + " for [nomadnetwork.node]") # upsert announce to database - self.db_upsert_announce(announced_identity, destination_hash, aspect, app_data) + self.db_upsert_announce(announced_identity, destination_hash, aspect, app_data, announce_packet_hash) # find announce from database announce = database.Announce.get_or_none(database.Announce.destination_hash == destination_hash.hex()) diff --git a/package-lock.json b/package-lock.json index ba3d5fa..99a8aae 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "reticulum-meshchat", - "version": "1.14.0", + "version": "1.16.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "reticulum-meshchat", - "version": "1.14.0", + "version": "1.16.0", "license": "MIT", "dependencies": { "@mdi/js": "^7.4.47", diff --git a/package.json b/package.json index 6781521..270953a 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "reticulum-meshchat", - "version": "1.14.0", + "version": "1.16.0", "description": "", "main": "electron/main.js", "scripts": { diff --git a/src/backend/announce_handler.py b/src/backend/announce_handler.py index 5d2cd3a..c322d47 100644 --- a/src/backend/announce_handler.py +++ b/src/backend/announce_handler.py @@ -7,10 +7,10 @@ class AnnounceHandler: self.received_announce_callback = received_announce_callback # we will just pass the received announce back to the provided callback - def received_announce(self, destination_hash, announced_identity, app_data): + def received_announce(self, destination_hash, announced_identity, app_data, announce_packet_hash): try: # handle received announce - self.received_announce_callback(self.aspect_filter, destination_hash, announced_identity, app_data) + self.received_announce_callback(self.aspect_filter, destination_hash, announced_identity, app_data, announce_packet_hash) except: # ignore failure to handle received announce pass diff --git a/src/frontend/components/App.vue b/src/frontend/components/App.vue index 8653391..0300143 100644 --- a/src/frontend/components/App.vue +++ b/src/frontend/components/App.vue @@ -2,12 +2,10 @@
-
+
-