Merge branch 'liamcottle:master' into docker-image
This commit is contained in:
@@ -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
|
||||
|
||||
13
database.py
13
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))
|
||||
|
||||
@@ -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'));
|
||||
|
||||
|
||||
132
meshchat.py
132
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())
|
||||
|
||||
4
package-lock.json
generated
4
package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "reticulum-meshchat",
|
||||
"version": "1.14.0",
|
||||
"version": "1.16.0",
|
||||
"description": "",
|
||||
"main": "electron/main.js",
|
||||
"scripts": {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -2,12 +2,10 @@
|
||||
<div :class="{'dark': config?.theme === 'dark'}" class="h-screen w-full flex flex-col">
|
||||
|
||||
<!-- header -->
|
||||
<div class="flex bg-white dark:bg-zinc-950 p-2 border-gray-300 dark:border-zinc-900 border-b">
|
||||
<div class="flex bg-white dark:bg-zinc-950 p-2 border-gray-300 dark:border-zinc-900 border-b min-h-16">
|
||||
<div class="flex w-full">
|
||||
<div class="hidden sm:flex my-auto border border-gray-300 rounded-md w-10 h-10 mr-3 shadow bg-gray-50">
|
||||
<div class="flex mx-auto my-auto">
|
||||
<img class="w-9 h-9" src="/assets/images/logo.png" />
|
||||
</div>
|
||||
<div class="hidden sm:flex my-auto w-12 h-12 mr-2">
|
||||
<img class="w-12 h-12" src="/assets/images/logo-chat-bubble.png" />
|
||||
</div>
|
||||
<div class="my-auto">
|
||||
<div @click="onAppNameClick" class="font-bold cursor-pointer text-gray-900 dark:text-zinc-100">Reticulum MeshChat</div>
|
||||
@@ -103,6 +101,18 @@
|
||||
</SidebarLink>
|
||||
</li>
|
||||
|
||||
<!-- tools -->
|
||||
<li>
|
||||
<SidebarLink :to="{ name: 'tools' }">
|
||||
<template v-slot:icon>
|
||||
<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.42 15.17 17.25 21A2.652 2.652 0 0 0 21 17.25l-5.877-5.877M11.42 15.17l2.496-3.03c.317-.384.74-.626 1.208-.766M11.42 15.17l-4.655 5.653a2.548 2.548 0 1 1-3.586-3.586l6.837-5.63m5.108-.233c.55-.164 1.163-.188 1.743-.14a4.5 4.5 0 0 0 4.486-6.336l-3.276 3.277a3.004 3.004 0 0 1-2.25-2.25l3.276-3.276a4.5 4.5 0 0 0-6.336 4.486c.091 1.076-.071 2.264-.904 2.95l-.102.085m-1.745 1.437L5.909 7.5H4.5L2.25 3.75l1.5-1.5L7.5 4.5v1.409l4.26 4.26m-1.745 1.437 1.745-1.437m6.615 8.206L15.75 15.75M4.867 19.125h.008v.008h-.008v-.008Z" />
|
||||
</svg>
|
||||
</template>
|
||||
<template v-slot:text>Tools</template>
|
||||
</SidebarLink>
|
||||
</li>
|
||||
|
||||
<!-- settings -->
|
||||
<li>
|
||||
<SidebarLink :to="{ name: 'settings' }">
|
||||
|
||||
92
src/frontend/components/DropDownMenu.vue
Normal file
92
src/frontend/components/DropDownMenu.vue
Normal file
@@ -0,0 +1,92 @@
|
||||
<template>
|
||||
<div v-click-outside="{ handler: onClickOutsideMenu, capture: true }" class="cursor-default relative inline-block text-left">
|
||||
|
||||
<!-- menu button -->
|
||||
<div ref="dropdown-button" @click.stop="toggleMenu">
|
||||
<slot name="button"/>
|
||||
</div>
|
||||
|
||||
<!-- drop down menu -->
|
||||
<Transition
|
||||
enter-active-class="transition ease-out duration-100"
|
||||
enter-from-class="transform opacity-0 scale-95"
|
||||
enter-to-class="transform opacity-100 scale-100"
|
||||
leave-active-class="transition ease-in duration-75"
|
||||
leave-from-class="transform opacity-100 scale-100"
|
||||
leave-to-class="transform opacity-0 scale-95">
|
||||
<div v-if="isShowingMenu" @click.stop="hideMenu" class="overflow-hidden absolute right-0 z-10 mr-4 w-56 rounded-md bg-white shadow-md border border-gray-200 focus:outline-none" :class="[ dropdownClass ]">
|
||||
<slot name="items"/>
|
||||
</div>
|
||||
</Transition>
|
||||
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
name: 'DropDownMenu',
|
||||
data() {
|
||||
return {
|
||||
isShowingMenu: false,
|
||||
dropdownClass: null,
|
||||
};
|
||||
},
|
||||
methods: {
|
||||
toggleMenu() {
|
||||
if(this.isShowingMenu){
|
||||
this.hideMenu();
|
||||
} else {
|
||||
this.showMenu();
|
||||
}
|
||||
},
|
||||
showMenu() {
|
||||
this.isShowingMenu = true;
|
||||
this.adjustDropdownPosition();
|
||||
},
|
||||
hideMenu() {
|
||||
this.isShowingMenu = false;
|
||||
},
|
||||
onClickOutsideMenu(event) {
|
||||
if(this.isShowingMenu){
|
||||
event.preventDefault();
|
||||
this.hideMenu();
|
||||
}
|
||||
},
|
||||
adjustDropdownPosition() {
|
||||
this.$nextTick(() => {
|
||||
|
||||
// find button and dropdown
|
||||
const button = this.$refs["dropdown-button"];
|
||||
const dropdown = button.nextElementSibling;
|
||||
|
||||
// do nothing if not found
|
||||
if(!button || !dropdown){
|
||||
return;
|
||||
}
|
||||
|
||||
// get bounding box of button and dropdown
|
||||
const buttonRect = button.getBoundingClientRect();
|
||||
const dropdownRect = dropdown.getBoundingClientRect();
|
||||
|
||||
// calculate how much space is under and above the button
|
||||
const spaceBelowButton = window.innerHeight - buttonRect.bottom;
|
||||
const spaceAboveButton = buttonRect.top;
|
||||
|
||||
// calculate if there is enough space available to show dropdown
|
||||
const hasEnoughSpaceAboveButton = spaceAboveButton > dropdownRect.height;
|
||||
const hasEnoughSpaceBelowButton = spaceBelowButton > dropdownRect.height;
|
||||
|
||||
// show dropdown above button
|
||||
if(hasEnoughSpaceAboveButton && !hasEnoughSpaceBelowButton){
|
||||
this.dropdownClass = "bottom-0 mb-12";
|
||||
return;
|
||||
}
|
||||
|
||||
// otherwise fallback to showing dropdown below button
|
||||
this.dropdownClass = "top-0 mt-12";
|
||||
|
||||
});
|
||||
},
|
||||
},
|
||||
}
|
||||
</script>
|
||||
11
src/frontend/components/DropDownMenuItem.vue
Normal file
11
src/frontend/components/DropDownMenuItem.vue
Normal file
@@ -0,0 +1,11 @@
|
||||
<template>
|
||||
<div class="cursor-pointer flex p-3 space-x-2 text-sm text-gray-500 hover:bg-gray-100">
|
||||
<slot/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
name: 'DropDownMenuItem',
|
||||
}
|
||||
</script>
|
||||
11
src/frontend/components/IconButton.vue
Normal file
11
src/frontend/components/IconButton.vue
Normal file
@@ -0,0 +1,11 @@
|
||||
<template>
|
||||
<button type="button" class="p-2 rounded-full text-gray-700 bg-gray-100 hover:bg-gray-200">
|
||||
<slot/>
|
||||
</button>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
name: 'IconButton',
|
||||
}
|
||||
</script>
|
||||
142
src/frontend/components/messages/ConversationDropDownMenu.vue
Normal file
142
src/frontend/components/messages/ConversationDropDownMenu.vue
Normal file
@@ -0,0 +1,142 @@
|
||||
<template>
|
||||
<DropDownMenu>
|
||||
<template v-slot:button>
|
||||
<IconButton>
|
||||
<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>
|
||||
|
||||
<!-- call button -->
|
||||
<a :href="`call.html?destination_hash=${peer.destination_hash}`" target="_blank">
|
||||
<DropDownMenuItem>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" class="size-5">
|
||||
<path fill-rule="evenodd" d="M1.5 4.5a3 3 0 0 1 3-3h1.372c.86 0 1.61.586 1.819 1.42l1.105 4.423a1.875 1.875 0 0 1-.694 1.955l-1.293.97c-.135.101-.164.249-.126.352a11.285 11.285 0 0 0 6.697 6.697c.103.038.25.009.352-.126l.97-1.293a1.875 1.875 0 0 1 1.955-.694l4.423 1.105c.834.209 1.42.959 1.42 1.82V19.5a3 3 0 0 1-3 3h-2.25C8.552 22.5 1.5 15.448 1.5 6.75V4.5Z" clip-rule="evenodd" />
|
||||
</svg>
|
||||
<span>Start a Call</span>
|
||||
</DropDownMenuItem>
|
||||
</a>
|
||||
|
||||
<!-- ping button -->
|
||||
<DropDownMenuItem @click="onPingDestination">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" class="size-5">
|
||||
<path fill-rule="evenodd" d="M14.615 1.595a.75.75 0 0 1 .359.852L12.982 9.75h7.268a.75.75 0 0 1 .548 1.262l-10.5 11.25a.75.75 0 0 1-1.272-.71l1.992-7.302H3.75a.75.75 0 0 1-.548-1.262l10.5-11.25a.75.75 0 0 1 .913-.143Z" clip-rule="evenodd" />
|
||||
</svg>
|
||||
<span>Ping Destination</span>
|
||||
</DropDownMenuItem>
|
||||
|
||||
<!-- set custom display name button -->
|
||||
<DropDownMenuItem @click="onSetCustomDisplayName">
|
||||
<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>Set Custom Display Name</span>
|
||||
</DropDownMenuItem>
|
||||
|
||||
<!-- delete message history button -->
|
||||
<div class="border-t">
|
||||
<DropDownMenuItem @click="onDeleteMessageHistory">
|
||||
<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">Delete Message History</span>
|
||||
</DropDownMenuItem>
|
||||
</div>
|
||||
|
||||
</template>
|
||||
</DropDownMenu>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import DropDownMenu from "../DropDownMenu.vue";
|
||||
import DropDownMenuItem from "../DropDownMenuItem.vue";
|
||||
import IconButton from "../IconButton.vue";
|
||||
import DialogUtils from "../../js/DialogUtils";
|
||||
|
||||
export default {
|
||||
name: 'ConversationDropDownMenu',
|
||||
components: {
|
||||
IconButton,
|
||||
DropDownMenuItem,
|
||||
DropDownMenu,
|
||||
},
|
||||
props: {
|
||||
peer: Object,
|
||||
},
|
||||
emits: [
|
||||
"conversation-deleted",
|
||||
"set-custom-display-name",
|
||||
],
|
||||
methods: {
|
||||
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!")){
|
||||
return;
|
||||
}
|
||||
|
||||
// delete all lxmf messages from "us to destination" and from "destination to us"
|
||||
try {
|
||||
await window.axios.delete(`/api/v1/lxmf-messages/conversation/${this.peer.destination_hash}`);
|
||||
} catch(e) {
|
||||
DialogUtils.alert("failed to delete conversation");
|
||||
console.log(e);
|
||||
}
|
||||
|
||||
// fire callback
|
||||
this.$emit("conversation-deleted");
|
||||
|
||||
},
|
||||
async onSetCustomDisplayName() {
|
||||
this.$emit("set-custom-display-name");
|
||||
},
|
||||
async onPingDestination() {
|
||||
try {
|
||||
|
||||
// ping destination
|
||||
const response = await window.axios.get(`/api/v1/ping/${this.peer.destination_hash}/lxmf.delivery`, {
|
||||
params: {
|
||||
timeout: 30,
|
||||
},
|
||||
});
|
||||
|
||||
const pingResult = response.data.ping_result;
|
||||
const rttMilliseconds = (pingResult.rtt * 1000).toFixed(3);
|
||||
const rttDurationString = `${rttMilliseconds} ms`;
|
||||
|
||||
const info = [
|
||||
`Valid reply from ${this.peer.destination_hash}`,
|
||||
`Duration: ${rttDurationString}`,
|
||||
`Hops There: ${pingResult.hops_there}`,
|
||||
`Hops Back: ${pingResult.hops_back}`,
|
||||
];
|
||||
|
||||
// add signal quality if available
|
||||
if(pingResult.quality != null){
|
||||
info.push(`Signal Quality: ${pingResult.quality}%`);
|
||||
}
|
||||
|
||||
// add rssi if available
|
||||
if(pingResult.rssi != null){
|
||||
info.push(`RSSI: ${pingResult.rssi}dBm`);
|
||||
}
|
||||
|
||||
// add snr if available
|
||||
if(pingResult.snr != null){
|
||||
info.push(`SNR: ${pingResult.snr}dB`);
|
||||
}
|
||||
|
||||
// show result
|
||||
DialogUtils.alert(info.join("\n"));
|
||||
|
||||
} catch(e) {
|
||||
console.log(e);
|
||||
const message = e.response?.data?.message ?? "Ping failed. Try again later";
|
||||
DialogUtils.alert(message);
|
||||
}
|
||||
},
|
||||
},
|
||||
}
|
||||
</script>
|
||||
@@ -6,6 +6,16 @@
|
||||
<!-- header -->
|
||||
<div class="flex p-2 border-b border-gray-300 dark:border-zinc-800">
|
||||
|
||||
<!-- peer icon -->
|
||||
<div class="my-auto mr-2">
|
||||
<div v-if="selectedPeer.lxmf_user_icon" class="p-2 rounded" :style="{ 'color': selectedPeer.lxmf_user_icon.foreground_colour, 'background-color': selectedPeer.lxmf_user_icon.background_colour }">
|
||||
<MaterialDesignIcon :icon-name="selectedPeer.lxmf_user_icon.icon_name" class="w-6 h-6"/>
|
||||
</div>
|
||||
<div v-else class="bg-gray-200 dark:bg-zinc-700 text-gray-500 dark:text-gray-400 p-2 rounded">
|
||||
<MaterialDesignIcon icon-name="account-outline" class="w-6 h-6"/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- peer info -->
|
||||
<div>
|
||||
<div @click="updateCustomDisplayName" class="flex cursor-pointer">
|
||||
@@ -18,36 +28,46 @@
|
||||
<div class="my-auto font-semibold dark:text-white" :title="selectedPeer.display_name">{{ selectedPeer.custom_display_name ?? selectedPeer.display_name }}</div>
|
||||
</div>
|
||||
<div class="text-sm dark:text-zinc-300">
|
||||
<{{ selectedPeer.destination_hash }}>
|
||||
<span v-if="selectedPeerPath" @click="onDestinationPathClick(selectedPeerPath)" class="cursor-pointer">{{ selectedPeerPath.hops }} {{ selectedPeerPath.hops === 1 ? 'hop' : 'hops' }} away</span>
|
||||
<span v-if="selectedPeerLxmfStampInfo && selectedPeerLxmfStampInfo.stamp_cost"> • <span @click="onStampInfoClick(selectedPeerLxmfStampInfo)" class="cursor-pointer">Stamp Cost {{ selectedPeerLxmfStampInfo.stamp_cost }}</span></span>
|
||||
|
||||
<!-- destination hash -->
|
||||
<div class="inline-block mr-1">
|
||||
<div><{{ selectedPeer.destination_hash }}></div>
|
||||
</div>
|
||||
|
||||
<div class="inline-block">
|
||||
<div class="flex space-x-1">
|
||||
|
||||
<!-- hops away -->
|
||||
<span v-if="selectedPeerPath" @click="onDestinationPathClick(selectedPeerPath)" class="flex my-auto cursor-pointer">
|
||||
<span v-if="selectedPeerPath.hops === 0 || selectedPeerPath.hops === 1">Direct</span>
|
||||
<span v-else>{{ selectedPeerPath.hops }} hops away</span>
|
||||
</span>
|
||||
|
||||
<!-- snr -->
|
||||
<span v-if="selectedPeerSignalMetrics?.snr != null" class="flex my-auto space-x-1">
|
||||
<span v-if="selectedPeerPath">•</span>
|
||||
<span @click="onSignalMetricsClick(selectedPeerSignalMetrics)" class="cursor-pointer">SNR {{ selectedPeerSignalMetrics.snr }}</span>
|
||||
</span>
|
||||
|
||||
<!-- stamp cost -->
|
||||
<span v-if="selectedPeerLxmfStampInfo?.stamp_cost" class="flex my-auto space-x-1">
|
||||
<span v-if="selectedPeerPath || selectedPeerSignalMetrics?.snr != null">•</span>
|
||||
<span @click="onStampInfoClick(selectedPeerLxmfStampInfo)" class="cursor-pointer">Stamp Cost {{ selectedPeerLxmfStampInfo.stamp_cost }}</span>
|
||||
</span>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- call button -->
|
||||
<div class="ml-auto my-auto mr-2">
|
||||
<a :href="`call.html?destination_hash=${selectedPeer.destination_hash}`" target="_blank" class="cursor-pointer">
|
||||
<div class="flex text-gray-700 bg-gray-100 hover:bg-gray-200 p-2 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="w-5 h-5">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M2.25 6.75c0 8.284 6.716 15 15 15h2.25a2.25 2.25 0 0 0 2.25-2.25v-1.372c0-.516-.351-.966-.852-1.091l-4.423-1.106c-.44-.11-.902.055-1.173.417l-.97 1.293c-.282.376-.769.542-1.21.38a12.035 12.035 0 0 1-7.143-7.143c-.162-.441.004-.928.38-1.21l1.293-.97c.363-.271.527-.734.417-1.173L6.963 3.102a1.125 1.125 0 0 0-1.091-.852H4.5A2.25 2.25 0 0 0 2.25 4.5v2.25Z" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<!-- delete button -->
|
||||
<div class="my-auto mr-2">
|
||||
<div @click="deleteConversation" class="cursor-pointer">
|
||||
<div class="flex text-gray-700 bg-gray-100 hover:bg-gray-200 p-2 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="w-5 h-5">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="m14.74 9-.346 9m-4.788 0L9.26 9m9.968-3.21c.342.052.682.107 1.022.166m-1.022-.165L18.16 19.673a2.25 2.25 0 0 1-2.244 2.077H8.084a2.25 2.25 0 0 1-2.244-2.077L4.772 5.79m14.456 0a48.108 48.108 0 0 0-3.478-.397m-12 .562c.34-.059.68-.114 1.022-.165m0 0a48.11 48.11 0 0 1 3.478-.397m7.5 0v-.916c0-1.18-.91-2.164-2.09-2.201a51.964 51.964 0 0 0-3.32 0c-1.18.037-2.09 1.022-2.09 2.201v.916m7.5 0a48.667 48.667 0 0 0-7.5 0" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- dropdown menu -->
|
||||
<div class="ml-auto my-auto mx-2">
|
||||
<ConversationDropDownMenu
|
||||
v-if="selectedPeer"
|
||||
:peer="selectedPeer"
|
||||
@conversation-deleted="onConversationDeleted"
|
||||
@set-custom-display-name="updateCustomDisplayName"/>
|
||||
</div>
|
||||
|
||||
<!-- close button -->
|
||||
@@ -376,10 +396,14 @@ import WebSocketConnection from "../../js/WebSocketConnection";
|
||||
import AddAudioButton from "./AddAudioButton.vue";
|
||||
import moment from "moment";
|
||||
import SendMessageButton from "./SendMessageButton.vue";
|
||||
import MaterialDesignIcon from "../MaterialDesignIcon.vue";
|
||||
import ConversationDropDownMenu from "./ConversationDropDownMenu.vue";
|
||||
|
||||
export default {
|
||||
name: 'ConversationViewer',
|
||||
components: {
|
||||
ConversationDropDownMenu,
|
||||
MaterialDesignIcon,
|
||||
SendMessageButton,
|
||||
AddAudioButton,
|
||||
},
|
||||
@@ -393,6 +417,7 @@ export default {
|
||||
|
||||
selectedPeerPath: null,
|
||||
selectedPeerLxmfStampInfo: null,
|
||||
selectedPeerSignalMetrics: null,
|
||||
|
||||
lxmfMessagesRequestSequence: 0,
|
||||
chatItems: [],
|
||||
@@ -465,12 +490,16 @@ export default {
|
||||
// reset
|
||||
this.chatItems = [];
|
||||
this.hasMorePrevious = true;
|
||||
this.selectedPeerPath = null;
|
||||
this.selectedPeerLxmfStampInfo = null;
|
||||
this.selectedPeerSignalMetrics = null;
|
||||
if(!this.selectedPeer){
|
||||
return;
|
||||
}
|
||||
|
||||
this.getPeerPath();
|
||||
this.getPeerLxmfStampInfo();
|
||||
this.getPeerSignalMetrics();
|
||||
|
||||
// load 1 page of previous messages
|
||||
await this.loadPrevious();
|
||||
@@ -540,15 +569,18 @@ export default {
|
||||
const json = JSON.parse(message.data);
|
||||
switch(json.type){
|
||||
case 'announce': {
|
||||
// update stamp info if an announce is received from the selected peer
|
||||
// update stamp info and signal metrics if an announce is received from the selected peer
|
||||
if(json.announce.destination_hash === this.selectedPeer?.destination_hash){
|
||||
await this.getPeerPath();
|
||||
await this.getPeerLxmfStampInfo();
|
||||
await this.getPeerSignalMetrics();
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'lxmf.delivery': {
|
||||
this.onLxmfMessageReceived(json.lxmf_message);
|
||||
await this.getPeerPath();
|
||||
await this.getPeerSignalMetrics();
|
||||
break;
|
||||
}
|
||||
case 'lxmf_message_created': {
|
||||
@@ -632,10 +664,6 @@ export default {
|
||||
}
|
||||
},
|
||||
async getPeerPath() {
|
||||
|
||||
// clear previous known path
|
||||
this.selectedPeerPath = null;
|
||||
|
||||
if(this.selectedPeer){
|
||||
try {
|
||||
|
||||
@@ -647,15 +675,14 @@ export default {
|
||||
|
||||
} catch(e) {
|
||||
console.log(e);
|
||||
|
||||
// clear previous known path
|
||||
this.selectedPeerPath = null;
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
},
|
||||
async getPeerLxmfStampInfo() {
|
||||
|
||||
// clear previous stamp info
|
||||
this.selectedPeerLxmfStampInfo = null;
|
||||
|
||||
if(this.selectedPeer){
|
||||
try {
|
||||
|
||||
@@ -666,10 +693,34 @@ export default {
|
||||
this.selectedPeerLxmfStampInfo = response.data.lxmf_stamp_info;
|
||||
|
||||
} catch(e) {
|
||||
|
||||
console.log(e);
|
||||
|
||||
// clear previous stamp info
|
||||
this.selectedPeerLxmfStampInfo = null;
|
||||
|
||||
}
|
||||
}
|
||||
},
|
||||
async getPeerSignalMetrics() {
|
||||
if(this.selectedPeer){
|
||||
try {
|
||||
|
||||
// get signal metrics
|
||||
const response = await window.axios.get(`/api/v1/destination/${this.selectedPeer.destination_hash}/signal-metrics`);
|
||||
|
||||
// update ui
|
||||
this.selectedPeerSignalMetrics = response.data.signal_metrics;
|
||||
|
||||
} catch(e) {
|
||||
|
||||
console.log(e);
|
||||
|
||||
// clear previous signal metrics
|
||||
this.selectedPeerSignalMetrics = null;
|
||||
|
||||
}
|
||||
}
|
||||
},
|
||||
onDestinationPathClick(path) {
|
||||
DialogUtils.alert(`${path.hops} ${ path.hops === 1 ? 'hop' : 'hops' } away via ${path.next_hop_interface}`);
|
||||
@@ -709,6 +760,13 @@ export default {
|
||||
DialogUtils.alert(`This peer has enabled stamp security.\n\nYour device must have a ticket, or solve an automated proof of work task each time you send them a message.\n\nTime per message: ${estimatedTimeForStamp}`);
|
||||
|
||||
},
|
||||
onSignalMetricsClick(signalMetrics) {
|
||||
DialogUtils.alert([
|
||||
`Signal Quality: ${ signalMetrics.quality ?? '???' }%`,
|
||||
`RSSI: ${ signalMetrics.rssi ?? '???' }dBm`,
|
||||
`SNR: ${ signalMetrics.snr ?? '???'}dB`,
|
||||
].join("\n"));
|
||||
},
|
||||
scrollMessagesToBottom: function() {
|
||||
// next tick waits for the ui to have the new elements added
|
||||
this.$nextTick(() => {
|
||||
@@ -771,25 +829,7 @@ export default {
|
||||
}
|
||||
|
||||
},
|
||||
async deleteConversation() {
|
||||
|
||||
// do nothing if no peer selected
|
||||
if(!this.selectedPeer){
|
||||
return;
|
||||
}
|
||||
|
||||
// ask user to confirm deleting conversation history
|
||||
if(!confirm("Are you sure you want to delete all messages from this conversation? This can not be undone!")){
|
||||
return;
|
||||
}
|
||||
|
||||
// delete all lxmf messages from "us to destination" and from "destination to us"
|
||||
try {
|
||||
await window.axios.delete(`/api/v1/lxmf-messages/conversation/${this.selectedPeer.destination_hash}`);
|
||||
} catch(e) {
|
||||
DialogUtils.alert("failed to delete conversation");
|
||||
console.log(e);
|
||||
}
|
||||
async onConversationDeleted() {
|
||||
|
||||
// reload conversation
|
||||
await this.initialLoad();
|
||||
|
||||
@@ -26,9 +26,7 @@
|
||||
<MaterialDesignIcon :icon-name="conversation.lxmf_user_icon.icon_name" class="w-6 h-6"/>
|
||||
</div>
|
||||
<div v-else class="bg-gray-200 dark:bg-zinc-700 text-gray-500 dark:text-gray-400 p-2 rounded">
|
||||
<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="M15.75 6a3.75 3.75 0 1 1-7.5 0 3.75 3.75 0 0 1 7.5 0ZM4.501 20.118a7.5 7.5 0 0 1 14.998 0A17.933 17.933 0 0 1 12 21.75c-2.676 0-5.216-.584-7.499-1.632Z" />
|
||||
</svg>
|
||||
<MaterialDesignIcon icon-name="account-outline" class="w-6 h-6"/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mr-auto">
|
||||
@@ -83,15 +81,36 @@
|
||||
<div v-if="searchedPeers.length > 0" class="w-full">
|
||||
<div @click="onPeerClick(peer)" v-for="peer of searchedPeers" class="flex cursor-pointer p-2 border-l-2" :class="[ peer.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-700 text-gray-500 dark:text-gray-400 p-2 rounded">
|
||||
<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="M15.75 6a3.75 3.75 0 1 1-7.5 0 3.75 3.75 0 0 1 7.5 0ZM4.501 20.118a7.5 7.5 0 0 1 14.998 0A17.933 17.933 0 0 1 12 21.75c-2.676 0-5.216-.584-7.499-1.632Z" />
|
||||
</svg>
|
||||
<div v-if="peer.lxmf_user_icon" class="p-2 rounded" :style="{ 'color': peer.lxmf_user_icon.foreground_colour, 'background-color': peer.lxmf_user_icon.background_colour }">
|
||||
<MaterialDesignIcon :icon-name="peer.lxmf_user_icon.icon_name" class="w-6 h-6"/>
|
||||
</div>
|
||||
<div v-else class="bg-gray-200 dark:bg-zinc-700 text-gray-500 dark:text-gray-400 p-2 rounded">
|
||||
<MaterialDesignIcon icon-name="account-outline" class="w-6 h-6"/>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="text-gray-900 dark:text-gray-100">{{ peer.custom_display_name ?? peer.display_name }}</div>
|
||||
<div class="text-gray-500 dark:text-gray-400 text-sm">{{ formatTimeAgo(peer.updated_at) }}</div>
|
||||
<div class="flex space-x-1 text-gray-500 dark:text-gray-400 text-sm">
|
||||
|
||||
<!-- time ago -->
|
||||
<span class="flex my-auto space-x-1">
|
||||
{{ formatTimeAgo(peer.updated_at) }}
|
||||
</span>
|
||||
|
||||
<!-- hops away -->
|
||||
<span v-if="peer.hops != null && peer.hops !== 128" class="flex my-auto text-sm text-gray-500 space-x-1">
|
||||
<span>•</span>
|
||||
<span v-if="peer.hops === 0 || peer.hops === 1">Direct</span>
|
||||
<span v-else>{{ peer.hops }} hops</span>
|
||||
</span>
|
||||
|
||||
<!-- snr -->
|
||||
<span v-if="peer.snr != null" class="flex my-auto space-x-1">
|
||||
<span>•</span>
|
||||
<span>SNR {{ peer.snr }}</span>
|
||||
</span>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -25,9 +25,7 @@
|
||||
>
|
||||
<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">
|
||||
<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="M5.25 14.25h13.5m-13.5 0a3 3 0 0 1-3-3m3 3a3 3 0 1 0 0 6h13.5a3 3 0 1 0 0-6m-16.5-3a3 3 0 0 1 3-3h13.5a3 3 0 0 1 3 3m-19.5 0a4.5 4.5 0 0 1 .9-2.7L5.737 5.1a3.375 3.375 0 0 1 2.7-1.35h7.126c1.062 0 2.062.5 2.7 1.35l2.587 3.45a4.5 4.5 0 0 1 .9 2.7m0 0a3 3 0 0 1-3 3m0 3h.008v.008h-.008v-.008Zm0-6h.008v.008h-.008v-.008Zm-3 6h.008v.008h-.008v-.008Zm0-6h.008v.008h-.008v-.008Z" />
|
||||
</svg>
|
||||
<MaterialDesignIcon icon-name="server-network-outline" class="w-6 h-6"/>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
@@ -66,9 +64,11 @@
|
||||
<script>
|
||||
|
||||
import Utils from "../../js/Utils";
|
||||
import MaterialDesignIcon from "../MaterialDesignIcon.vue";
|
||||
|
||||
export default {
|
||||
name: 'NomadNetworkSidebar',
|
||||
components: {MaterialDesignIcon},
|
||||
props: {
|
||||
nodes: Object,
|
||||
selectedDestinationHash: String,
|
||||
|
||||
223
src/frontend/components/ping/PingPage.vue
Normal file
223
src/frontend/components/ping/PingPage.vue
Normal file
@@ -0,0 +1,223 @@
|
||||
<template>
|
||||
<div class="flex flex-col flex-1 overflow-hidden min-w-full sm:min-w-[500px] dark:bg-zinc-950">
|
||||
<div class="flex flex-col h-full space-y-2 p-2 overflow-y-auto">
|
||||
|
||||
<!-- appearance -->
|
||||
<div class="bg-white dark:bg-zinc-800 rounded shadow">
|
||||
<div class="flex border-b border-gray-300 dark:border-zinc-700 text-gray-700 dark:text-gray-200 p-2 font-semibold">Ping</div>
|
||||
<div class="dark:divide-zinc-700 text-gray-900 dark:text-gray-100 p-2">
|
||||
Only lxmf.delivery destinations can be pinged.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- inputs -->
|
||||
<div class="bg-white dark:bg-zinc-800 rounded shadow">
|
||||
<div class="divide-y divide-gray-300 dark:divide-zinc-700 text-gray-900 dark:text-gray-100">
|
||||
|
||||
<div class="p-2">
|
||||
<div class="text-sm font-medium text-gray-900 dark:text-gray-100">Destination Hash</div>
|
||||
<div class="flex">
|
||||
<input v-model="destinationHash" type="text" placeholder="e.g: 7b746057a7294469799cd8d7d429676a" 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 focus:border-blue-500 dark:focus:ring-blue-600 dark:focus:border-blue-600 block w-full p-2.5">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="p-2">
|
||||
<div class="text-sm font-medium text-gray-900 dark:text-gray-100">Ping Timeout (seconds)</div>
|
||||
<div class="flex">
|
||||
<input v-model="timeout" type="number" placeholder="Timeout" 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 focus:border-blue-500 dark:focus:ring-blue-600 dark:focus:border-blue-600 block w-full p-2.5">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="p-2 space-x-1">
|
||||
<button v-if="!isRunning" @click="start" type="button" class="my-auto inline-flex items-center gap-x-1 rounded-md bg-gray-500 px-2 py-1 text-sm font-semibold text-white shadow-sm hover:bg-gray-400 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-gray-500 dark:bg-zinc-700 dark:text-white dark:hover:bg-zinc-600 dark:focus-visible:outline-zinc-500">
|
||||
Start
|
||||
</button>
|
||||
<button v-if="isRunning" @click="stop" type="button" class="my-auto inline-flex items-center gap-x-1 rounded-md bg-gray-500 px-2 py-1 text-sm font-semibold text-white shadow-sm hover:bg-gray-400 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-gray-500 dark:bg-zinc-700 dark:text-white dark:hover:bg-zinc-600 dark:focus-visible:outline-zinc-500">
|
||||
Stop
|
||||
</button>
|
||||
<button @click="clear" type="button" class="my-auto inline-flex items-center gap-x-1 rounded-md bg-gray-500 px-2 py-1 text-sm font-semibold text-white shadow-sm hover:bg-gray-400 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-gray-500 dark:bg-zinc-700 dark:text-white dark:hover:bg-zinc-600 dark:focus-visible:outline-zinc-500">
|
||||
Clear Results
|
||||
</button>
|
||||
<button @click="dropPath" type="button" class="my-auto inline-flex items-center gap-x-1 rounded-md bg-red-500 px-2 py-1 text-sm font-semibold text-white shadow-sm hover:bg-red-400 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-red-500">
|
||||
Drop Path
|
||||
</button>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- results -->
|
||||
<div class="flex flex-col h-full bg-white dark:bg-zinc-800 rounded shadow overflow-hidden min-h-52">
|
||||
<div class="flex border-b border-gray-300 dark:border-zinc-700 text-gray-700 dark:text-gray-200 p-2 font-semibold">Results</div>
|
||||
<div id="results" class="flex flex-col h-full bg-black text-white dark:bg-zinc-800 dark:text-gray-200 p-2 overflow-y-scroll font-mono">
|
||||
<div v-for="pingResult of pingResults">{{ pingResult }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import {CanceledError} from "axios";
|
||||
import DialogUtils from "../../js/DialogUtils";
|
||||
|
||||
export default {
|
||||
name: 'PingPage',
|
||||
data() {
|
||||
return {
|
||||
isRunning: false,
|
||||
destinationHash: null,
|
||||
timeout: 10,
|
||||
seq: 0,
|
||||
pingResults: [],
|
||||
abortController: null,
|
||||
};
|
||||
},
|
||||
beforeUnmount() {
|
||||
this.stop();
|
||||
},
|
||||
methods: {
|
||||
async start() {
|
||||
|
||||
// do nothing if already running
|
||||
if(this.isRunning){
|
||||
return;
|
||||
}
|
||||
|
||||
// simple check to ensure destination hash is valid
|
||||
if(this.destinationHash == null || this.destinationHash.length !== 32){
|
||||
DialogUtils.alert("Invalid Destination Hash!");
|
||||
return;
|
||||
}
|
||||
|
||||
// simple check to ensure destination hash is valid
|
||||
if(this.timeout == null || this.timeout < 0){
|
||||
DialogUtils.alert("Timeout must be a number!");
|
||||
return;
|
||||
}
|
||||
|
||||
// we are now running ping
|
||||
this.seq = 0;
|
||||
this.isRunning = true;
|
||||
this.abortController = new AbortController();
|
||||
|
||||
// run ping until stopped
|
||||
while(this.isRunning){
|
||||
|
||||
// run ping
|
||||
await this.ping();
|
||||
|
||||
// wait a bit before running next ping
|
||||
await this.sleep(1000);
|
||||
|
||||
}
|
||||
|
||||
},
|
||||
async stop() {
|
||||
this.isRunning = false;
|
||||
this.abortController.abort();
|
||||
},
|
||||
async clear() {
|
||||
this.pingResults = [];
|
||||
},
|
||||
async sleep(millis) {
|
||||
return new Promise((resolve, reject) => setTimeout(resolve, millis));
|
||||
},
|
||||
async ping() {
|
||||
try {
|
||||
|
||||
this.seq++;
|
||||
|
||||
// ping destination
|
||||
const response = await window.axios.get(`/api/v1/ping/${this.destinationHash}/lxmf.delivery`, {
|
||||
signal: this.abortController.signal,
|
||||
params: {
|
||||
timeout: this.timeout,
|
||||
},
|
||||
});
|
||||
|
||||
const pingResult = response.data.ping_result;
|
||||
const rttMilliseconds = (pingResult.rtt * 1000).toFixed(3);
|
||||
const rttDurationString = `${rttMilliseconds}ms`;
|
||||
|
||||
const info = [
|
||||
`seq=${this.seq}`,
|
||||
`duration=${rttDurationString}`,
|
||||
`hops_there=${pingResult.hops_there}`,
|
||||
`hops_back=${pingResult.hops_back}`,
|
||||
];
|
||||
|
||||
// add rssi if available
|
||||
if(pingResult.rssi != null){
|
||||
info.push(`rssi=${pingResult.rssi}dBm`);
|
||||
}
|
||||
|
||||
// add snr if available
|
||||
if(pingResult.snr != null){
|
||||
info.push(`snr=${pingResult.snr}dB`);
|
||||
}
|
||||
|
||||
// add signal quality if available
|
||||
if(pingResult.quality != null){
|
||||
info.push(`quality=${pingResult.quality}%`);
|
||||
}
|
||||
|
||||
// add receiving interface
|
||||
info.push(`via=${pingResult.receiving_interface}`);
|
||||
|
||||
// update ui
|
||||
this.addPingResult(info.join(" "));
|
||||
|
||||
} catch(e) {
|
||||
|
||||
// ignore cancelled error
|
||||
if(e instanceof CanceledError){
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(e);
|
||||
|
||||
// add ping error to results
|
||||
const message = e.response?.data?.message ?? e;
|
||||
this.addPingResult(`seq=${this.seq} error=${message}`);
|
||||
|
||||
}
|
||||
},
|
||||
async dropPath() {
|
||||
|
||||
// simple check to ensure destination hash is valid
|
||||
if(this.destinationHash == null || this.destinationHash.length !== 32){
|
||||
DialogUtils.alert("Invalid Destination Hash!");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await window.axios.post(`/api/v1/destination/${this.destinationHash}/drop-path`);
|
||||
DialogUtils.alert(response.data.message);
|
||||
} catch(e) {
|
||||
console.log(e);
|
||||
const message = e.response?.data?.message ?? `Failed to drop path: ${e}`;
|
||||
DialogUtils.alert(message);
|
||||
}
|
||||
|
||||
},
|
||||
addPingResult(result) {
|
||||
this.pingResults.push(result);
|
||||
this.scrollPingResultsToBottom();
|
||||
},
|
||||
scrollPingResultsToBottom: function() {
|
||||
// next tick waits for the ui to have the new elements added
|
||||
this.$nextTick(() => {
|
||||
// set timeout with zero millis seems to fix issue where it doesn't scroll all the way to the bottom...
|
||||
setTimeout(() => {
|
||||
const container = document.getElementById("results");
|
||||
if(container){
|
||||
container.scrollTop = container.scrollHeight;
|
||||
}
|
||||
}, 0);
|
||||
});
|
||||
},
|
||||
},
|
||||
}
|
||||
</script>
|
||||
60
src/frontend/components/tools/ToolsPage.vue
Normal file
60
src/frontend/components/tools/ToolsPage.vue
Normal file
@@ -0,0 +1,60 @@
|
||||
<template>
|
||||
<div class="flex flex-col flex-1 overflow-hidden min-w-full sm:min-w-[500px] dark:bg-zinc-950">
|
||||
<div class="overflow-y-auto space-y-2 p-2">
|
||||
|
||||
<!-- appearance -->
|
||||
<div class="bg-white dark:bg-zinc-800 rounded shadow">
|
||||
<div class="flex border-b border-gray-300 dark:border-zinc-700 text-gray-700 dark:text-gray-200 p-2 font-semibold">Tools</div>
|
||||
<div class="dark:divide-zinc-700 text-gray-900 dark:text-gray-100 p-2">
|
||||
A collection of useful tools bundled with MeshChat
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ping -->
|
||||
<RouterLink :to="{ name: 'ping' }" class="group flex bg-white dark:bg-zinc-800 p-2 rounded shadow hover:bg-gray-50 dark:hover:bg-zinc-700">
|
||||
<div class="mr-2">
|
||||
<div class="flex bg-gray-300 text-gray-500 rounded shadow p-2">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" class="size-10">
|
||||
<path fill-rule="evenodd" d="M14.615 1.595a.75.75 0 0 1 .359.852L12.982 9.75h7.268a.75.75 0 0 1 .548 1.262l-10.5 11.25a.75.75 0 0 1-1.272-.71l1.992-7.302H3.75a.75.75 0 0 1-.548-1.262l10.5-11.25a.75.75 0 0 1 .913-.143Z" clip-rule="evenodd" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
<div class="my-auto mr-auto dark:text-gray-200">
|
||||
<div class="font-bold">Ping</div>
|
||||
<div class="text-sm">Allows you to ping a destination hash.</div>
|
||||
</div>
|
||||
<div class="my-auto text-gray-400 group-hover:text-gray-500">
|
||||
<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="m8.25 4.5 7.5 7.5-7.5 7.5"></path>
|
||||
</svg>
|
||||
</div>
|
||||
</RouterLink>
|
||||
|
||||
<!-- rnode flasher -->
|
||||
<a target="_blank" href="/rnode-flasher/index.html" class="group flex bg-white dark:bg-zinc-800 p-2 rounded shadow hover:bg-gray-50 dark:hover:bg-zinc-700">
|
||||
<div class="mr-2">
|
||||
<div class="flex bg-gray-300 text-white rounded shadow">
|
||||
<img src="/rnode-flasher/reticulum_logo_512.png" class="size-14"/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="my-auto mr-auto dark:text-gray-200">
|
||||
<div class="font-bold">RNode Flasher</div>
|
||||
<div class="text-sm">Flash RNode firmware to supported devices.</div>
|
||||
</div>
|
||||
<div class="my-auto text-gray-400 group-hover:text-gray-500">
|
||||
<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="m8.25 4.5 7.5 7.5-7.5 7.5"></path>
|
||||
</svg>
|
||||
</div>
|
||||
</a>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
|
||||
export default {
|
||||
name: 'ToolsPage',
|
||||
}
|
||||
</script>
|
||||
@@ -54,9 +54,9 @@ class Utils {
|
||||
|
||||
if(parsedSeconds.minutes > 0){
|
||||
if(parsedSeconds.minutes === 1){
|
||||
return "a minute ago";
|
||||
return "1 min ago";
|
||||
} else {
|
||||
return parsedSeconds.minutes + " minutes ago";
|
||||
return parsedSeconds.minutes + " mins ago";
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -60,11 +60,21 @@ const router = createRouter({
|
||||
path: '/propagation-nodes',
|
||||
component: defineAsyncComponent(() => import("./components/propagation-nodes/PropagationNodesPage.vue")),
|
||||
},
|
||||
{
|
||||
name: "ping",
|
||||
path: '/ping',
|
||||
component: defineAsyncComponent(() => import("./components/ping/PingPage.vue")),
|
||||
},
|
||||
{
|
||||
name: "settings",
|
||||
path: '/settings',
|
||||
component: defineAsyncComponent(() => import("./components/settings/SettingsPage.vue")),
|
||||
},
|
||||
{
|
||||
name: "tools",
|
||||
path: '/tools',
|
||||
component: defineAsyncComponent(() => import("./components/tools/ToolsPage.vue")),
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
|
||||
BIN
src/frontend/public/assets/images/logo-chat-bubble.png
Normal file
BIN
src/frontend/public/assets/images/logo-chat-bubble.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 109 KiB |
21
src/frontend/public/rnode-flasher/LICENSE
Normal file
21
src/frontend/public/rnode-flasher/LICENSE
Normal file
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2024 Liam Cottle
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
86
src/frontend/public/rnode-flasher/README.md
Normal file
86
src/frontend/public/rnode-flasher/README.md
Normal file
@@ -0,0 +1,86 @@
|
||||
# RNode Flasher
|
||||
|
||||
A _work-in-progress_ web based firmware flasher for [Reticulum](https://github.com/markqvist/Reticulum) / [RNode_Firmware](https://github.com/markqvist/RNode_Firmware).
|
||||
|
||||
- It is written in javascript and uses the [Web Serial APIs](https://developer.mozilla.org/en-US/docs/Web/API/Web_Serial_API).
|
||||
- It supports putting relevant devices into DFU mode.
|
||||
- It supports flashing firmware from a zip file.
|
||||
|
||||
At this time, it does not support flashing bootloaders or softdevices for the nRF boards.
|
||||
|
||||
## How does it work?
|
||||
|
||||
I wanted something simple, for flashing RNode firmware to a nRF52 RAK4631 in a web browser.
|
||||
|
||||
So, I spent a bit of time working through the source code of [adafruit-nrfutil](https://github.com/adafruit/Adafruit_nRF52_nrfutil) and wrote a javascript implementation of [dfu_transport_serial.py](https://github.com/adafruit/Adafruit_nRF52_nrfutil/blob/master/nordicsemi/dfu/dfu_transport_serial.py)
|
||||
|
||||
Generally, you would use the following command to flash a firmware.zip to your device;
|
||||
|
||||
```
|
||||
adafruit-nrfutil dfu serial --package firmware.zip -p /dev/cu.usbmodem14401 -b 115200 -t 1200
|
||||
```
|
||||
|
||||
The [nrf52_dfu_flasher.js](js/nrf52_dfu_flasher.js) in this project implements a javascript, web based version of the above command.
|
||||
|
||||
There was an existing package called [pc-nrf-dfu-js](https://github.com/NordicSemiconductor/pc-nrf-dfu-js), however this repo had been archived and didn't appear to support the latest DFU protocol.
|
||||
|
||||
## How to use it?
|
||||
|
||||
- Open https://liamcottle.github.io/rnode-flasher/ in your web browser.
|
||||
- Select your device.
|
||||
- Put your device into DFU mode (for nRF52 boards)
|
||||
- Select a firmware file and click flash.
|
||||
- Once flashed, your device should reboot into the new firmware.
|
||||
- For new devices that have never been provisioned, you should click "Provision" to configure the EEPROM.
|
||||
- Every time you flash new firmware, you should also click "Set Firmware Hash".
|
||||
|
||||
> Note: At this time, firmware hashes for RNode are not automatically configured.
|
||||
|
||||
## What is needed to set up a new RNode?
|
||||
|
||||
> Note: This is a technical overview of how the RNode device provisioning works.
|
||||
> Most of this is taken care of by the code base, and this section just makes it easier to understand what is going on.
|
||||
|
||||
To set up a new RNode device, you will need to do a few things;
|
||||
|
||||
- Obtain supported hardware, such as a RAK4631
|
||||
- Obtain an RNode firmware file
|
||||
- Put your device into DFU mode
|
||||
- Flash the firmware file
|
||||
- Provision the EEPROM
|
||||
|
||||
Once the firmware is flashed to the device, you will need to provision the EEPROM;
|
||||
|
||||
- Set firmware hash in eeprom
|
||||
- Collect device info
|
||||
- `product`
|
||||
- `model`
|
||||
- `hardware_revision`
|
||||
- `serial_number`
|
||||
- `made` (unix timestamp of device creation)
|
||||
- Write device info to eeprom
|
||||
- Create an MD5 checksum of the device info
|
||||
- Write 16 byte device info checksum to eeprom
|
||||
- Sign device info checksum with signing key to use as signature
|
||||
- Write 128 byte signature to eeprom
|
||||
- Write `ROM.INFO_LOCK_BYTE` to `ROM.ADDR_INFO_LOCK` in eeprom
|
||||
- Read eeprom and validate checksums and signatures to ensure all is correct
|
||||
|
||||
## TODO
|
||||
|
||||
- support configuring eeprom with device signatures and firmware hashes
|
||||
- support flashing existing firmware files from api
|
||||
- calculate on air bitrate based on tnc settings
|
||||
- try using [web-serial-polyfill](https://github.com/google/web-serial-polyfill) to support flashing from Android device?
|
||||
|
||||
## License
|
||||
|
||||
MIT
|
||||
|
||||
## References
|
||||
|
||||
- https://github.com/adafruit/Adafruit_nRF52_nrfutil
|
||||
- https://github.com/adafruit/Adafruit_nRF52_nrfutil/blob/master/nordicsemi/dfu/dfu_transport_serial.py
|
||||
- https://github.com/markqvist/RNode_Firmware/blob/master/RNode_Firmware.ino
|
||||
- https://github.com/markqvist/RNode_Firmware/blob/master/Framing.h
|
||||
- https://github.com/markqvist/RNode_Firmware/blob/master/Utilities.h
|
||||
1783
src/frontend/public/rnode-flasher/index.html
Normal file
1783
src/frontend/public/rnode-flasher/index.html
Normal file
File diff suppressed because it is too large
Load Diff
760
src/frontend/public/rnode-flasher/js/crypto-js@3.9.1-1/core.js
Normal file
760
src/frontend/public/rnode-flasher/js/crypto-js@3.9.1-1/core.js
Normal file
@@ -0,0 +1,760 @@
|
||||
;(function (root, factory) {
|
||||
if (typeof exports === "object") {
|
||||
// CommonJS
|
||||
module.exports = exports = factory();
|
||||
}
|
||||
else if (typeof define === "function" && define.amd) {
|
||||
// AMD
|
||||
define([], factory);
|
||||
}
|
||||
else {
|
||||
// Global (browser)
|
||||
root.CryptoJS = factory();
|
||||
}
|
||||
}(this, function () {
|
||||
|
||||
/**
|
||||
* CryptoJS core components.
|
||||
*/
|
||||
var CryptoJS = CryptoJS || (function (Math, undefined) {
|
||||
/*
|
||||
* Local polyfil of Object.create
|
||||
*/
|
||||
var create = Object.create || (function () {
|
||||
function F() {};
|
||||
|
||||
return function (obj) {
|
||||
var subtype;
|
||||
|
||||
F.prototype = obj;
|
||||
|
||||
subtype = new F();
|
||||
|
||||
F.prototype = null;
|
||||
|
||||
return subtype;
|
||||
};
|
||||
}())
|
||||
|
||||
/**
|
||||
* CryptoJS namespace.
|
||||
*/
|
||||
var C = {};
|
||||
|
||||
/**
|
||||
* Library namespace.
|
||||
*/
|
||||
var C_lib = C.lib = {};
|
||||
|
||||
/**
|
||||
* Base object for prototypal inheritance.
|
||||
*/
|
||||
var Base = C_lib.Base = (function () {
|
||||
|
||||
|
||||
return {
|
||||
/**
|
||||
* Creates a new object that inherits from this object.
|
||||
*
|
||||
* @param {Object} overrides Properties to copy into the new object.
|
||||
*
|
||||
* @return {Object} The new object.
|
||||
*
|
||||
* @static
|
||||
*
|
||||
* @example
|
||||
*
|
||||
* var MyType = CryptoJS.lib.Base.extend({
|
||||
* field: 'value',
|
||||
*
|
||||
* method: function () {
|
||||
* }
|
||||
* });
|
||||
*/
|
||||
extend: function (overrides) {
|
||||
// Spawn
|
||||
var subtype = create(this);
|
||||
|
||||
// Augment
|
||||
if (overrides) {
|
||||
subtype.mixIn(overrides);
|
||||
}
|
||||
|
||||
// Create default initializer
|
||||
if (!subtype.hasOwnProperty('init') || this.init === subtype.init) {
|
||||
subtype.init = function () {
|
||||
subtype.$super.init.apply(this, arguments);
|
||||
};
|
||||
}
|
||||
|
||||
// Initializer's prototype is the subtype object
|
||||
subtype.init.prototype = subtype;
|
||||
|
||||
// Reference supertype
|
||||
subtype.$super = this;
|
||||
|
||||
return subtype;
|
||||
},
|
||||
|
||||
/**
|
||||
* Extends this object and runs the init method.
|
||||
* Arguments to create() will be passed to init().
|
||||
*
|
||||
* @return {Object} The new object.
|
||||
*
|
||||
* @static
|
||||
*
|
||||
* @example
|
||||
*
|
||||
* var instance = MyType.create();
|
||||
*/
|
||||
create: function () {
|
||||
var instance = this.extend();
|
||||
instance.init.apply(instance, arguments);
|
||||
|
||||
return instance;
|
||||
},
|
||||
|
||||
/**
|
||||
* Initializes a newly created object.
|
||||
* Override this method to add some logic when your objects are created.
|
||||
*
|
||||
* @example
|
||||
*
|
||||
* var MyType = CryptoJS.lib.Base.extend({
|
||||
* init: function () {
|
||||
* // ...
|
||||
* }
|
||||
* });
|
||||
*/
|
||||
init: function () {
|
||||
},
|
||||
|
||||
/**
|
||||
* Copies properties into this object.
|
||||
*
|
||||
* @param {Object} properties The properties to mix in.
|
||||
*
|
||||
* @example
|
||||
*
|
||||
* MyType.mixIn({
|
||||
* field: 'value'
|
||||
* });
|
||||
*/
|
||||
mixIn: function (properties) {
|
||||
for (var propertyName in properties) {
|
||||
if (properties.hasOwnProperty(propertyName)) {
|
||||
this[propertyName] = properties[propertyName];
|
||||
}
|
||||
}
|
||||
|
||||
// IE won't copy toString using the loop above
|
||||
if (properties.hasOwnProperty('toString')) {
|
||||
this.toString = properties.toString;
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Creates a copy of this object.
|
||||
*
|
||||
* @return {Object} The clone.
|
||||
*
|
||||
* @example
|
||||
*
|
||||
* var clone = instance.clone();
|
||||
*/
|
||||
clone: function () {
|
||||
return this.init.prototype.extend(this);
|
||||
}
|
||||
};
|
||||
}());
|
||||
|
||||
/**
|
||||
* An array of 32-bit words.
|
||||
*
|
||||
* @property {Array} words The array of 32-bit words.
|
||||
* @property {number} sigBytes The number of significant bytes in this word array.
|
||||
*/
|
||||
var WordArray = C_lib.WordArray = Base.extend({
|
||||
/**
|
||||
* Initializes a newly created word array.
|
||||
*
|
||||
* @param {Array} words (Optional) An array of 32-bit words.
|
||||
* @param {number} sigBytes (Optional) The number of significant bytes in the words.
|
||||
*
|
||||
* @example
|
||||
*
|
||||
* var wordArray = CryptoJS.lib.WordArray.create();
|
||||
* var wordArray = CryptoJS.lib.WordArray.create([0x00010203, 0x04050607]);
|
||||
* var wordArray = CryptoJS.lib.WordArray.create([0x00010203, 0x04050607], 6);
|
||||
*/
|
||||
init: function (words, sigBytes) {
|
||||
words = this.words = words || [];
|
||||
|
||||
if (sigBytes != undefined) {
|
||||
this.sigBytes = sigBytes;
|
||||
} else {
|
||||
this.sigBytes = words.length * 4;
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Converts this word array to a string.
|
||||
*
|
||||
* @param {Encoder} encoder (Optional) The encoding strategy to use. Default: CryptoJS.enc.Hex
|
||||
*
|
||||
* @return {string} The stringified word array.
|
||||
*
|
||||
* @example
|
||||
*
|
||||
* var string = wordArray + '';
|
||||
* var string = wordArray.toString();
|
||||
* var string = wordArray.toString(CryptoJS.enc.Utf8);
|
||||
*/
|
||||
toString: function (encoder) {
|
||||
return (encoder || Hex).stringify(this);
|
||||
},
|
||||
|
||||
/**
|
||||
* Concatenates a word array to this word array.
|
||||
*
|
||||
* @param {WordArray} wordArray The word array to append.
|
||||
*
|
||||
* @return {WordArray} This word array.
|
||||
*
|
||||
* @example
|
||||
*
|
||||
* wordArray1.concat(wordArray2);
|
||||
*/
|
||||
concat: function (wordArray) {
|
||||
// Shortcuts
|
||||
var thisWords = this.words;
|
||||
var thatWords = wordArray.words;
|
||||
var thisSigBytes = this.sigBytes;
|
||||
var thatSigBytes = wordArray.sigBytes;
|
||||
|
||||
// Clamp excess bits
|
||||
this.clamp();
|
||||
|
||||
// Concat
|
||||
if (thisSigBytes % 4) {
|
||||
// Copy one byte at a time
|
||||
for (var i = 0; i < thatSigBytes; i++) {
|
||||
var thatByte = (thatWords[i >>> 2] >>> (24 - (i % 4) * 8)) & 0xff;
|
||||
thisWords[(thisSigBytes + i) >>> 2] |= thatByte << (24 - ((thisSigBytes + i) % 4) * 8);
|
||||
}
|
||||
} else {
|
||||
// Copy one word at a time
|
||||
for (var i = 0; i < thatSigBytes; i += 4) {
|
||||
thisWords[(thisSigBytes + i) >>> 2] = thatWords[i >>> 2];
|
||||
}
|
||||
}
|
||||
this.sigBytes += thatSigBytes;
|
||||
|
||||
// Chainable
|
||||
return this;
|
||||
},
|
||||
|
||||
/**
|
||||
* Removes insignificant bits.
|
||||
*
|
||||
* @example
|
||||
*
|
||||
* wordArray.clamp();
|
||||
*/
|
||||
clamp: function () {
|
||||
// Shortcuts
|
||||
var words = this.words;
|
||||
var sigBytes = this.sigBytes;
|
||||
|
||||
// Clamp
|
||||
words[sigBytes >>> 2] &= 0xffffffff << (32 - (sigBytes % 4) * 8);
|
||||
words.length = Math.ceil(sigBytes / 4);
|
||||
},
|
||||
|
||||
/**
|
||||
* Creates a copy of this word array.
|
||||
*
|
||||
* @return {WordArray} The clone.
|
||||
*
|
||||
* @example
|
||||
*
|
||||
* var clone = wordArray.clone();
|
||||
*/
|
||||
clone: function () {
|
||||
var clone = Base.clone.call(this);
|
||||
clone.words = this.words.slice(0);
|
||||
|
||||
return clone;
|
||||
},
|
||||
|
||||
/**
|
||||
* Creates a word array filled with random bytes.
|
||||
*
|
||||
* @param {number} nBytes The number of random bytes to generate.
|
||||
*
|
||||
* @return {WordArray} The random word array.
|
||||
*
|
||||
* @static
|
||||
*
|
||||
* @example
|
||||
*
|
||||
* var wordArray = CryptoJS.lib.WordArray.random(16);
|
||||
*/
|
||||
random: function (nBytes) {
|
||||
var words = [];
|
||||
|
||||
var r = (function (m_w) {
|
||||
var m_w = m_w;
|
||||
var m_z = 0x3ade68b1;
|
||||
var mask = 0xffffffff;
|
||||
|
||||
return function () {
|
||||
m_z = (0x9069 * (m_z & 0xFFFF) + (m_z >> 0x10)) & mask;
|
||||
m_w = (0x4650 * (m_w & 0xFFFF) + (m_w >> 0x10)) & mask;
|
||||
var result = ((m_z << 0x10) + m_w) & mask;
|
||||
result /= 0x100000000;
|
||||
result += 0.5;
|
||||
return result * (Math.random() > .5 ? 1 : -1);
|
||||
}
|
||||
});
|
||||
|
||||
for (var i = 0, rcache; i < nBytes; i += 4) {
|
||||
var _r = r((rcache || Math.random()) * 0x100000000);
|
||||
|
||||
rcache = _r() * 0x3ade67b7;
|
||||
words.push((_r() * 0x100000000) | 0);
|
||||
}
|
||||
|
||||
return new WordArray.init(words, nBytes);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Encoder namespace.
|
||||
*/
|
||||
var C_enc = C.enc = {};
|
||||
|
||||
/**
|
||||
* Hex encoding strategy.
|
||||
*/
|
||||
var Hex = C_enc.Hex = {
|
||||
/**
|
||||
* Converts a word array to a hex string.
|
||||
*
|
||||
* @param {WordArray} wordArray The word array.
|
||||
*
|
||||
* @return {string} The hex string.
|
||||
*
|
||||
* @static
|
||||
*
|
||||
* @example
|
||||
*
|
||||
* var hexString = CryptoJS.enc.Hex.stringify(wordArray);
|
||||
*/
|
||||
stringify: function (wordArray) {
|
||||
// Shortcuts
|
||||
var words = wordArray.words;
|
||||
var sigBytes = wordArray.sigBytes;
|
||||
|
||||
// Convert
|
||||
var hexChars = [];
|
||||
for (var i = 0; i < sigBytes; i++) {
|
||||
var bite = (words[i >>> 2] >>> (24 - (i % 4) * 8)) & 0xff;
|
||||
hexChars.push((bite >>> 4).toString(16));
|
||||
hexChars.push((bite & 0x0f).toString(16));
|
||||
}
|
||||
|
||||
return hexChars.join('');
|
||||
},
|
||||
|
||||
/**
|
||||
* Converts a hex string to a word array.
|
||||
*
|
||||
* @param {string} hexStr The hex string.
|
||||
*
|
||||
* @return {WordArray} The word array.
|
||||
*
|
||||
* @static
|
||||
*
|
||||
* @example
|
||||
*
|
||||
* var wordArray = CryptoJS.enc.Hex.parse(hexString);
|
||||
*/
|
||||
parse: function (hexStr) {
|
||||
// Shortcut
|
||||
var hexStrLength = hexStr.length;
|
||||
|
||||
// Convert
|
||||
var words = [];
|
||||
for (var i = 0; i < hexStrLength; i += 2) {
|
||||
words[i >>> 3] |= parseInt(hexStr.substr(i, 2), 16) << (24 - (i % 8) * 4);
|
||||
}
|
||||
|
||||
return new WordArray.init(words, hexStrLength / 2);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Latin1 encoding strategy.
|
||||
*/
|
||||
var Latin1 = C_enc.Latin1 = {
|
||||
/**
|
||||
* Converts a word array to a Latin1 string.
|
||||
*
|
||||
* @param {WordArray} wordArray The word array.
|
||||
*
|
||||
* @return {string} The Latin1 string.
|
||||
*
|
||||
* @static
|
||||
*
|
||||
* @example
|
||||
*
|
||||
* var latin1String = CryptoJS.enc.Latin1.stringify(wordArray);
|
||||
*/
|
||||
stringify: function (wordArray) {
|
||||
// Shortcuts
|
||||
var words = wordArray.words;
|
||||
var sigBytes = wordArray.sigBytes;
|
||||
|
||||
// Convert
|
||||
var latin1Chars = [];
|
||||
for (var i = 0; i < sigBytes; i++) {
|
||||
var bite = (words[i >>> 2] >>> (24 - (i % 4) * 8)) & 0xff;
|
||||
latin1Chars.push(String.fromCharCode(bite));
|
||||
}
|
||||
|
||||
return latin1Chars.join('');
|
||||
},
|
||||
|
||||
/**
|
||||
* Converts a Latin1 string to a word array.
|
||||
*
|
||||
* @param {string} latin1Str The Latin1 string.
|
||||
*
|
||||
* @return {WordArray} The word array.
|
||||
*
|
||||
* @static
|
||||
*
|
||||
* @example
|
||||
*
|
||||
* var wordArray = CryptoJS.enc.Latin1.parse(latin1String);
|
||||
*/
|
||||
parse: function (latin1Str) {
|
||||
// Shortcut
|
||||
var latin1StrLength = latin1Str.length;
|
||||
|
||||
// Convert
|
||||
var words = [];
|
||||
for (var i = 0; i < latin1StrLength; i++) {
|
||||
words[i >>> 2] |= (latin1Str.charCodeAt(i) & 0xff) << (24 - (i % 4) * 8);
|
||||
}
|
||||
|
||||
return new WordArray.init(words, latin1StrLength);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* UTF-8 encoding strategy.
|
||||
*/
|
||||
var Utf8 = C_enc.Utf8 = {
|
||||
/**
|
||||
* Converts a word array to a UTF-8 string.
|
||||
*
|
||||
* @param {WordArray} wordArray The word array.
|
||||
*
|
||||
* @return {string} The UTF-8 string.
|
||||
*
|
||||
* @static
|
||||
*
|
||||
* @example
|
||||
*
|
||||
* var utf8String = CryptoJS.enc.Utf8.stringify(wordArray);
|
||||
*/
|
||||
stringify: function (wordArray) {
|
||||
try {
|
||||
return decodeURIComponent(escape(Latin1.stringify(wordArray)));
|
||||
} catch (e) {
|
||||
throw new Error('Malformed UTF-8 data');
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Converts a UTF-8 string to a word array.
|
||||
*
|
||||
* @param {string} utf8Str The UTF-8 string.
|
||||
*
|
||||
* @return {WordArray} The word array.
|
||||
*
|
||||
* @static
|
||||
*
|
||||
* @example
|
||||
*
|
||||
* var wordArray = CryptoJS.enc.Utf8.parse(utf8String);
|
||||
*/
|
||||
parse: function (utf8Str) {
|
||||
return Latin1.parse(unescape(encodeURIComponent(utf8Str)));
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Abstract buffered block algorithm template.
|
||||
*
|
||||
* The property blockSize must be implemented in a concrete subtype.
|
||||
*
|
||||
* @property {number} _minBufferSize The number of blocks that should be kept unprocessed in the buffer. Default: 0
|
||||
*/
|
||||
var BufferedBlockAlgorithm = C_lib.BufferedBlockAlgorithm = Base.extend({
|
||||
/**
|
||||
* Resets this block algorithm's data buffer to its initial state.
|
||||
*
|
||||
* @example
|
||||
*
|
||||
* bufferedBlockAlgorithm.reset();
|
||||
*/
|
||||
reset: function () {
|
||||
// Initial values
|
||||
this._data = new WordArray.init();
|
||||
this._nDataBytes = 0;
|
||||
},
|
||||
|
||||
/**
|
||||
* Adds new data to this block algorithm's buffer.
|
||||
*
|
||||
* @param {WordArray|string} data The data to append. Strings are converted to a WordArray using UTF-8.
|
||||
*
|
||||
* @example
|
||||
*
|
||||
* bufferedBlockAlgorithm._append('data');
|
||||
* bufferedBlockAlgorithm._append(wordArray);
|
||||
*/
|
||||
_append: function (data) {
|
||||
// Convert string to WordArray, else assume WordArray already
|
||||
if (typeof data == 'string') {
|
||||
data = Utf8.parse(data);
|
||||
}
|
||||
|
||||
// Append
|
||||
this._data.concat(data);
|
||||
this._nDataBytes += data.sigBytes;
|
||||
},
|
||||
|
||||
/**
|
||||
* Processes available data blocks.
|
||||
*
|
||||
* This method invokes _doProcessBlock(offset), which must be implemented by a concrete subtype.
|
||||
*
|
||||
* @param {boolean} doFlush Whether all blocks and partial blocks should be processed.
|
||||
*
|
||||
* @return {WordArray} The processed data.
|
||||
*
|
||||
* @example
|
||||
*
|
||||
* var processedData = bufferedBlockAlgorithm._process();
|
||||
* var processedData = bufferedBlockAlgorithm._process(!!'flush');
|
||||
*/
|
||||
_process: function (doFlush) {
|
||||
// Shortcuts
|
||||
var data = this._data;
|
||||
var dataWords = data.words;
|
||||
var dataSigBytes = data.sigBytes;
|
||||
var blockSize = this.blockSize;
|
||||
var blockSizeBytes = blockSize * 4;
|
||||
|
||||
// Count blocks ready
|
||||
var nBlocksReady = dataSigBytes / blockSizeBytes;
|
||||
if (doFlush) {
|
||||
// Round up to include partial blocks
|
||||
nBlocksReady = Math.ceil(nBlocksReady);
|
||||
} else {
|
||||
// Round down to include only full blocks,
|
||||
// less the number of blocks that must remain in the buffer
|
||||
nBlocksReady = Math.max((nBlocksReady | 0) - this._minBufferSize, 0);
|
||||
}
|
||||
|
||||
// Count words ready
|
||||
var nWordsReady = nBlocksReady * blockSize;
|
||||
|
||||
// Count bytes ready
|
||||
var nBytesReady = Math.min(nWordsReady * 4, dataSigBytes);
|
||||
|
||||
// Process blocks
|
||||
if (nWordsReady) {
|
||||
for (var offset = 0; offset < nWordsReady; offset += blockSize) {
|
||||
// Perform concrete-algorithm logic
|
||||
this._doProcessBlock(dataWords, offset);
|
||||
}
|
||||
|
||||
// Remove processed words
|
||||
var processedWords = dataWords.splice(0, nWordsReady);
|
||||
data.sigBytes -= nBytesReady;
|
||||
}
|
||||
|
||||
// Return processed words
|
||||
return new WordArray.init(processedWords, nBytesReady);
|
||||
},
|
||||
|
||||
/**
|
||||
* Creates a copy of this object.
|
||||
*
|
||||
* @return {Object} The clone.
|
||||
*
|
||||
* @example
|
||||
*
|
||||
* var clone = bufferedBlockAlgorithm.clone();
|
||||
*/
|
||||
clone: function () {
|
||||
var clone = Base.clone.call(this);
|
||||
clone._data = this._data.clone();
|
||||
|
||||
return clone;
|
||||
},
|
||||
|
||||
_minBufferSize: 0
|
||||
});
|
||||
|
||||
/**
|
||||
* Abstract hasher template.
|
||||
*
|
||||
* @property {number} blockSize The number of 32-bit words this hasher operates on. Default: 16 (512 bits)
|
||||
*/
|
||||
var Hasher = C_lib.Hasher = BufferedBlockAlgorithm.extend({
|
||||
/**
|
||||
* Configuration options.
|
||||
*/
|
||||
cfg: Base.extend(),
|
||||
|
||||
/**
|
||||
* Initializes a newly created hasher.
|
||||
*
|
||||
* @param {Object} cfg (Optional) The configuration options to use for this hash computation.
|
||||
*
|
||||
* @example
|
||||
*
|
||||
* var hasher = CryptoJS.algo.SHA256.create();
|
||||
*/
|
||||
init: function (cfg) {
|
||||
// Apply config defaults
|
||||
this.cfg = this.cfg.extend(cfg);
|
||||
|
||||
// Set initial values
|
||||
this.reset();
|
||||
},
|
||||
|
||||
/**
|
||||
* Resets this hasher to its initial state.
|
||||
*
|
||||
* @example
|
||||
*
|
||||
* hasher.reset();
|
||||
*/
|
||||
reset: function () {
|
||||
// Reset data buffer
|
||||
BufferedBlockAlgorithm.reset.call(this);
|
||||
|
||||
// Perform concrete-hasher logic
|
||||
this._doReset();
|
||||
},
|
||||
|
||||
/**
|
||||
* Updates this hasher with a message.
|
||||
*
|
||||
* @param {WordArray|string} messageUpdate The message to append.
|
||||
*
|
||||
* @return {Hasher} This hasher.
|
||||
*
|
||||
* @example
|
||||
*
|
||||
* hasher.update('message');
|
||||
* hasher.update(wordArray);
|
||||
*/
|
||||
update: function (messageUpdate) {
|
||||
// Append
|
||||
this._append(messageUpdate);
|
||||
|
||||
// Update the hash
|
||||
this._process();
|
||||
|
||||
// Chainable
|
||||
return this;
|
||||
},
|
||||
|
||||
/**
|
||||
* Finalizes the hash computation.
|
||||
* Note that the finalize operation is effectively a destructive, read-once operation.
|
||||
*
|
||||
* @param {WordArray|string} messageUpdate (Optional) A final message update.
|
||||
*
|
||||
* @return {WordArray} The hash.
|
||||
*
|
||||
* @example
|
||||
*
|
||||
* var hash = hasher.finalize();
|
||||
* var hash = hasher.finalize('message');
|
||||
* var hash = hasher.finalize(wordArray);
|
||||
*/
|
||||
finalize: function (messageUpdate) {
|
||||
// Final message update
|
||||
if (messageUpdate) {
|
||||
this._append(messageUpdate);
|
||||
}
|
||||
|
||||
// Perform concrete-hasher logic
|
||||
var hash = this._doFinalize();
|
||||
|
||||
return hash;
|
||||
},
|
||||
|
||||
blockSize: 512/32,
|
||||
|
||||
/**
|
||||
* Creates a shortcut function to a hasher's object interface.
|
||||
*
|
||||
* @param {Hasher} hasher The hasher to create a helper for.
|
||||
*
|
||||
* @return {Function} The shortcut function.
|
||||
*
|
||||
* @static
|
||||
*
|
||||
* @example
|
||||
*
|
||||
* var SHA256 = CryptoJS.lib.Hasher._createHelper(CryptoJS.algo.SHA256);
|
||||
*/
|
||||
_createHelper: function (hasher) {
|
||||
return function (message, cfg) {
|
||||
return new hasher.init(cfg).finalize(message);
|
||||
};
|
||||
},
|
||||
|
||||
/**
|
||||
* Creates a shortcut function to the HMAC's object interface.
|
||||
*
|
||||
* @param {Hasher} hasher The hasher to use in this HMAC helper.
|
||||
*
|
||||
* @return {Function} The shortcut function.
|
||||
*
|
||||
* @static
|
||||
*
|
||||
* @example
|
||||
*
|
||||
* var HmacSHA256 = CryptoJS.lib.Hasher._createHmacHelper(CryptoJS.algo.SHA256);
|
||||
*/
|
||||
_createHmacHelper: function (hasher) {
|
||||
return function (message, key) {
|
||||
return new C_algo.HMAC.init(hasher, key).finalize(message);
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Algorithm namespace.
|
||||
*/
|
||||
var C_algo = C.algo = {};
|
||||
|
||||
return C;
|
||||
}(Math));
|
||||
|
||||
|
||||
return CryptoJS;
|
||||
|
||||
}));
|
||||
268
src/frontend/public/rnode-flasher/js/crypto-js@3.9.1-1/md5.js
Normal file
268
src/frontend/public/rnode-flasher/js/crypto-js@3.9.1-1/md5.js
Normal file
@@ -0,0 +1,268 @@
|
||||
;(function (root, factory) {
|
||||
if (typeof exports === "object") {
|
||||
// CommonJS
|
||||
module.exports = exports = factory(require("./core"));
|
||||
}
|
||||
else if (typeof define === "function" && define.amd) {
|
||||
// AMD
|
||||
define(["./core"], factory);
|
||||
}
|
||||
else {
|
||||
// Global (browser)
|
||||
factory(root.CryptoJS);
|
||||
}
|
||||
}(this, function (CryptoJS) {
|
||||
|
||||
(function (Math) {
|
||||
// Shortcuts
|
||||
var C = CryptoJS;
|
||||
var C_lib = C.lib;
|
||||
var WordArray = C_lib.WordArray;
|
||||
var Hasher = C_lib.Hasher;
|
||||
var C_algo = C.algo;
|
||||
|
||||
// Constants table
|
||||
var T = [];
|
||||
|
||||
// Compute constants
|
||||
(function () {
|
||||
for (var i = 0; i < 64; i++) {
|
||||
T[i] = (Math.abs(Math.sin(i + 1)) * 0x100000000) | 0;
|
||||
}
|
||||
}());
|
||||
|
||||
/**
|
||||
* MD5 hash algorithm.
|
||||
*/
|
||||
var MD5 = C_algo.MD5 = Hasher.extend({
|
||||
_doReset: function () {
|
||||
this._hash = new WordArray.init([
|
||||
0x67452301, 0xefcdab89,
|
||||
0x98badcfe, 0x10325476
|
||||
]);
|
||||
},
|
||||
|
||||
_doProcessBlock: function (M, offset) {
|
||||
// Swap endian
|
||||
for (var i = 0; i < 16; i++) {
|
||||
// Shortcuts
|
||||
var offset_i = offset + i;
|
||||
var M_offset_i = M[offset_i];
|
||||
|
||||
M[offset_i] = (
|
||||
(((M_offset_i << 8) | (M_offset_i >>> 24)) & 0x00ff00ff) |
|
||||
(((M_offset_i << 24) | (M_offset_i >>> 8)) & 0xff00ff00)
|
||||
);
|
||||
}
|
||||
|
||||
// Shortcuts
|
||||
var H = this._hash.words;
|
||||
|
||||
var M_offset_0 = M[offset + 0];
|
||||
var M_offset_1 = M[offset + 1];
|
||||
var M_offset_2 = M[offset + 2];
|
||||
var M_offset_3 = M[offset + 3];
|
||||
var M_offset_4 = M[offset + 4];
|
||||
var M_offset_5 = M[offset + 5];
|
||||
var M_offset_6 = M[offset + 6];
|
||||
var M_offset_7 = M[offset + 7];
|
||||
var M_offset_8 = M[offset + 8];
|
||||
var M_offset_9 = M[offset + 9];
|
||||
var M_offset_10 = M[offset + 10];
|
||||
var M_offset_11 = M[offset + 11];
|
||||
var M_offset_12 = M[offset + 12];
|
||||
var M_offset_13 = M[offset + 13];
|
||||
var M_offset_14 = M[offset + 14];
|
||||
var M_offset_15 = M[offset + 15];
|
||||
|
||||
// Working varialbes
|
||||
var a = H[0];
|
||||
var b = H[1];
|
||||
var c = H[2];
|
||||
var d = H[3];
|
||||
|
||||
// Computation
|
||||
a = FF(a, b, c, d, M_offset_0, 7, T[0]);
|
||||
d = FF(d, a, b, c, M_offset_1, 12, T[1]);
|
||||
c = FF(c, d, a, b, M_offset_2, 17, T[2]);
|
||||
b = FF(b, c, d, a, M_offset_3, 22, T[3]);
|
||||
a = FF(a, b, c, d, M_offset_4, 7, T[4]);
|
||||
d = FF(d, a, b, c, M_offset_5, 12, T[5]);
|
||||
c = FF(c, d, a, b, M_offset_6, 17, T[6]);
|
||||
b = FF(b, c, d, a, M_offset_7, 22, T[7]);
|
||||
a = FF(a, b, c, d, M_offset_8, 7, T[8]);
|
||||
d = FF(d, a, b, c, M_offset_9, 12, T[9]);
|
||||
c = FF(c, d, a, b, M_offset_10, 17, T[10]);
|
||||
b = FF(b, c, d, a, M_offset_11, 22, T[11]);
|
||||
a = FF(a, b, c, d, M_offset_12, 7, T[12]);
|
||||
d = FF(d, a, b, c, M_offset_13, 12, T[13]);
|
||||
c = FF(c, d, a, b, M_offset_14, 17, T[14]);
|
||||
b = FF(b, c, d, a, M_offset_15, 22, T[15]);
|
||||
|
||||
a = GG(a, b, c, d, M_offset_1, 5, T[16]);
|
||||
d = GG(d, a, b, c, M_offset_6, 9, T[17]);
|
||||
c = GG(c, d, a, b, M_offset_11, 14, T[18]);
|
||||
b = GG(b, c, d, a, M_offset_0, 20, T[19]);
|
||||
a = GG(a, b, c, d, M_offset_5, 5, T[20]);
|
||||
d = GG(d, a, b, c, M_offset_10, 9, T[21]);
|
||||
c = GG(c, d, a, b, M_offset_15, 14, T[22]);
|
||||
b = GG(b, c, d, a, M_offset_4, 20, T[23]);
|
||||
a = GG(a, b, c, d, M_offset_9, 5, T[24]);
|
||||
d = GG(d, a, b, c, M_offset_14, 9, T[25]);
|
||||
c = GG(c, d, a, b, M_offset_3, 14, T[26]);
|
||||
b = GG(b, c, d, a, M_offset_8, 20, T[27]);
|
||||
a = GG(a, b, c, d, M_offset_13, 5, T[28]);
|
||||
d = GG(d, a, b, c, M_offset_2, 9, T[29]);
|
||||
c = GG(c, d, a, b, M_offset_7, 14, T[30]);
|
||||
b = GG(b, c, d, a, M_offset_12, 20, T[31]);
|
||||
|
||||
a = HH(a, b, c, d, M_offset_5, 4, T[32]);
|
||||
d = HH(d, a, b, c, M_offset_8, 11, T[33]);
|
||||
c = HH(c, d, a, b, M_offset_11, 16, T[34]);
|
||||
b = HH(b, c, d, a, M_offset_14, 23, T[35]);
|
||||
a = HH(a, b, c, d, M_offset_1, 4, T[36]);
|
||||
d = HH(d, a, b, c, M_offset_4, 11, T[37]);
|
||||
c = HH(c, d, a, b, M_offset_7, 16, T[38]);
|
||||
b = HH(b, c, d, a, M_offset_10, 23, T[39]);
|
||||
a = HH(a, b, c, d, M_offset_13, 4, T[40]);
|
||||
d = HH(d, a, b, c, M_offset_0, 11, T[41]);
|
||||
c = HH(c, d, a, b, M_offset_3, 16, T[42]);
|
||||
b = HH(b, c, d, a, M_offset_6, 23, T[43]);
|
||||
a = HH(a, b, c, d, M_offset_9, 4, T[44]);
|
||||
d = HH(d, a, b, c, M_offset_12, 11, T[45]);
|
||||
c = HH(c, d, a, b, M_offset_15, 16, T[46]);
|
||||
b = HH(b, c, d, a, M_offset_2, 23, T[47]);
|
||||
|
||||
a = II(a, b, c, d, M_offset_0, 6, T[48]);
|
||||
d = II(d, a, b, c, M_offset_7, 10, T[49]);
|
||||
c = II(c, d, a, b, M_offset_14, 15, T[50]);
|
||||
b = II(b, c, d, a, M_offset_5, 21, T[51]);
|
||||
a = II(a, b, c, d, M_offset_12, 6, T[52]);
|
||||
d = II(d, a, b, c, M_offset_3, 10, T[53]);
|
||||
c = II(c, d, a, b, M_offset_10, 15, T[54]);
|
||||
b = II(b, c, d, a, M_offset_1, 21, T[55]);
|
||||
a = II(a, b, c, d, M_offset_8, 6, T[56]);
|
||||
d = II(d, a, b, c, M_offset_15, 10, T[57]);
|
||||
c = II(c, d, a, b, M_offset_6, 15, T[58]);
|
||||
b = II(b, c, d, a, M_offset_13, 21, T[59]);
|
||||
a = II(a, b, c, d, M_offset_4, 6, T[60]);
|
||||
d = II(d, a, b, c, M_offset_11, 10, T[61]);
|
||||
c = II(c, d, a, b, M_offset_2, 15, T[62]);
|
||||
b = II(b, c, d, a, M_offset_9, 21, T[63]);
|
||||
|
||||
// Intermediate hash value
|
||||
H[0] = (H[0] + a) | 0;
|
||||
H[1] = (H[1] + b) | 0;
|
||||
H[2] = (H[2] + c) | 0;
|
||||
H[3] = (H[3] + d) | 0;
|
||||
},
|
||||
|
||||
_doFinalize: function () {
|
||||
// Shortcuts
|
||||
var data = this._data;
|
||||
var dataWords = data.words;
|
||||
|
||||
var nBitsTotal = this._nDataBytes * 8;
|
||||
var nBitsLeft = data.sigBytes * 8;
|
||||
|
||||
// Add padding
|
||||
dataWords[nBitsLeft >>> 5] |= 0x80 << (24 - nBitsLeft % 32);
|
||||
|
||||
var nBitsTotalH = Math.floor(nBitsTotal / 0x100000000);
|
||||
var nBitsTotalL = nBitsTotal;
|
||||
dataWords[(((nBitsLeft + 64) >>> 9) << 4) + 15] = (
|
||||
(((nBitsTotalH << 8) | (nBitsTotalH >>> 24)) & 0x00ff00ff) |
|
||||
(((nBitsTotalH << 24) | (nBitsTotalH >>> 8)) & 0xff00ff00)
|
||||
);
|
||||
dataWords[(((nBitsLeft + 64) >>> 9) << 4) + 14] = (
|
||||
(((nBitsTotalL << 8) | (nBitsTotalL >>> 24)) & 0x00ff00ff) |
|
||||
(((nBitsTotalL << 24) | (nBitsTotalL >>> 8)) & 0xff00ff00)
|
||||
);
|
||||
|
||||
data.sigBytes = (dataWords.length + 1) * 4;
|
||||
|
||||
// Hash final blocks
|
||||
this._process();
|
||||
|
||||
// Shortcuts
|
||||
var hash = this._hash;
|
||||
var H = hash.words;
|
||||
|
||||
// Swap endian
|
||||
for (var i = 0; i < 4; i++) {
|
||||
// Shortcut
|
||||
var H_i = H[i];
|
||||
|
||||
H[i] = (((H_i << 8) | (H_i >>> 24)) & 0x00ff00ff) |
|
||||
(((H_i << 24) | (H_i >>> 8)) & 0xff00ff00);
|
||||
}
|
||||
|
||||
// Return final computed hash
|
||||
return hash;
|
||||
},
|
||||
|
||||
clone: function () {
|
||||
var clone = Hasher.clone.call(this);
|
||||
clone._hash = this._hash.clone();
|
||||
|
||||
return clone;
|
||||
}
|
||||
});
|
||||
|
||||
function FF(a, b, c, d, x, s, t) {
|
||||
var n = a + ((b & c) | (~b & d)) + x + t;
|
||||
return ((n << s) | (n >>> (32 - s))) + b;
|
||||
}
|
||||
|
||||
function GG(a, b, c, d, x, s, t) {
|
||||
var n = a + ((b & d) | (c & ~d)) + x + t;
|
||||
return ((n << s) | (n >>> (32 - s))) + b;
|
||||
}
|
||||
|
||||
function HH(a, b, c, d, x, s, t) {
|
||||
var n = a + (b ^ c ^ d) + x + t;
|
||||
return ((n << s) | (n >>> (32 - s))) + b;
|
||||
}
|
||||
|
||||
function II(a, b, c, d, x, s, t) {
|
||||
var n = a + (c ^ (b | ~d)) + x + t;
|
||||
return ((n << s) | (n >>> (32 - s))) + b;
|
||||
}
|
||||
|
||||
/**
|
||||
* Shortcut function to the hasher's object interface.
|
||||
*
|
||||
* @param {WordArray|string} message The message to hash.
|
||||
*
|
||||
* @return {WordArray} The hash.
|
||||
*
|
||||
* @static
|
||||
*
|
||||
* @example
|
||||
*
|
||||
* var hash = CryptoJS.MD5('message');
|
||||
* var hash = CryptoJS.MD5(wordArray);
|
||||
*/
|
||||
C.MD5 = Hasher._createHelper(MD5);
|
||||
|
||||
/**
|
||||
* Shortcut function to the HMAC's object interface.
|
||||
*
|
||||
* @param {WordArray|string} message The message to hash.
|
||||
* @param {WordArray|string} key The secret key.
|
||||
*
|
||||
* @return {WordArray} The HMAC.
|
||||
*
|
||||
* @static
|
||||
*
|
||||
* @example
|
||||
*
|
||||
* var hmac = CryptoJS.HmacMD5(message, key);
|
||||
*/
|
||||
C.HmacMD5 = Hasher._createHmacHelper(MD5);
|
||||
}(Math));
|
||||
|
||||
|
||||
return CryptoJS.MD5;
|
||||
|
||||
}));
|
||||
File diff suppressed because one or more lines are too long
446
src/frontend/public/rnode-flasher/js/nrf52_dfu_flasher.js
Normal file
446
src/frontend/public/rnode-flasher/js/nrf52_dfu_flasher.js
Normal file
@@ -0,0 +1,446 @@
|
||||
/**
|
||||
* A Web Serial based nRF52 flasher written by liam@liamcottle.com based on dfu_transport.serial.py
|
||||
* https://github.com/adafruit/Adafruit_nRF52_nrfutil/blob/master/nordicsemi/dfu/dfu_transport_serial.py
|
||||
*/
|
||||
class Nrf52DfuFlasher {
|
||||
|
||||
DFU_TOUCH_BAUD = 1200;
|
||||
SERIAL_PORT_OPEN_WAIT_TIME = 0.1;
|
||||
TOUCH_RESET_WAIT_TIME = 1.5;
|
||||
|
||||
FLASH_BAUD = 115200;
|
||||
|
||||
HEX_TYPE_APPLICATION = 4;
|
||||
|
||||
DFU_INIT_PACKET = 1;
|
||||
DFU_START_PACKET = 3;
|
||||
DFU_DATA_PACKET = 4;
|
||||
DFU_STOP_DATA_PACKET = 5;
|
||||
|
||||
DATA_INTEGRITY_CHECK_PRESENT = 1;
|
||||
RELIABLE_PACKET = 1;
|
||||
HCI_PACKET_TYPE = 14;
|
||||
|
||||
FLASH_PAGE_SIZE = 4096;
|
||||
FLASH_PAGE_ERASE_TIME = 0.0897;
|
||||
FLASH_WORD_WRITE_TIME = 0.000100;
|
||||
FLASH_PAGE_WRITE_TIME = (this.FLASH_PAGE_SIZE/4) * this.FLASH_WORD_WRITE_TIME;
|
||||
|
||||
// The DFU packet max size
|
||||
DFU_PACKET_MAX_SIZE = 512;
|
||||
|
||||
constructor(serialPort) {
|
||||
this.serialPort = serialPort;
|
||||
this.sequenceNumber = 0;
|
||||
this.sd_size = 0;
|
||||
this.total_size = 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Waits for the provided milliseconds, and then resolves.
|
||||
* @param millis
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
async sleepMillis(millis) {
|
||||
await new Promise((resolve) => {
|
||||
setTimeout(resolve, millis);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Writes the provided data to the Serial Port.
|
||||
* @param data
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
async sendPacket(data) {
|
||||
const writer = this.serialPort.writable.getWriter();
|
||||
try {
|
||||
await writer.write(new Uint8Array(data));
|
||||
} finally {
|
||||
writer.releaseLock();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Puts an nRF52 board into DFU mode by quickly opening and closing a serial port.
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
async enterDfuMode() {
|
||||
|
||||
// open port
|
||||
await this.serialPort.open({
|
||||
baudRate: this.DFU_TOUCH_BAUD,
|
||||
});
|
||||
|
||||
// wait SERIAL_PORT_OPEN_WAIT_TIME before closing port
|
||||
await this.sleepMillis(this.SERIAL_PORT_OPEN_WAIT_TIME * 1000);
|
||||
|
||||
// close port
|
||||
await this.serialPort.close();
|
||||
|
||||
// wait TOUCH_RESET_WAIT_TIME for device to enter into DFU mode
|
||||
await this.sleepMillis(this.TOUCH_RESET_WAIT_TIME * 1000);
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
* Flashes the provided firmware zip.
|
||||
* @param firmwareZipBlob
|
||||
* @param progressCallback
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
async flash(firmwareZipBlob, progressCallback) {
|
||||
|
||||
// read zip file
|
||||
const blobReader = new window.zip.BlobReader(firmwareZipBlob);
|
||||
const zipReader = new window.zip.ZipReader(blobReader);
|
||||
const zipEntries = await zipReader.getEntries();
|
||||
|
||||
// find manifest file
|
||||
const manifestFile = zipEntries.find((zipEntry) => zipEntry.filename === "manifest.json");
|
||||
if(!manifestFile){
|
||||
throw "manifest.json not found in firmware file!";
|
||||
}
|
||||
|
||||
// read manifest file as text
|
||||
const text = await manifestFile.getData(new window.zip.TextWriter());
|
||||
|
||||
// parse manifest json
|
||||
const json = JSON.parse(text);
|
||||
const manifest = json.manifest;
|
||||
|
||||
// todo softdevice_bootloader
|
||||
// if self.manifest.softdevice_bootloader:
|
||||
// self._dfu_send_image(HexType.SD_BL, self.manifest.softdevice_bootloader)
|
||||
|
||||
// todo softdevice
|
||||
// if self.manifest.softdevice:
|
||||
// self._dfu_send_image(HexType.SOFTDEVICE, self.manifest.softdevice)
|
||||
|
||||
// todo bootloader
|
||||
// if self.manifest.bootloader:
|
||||
// self._dfu_send_image(HexType.BOOTLOADER, self.manifest.bootloader)
|
||||
|
||||
// flash application image
|
||||
if(manifest.application){
|
||||
await this.dfuSendImage(this.HEX_TYPE_APPLICATION, zipEntries, manifest.application, progressCallback);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
* Sends the firmware image to the device in DFU mode.
|
||||
* @param programMode
|
||||
* @param zipEntries
|
||||
* @param firmwareManifest
|
||||
* @param progressCallback
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
async dfuSendImage(programMode, zipEntries, firmwareManifest, progressCallback) {
|
||||
|
||||
// open port
|
||||
await this.serialPort.open({
|
||||
baudRate: this.FLASH_BAUD,
|
||||
});
|
||||
|
||||
// wait SERIAL_PORT_OPEN_WAIT_TIME
|
||||
await this.sleepMillis(this.SERIAL_PORT_OPEN_WAIT_TIME * 1000);
|
||||
|
||||
// file sizes
|
||||
var softdeviceSize = 0
|
||||
var bootloaderSize = 0
|
||||
var applicationSize = 0
|
||||
|
||||
// read bin file (firmware)
|
||||
const binFile = zipEntries.find((zipEntry) => zipEntry.filename === firmwareManifest.bin_file);
|
||||
const firmware = await binFile.getData(new window.zip.Uint8ArrayWriter());
|
||||
|
||||
// read dat file (init packet)
|
||||
const datFile = zipEntries.find((zipEntry) => zipEntry.filename === firmwareManifest.dat_file);
|
||||
const init_packet = await datFile.getData(new window.zip.Uint8ArrayWriter());
|
||||
|
||||
// only support flashing application for now
|
||||
if(programMode !== this.HEX_TYPE_APPLICATION){
|
||||
throw "not implemented";
|
||||
}
|
||||
|
||||
// determine application size
|
||||
if(programMode === this.HEX_TYPE_APPLICATION){
|
||||
applicationSize = firmware.length;
|
||||
}
|
||||
|
||||
console.log("Sending DFU start packet");
|
||||
await this.sendStartDfu(programMode, softdeviceSize, bootloaderSize, applicationSize);
|
||||
|
||||
console.log("Sending DFU init packet");
|
||||
await this.sendInitPacket(init_packet);
|
||||
|
||||
console.log("Sending firmware");
|
||||
await this.sendFirmware(firmware, progressCallback);
|
||||
|
||||
// todo
|
||||
// sleep(self.dfu_transport.get_activate_wait_time())
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculates CRC16 on the provided binaryData
|
||||
* @param {Uint8Array} binaryData - Array with data to run CRC16 calculation on
|
||||
* @param {number} crc - CRC value to start calculation with
|
||||
* @return {number} - Calculated CRC value of binaryData
|
||||
*/
|
||||
calcCrc16(binaryData, crc = 0xffff) {
|
||||
|
||||
if(!(binaryData instanceof Uint8Array)){
|
||||
throw new Error("calcCrc16 requires Uint8Array input");
|
||||
}
|
||||
|
||||
for(let b of binaryData){
|
||||
crc = (crc >> 8 & 0x00FF) | (crc << 8 & 0xFF00);
|
||||
crc ^= b;
|
||||
crc ^= (crc & 0x00FF) >> 4;
|
||||
crc ^= (crc << 8) << 4;
|
||||
crc ^= ((crc & 0x00FF) << 4) << 1;
|
||||
}
|
||||
|
||||
return crc & 0xFFFF;
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
* Encode esc characters in a SLIP package.
|
||||
* Replace 0xC0 with 0xDBDC and 0xDB with 0xDBDD.
|
||||
* @param dataIn
|
||||
* @returns {*[]}
|
||||
*/
|
||||
slipEncodeEscChars(dataIn) {
|
||||
|
||||
let result = [];
|
||||
|
||||
for(let i = 0; i < dataIn.length; i++){
|
||||
let char = dataIn[i];
|
||||
if(char === 0xC0){
|
||||
result.push(0xDB);
|
||||
result.push(0xDC);
|
||||
} else if(char === 0xDB) {
|
||||
result.push(0xDB);
|
||||
result.push(0xDD);
|
||||
} else {
|
||||
result.push(char);
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates an HCI packet from the provided frame data.
|
||||
* https://github.com/adafruit/Adafruit_nRF52_nrfutil/blob/master/nordicsemi/dfu/dfu_transport_serial.py#L332
|
||||
* @param frame
|
||||
* @returns {*[]}
|
||||
*/
|
||||
createHciPacketFromFrame(frame) {
|
||||
|
||||
// increase sequence number, but roll over at 8
|
||||
this.sequenceNumber = (this.sequenceNumber + 1) % 8;
|
||||
|
||||
// create slip header
|
||||
const slipHeaderBytes = this.createSlipHeader(
|
||||
this.sequenceNumber,
|
||||
this.DATA_INTEGRITY_CHECK_PRESENT,
|
||||
this.RELIABLE_PACKET,
|
||||
this.HCI_PACKET_TYPE,
|
||||
frame.length,
|
||||
);
|
||||
|
||||
// create packet data
|
||||
let data = [
|
||||
...slipHeaderBytes,
|
||||
...frame,
|
||||
];
|
||||
|
||||
// add crc of data
|
||||
const crc = this.calcCrc16(new Uint8Array(data), 0xffff);
|
||||
data.push(crc & 0xFF);
|
||||
data.push((crc & 0xFF00) >> 8);
|
||||
|
||||
// add escape characters
|
||||
return [
|
||||
0xc0,
|
||||
...this.slipEncodeEscChars(data),
|
||||
0xc0,
|
||||
];
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate how long we should wait for erasing data.
|
||||
* @returns {number}
|
||||
*/
|
||||
getEraseWaitTime() {
|
||||
// always wait at least 0.5 seconds
|
||||
return Math.max(0.5, ((this.total_size / this.FLASH_PAGE_SIZE) + 1) * this.FLASH_PAGE_ERASE_TIME);
|
||||
}
|
||||
|
||||
/**
|
||||
* Constructs the image size packet sent in the DFU Start packet.
|
||||
* @param softdeviceSize
|
||||
* @param bootloaderSize
|
||||
* @param appSize
|
||||
* @returns {number[]}
|
||||
*/
|
||||
createImageSizePacket(softdeviceSize = 0, bootloaderSize = 0, appSize = 0) {
|
||||
return [
|
||||
...this.int32ToBytes(softdeviceSize),
|
||||
...this.int32ToBytes(bootloaderSize),
|
||||
...this.int32ToBytes(appSize),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Sends the DFU Start packet to the device.
|
||||
* @param mode
|
||||
* @param softdevice_size
|
||||
* @param bootloader_size
|
||||
* @param app_size
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
async sendStartDfu(mode, softdevice_size = 0, bootloader_size = 0, app_size = 0){
|
||||
|
||||
// create frame
|
||||
const frame = [
|
||||
...this.int32ToBytes(this.DFU_START_PACKET),
|
||||
...this.int32ToBytes(mode),
|
||||
...this.createImageSizePacket(softdevice_size, bootloader_size, app_size),
|
||||
];
|
||||
|
||||
// send hci packet
|
||||
await this.sendPacket(this.createHciPacketFromFrame(frame));
|
||||
|
||||
// remember file sizes for calculating erase wait time
|
||||
this.sd_size = softdevice_size;
|
||||
this.total_size = softdevice_size + bootloader_size + app_size;
|
||||
|
||||
// wait for initial erase
|
||||
await this.sleepMillis(this.getEraseWaitTime() * 1000);
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
* Sends the DFU Init packet to the device.
|
||||
* @param initPacket
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
async sendInitPacket(initPacket){
|
||||
|
||||
// create frame
|
||||
const frame = [
|
||||
...this.int32ToBytes(this.DFU_INIT_PACKET),
|
||||
...initPacket,
|
||||
...this.int16ToBytes(0x0000), // padding required
|
||||
];
|
||||
|
||||
// send hci packet
|
||||
await this.sendPacket(this.createHciPacketFromFrame(frame));
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
* Sends the firmware file to the device in multiple chunks.
|
||||
* @param firmware
|
||||
* @param progressCallback
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
async sendFirmware(firmware, progressCallback) {
|
||||
|
||||
const packets = [];
|
||||
var packetsSent = 0;
|
||||
|
||||
// chunk firmware into separate packets
|
||||
for(let i = 0; i < firmware.length; i += this.DFU_PACKET_MAX_SIZE){
|
||||
packets.push(this.createHciPacketFromFrame([
|
||||
...this.int32ToBytes(this.DFU_DATA_PACKET),
|
||||
...firmware.slice(i, i + this.DFU_PACKET_MAX_SIZE),
|
||||
]));
|
||||
}
|
||||
|
||||
// send initial progress
|
||||
if(progressCallback){
|
||||
progressCallback(0);
|
||||
}
|
||||
|
||||
// send each packet one after the other
|
||||
for(var i = 0; i < packets.length; i++){
|
||||
|
||||
// send packet
|
||||
await this.sendPacket(packets[i]);
|
||||
|
||||
// wait a bit to allow device to write before sending next packet
|
||||
await this.sleepMillis(this.FLASH_PAGE_WRITE_TIME * 1000);
|
||||
|
||||
// update progress
|
||||
packetsSent++;
|
||||
if(progressCallback){
|
||||
const progress = Math.floor((packetsSent / packets.length) * 100);
|
||||
progressCallback(progress);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// finished sending firmware, send DFU Stop Data packet
|
||||
await this.sendPacket(this.createHciPacketFromFrame([
|
||||
...this.int32ToBytes(this.DFU_STOP_DATA_PACKET),
|
||||
]));
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a SLIP header.
|
||||
*
|
||||
* For a description of the SLIP header go to:
|
||||
* http://developer.nordicsemi.com/nRF51_SDK/doc/7.2.0/s110/html/a00093.html
|
||||
*
|
||||
* @param {number} seq - Packet sequence number
|
||||
* @param {number} dip - Data integrity check
|
||||
* @param {number} rp - Reliable packet
|
||||
* @param {number} pktType - Payload packet
|
||||
* @param {number} pktLen - Packet length
|
||||
* @return {Uint8Array} - SLIP header
|
||||
*/
|
||||
createSlipHeader(seq, dip, rp, pktType, pktLen) {
|
||||
let ints = [0, 0, 0, 0];
|
||||
ints[0] = seq | (((seq + 1) % 8) << 3) | (dip << 6) | (rp << 7);
|
||||
ints[1] = pktType | ((pktLen & 0x000F) << 4);
|
||||
ints[2] = (pktLen & 0x0FF0) >> 4;
|
||||
ints[3] = (~(ints[0] + ints[1] + ints[2]) + 1) & 0xFF;
|
||||
return new Uint8Array(ints);
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts the provided int32 to 4 bytes.
|
||||
* @param num
|
||||
* @returns {number[]}
|
||||
*/
|
||||
int32ToBytes(num){
|
||||
return [
|
||||
(num & 0x000000ff),
|
||||
(num & 0x0000ff00) >> 8,
|
||||
(num & 0x00ff0000) >> 16,
|
||||
(num & 0xff000000) >> 24,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts the provided int16 to 2 bytes.
|
||||
* @param num
|
||||
* @returns {number[]}
|
||||
*/
|
||||
int16ToBytes(num){
|
||||
return [
|
||||
(num & 0x00FF),
|
||||
(num & 0xFF00) >> 8,
|
||||
];
|
||||
}
|
||||
|
||||
}
|
||||
961
src/frontend/public/rnode-flasher/js/rnode.js
Normal file
961
src/frontend/public/rnode-flasher/js/rnode.js
Normal file
@@ -0,0 +1,961 @@
|
||||
class Utils {
|
||||
|
||||
/**
|
||||
* Waits for the provided milliseconds, and then resolves.
|
||||
* @param millis
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
static async sleepMillis(millis) {
|
||||
await new Promise((resolve) => {
|
||||
setTimeout(resolve, millis);
|
||||
});
|
||||
}
|
||||
|
||||
static bytesToHex(bytes) {
|
||||
for(var hex = [], i = 0; i < bytes.length; i++){
|
||||
var current = bytes[i] < 0 ? bytes[i] + 256 : bytes[i];
|
||||
hex.push((current >>> 4).toString(16));
|
||||
hex.push((current & 0xF).toString(16));
|
||||
}
|
||||
return hex.join("");
|
||||
}
|
||||
|
||||
static md5(data) {
|
||||
var bytes = [];
|
||||
const hash = CryptoJS.MD5(CryptoJS.enc.Hex.parse(this.bytesToHex(data)));
|
||||
for(var i = 0; i < hash.sigBytes; i++){
|
||||
bytes.push((hash.words[i >>> 2] >>> (24 - (i % 4) * 8)) & 0xff);
|
||||
}
|
||||
return bytes;
|
||||
}
|
||||
|
||||
static packUInt32BE(value) {
|
||||
const buffer = new ArrayBuffer(4);
|
||||
const view = new DataView(buffer);
|
||||
view.setUint32(0, value, false);
|
||||
return new Uint8Array(buffer);
|
||||
}
|
||||
|
||||
static unpackUInt32BE(byteArray) {
|
||||
const buffer = new Uint8Array(byteArray).buffer;
|
||||
const view = new DataView(buffer);
|
||||
return view.getUint32(0, false);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
class RNode {
|
||||
|
||||
KISS_FEND = 0xC0;
|
||||
KISS_FESC = 0xDB;
|
||||
KISS_TFEND = 0xDC;
|
||||
KISS_TFESC = 0xDD;
|
||||
|
||||
CMD_FREQUENCY = 0x01;
|
||||
CMD_BANDWIDTH = 0x02;
|
||||
CMD_TXPOWER = 0x03;
|
||||
CMD_SF = 0x04;
|
||||
CMD_CR = 0x05;
|
||||
CMD_RADIO_STATE = 0x06;
|
||||
|
||||
CMD_STAT_RX = 0x21;
|
||||
CMD_STAT_TX = 0x22
|
||||
CMD_STAT_RSSI = 0x23;
|
||||
CMD_STAT_SNR = 0x24;
|
||||
|
||||
CMD_BOARD = 0x47;
|
||||
CMD_PLATFORM = 0x48;
|
||||
CMD_MCU = 0x49;
|
||||
CMD_RESET = 0x55;
|
||||
CMD_RESET_BYTE = 0xF8;
|
||||
CMD_DEV_HASH = 0x56;
|
||||
CMD_FW_VERSION = 0x50;
|
||||
CMD_ROM_READ = 0x51;
|
||||
CMD_ROM_WRITE = 0x52;
|
||||
CMD_CONF_SAVE = 0x53;
|
||||
CMD_CONF_DELETE = 0x54;
|
||||
CMD_FW_HASH = 0x58;
|
||||
CMD_UNLOCK_ROM = 0x59;
|
||||
ROM_UNLOCK_BYTE = 0xF8;
|
||||
CMD_HASHES = 0x60;
|
||||
CMD_FW_UPD = 0x61;
|
||||
|
||||
CMD_BT_CTRL = 0x46;
|
||||
CMD_BT_PIN = 0x62;
|
||||
|
||||
CMD_DISP_READ = 0x66;
|
||||
|
||||
CMD_DETECT = 0x08;
|
||||
DETECT_REQ = 0x73;
|
||||
DETECT_RESP = 0x46;
|
||||
|
||||
RADIO_STATE_OFF = 0x00;
|
||||
RADIO_STATE_ON = 0x01;
|
||||
RADIO_STATE_ASK = 0xFF;
|
||||
|
||||
CMD_ERROR = 0x90
|
||||
ERROR_INITRADIO = 0x01
|
||||
ERROR_TXFAILED = 0x02
|
||||
ERROR_EEPROM_LOCKED = 0x03
|
||||
|
||||
PLATFORM_AVR = 0x90;
|
||||
PLATFORM_ESP32 = 0x80;
|
||||
PLATFORM_NRF52 = 0x70;
|
||||
|
||||
MCU_1284P = 0x91;
|
||||
MCU_2560 = 0x92;
|
||||
MCU_ESP32 = 0x81;
|
||||
MCU_NRF52 = 0x71;
|
||||
|
||||
BOARD_RNODE = 0x31;
|
||||
BOARD_HMBRW = 0x32;
|
||||
BOARD_TBEAM = 0x33;
|
||||
BOARD_HUZZAH32 = 0x34;
|
||||
BOARD_GENERIC_ESP32 = 0x35;
|
||||
BOARD_LORA32_V2_0 = 0x36;
|
||||
BOARD_LORA32_V2_1 = 0x37;
|
||||
BOARD_RAK4631 = 0x51;
|
||||
|
||||
HASH_TYPE_TARGET_FIRMWARE = 0x01;
|
||||
HASH_TYPE_FIRMWARE = 0x02;
|
||||
|
||||
constructor(serialPort) {
|
||||
this.serialPort = serialPort;
|
||||
this.readable = serialPort.readable;
|
||||
this.writable = serialPort.writable;
|
||||
}
|
||||
|
||||
static async fromSerialPort(serialPort) {
|
||||
|
||||
// open port
|
||||
await serialPort.open({
|
||||
baudRate: 115200,
|
||||
});
|
||||
|
||||
return new RNode(serialPort);
|
||||
|
||||
}
|
||||
|
||||
async close() {
|
||||
try {
|
||||
await this.serialPort.close();
|
||||
} catch(e) {
|
||||
console.log("failed to close serial port, ignoring...", e);
|
||||
}
|
||||
}
|
||||
|
||||
async write(bytes) {
|
||||
const writer = this.writable.getWriter();
|
||||
try {
|
||||
await writer.write(new Uint8Array(bytes));
|
||||
} finally {
|
||||
writer.releaseLock();
|
||||
}
|
||||
}
|
||||
|
||||
async readFromSerialPort(timeoutMillis) {
|
||||
return new Promise(async (resolve, reject) => {
|
||||
|
||||
// create reader
|
||||
const reader = this.readable.getReader();
|
||||
|
||||
// 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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error reading from serial port: ', error);
|
||||
} finally {
|
||||
reader.releaseLock();
|
||||
}
|
||||
|
||||
});
|
||||
}
|
||||
|
||||
handleKISSFrame(frame) {
|
||||
|
||||
let 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) {
|
||||
data.push(this.KISS_FEND);
|
||||
} else if (byte === this.KISS_TFESC) {
|
||||
data.push(this.KISS_FESC);
|
||||
}
|
||||
escaping = false;
|
||||
} else {
|
||||
if (byte === this.KISS_FESC) {
|
||||
escaping = true;
|
||||
} else if (byte === this.KISS_FEND) {
|
||||
// Ignore the end frame delimiter
|
||||
break;
|
||||
} else {
|
||||
data.push(byte);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
//console.log('Received KISS frame data:', new Uint8Array(data));
|
||||
return data;
|
||||
|
||||
}
|
||||
|
||||
createKissFrame(data) {
|
||||
let frame = [this.KISS_FEND];
|
||||
for(let byte of data){
|
||||
if(byte === this.KISS_FEND){
|
||||
frame.push(this.KISS_FESC, this.KISS_TFEND);
|
||||
} else if(byte === this.KISS_FESC){
|
||||
frame.push(this.KISS_FESC, this.KISS_TFESC);
|
||||
} else {
|
||||
frame.push(byte);
|
||||
}
|
||||
}
|
||||
frame.push(this.KISS_FEND);
|
||||
return new Uint8Array(frame);
|
||||
}
|
||||
|
||||
async sendKissCommand(data) {
|
||||
await this.write(this.createKissFrame(data));
|
||||
}
|
||||
|
||||
async reset() {
|
||||
await this.sendKissCommand([
|
||||
this.CMD_RESET,
|
||||
this.CMD_RESET_BYTE,
|
||||
]);
|
||||
}
|
||||
|
||||
async detect() {
|
||||
|
||||
// ask if device is rnode
|
||||
await this.sendKissCommand([
|
||||
this.CMD_DETECT,
|
||||
this.DETECT_REQ,
|
||||
]);
|
||||
|
||||
// read response from device
|
||||
const [ command, responseByte ] = await this.readFromSerialPort();
|
||||
|
||||
// device is an rnode if response is as expected
|
||||
return command === this.CMD_DETECT && responseByte === this.DETECT_RESP;
|
||||
|
||||
}
|
||||
|
||||
async getFirmwareVersion() {
|
||||
|
||||
await this.sendKissCommand([
|
||||
this.CMD_FW_VERSION,
|
||||
0x00,
|
||||
]);
|
||||
|
||||
// read response from device
|
||||
var [ command, majorVersion, minorVersion ] = await this.readFromSerialPort();
|
||||
if(minorVersion.length === 1){
|
||||
minorVersion = "0" + minorVersion;
|
||||
}
|
||||
|
||||
// 1.23
|
||||
return majorVersion + "." + minorVersion;
|
||||
|
||||
}
|
||||
|
||||
async getPlatform() {
|
||||
|
||||
await this.sendKissCommand([
|
||||
this.CMD_PLATFORM,
|
||||
0x00,
|
||||
]);
|
||||
|
||||
// read response from device
|
||||
const [ command, platformByte ] = await this.readFromSerialPort();
|
||||
return platformByte;
|
||||
|
||||
}
|
||||
|
||||
async getMcu() {
|
||||
|
||||
await this.sendKissCommand([
|
||||
this.CMD_MCU,
|
||||
0x00,
|
||||
]);
|
||||
|
||||
// read response from device
|
||||
const [ command, mcuByte ] = await this.readFromSerialPort();
|
||||
return mcuByte;
|
||||
|
||||
}
|
||||
|
||||
async getBoard() {
|
||||
|
||||
await this.sendKissCommand([
|
||||
this.CMD_BOARD,
|
||||
0x00,
|
||||
]);
|
||||
|
||||
// read response from device
|
||||
const [ command, boardByte ] = await this.readFromSerialPort();
|
||||
return boardByte;
|
||||
|
||||
}
|
||||
|
||||
async getDeviceHash() {
|
||||
|
||||
await this.sendKissCommand([
|
||||
this.CMD_DEV_HASH,
|
||||
0x01, // anything != 0x00
|
||||
]);
|
||||
|
||||
// read response from device
|
||||
const [ command, ...deviceHash ] = await this.readFromSerialPort();
|
||||
return deviceHash;
|
||||
|
||||
}
|
||||
|
||||
async getTargetFirmwareHash() {
|
||||
|
||||
await this.sendKissCommand([
|
||||
this.CMD_HASHES,
|
||||
this.HASH_TYPE_TARGET_FIRMWARE,
|
||||
]);
|
||||
|
||||
// read response from device
|
||||
const [ command, hashType, ...targetFirmwareHash ] = await this.readFromSerialPort();
|
||||
return targetFirmwareHash;
|
||||
|
||||
}
|
||||
|
||||
async getFirmwareHash() {
|
||||
|
||||
await this.sendKissCommand([
|
||||
this.CMD_HASHES,
|
||||
this.HASH_TYPE_FIRMWARE,
|
||||
]);
|
||||
|
||||
// read response from device
|
||||
const [ command, hashType, ...firmwareHash ] = await this.readFromSerialPort();
|
||||
return firmwareHash;
|
||||
|
||||
}
|
||||
|
||||
async getRom() {
|
||||
|
||||
await this.sendKissCommand([
|
||||
this.CMD_ROM_READ,
|
||||
0x00,
|
||||
]);
|
||||
|
||||
// read response from device
|
||||
const [ command, ...eepromBytes ] = await this.readFromSerialPort();
|
||||
return eepromBytes;
|
||||
|
||||
}
|
||||
|
||||
async getFrequency() {
|
||||
|
||||
await this.sendKissCommand([
|
||||
this.CMD_FREQUENCY,
|
||||
// request frequency by sending zero as 4 bytes
|
||||
0x00,
|
||||
0x00,
|
||||
0x00,
|
||||
0x00,
|
||||
]);
|
||||
|
||||
// read response from device
|
||||
const [ command, ...frequencyBytes ] = await this.readFromSerialPort();
|
||||
|
||||
// convert 4 bytes to 32bit integer representing frequency in hertz
|
||||
const frequencyInHz = frequencyBytes[0] << 24 | frequencyBytes[1] << 16 | frequencyBytes[2] << 8 | frequencyBytes[3];
|
||||
return frequencyInHz;
|
||||
|
||||
}
|
||||
|
||||
async getBandwidth() {
|
||||
|
||||
await this.sendKissCommand([
|
||||
this.CMD_BANDWIDTH,
|
||||
// request bandwidth by sending zero as 4 bytes
|
||||
0x00,
|
||||
0x00,
|
||||
0x00,
|
||||
0x00,
|
||||
]);
|
||||
|
||||
// read response from device
|
||||
const [ command, ...bandwidthBytes ] = await this.readFromSerialPort();
|
||||
|
||||
// convert 4 bytes to 32bit integer representing bandwidth in hertz
|
||||
const bandwidthInHz = bandwidthBytes[0] << 24 | bandwidthBytes[1] << 16 | bandwidthBytes[2] << 8 | bandwidthBytes[3];
|
||||
return bandwidthInHz;
|
||||
|
||||
}
|
||||
|
||||
async getTxPower() {
|
||||
|
||||
await this.sendKissCommand([
|
||||
this.CMD_TXPOWER,
|
||||
0xFF, // request tx power
|
||||
]);
|
||||
|
||||
// read response from device
|
||||
const [ command, txPower ] = await this.readFromSerialPort();
|
||||
|
||||
return txPower;
|
||||
|
||||
}
|
||||
|
||||
async getSpreadingFactor() {
|
||||
|
||||
await this.sendKissCommand([
|
||||
this.CMD_SF,
|
||||
0xFF, // request spreading factor
|
||||
]);
|
||||
|
||||
// read response from device
|
||||
const [ command, spreadingFactor ] = await this.readFromSerialPort();
|
||||
|
||||
return spreadingFactor;
|
||||
|
||||
}
|
||||
|
||||
async getCodingRate() {
|
||||
|
||||
await this.sendKissCommand([
|
||||
this.CMD_CR,
|
||||
0xFF, // request coding rate
|
||||
]);
|
||||
|
||||
// read response from device
|
||||
const [ command, codingRate ] = await this.readFromSerialPort();
|
||||
|
||||
return codingRate;
|
||||
|
||||
}
|
||||
|
||||
async getRadioState() {
|
||||
|
||||
await this.sendKissCommand([
|
||||
this.CMD_RADIO_STATE,
|
||||
0xFF, // request radio state
|
||||
]);
|
||||
|
||||
// read response from device
|
||||
const [ command, radioState ] = await this.readFromSerialPort();
|
||||
|
||||
return radioState;
|
||||
|
||||
}
|
||||
|
||||
async getRxStat() {
|
||||
|
||||
await this.sendKissCommand([
|
||||
this.CMD_STAT_RX,
|
||||
0x00,
|
||||
]);
|
||||
|
||||
// read response from device
|
||||
const [ command, ...statBytes ] = await this.readFromSerialPort();
|
||||
|
||||
// convert 4 bytes to 32bit integer
|
||||
const stat = statBytes[0] << 24 | statBytes[1] << 16 | statBytes[2] << 8 | statBytes[3];
|
||||
return stat;
|
||||
|
||||
}
|
||||
|
||||
async getTxStat() {
|
||||
|
||||
await this.sendKissCommand([
|
||||
this.CMD_STAT_TX,
|
||||
0x00,
|
||||
]);
|
||||
|
||||
// read response from device
|
||||
const [ command, ...statBytes ] = await this.readFromSerialPort();
|
||||
|
||||
// convert 4 bytes to 32bit integer
|
||||
const stat = statBytes[0] << 24 | statBytes[1] << 16 | statBytes[2] << 8 | statBytes[3];
|
||||
return stat;
|
||||
|
||||
}
|
||||
|
||||
async getRssiStat() {
|
||||
|
||||
await this.sendKissCommand([
|
||||
this.CMD_STAT_RSSI,
|
||||
0x00,
|
||||
]);
|
||||
|
||||
// read response from device
|
||||
const [ command, rssi ] = await this.readFromSerialPort();
|
||||
|
||||
return rssi;
|
||||
|
||||
}
|
||||
|
||||
async disableBluetooth() {
|
||||
await this.sendKissCommand([
|
||||
this.CMD_BT_CTRL,
|
||||
0x00, // stop
|
||||
]);
|
||||
}
|
||||
|
||||
async enableBluetooth() {
|
||||
await this.sendKissCommand([
|
||||
this.CMD_BT_CTRL,
|
||||
0x01, // start
|
||||
]);
|
||||
}
|
||||
|
||||
async startBluetoothPairing() {
|
||||
|
||||
// enable pairing
|
||||
await this.sendKissCommand([
|
||||
this.CMD_BT_CTRL,
|
||||
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,
|
||||
0x01,
|
||||
]);
|
||||
|
||||
// read response from device
|
||||
const [ command, ...displayBuffer ] = await this.readFromSerialPort();
|
||||
|
||||
return displayBuffer;
|
||||
|
||||
}
|
||||
|
||||
async setFrequency(frequencyInHz) {
|
||||
|
||||
const c1 = frequencyInHz >> 24;
|
||||
const c2 = frequencyInHz >> 16 & 0xFF;
|
||||
const c3 = frequencyInHz >> 8 & 0xFF;
|
||||
const c4 = frequencyInHz & 0xFF;
|
||||
|
||||
await this.sendKissCommand([
|
||||
this.CMD_FREQUENCY,
|
||||
c1,
|
||||
c2,
|
||||
c3,
|
||||
c4,
|
||||
]);
|
||||
|
||||
}
|
||||
|
||||
async setBandwidth(bandwidthInHz) {
|
||||
|
||||
const c1 = bandwidthInHz >> 24;
|
||||
const c2 = bandwidthInHz >> 16 & 0xFF;
|
||||
const c3 = bandwidthInHz >> 8 & 0xFF;
|
||||
const c4 = bandwidthInHz & 0xFF;
|
||||
|
||||
await this.sendKissCommand([
|
||||
this.CMD_BANDWIDTH,
|
||||
c1,
|
||||
c2,
|
||||
c3,
|
||||
c4,
|
||||
]);
|
||||
|
||||
}
|
||||
|
||||
async setTxPower(db) {
|
||||
await this.sendKissCommand([
|
||||
this.CMD_TXPOWER,
|
||||
db,
|
||||
]);
|
||||
}
|
||||
|
||||
async setSpreadingFactor(spreadingFactor) {
|
||||
await this.sendKissCommand([
|
||||
this.CMD_SF,
|
||||
spreadingFactor,
|
||||
]);
|
||||
}
|
||||
|
||||
async setCodingRate(codingRate) {
|
||||
await this.sendKissCommand([
|
||||
this.CMD_CR,
|
||||
codingRate,
|
||||
]);
|
||||
}
|
||||
|
||||
async setRadioStateOn() {
|
||||
await this.sendKissCommand([
|
||||
this.CMD_RADIO_STATE,
|
||||
this.RADIO_STATE_ON,
|
||||
]);
|
||||
}
|
||||
|
||||
async setRadioStateOff() {
|
||||
await this.sendKissCommand([
|
||||
this.CMD_RADIO_STATE,
|
||||
this.RADIO_STATE_OFF,
|
||||
]);
|
||||
}
|
||||
|
||||
// setTNCMode
|
||||
async saveConfig() {
|
||||
await this.sendKissCommand([
|
||||
this.CMD_CONF_SAVE,
|
||||
0x00,
|
||||
]);
|
||||
}
|
||||
|
||||
// setNormalMode
|
||||
async deleteConfig() {
|
||||
await this.sendKissCommand([
|
||||
this.CMD_CONF_DELETE,
|
||||
0x00,
|
||||
]);
|
||||
}
|
||||
|
||||
async indicateFirmwareUpdate() {
|
||||
await this.sendKissCommand([
|
||||
this.CMD_FW_UPD,
|
||||
0x01,
|
||||
]);
|
||||
}
|
||||
|
||||
async setFirmwareHash(hash) {
|
||||
await this.sendKissCommand([
|
||||
this.CMD_FW_HASH,
|
||||
...hash,
|
||||
]);
|
||||
}
|
||||
|
||||
async writeRom(address, value) {
|
||||
|
||||
// write to rom
|
||||
await this.sendKissCommand([
|
||||
this.CMD_ROM_WRITE,
|
||||
address,
|
||||
value,
|
||||
]);
|
||||
|
||||
// wait a bit to allow device to write to rom
|
||||
await Utils.sleepMillis(85);
|
||||
|
||||
}
|
||||
|
||||
async wipeRom() {
|
||||
|
||||
await this.sendKissCommand([
|
||||
this.CMD_UNLOCK_ROM,
|
||||
this.ROM_UNLOCK_BYTE,
|
||||
]);
|
||||
|
||||
// wiping can take up to 30 seconds
|
||||
await Utils.sleepMillis(30000);
|
||||
|
||||
}
|
||||
|
||||
async getRomAsObject() {
|
||||
const rom = await this.getRom();
|
||||
return new ROM(rom);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
class ROM {
|
||||
|
||||
static PLATFORM_AVR = 0x90
|
||||
static PLATFORM_ESP32 = 0x80
|
||||
static PLATFORM_NRF52 = 0x70
|
||||
|
||||
static MCU_1284P = 0x91
|
||||
static MCU_2560 = 0x92
|
||||
static MCU_ESP32 = 0x81
|
||||
static MCU_NRF52 = 0x71
|
||||
|
||||
static PRODUCT_RAK4631 = 0x10
|
||||
static MODEL_11 = 0x11
|
||||
static MODEL_12 = 0x12
|
||||
|
||||
static PRODUCT_RNODE = 0x03
|
||||
static MODEL_A1 = 0xA1
|
||||
static MODEL_A6 = 0xA6
|
||||
static MODEL_A4 = 0xA4
|
||||
static MODEL_A9 = 0xA9
|
||||
static MODEL_A3 = 0xA3
|
||||
static MODEL_A8 = 0xA8
|
||||
static MODEL_A2 = 0xA2
|
||||
static MODEL_A7 = 0xA7
|
||||
static MODEL_A5 = 0xA5;
|
||||
static MODEL_AA = 0xAA;
|
||||
|
||||
static PRODUCT_T32_10 = 0xB2
|
||||
static MODEL_BA = 0xBA
|
||||
static MODEL_BB = 0xBB
|
||||
|
||||
static PRODUCT_T32_20 = 0xB0
|
||||
static MODEL_B3 = 0xB3
|
||||
static MODEL_B8 = 0xB8
|
||||
|
||||
static PRODUCT_T32_21 = 0xB1
|
||||
static MODEL_B4 = 0xB4
|
||||
static MODEL_B9 = 0xB9
|
||||
static MODEL_B4_TCXO = 0x04 // The TCXO model codes are only used here to select the
|
||||
static MODEL_B9_TCXO = 0x09 // correct firmware, actual model codes in firmware is still 0xB4 and 0xB9.
|
||||
|
||||
static PRODUCT_H32_V2 = 0xC0
|
||||
static MODEL_C4 = 0xC4
|
||||
static MODEL_C9 = 0xC9
|
||||
|
||||
static PRODUCT_H32_V3 = 0xC1
|
||||
static MODEL_C5 = 0xC5
|
||||
static MODEL_CA = 0xCA
|
||||
|
||||
static PRODUCT_TBEAM = 0xE0
|
||||
static MODEL_E4 = 0xE4
|
||||
static MODEL_E9 = 0xE9
|
||||
static MODEL_E3 = 0xE3
|
||||
static MODEL_E8 = 0xE8
|
||||
|
||||
static PRODUCT_TBEAM_S_V1 = 0xEA;
|
||||
static MODEL_DB = 0xDB
|
||||
static MODEL_DC = 0xDC
|
||||
|
||||
static PRODUCT_TDECK = 0xD0;
|
||||
static MODEL_D4 = 0xD4;
|
||||
static MODEL_D9 = 0xD9;
|
||||
|
||||
static PRODUCT_TECHO = 0x15;
|
||||
static MODEL_T4 = 0x16;
|
||||
static MODEL_T9 = 0x17;
|
||||
|
||||
static PRODUCT_HMBRW = 0xF0
|
||||
static MODEL_FF = 0xFF
|
||||
static MODEL_FE = 0xFE
|
||||
|
||||
static ADDR_PRODUCT = 0x00
|
||||
static ADDR_MODEL = 0x01
|
||||
static ADDR_HW_REV = 0x02
|
||||
static ADDR_SERIAL = 0x03
|
||||
static ADDR_MADE = 0x07
|
||||
static ADDR_CHKSUM = 0x0B
|
||||
static ADDR_SIGNATURE = 0x1B
|
||||
static ADDR_INFO_LOCK = 0x9B
|
||||
static ADDR_CONF_SF = 0x9C
|
||||
static ADDR_CONF_CR = 0x9D
|
||||
static ADDR_CONF_TXP = 0x9E
|
||||
static ADDR_CONF_BW = 0x9F
|
||||
static ADDR_CONF_FREQ = 0xA3
|
||||
static ADDR_CONF_OK = 0xA7
|
||||
|
||||
static INFO_LOCK_BYTE = 0x73
|
||||
static CONF_OK_BYTE = 0x73
|
||||
|
||||
static BOARD_RNODE = 0x31
|
||||
static BOARD_HMBRW = 0x32
|
||||
static BOARD_TBEAM = 0x33
|
||||
static BOARD_HUZZAH32 = 0x34
|
||||
static BOARD_GENERIC_ESP32 = 0x35
|
||||
static BOARD_LORA32_V2_0 = 0x36
|
||||
static BOARD_LORA32_V2_1 = 0x37
|
||||
static BOARD_RAK4631 = 0x51
|
||||
|
||||
static MANUAL_FLASH_MODELS = [ROM.MODEL_A1, ROM.MODEL_A6]
|
||||
|
||||
constructor(eeprom) {
|
||||
this.eeprom = eeprom;
|
||||
}
|
||||
|
||||
getProduct() {
|
||||
return this.eeprom[ROM.ADDR_PRODUCT];
|
||||
}
|
||||
|
||||
getModel() {
|
||||
return this.eeprom[ROM.ADDR_MODEL];
|
||||
}
|
||||
|
||||
getHardwareRevision() {
|
||||
return this.eeprom[ROM.ADDR_HW_REV];
|
||||
}
|
||||
|
||||
getSerialNumber() {
|
||||
return [
|
||||
this.eeprom[ROM.ADDR_SERIAL],
|
||||
this.eeprom[ROM.ADDR_SERIAL + 1],
|
||||
this.eeprom[ROM.ADDR_SERIAL + 2],
|
||||
this.eeprom[ROM.ADDR_SERIAL + 3],
|
||||
];
|
||||
}
|
||||
|
||||
getMade() {
|
||||
return [
|
||||
this.eeprom[ROM.ADDR_MADE],
|
||||
this.eeprom[ROM.ADDR_MADE + 1],
|
||||
this.eeprom[ROM.ADDR_MADE + 2],
|
||||
this.eeprom[ROM.ADDR_MADE + 3],
|
||||
];
|
||||
}
|
||||
|
||||
getChecksum() {
|
||||
const checksum = [];
|
||||
for(var i = 0; i < 16; i++){
|
||||
checksum.push(this.eeprom[ROM.ADDR_CHKSUM + i]);
|
||||
}
|
||||
return checksum;
|
||||
}
|
||||
|
||||
getSignature() {
|
||||
const signature = [];
|
||||
for(var i = 0; i < 128; i++){
|
||||
signature.push(this.eeprom[ROM.ADDR_SIGNATURE + i]);
|
||||
}
|
||||
return signature;
|
||||
}
|
||||
|
||||
getCalculatedChecksum() {
|
||||
return Utils.md5([
|
||||
this.getProduct(),
|
||||
this.getModel(),
|
||||
this.getHardwareRevision(),
|
||||
...this.getSerialNumber(),
|
||||
...this.getMade(),
|
||||
]);
|
||||
}
|
||||
|
||||
getConfiguredSpreadingFactor() {
|
||||
return this.eeprom[ROM.ADDR_CONF_SF];
|
||||
}
|
||||
|
||||
getConfiguredCodingRate() {
|
||||
return this.eeprom[ROM.ADDR_CONF_CR];
|
||||
}
|
||||
|
||||
getConfiguredTxPower() {
|
||||
return this.eeprom[ROM.ADDR_CONF_TXP];
|
||||
}
|
||||
|
||||
getConfiguredFrequency() {
|
||||
return this.eeprom[ROM.ADDR_CONF_FREQ] << 24
|
||||
| this.eeprom[ROM.ADDR_CONF_FREQ + 1] << 16
|
||||
| this.eeprom[ROM.ADDR_CONF_FREQ + 2] << 8
|
||||
| this.eeprom[ROM.ADDR_CONF_FREQ + 3];
|
||||
}
|
||||
|
||||
getConfiguredBandwidth() {
|
||||
return this.eeprom[ROM.ADDR_CONF_BW] << 24
|
||||
| this.eeprom[ROM.ADDR_CONF_BW + 1] << 16
|
||||
| this.eeprom[ROM.ADDR_CONF_BW + 2] << 8
|
||||
| this.eeprom[ROM.ADDR_CONF_BW + 3];
|
||||
}
|
||||
|
||||
isInfoLocked() {
|
||||
return this.eeprom[ROM.ADDR_INFO_LOCK] === ROM.INFO_LOCK_BYTE;
|
||||
}
|
||||
|
||||
isConfigured() {
|
||||
return this.eeprom[ROM.ADDR_CONF_OK] === ROM.CONF_OK_BYTE;
|
||||
}
|
||||
|
||||
parse() {
|
||||
|
||||
// ensure info lock byte is set
|
||||
if(!this.isInfoLocked()){
|
||||
return null;
|
||||
}
|
||||
|
||||
// convert to hex
|
||||
const checksumHex = Utils.bytesToHex(this.getChecksum());
|
||||
const calculatedChecksumHex = Utils.bytesToHex(this.getCalculatedChecksum());
|
||||
const signatureHex = Utils.bytesToHex(this.getSignature());
|
||||
|
||||
// add details
|
||||
var details = {
|
||||
is_provisioned: true,
|
||||
is_configured: this.isConfigured(),
|
||||
product: this.getProduct(),
|
||||
model: this.getModel(),
|
||||
hardware_revision: this.getHardwareRevision(),
|
||||
serial_number: Utils.unpackUInt32BE(this.getSerialNumber()),
|
||||
made: Utils.unpackUInt32BE(this.getMade()),
|
||||
checksum: checksumHex,
|
||||
calculated_checksum: calculatedChecksumHex,
|
||||
signature: signatureHex,
|
||||
}
|
||||
|
||||
// if configured, add configuration to details
|
||||
if(details.is_configured){
|
||||
details = {
|
||||
...details,
|
||||
configured_spreading_factor: this.getConfiguredSpreadingFactor(),
|
||||
configured_coding_rate: this.getConfiguredCodingRate(),
|
||||
configured_tx_power: this.getConfiguredTxPower(),
|
||||
configured_frequency: this.getConfiguredFrequency(),
|
||||
configured_bandwidth: this.getConfiguredBandwidth(),
|
||||
};
|
||||
}
|
||||
|
||||
// if checksum in eeprom does not match checksum calculated from info, it is not provisioned
|
||||
if(details.checksum !== details.calculated_checksum){
|
||||
details.is_provisioned = false;
|
||||
}
|
||||
|
||||
return details;
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
16725
src/frontend/public/rnode-flasher/js/vue@3.4.26/dist/vue.global.js
vendored
Normal file
16725
src/frontend/public/rnode-flasher/js/vue@3.4.26/dist/vue.global.js
vendored
Normal file
File diff suppressed because it is too large
Load Diff
491
src/frontend/public/rnode-flasher/js/web-serial-polyfill@1.0.15/dist/serial.js
vendored
Normal file
491
src/frontend/public/rnode-flasher/js/web-serial-polyfill@1.0.15/dist/serial.js
vendored
Normal file
@@ -0,0 +1,491 @@
|
||||
/*
|
||||
* Copyright 2019 Google LLC
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in
|
||||
* compliance with the License. You may obtain a copy of
|
||||
* the License at
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in
|
||||
* writing, software distributed under the License is
|
||||
* distributed on an "AS IS" BASIS, WITHOUT WARRANTIES
|
||||
* OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing
|
||||
* permissions and limitations under the License.
|
||||
*/
|
||||
'use strict';
|
||||
export var SerialPolyfillProtocol;
|
||||
(function (SerialPolyfillProtocol) {
|
||||
SerialPolyfillProtocol[SerialPolyfillProtocol["UsbCdcAcm"] = 0] = "UsbCdcAcm";
|
||||
})(SerialPolyfillProtocol || (SerialPolyfillProtocol = {}));
|
||||
const kSetLineCoding = 0x20;
|
||||
const kSetControlLineState = 0x22;
|
||||
const kSendBreak = 0x23;
|
||||
const kDefaultBufferSize = 255;
|
||||
const kDefaultDataBits = 8;
|
||||
const kDefaultParity = 'none';
|
||||
const kDefaultStopBits = 1;
|
||||
const kAcceptableDataBits = [16, 8, 7, 6, 5];
|
||||
const kAcceptableStopBits = [1, 2];
|
||||
const kAcceptableParity = ['none', 'even', 'odd'];
|
||||
const kParityIndexMapping = ['none', 'odd', 'even'];
|
||||
const kStopBitsIndexMapping = [1, 1.5, 2];
|
||||
const kDefaultPolyfillOptions = {
|
||||
protocol: SerialPolyfillProtocol.UsbCdcAcm,
|
||||
usbControlInterfaceClass: 2,
|
||||
usbTransferInterfaceClass: 10,
|
||||
};
|
||||
/**
|
||||
* Utility function to get the interface implementing a desired class.
|
||||
* @param {USBDevice} device The USB device.
|
||||
* @param {number} classCode The desired interface class.
|
||||
* @return {USBInterface} The first interface found that implements the desired
|
||||
* class.
|
||||
* @throws TypeError if no interface is found.
|
||||
*/
|
||||
function findInterface(device, classCode) {
|
||||
const configuration = device.configurations[0];
|
||||
for (const iface of configuration.interfaces) {
|
||||
const alternate = iface.alternates[0];
|
||||
if (alternate.interfaceClass === classCode) {
|
||||
return iface;
|
||||
}
|
||||
}
|
||||
throw new TypeError(`Unable to find interface with class ${classCode}.`);
|
||||
}
|
||||
/**
|
||||
* Utility function to get an endpoint with a particular direction.
|
||||
* @param {USBInterface} iface The interface to search.
|
||||
* @param {USBDirection} direction The desired transfer direction.
|
||||
* @return {USBEndpoint} The first endpoint with the desired transfer direction.
|
||||
* @throws TypeError if no endpoint is found.
|
||||
*/
|
||||
function findEndpoint(iface, direction) {
|
||||
const alternate = iface.alternates[0];
|
||||
for (const endpoint of alternate.endpoints) {
|
||||
if (endpoint.direction == direction) {
|
||||
return endpoint;
|
||||
}
|
||||
}
|
||||
throw new TypeError(`Interface ${iface.interfaceNumber} does not have an ` +
|
||||
`${direction} endpoint.`);
|
||||
}
|
||||
/**
|
||||
* Implementation of the underlying source API[1] which reads data from a USB
|
||||
* endpoint. This can be used to construct a ReadableStream.
|
||||
*
|
||||
* [1]: https://streams.spec.whatwg.org/#underlying-source-api
|
||||
*/
|
||||
class UsbEndpointUnderlyingSource {
|
||||
/**
|
||||
* Constructs a new UnderlyingSource that will pull data from the specified
|
||||
* endpoint on the given USB device.
|
||||
*
|
||||
* @param {USBDevice} device
|
||||
* @param {USBEndpoint} endpoint
|
||||
* @param {function} onError function to be called on error
|
||||
*/
|
||||
constructor(device, endpoint, onError) {
|
||||
this.type = 'bytes';
|
||||
this.device_ = device;
|
||||
this.endpoint_ = endpoint;
|
||||
this.onError_ = onError;
|
||||
}
|
||||
/**
|
||||
* Reads a chunk of data from the device.
|
||||
*
|
||||
* @param {ReadableByteStreamController} controller
|
||||
*/
|
||||
pull(controller) {
|
||||
(async () => {
|
||||
var _a;
|
||||
let chunkSize;
|
||||
if (controller.desiredSize) {
|
||||
const d = controller.desiredSize / this.endpoint_.packetSize;
|
||||
chunkSize = Math.ceil(d) * this.endpoint_.packetSize;
|
||||
}
|
||||
else {
|
||||
chunkSize = this.endpoint_.packetSize;
|
||||
}
|
||||
try {
|
||||
const result = await this.device_.transferIn(this.endpoint_.endpointNumber, chunkSize);
|
||||
if (result.status != 'ok') {
|
||||
controller.error(`USB error: ${result.status}`);
|
||||
this.onError_();
|
||||
}
|
||||
if ((_a = result.data) === null || _a === void 0 ? void 0 : _a.buffer) {
|
||||
const chunk = new Uint8Array(result.data.buffer, result.data.byteOffset, result.data.byteLength);
|
||||
controller.enqueue(chunk);
|
||||
}
|
||||
}
|
||||
catch (error) {
|
||||
controller.error(error.toString());
|
||||
this.onError_();
|
||||
}
|
||||
})();
|
||||
}
|
||||
}
|
||||
/**
|
||||
* Implementation of the underlying sink API[2] which writes data to a USB
|
||||
* endpoint. This can be used to construct a WritableStream.
|
||||
*
|
||||
* [2]: https://streams.spec.whatwg.org/#underlying-sink-api
|
||||
*/
|
||||
class UsbEndpointUnderlyingSink {
|
||||
/**
|
||||
* Constructs a new UnderlyingSink that will write data to the specified
|
||||
* endpoint on the given USB device.
|
||||
*
|
||||
* @param {USBDevice} device
|
||||
* @param {USBEndpoint} endpoint
|
||||
* @param {function} onError function to be called on error
|
||||
*/
|
||||
constructor(device, endpoint, onError) {
|
||||
this.device_ = device;
|
||||
this.endpoint_ = endpoint;
|
||||
this.onError_ = onError;
|
||||
}
|
||||
/**
|
||||
* Writes a chunk to the device.
|
||||
*
|
||||
* @param {Uint8Array} chunk
|
||||
* @param {WritableStreamDefaultController} controller
|
||||
*/
|
||||
async write(chunk, controller) {
|
||||
try {
|
||||
const result = await this.device_.transferOut(this.endpoint_.endpointNumber, chunk);
|
||||
if (result.status != 'ok') {
|
||||
controller.error(result.status);
|
||||
this.onError_();
|
||||
}
|
||||
}
|
||||
catch (error) {
|
||||
controller.error(error.toString());
|
||||
this.onError_();
|
||||
}
|
||||
}
|
||||
}
|
||||
/** a class used to control serial devices over WebUSB */
|
||||
export class SerialPort {
|
||||
/**
|
||||
* constructor taking a WebUSB device that creates a SerialPort instance.
|
||||
* @param {USBDevice} device A device acquired from the WebUSB API
|
||||
* @param {SerialPolyfillOptions} polyfillOptions Optional options to
|
||||
* configure the polyfill.
|
||||
*/
|
||||
constructor(device, polyfillOptions) {
|
||||
this.polyfillOptions_ = Object.assign(Object.assign({}, kDefaultPolyfillOptions), polyfillOptions);
|
||||
this.outputSignals_ = {
|
||||
dataTerminalReady: false,
|
||||
requestToSend: false,
|
||||
break: false,
|
||||
};
|
||||
this.device_ = device;
|
||||
this.controlInterface_ = findInterface(this.device_, this.polyfillOptions_.usbControlInterfaceClass);
|
||||
this.transferInterface_ = findInterface(this.device_, this.polyfillOptions_.usbTransferInterfaceClass);
|
||||
this.inEndpoint_ = findEndpoint(this.transferInterface_, 'in');
|
||||
this.outEndpoint_ = findEndpoint(this.transferInterface_, 'out');
|
||||
}
|
||||
/**
|
||||
* Getter for the readable attribute. Constructs a new ReadableStream as
|
||||
* necessary.
|
||||
* @return {ReadableStream} the current readable stream
|
||||
*/
|
||||
get readable() {
|
||||
var _a;
|
||||
if (!this.readable_ && this.device_.opened) {
|
||||
this.readable_ = new ReadableStream(new UsbEndpointUnderlyingSource(this.device_, this.inEndpoint_, () => {
|
||||
this.readable_ = null;
|
||||
}), {
|
||||
highWaterMark: (_a = this.serialOptions_.bufferSize) !== null && _a !== void 0 ? _a : kDefaultBufferSize,
|
||||
});
|
||||
}
|
||||
return this.readable_;
|
||||
}
|
||||
/**
|
||||
* Getter for the writable attribute. Constructs a new WritableStream as
|
||||
* necessary.
|
||||
* @return {WritableStream} the current writable stream
|
||||
*/
|
||||
get writable() {
|
||||
var _a;
|
||||
if (!this.writable_ && this.device_.opened) {
|
||||
this.writable_ = new WritableStream(new UsbEndpointUnderlyingSink(this.device_, this.outEndpoint_, () => {
|
||||
this.writable_ = null;
|
||||
}), new ByteLengthQueuingStrategy({
|
||||
highWaterMark: (_a = this.serialOptions_.bufferSize) !== null && _a !== void 0 ? _a : kDefaultBufferSize,
|
||||
}));
|
||||
}
|
||||
return this.writable_;
|
||||
}
|
||||
/**
|
||||
* a function that opens the device and claims all interfaces needed to
|
||||
* control and communicate to and from the serial device
|
||||
* @param {SerialOptions} options Object containing serial options
|
||||
* @return {Promise<void>} A promise that will resolve when device is ready
|
||||
* for communication
|
||||
*/
|
||||
async open(options) {
|
||||
this.serialOptions_ = options;
|
||||
this.validateOptions();
|
||||
try {
|
||||
await this.device_.open();
|
||||
if (this.device_.configuration === null) {
|
||||
await this.device_.selectConfiguration(1);
|
||||
}
|
||||
await this.device_.claimInterface(this.controlInterface_.interfaceNumber);
|
||||
if (this.controlInterface_ !== this.transferInterface_) {
|
||||
await this.device_.claimInterface(this.transferInterface_.interfaceNumber);
|
||||
}
|
||||
await this.setLineCoding();
|
||||
await this.setSignals({ dataTerminalReady: true });
|
||||
}
|
||||
catch (error) {
|
||||
if (this.device_.opened) {
|
||||
await this.device_.close();
|
||||
}
|
||||
throw new Error('Error setting up device: ' + error.toString());
|
||||
}
|
||||
}
|
||||
/**
|
||||
* Closes the port.
|
||||
*
|
||||
* @return {Promise<void>} A promise that will resolve when the port is
|
||||
* closed.
|
||||
*/
|
||||
async close() {
|
||||
const promises = [];
|
||||
if (this.readable_) {
|
||||
promises.push(this.readable_.cancel());
|
||||
}
|
||||
if (this.writable_) {
|
||||
promises.push(this.writable_.abort());
|
||||
}
|
||||
await Promise.all(promises);
|
||||
this.readable_ = null;
|
||||
this.writable_ = null;
|
||||
if (this.device_.opened) {
|
||||
await this.setSignals({ dataTerminalReady: false, requestToSend: false });
|
||||
await this.device_.close();
|
||||
}
|
||||
}
|
||||
/**
|
||||
* Forgets the port.
|
||||
*
|
||||
* @return {Promise<void>} A promise that will resolve when the port is
|
||||
* forgotten.
|
||||
*/
|
||||
async forget() {
|
||||
return this.device_.forget();
|
||||
}
|
||||
/**
|
||||
* A function that returns properties of the device.
|
||||
* @return {SerialPortInfo} Device properties.
|
||||
*/
|
||||
getInfo() {
|
||||
return {
|
||||
usbVendorId: this.device_.vendorId,
|
||||
usbProductId: this.device_.productId,
|
||||
};
|
||||
}
|
||||
/**
|
||||
* A function used to change the serial settings of the device
|
||||
* @param {object} options the object which carries serial settings data
|
||||
* @return {Promise<void>} A promise that will resolve when the options are
|
||||
* set
|
||||
*/
|
||||
reconfigure(options) {
|
||||
this.serialOptions_ = Object.assign(Object.assign({}, this.serialOptions_), options);
|
||||
this.validateOptions();
|
||||
return this.setLineCoding();
|
||||
}
|
||||
/**
|
||||
* Sets control signal state for the port.
|
||||
* @param {SerialOutputSignals} signals The signals to enable or disable.
|
||||
* @return {Promise<void>} a promise that is resolved when the signal state
|
||||
* has been changed.
|
||||
*/
|
||||
async setSignals(signals) {
|
||||
this.outputSignals_ = Object.assign(Object.assign({}, this.outputSignals_), signals);
|
||||
if (signals.dataTerminalReady !== undefined ||
|
||||
signals.requestToSend !== undefined) {
|
||||
// The Set_Control_Line_State command expects a bitmap containing the
|
||||
// values of all output signals that should be enabled or disabled.
|
||||
//
|
||||
// Ref: USB CDC specification version 1.1 §6.2.14.
|
||||
const value = (this.outputSignals_.dataTerminalReady ? 1 << 0 : 0) |
|
||||
(this.outputSignals_.requestToSend ? 1 << 1 : 0);
|
||||
await this.device_.controlTransferOut({
|
||||
'requestType': 'class',
|
||||
'recipient': 'interface',
|
||||
'request': kSetControlLineState,
|
||||
'value': value,
|
||||
'index': this.controlInterface_.interfaceNumber,
|
||||
});
|
||||
}
|
||||
if (signals.break !== undefined) {
|
||||
// The SendBreak command expects to be given a duration for how long the
|
||||
// break signal should be asserted. Passing 0xFFFF enables the signal
|
||||
// until 0x0000 is send.
|
||||
//
|
||||
// Ref: USB CDC specification version 1.1 §6.2.15.
|
||||
const value = this.outputSignals_.break ? 0xFFFF : 0x0000;
|
||||
await this.device_.controlTransferOut({
|
||||
'requestType': 'class',
|
||||
'recipient': 'interface',
|
||||
'request': kSendBreak,
|
||||
'value': value,
|
||||
'index': this.controlInterface_.interfaceNumber,
|
||||
});
|
||||
}
|
||||
}
|
||||
/**
|
||||
* Checks the serial options for validity and throws an error if it is
|
||||
* not valid
|
||||
*/
|
||||
validateOptions() {
|
||||
if (!this.isValidBaudRate(this.serialOptions_.baudRate)) {
|
||||
throw new RangeError('invalid Baud Rate ' + this.serialOptions_.baudRate);
|
||||
}
|
||||
if (!this.isValidDataBits(this.serialOptions_.dataBits)) {
|
||||
throw new RangeError('invalid dataBits ' + this.serialOptions_.dataBits);
|
||||
}
|
||||
if (!this.isValidStopBits(this.serialOptions_.stopBits)) {
|
||||
throw new RangeError('invalid stopBits ' + this.serialOptions_.stopBits);
|
||||
}
|
||||
if (!this.isValidParity(this.serialOptions_.parity)) {
|
||||
throw new RangeError('invalid parity ' + this.serialOptions_.parity);
|
||||
}
|
||||
}
|
||||
/**
|
||||
* Checks the baud rate for validity
|
||||
* @param {number} baudRate the baud rate to check
|
||||
* @return {boolean} A boolean that reflects whether the baud rate is valid
|
||||
*/
|
||||
isValidBaudRate(baudRate) {
|
||||
return baudRate % 1 === 0;
|
||||
}
|
||||
/**
|
||||
* Checks the data bits for validity
|
||||
* @param {number} dataBits the data bits to check
|
||||
* @return {boolean} A boolean that reflects whether the data bits setting is
|
||||
* valid
|
||||
*/
|
||||
isValidDataBits(dataBits) {
|
||||
if (typeof dataBits === 'undefined') {
|
||||
return true;
|
||||
}
|
||||
return kAcceptableDataBits.includes(dataBits);
|
||||
}
|
||||
/**
|
||||
* Checks the stop bits for validity
|
||||
* @param {number} stopBits the stop bits to check
|
||||
* @return {boolean} A boolean that reflects whether the stop bits setting is
|
||||
* valid
|
||||
*/
|
||||
isValidStopBits(stopBits) {
|
||||
if (typeof stopBits === 'undefined') {
|
||||
return true;
|
||||
}
|
||||
return kAcceptableStopBits.includes(stopBits);
|
||||
}
|
||||
/**
|
||||
* Checks the parity for validity
|
||||
* @param {string} parity the parity to check
|
||||
* @return {boolean} A boolean that reflects whether the parity is valid
|
||||
*/
|
||||
isValidParity(parity) {
|
||||
if (typeof parity === 'undefined') {
|
||||
return true;
|
||||
}
|
||||
return kAcceptableParity.includes(parity);
|
||||
}
|
||||
/**
|
||||
* sends the options alog the control interface to set them on the device
|
||||
* @return {Promise} a promise that will resolve when the options are set
|
||||
*/
|
||||
async setLineCoding() {
|
||||
var _a, _b, _c;
|
||||
// Ref: USB CDC specification version 1.1 §6.2.12.
|
||||
const buffer = new ArrayBuffer(7);
|
||||
const view = new DataView(buffer);
|
||||
view.setUint32(0, this.serialOptions_.baudRate, true);
|
||||
view.setUint8(4, kStopBitsIndexMapping.indexOf((_a = this.serialOptions_.stopBits) !== null && _a !== void 0 ? _a : kDefaultStopBits));
|
||||
view.setUint8(5, kParityIndexMapping.indexOf((_b = this.serialOptions_.parity) !== null && _b !== void 0 ? _b : kDefaultParity));
|
||||
view.setUint8(6, (_c = this.serialOptions_.dataBits) !== null && _c !== void 0 ? _c : kDefaultDataBits);
|
||||
const result = await this.device_.controlTransferOut({
|
||||
'requestType': 'class',
|
||||
'recipient': 'interface',
|
||||
'request': kSetLineCoding,
|
||||
'value': 0x00,
|
||||
'index': this.controlInterface_.interfaceNumber,
|
||||
}, buffer);
|
||||
if (result.status != 'ok') {
|
||||
throw new DOMException('NetworkError', 'Failed to set line coding.');
|
||||
}
|
||||
}
|
||||
}
|
||||
/** implementation of the global navigator.serial object */
|
||||
class Serial {
|
||||
/**
|
||||
* Requests permission to access a new port.
|
||||
*
|
||||
* @param {SerialPortRequestOptions} options
|
||||
* @param {SerialPolyfillOptions} polyfillOptions
|
||||
* @return {Promise<SerialPort>}
|
||||
*/
|
||||
async requestPort(options, polyfillOptions) {
|
||||
polyfillOptions = Object.assign(Object.assign({}, kDefaultPolyfillOptions), polyfillOptions);
|
||||
const usbFilters = [];
|
||||
if (options && options.filters) {
|
||||
for (const filter of options.filters) {
|
||||
const usbFilter = {
|
||||
classCode: polyfillOptions.usbControlInterfaceClass,
|
||||
};
|
||||
if (filter.usbVendorId !== undefined) {
|
||||
usbFilter.vendorId = filter.usbVendorId;
|
||||
}
|
||||
if (filter.usbProductId !== undefined) {
|
||||
usbFilter.productId = filter.usbProductId;
|
||||
}
|
||||
usbFilters.push(usbFilter);
|
||||
}
|
||||
}
|
||||
if (usbFilters.length === 0) {
|
||||
usbFilters.push({
|
||||
classCode: polyfillOptions.usbControlInterfaceClass,
|
||||
});
|
||||
}
|
||||
const device = await navigator.usb.requestDevice({ 'filters': usbFilters });
|
||||
const port = new SerialPort(device, polyfillOptions);
|
||||
return port;
|
||||
}
|
||||
/**
|
||||
* Get the set of currently available ports.
|
||||
*
|
||||
* @param {SerialPolyfillOptions} polyfillOptions Polyfill configuration that
|
||||
* should be applied to these ports.
|
||||
* @return {Promise<SerialPort[]>} a promise that is resolved with a list of
|
||||
* ports.
|
||||
*/
|
||||
async getPorts(polyfillOptions) {
|
||||
polyfillOptions = Object.assign(Object.assign({}, kDefaultPolyfillOptions), polyfillOptions);
|
||||
const devices = await navigator.usb.getDevices();
|
||||
const ports = [];
|
||||
devices.forEach((device) => {
|
||||
try {
|
||||
const port = new SerialPort(device, polyfillOptions);
|
||||
ports.push(port);
|
||||
}
|
||||
catch (e) {
|
||||
// Skip unrecognized port.
|
||||
}
|
||||
});
|
||||
return ports;
|
||||
}
|
||||
}
|
||||
/* an object to be used for starting the serial workflow */
|
||||
export const serial = new Serial();
|
||||
//# sourceMappingURL=serial.js.map
|
||||
1
src/frontend/public/rnode-flasher/js/zip.min.js
vendored
Normal file
1
src/frontend/public/rnode-flasher/js/zip.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
BIN
src/frontend/public/rnode-flasher/reticulum_logo_512.png
Normal file
BIN
src/frontend/public/rnode-flasher/reticulum_logo_512.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 85 KiB |
Reference in New Issue
Block a user