Merge branch 'liamcottle:master' into docker-image

This commit is contained in:
Neil
2024-12-24 13:37:32 +00:00
committed by GitHub
33 changed files with 22456 additions and 98 deletions

View File

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

View File

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

View File

@@ -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'));

View File

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

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

View File

@@ -1,6 +1,6 @@
{
"name": "reticulum-meshchat",
"version": "1.14.0",
"version": "1.16.0",
"description": "",
"main": "electron/main.js",
"scripts": {

View File

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

View File

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

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

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

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

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

View File

@@ -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();

View File

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

View File

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

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

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

View File

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

View File

@@ -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")),
},
],
})

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 109 KiB

View 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.

View 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

View File

File diff suppressed because it is too large Load Diff

View 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;
}));

View 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;
}));

View File

File diff suppressed because one or more lines are too long

View 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,
];
}
}

View 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;
}
}

View File

File diff suppressed because one or more lines are too long

View File

File diff suppressed because it is too large Load Diff

View 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

View File

File diff suppressed because one or more lines are too long

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 85 KiB