Compare commits
145 Commits
interface-
...
v1.23.2
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
58eef8d925 | ||
|
|
252407b0c8 | ||
|
|
b6b1a6d050 | ||
|
|
4fe8b43df1 | ||
|
|
6b3713e58f | ||
|
|
09bd78e194 | ||
|
|
3288fea934 | ||
|
|
47f544e2ee | ||
|
|
be3d8f1e80 | ||
|
|
01b1251589 | ||
|
|
936c298e15 | ||
|
|
f5dc06ab88 | ||
|
|
24e2ac9c65 | ||
|
|
349f50b87f | ||
|
|
5f8c476f18 | ||
|
|
dbf5361fe4 | ||
|
|
54a92ad5d5 | ||
|
|
d59e91ced3 | ||
|
|
31dacb357f | ||
|
|
daeda58b80 | ||
|
|
195daf343d | ||
|
|
c41e022e4f | ||
|
|
15c4355a58 | ||
|
|
a23f64067a | ||
|
|
cf72ac1ec8 | ||
|
|
b8d388fa56 | ||
|
|
d7080c8ca1 | ||
|
|
7c20529d62 | ||
|
|
c6eeab97e6 | ||
|
|
10c85cdba0 | ||
|
|
9ea98eb0f0 | ||
|
|
2662f96c8b | ||
|
|
59deac6d07 | ||
|
|
9d60707515 | ||
|
|
6f321741d7 | ||
|
|
eaf1b75c54 | ||
|
|
c59ed015ce | ||
|
|
d13b395a2c | ||
|
|
59c185354b | ||
|
|
9e7d0cdfeb | ||
|
|
e6ff5097c0 | ||
|
|
ee08a5619c | ||
|
|
c0bb0763a1 | ||
|
|
b6e41b3027 | ||
|
|
030a1e64a9 | ||
|
|
5802671e0d | ||
|
|
03d7b669ae | ||
|
|
a81c6787c7 | ||
|
|
a500b58d05 | ||
|
|
94179f9779 | ||
|
|
93b6104aef | ||
|
|
10bef61a90 | ||
|
|
0f31c9f8c0 | ||
|
|
2c518d1b31 | ||
|
|
176aed98ff | ||
|
|
e1ae122297 | ||
|
|
f6b1c65faa | ||
|
|
4f497620c8 | ||
|
|
4d816ae87c | ||
|
|
df8e98366b | ||
|
|
54b1d56107 | ||
|
|
ba118f7a9c | ||
|
|
e48c26042c | ||
|
|
d95878c659 | ||
|
|
734eaeed1b | ||
|
|
33e4888737 | ||
|
|
408a62dffe | ||
|
|
43a5a907c0 | ||
|
|
620c147dbd | ||
|
|
4555de5836 | ||
|
|
842dbeb0b4 | ||
|
|
9d2f3eebc8 | ||
|
|
b21e3fc026 | ||
|
|
abd70ae606 | ||
|
|
1e2d4387e7 | ||
|
|
d4b5b99045 | ||
|
|
ce52532522 | ||
|
|
6c43c2cc4f | ||
|
|
c5e4776dc1 | ||
|
|
dabd6c4a37 | ||
|
|
dacd2ea3f2 | ||
|
|
9741cdcd60 | ||
|
|
f87a360d5c | ||
|
|
9b62f60e18 | ||
|
|
019ba93d80 | ||
|
|
01562aff75 | ||
|
|
e2b844f2c2 | ||
|
|
c555d8f15b | ||
|
|
0dc3dc955f | ||
|
|
812ff6b887 | ||
|
|
3a13442bb9 | ||
|
|
d7375081f3 | ||
|
|
68ebe4a1c9 | ||
|
|
8b2520f3fa | ||
|
|
5e068b7341 | ||
|
|
d796722772 | ||
|
|
adad97e917 | ||
|
|
59eba2ff64 | ||
|
|
1bad77553c | ||
|
|
b215c4ac31 | ||
|
|
6af4e53de4 | ||
|
|
558e4c8b3d | ||
|
|
7d1681fbf1 | ||
|
|
580c907138 | ||
|
|
4ae83ca980 | ||
|
|
29c062d701 | ||
|
|
d4b204029a | ||
|
|
6f325d24e7 | ||
|
|
b5f9403c52 | ||
|
|
cf059fab63 | ||
|
|
a3565ef063 | ||
|
|
541dd8d4f1 | ||
|
|
6a1243f482 | ||
|
|
9b36120faa | ||
|
|
ff38d4c239 | ||
|
|
c5955295d7 | ||
|
|
5d022888b7 | ||
|
|
6b4bf0e31a | ||
|
|
48e56e5285 | ||
|
|
4b6978f7cc | ||
|
|
d3e8c2de9a | ||
|
|
282f08edb1 | ||
|
|
629e8d47fb | ||
|
|
3f73beff2e | ||
|
|
c55a02ffdc | ||
|
|
c26d27d01c | ||
|
|
6d233b759e | ||
|
|
1306593efc | ||
|
|
8a85a730ab | ||
|
|
e490782d41 | ||
|
|
7e63c1e752 | ||
|
|
64562c2dc8 | ||
|
|
b0e7e1d425 | ||
|
|
ddf144688e | ||
|
|
ed8ac77ecc | ||
|
|
b19ee171eb | ||
|
|
fabb6d5ca3 | ||
|
|
0b6b390388 | ||
|
|
82c67bb71c | ||
|
|
372e61ed7c | ||
|
|
9815decc99 | ||
|
|
65dfd6c540 | ||
|
|
de049aead5 | ||
|
|
99b225e484 | ||
|
|
3b47d2a521 |
36
.github/workflows/bearer.yml
vendored
Normal file
36
.github/workflows/bearer.yml
vendored
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
name: Security Scan
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- master
|
||||||
|
pull_request:
|
||||||
|
branches:
|
||||||
|
- master
|
||||||
|
schedule:
|
||||||
|
- cron: '0 0 * * 0' # Run weekly on Sunday
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
security-events: write
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
security-scan:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Bearer Security Scan
|
||||||
|
uses: bearer/bearer-action@v2
|
||||||
|
with:
|
||||||
|
scanner: sast
|
||||||
|
format: sarif
|
||||||
|
output: bearer.sarif
|
||||||
|
severity: critical,high
|
||||||
|
path: .
|
||||||
|
exit-code: 0
|
||||||
|
|
||||||
|
- name: Upload SARIF results
|
||||||
|
uses: github/codeql-action/upload-sarif@v2
|
||||||
|
with:
|
||||||
|
sarif_file: bearer.sarif
|
||||||
89
.github/workflows/build.yml
vendored
89
.github/workflows/build.yml
vendored
@@ -4,6 +4,13 @@ on:
|
|||||||
push:
|
push:
|
||||||
tags:
|
tags:
|
||||||
- "*"
|
- "*"
|
||||||
|
workflow_dispatch:
|
||||||
|
inputs:
|
||||||
|
retry_failed:
|
||||||
|
description: 'Retry failed jobs'
|
||||||
|
required: false
|
||||||
|
type: boolean
|
||||||
|
default: false
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
build_windows:
|
build_windows:
|
||||||
@@ -17,12 +24,12 @@ jobs:
|
|||||||
- name: Install NodeJS
|
- name: Install NodeJS
|
||||||
uses: actions/setup-node@v1
|
uses: actions/setup-node@v1
|
||||||
with:
|
with:
|
||||||
node-version: 18
|
node-version: 22
|
||||||
|
|
||||||
- name: Install Python
|
- name: Install Python
|
||||||
uses: actions/setup-python@v5
|
uses: actions/setup-python@v5
|
||||||
with:
|
with:
|
||||||
python-version: "3.11"
|
python-version: "3.13"
|
||||||
|
|
||||||
- name: Install Python Deps
|
- name: Install Python Deps
|
||||||
run: pip install -r requirements.txt
|
run: pip install -r requirements.txt
|
||||||
@@ -44,43 +51,43 @@ jobs:
|
|||||||
omitNameDuringUpdate: true
|
omitNameDuringUpdate: true
|
||||||
artifacts: "dist/*-win-installer.exe,dist/*-win-portable.exe"
|
artifacts: "dist/*-win-installer.exe,dist/*-win-portable.exe"
|
||||||
|
|
||||||
build_mac:
|
# build_mac:
|
||||||
runs-on: macos-13
|
# runs-on: macos-13
|
||||||
permissions:
|
# permissions:
|
||||||
contents: write
|
# contents: write
|
||||||
steps:
|
# steps:
|
||||||
- name: Clone Repo
|
# - name: Clone Repo
|
||||||
uses: actions/checkout@v1
|
# uses: actions/checkout@v1
|
||||||
|
|
||||||
- name: Install NodeJS
|
# - name: Install NodeJS
|
||||||
uses: actions/setup-node@v1
|
# uses: actions/setup-node@v1
|
||||||
with:
|
# with:
|
||||||
node-version: 18
|
# node-version: 20
|
||||||
|
|
||||||
- name: Install Python
|
# - name: Install Python
|
||||||
uses: actions/setup-python@v5
|
# uses: actions/setup-python@v5
|
||||||
with:
|
# with:
|
||||||
python-version: "3.11"
|
# python-version: "3.13"
|
||||||
|
|
||||||
- name: Install Python Deps
|
# - name: Install Python Deps
|
||||||
run: pip install -r requirements.txt
|
# run: pip install -r requirements.txt
|
||||||
|
|
||||||
- name: Install NodeJS Deps
|
# - name: Install NodeJS Deps
|
||||||
run: npm install
|
# run: npm install
|
||||||
|
|
||||||
- name: Build Electron App
|
# - name: Build Electron App
|
||||||
run: npm run dist
|
# run: npm run dist
|
||||||
|
|
||||||
- name: Create Release
|
# - name: Create Release
|
||||||
id: create_release
|
# id: create_release
|
||||||
uses: ncipollo/release-action@v1
|
# uses: ncipollo/release-action@v1
|
||||||
with:
|
# with:
|
||||||
draft: true
|
# draft: true
|
||||||
allowUpdates: true
|
# allowUpdates: true
|
||||||
replacesArtifacts: true
|
# replacesArtifacts: true
|
||||||
omitDraftDuringUpdate: true
|
# omitDraftDuringUpdate: true
|
||||||
omitNameDuringUpdate: true
|
# omitNameDuringUpdate: true
|
||||||
artifacts: "dist/*-mac.dmg"
|
# artifacts: "dist/*-mac.dmg"
|
||||||
|
|
||||||
build_linux:
|
build_linux:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
@@ -93,12 +100,12 @@ jobs:
|
|||||||
- name: Install NodeJS
|
- name: Install NodeJS
|
||||||
uses: actions/setup-node@v1
|
uses: actions/setup-node@v1
|
||||||
with:
|
with:
|
||||||
node-version: 18
|
node-version: 22
|
||||||
|
|
||||||
- name: Install Python
|
- name: Install Python
|
||||||
uses: actions/setup-python@v5
|
uses: actions/setup-python@v5
|
||||||
with:
|
with:
|
||||||
python-version: "3.11"
|
python-version: "3.13"
|
||||||
|
|
||||||
- name: Install Python Deps
|
- name: Install Python Deps
|
||||||
run: pip install -r requirements.txt
|
run: pip install -r requirements.txt
|
||||||
@@ -109,6 +116,12 @@ jobs:
|
|||||||
- name: Build Electron App
|
- name: Build Electron App
|
||||||
run: npm run dist
|
run: npm run dist
|
||||||
|
|
||||||
|
- name: Upload Flatpak Artifact
|
||||||
|
uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: flatpak
|
||||||
|
path: dist/*.flatpak
|
||||||
|
|
||||||
- name: Create Release
|
- name: Create Release
|
||||||
id: create_release
|
id: create_release
|
||||||
uses: ncipollo/release-action@v1
|
uses: ncipollo/release-action@v1
|
||||||
@@ -149,9 +162,9 @@ jobs:
|
|||||||
platforms: linux/amd64,linux/arm64
|
platforms: linux/amd64,linux/arm64
|
||||||
push: true
|
push: true
|
||||||
tags: |
|
tags: |
|
||||||
ghcr.io/liamcottle/reticulum-meshchat:latest
|
ghcr.io/sudo-ivan/reticulum-meshchat:latest
|
||||||
ghcr.io/liamcottle/reticulum-meshchat:${{ github.ref_name }}
|
ghcr.io/sudo-ivan/reticulum-meshchat:${{ github.ref_name }}
|
||||||
labels: |
|
labels: |
|
||||||
org.opencontainers.image.title=Reticulum MeshChat
|
org.opencontainers.image.title=Reticulum MeshChat
|
||||||
org.opencontainers.image.description=Docker image for Reticulum MeshChat
|
org.opencontainers.image.description=Docker image for Reticulum MeshChat
|
||||||
org.opencontainers.image.url=https://github.com/liamcottle/reticulum-meshchat/pkgs/container/reticulum-meshchat/
|
org.opencontainers.image.url=https://github.com/Sudo-Ivan/reticulum-meshchat/pkgs/container/reticulum-meshchat/
|
||||||
|
|||||||
6
.github/workflows/manual-docker-build.yml
vendored
6
.github/workflows/manual-docker-build.yml
vendored
@@ -33,10 +33,10 @@ jobs:
|
|||||||
platforms: linux/amd64,linux/arm64
|
platforms: linux/amd64,linux/arm64
|
||||||
push: true
|
push: true
|
||||||
tags: |
|
tags: |
|
||||||
ghcr.io/liamcottle/reticulum-meshchat:latest
|
ghcr.io/sudo-ivan/reticulum-meshchat:latest
|
||||||
ghcr.io/liamcottle/reticulum-meshchat:${{ github.ref_name }}
|
ghcr.io/sudo-ivan/reticulum-meshchat:${{ github.ref_name }}
|
||||||
labels: |
|
labels: |
|
||||||
org.opencontainers.image.title=Reticulum MeshChat
|
org.opencontainers.image.title=Reticulum MeshChat
|
||||||
org.opencontainers.image.description=Docker image for Reticulum MeshChat
|
org.opencontainers.image.description=Docker image for Reticulum MeshChat
|
||||||
org.opencontainers.image.url=https://github.com/liamcottle/reticulum-meshchat/pkgs/container/reticulum-meshchat/
|
org.opencontainers.image.url=https://github.com/Sudo-Ivan/reticulum-meshchat/pkgs/container/reticulum-meshchat/
|
||||||
|
|
||||||
|
|||||||
4
.gitignore
vendored
4
.gitignore
vendored
@@ -9,3 +9,7 @@ node_modules
|
|||||||
|
|
||||||
# local storage
|
# local storage
|
||||||
storage/
|
storage/
|
||||||
|
|
||||||
|
__pycache__/
|
||||||
|
|
||||||
|
config/
|
||||||
50
Dockerfile
50
Dockerfile
@@ -1,33 +1,51 @@
|
|||||||
# Build the frontend
|
# Build the frontend
|
||||||
FROM node:20-bookworm-slim AS build-frontend
|
FROM node:22-alpine AS build-frontend
|
||||||
|
|
||||||
WORKDIR /src
|
WORKDIR /src
|
||||||
|
|
||||||
# Copy required source files
|
# Copy required source files
|
||||||
COPY *.json .
|
COPY --chown=node:node *.json .
|
||||||
COPY *.js .
|
COPY --chown=node:node *.js .
|
||||||
COPY src/frontend ./src/frontend
|
COPY --chown=node:node src/frontend ./src/frontend
|
||||||
|
|
||||||
# Install NodeJS deps, exluding electron
|
# Fix permissions and install NodeJS deps
|
||||||
|
USER root
|
||||||
|
RUN chown -R node:node /src
|
||||||
|
USER node
|
||||||
RUN npm install --omit=dev && \
|
RUN npm install --omit=dev && \
|
||||||
npm run build-frontend
|
npm run build-frontend
|
||||||
|
|
||||||
# Main app build
|
# Main app build
|
||||||
FROM python:3.11-bookworm
|
FROM python:3.13-alpine
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
# Install Python deps
|
# Install system dependencies
|
||||||
COPY ./requirements.txt .
|
RUN apk add --no-cache \
|
||||||
RUN pip install -r requirements.txt
|
gcc \
|
||||||
|
musl-dev \
|
||||||
|
python3-dev \
|
||||||
|
libffi-dev \
|
||||||
|
openssl-dev
|
||||||
|
|
||||||
# Copy prebuilt frontend
|
# Create config directories with proper permissions
|
||||||
COPY --from=build-frontend /src/public public
|
RUN mkdir -p /config/.reticulum /config/.meshchat && \
|
||||||
|
chown -R 1000:1000 /config
|
||||||
|
|
||||||
|
# Install Python deps
|
||||||
|
COPY --chown=1000:1000 ./requirements.txt .
|
||||||
|
RUN pip install --no-cache-dir -r requirements.txt
|
||||||
|
|
||||||
|
# Create public directory and copy frontend
|
||||||
|
RUN mkdir -p /app/public
|
||||||
|
COPY --from=build-frontend --chown=1000:1000 /src/public/ /app/public/
|
||||||
|
|
||||||
# Copy other required source files
|
# Copy other required source files
|
||||||
COPY *.py .
|
COPY --chown=1000:1000 *.py .
|
||||||
COPY src/__init__.py ./src/__init__.py
|
COPY --chown=1000:1000 src/__init__.py ./src/__init__.py
|
||||||
COPY src/backend ./src/backend
|
COPY --chown=1000:1000 src/backend ./src/backend
|
||||||
COPY *.json .
|
COPY --chown=1000:1000 *.json .
|
||||||
|
|
||||||
CMD ["python", "meshchat.py", "--host=0.0.0.0", "--reticulum-config-dir=/config/.reticulum", "--storage-dir=/config/.meshchat", "--headless"]
|
USER 1000
|
||||||
|
ENTRYPOINT ["python"]
|
||||||
|
CMD ["meshchat.py", "--host=0.0.0.0", "--reticulum-config-dir=/config/.reticulum", "--storage-dir=/config/.meshchat", "--headless"]
|
||||||
|
|||||||
39
README.md
39
README.md
@@ -1,3 +1,26 @@
|
|||||||
|
# Ivans Custom Fork Edition
|
||||||
|
|
||||||
|
highly experimental and customized, only use if you live on the edge.
|
||||||
|
|
||||||
|
## Changes
|
||||||
|
|
||||||
|
- Drop unnecassary permissions (compose)
|
||||||
|
- Rootless (user 1000:1000)
|
||||||
|
- Resource Limits (compose)
|
||||||
|
- Alpine Image Variants.
|
||||||
|
- Updated Dependencies.
|
||||||
|
- Dark mode by default.
|
||||||
|
- Python 3.13 and Node 20.
|
||||||
|
- Ruff formatting and fixes.
|
||||||
|
- Flatpak support. (WIP)
|
||||||
|
|
||||||
|
## Security
|
||||||
|
|
||||||
|
- Bearer Security Scan Action
|
||||||
|
- [Socket](https://socket.dev/) Supply Chain Security/Analysis
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
<a href="https://github.com/liamcottle/reticulum-meshchat"><img src="./logo/logo-chat-bubble.png" width="150"></a>
|
<a href="https://github.com/liamcottle/reticulum-meshchat"><img src="./logo/logo-chat-bubble.png" width="150"></a>
|
||||||
</p>
|
</p>
|
||||||
@@ -9,7 +32,7 @@
|
|||||||
<a href="https://twitter.com/liamcottle"><img src="https://img.shields.io/badge/Twitter-@liamcottle-%231DA1F2?style=flat&logo=twitter" alt="twitter"/></a>
|
<a href="https://twitter.com/liamcottle"><img src="https://img.shields.io/badge/Twitter-@liamcottle-%231DA1F2?style=flat&logo=twitter" alt="twitter"/></a>
|
||||||
<br/>
|
<br/>
|
||||||
<a href="https://ko-fi.com/liamcottle"><img src="https://img.shields.io/badge/Donate%20a%20Coffee-liamcottle-yellow?style=flat&logo=buy-me-a-coffee" alt="donate on ko-fi"/></a>
|
<a href="https://ko-fi.com/liamcottle"><img src="https://img.shields.io/badge/Donate%20a%20Coffee-liamcottle-yellow?style=flat&logo=buy-me-a-coffee" alt="donate on ko-fi"/></a>
|
||||||
<a href="./donate.md"><img src="https://img.shields.io/badge/Donate%20Bitcoin-3FPBfiEwioWHFix3kZqe5bdU9F5o8mG8dh-%23FF9900?style=flat&logo=bitcoin" alt="donate bitcoin"/></a>
|
<a href="./donate.md"><img src="https://img.shields.io/badge/Donate%20Bitcoin-bc1qy22smke8n4c54evdxmp7lpy9p0e6m9tavtlg2q-%23FF9900?style=flat&logo=bitcoin" alt="donate bitcoin"/></a>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
## What is Reticulum MeshChat?
|
## What is Reticulum MeshChat?
|
||||||
@@ -283,20 +306,6 @@ I build the vite app everytime without hot reload, since MeshChat expects everyt
|
|||||||
## TODO
|
## TODO
|
||||||
|
|
||||||
- [ ] button to forget announces
|
- [ ] button to forget announces
|
||||||
- [ ] support for managing Reticulum interfaces via the web ui
|
|
||||||
- [x] AutoInterface
|
|
||||||
- [x] RNodeInterface
|
|
||||||
- [x] TCPClientInterface
|
|
||||||
- [x] TCPServerInterface
|
|
||||||
- [x] UDPInterface
|
|
||||||
- [ ] I2PInterface
|
|
||||||
- [ ] SerialInterface
|
|
||||||
- [ ] PipeInterface
|
|
||||||
- [ ] KISSInterface
|
|
||||||
- [ ] AX25KISSInterface
|
|
||||||
- [ ] Other Options
|
|
||||||
- [ ] network_name
|
|
||||||
- [ ] passphrase
|
|
||||||
|
|
||||||
# Notes
|
# Notes
|
||||||
|
|
||||||
|
|||||||
75
database.py
75
database.py
@@ -4,40 +4,47 @@ from peewee import *
|
|||||||
from playhouse.migrate import migrate as migrate_database, SqliteMigrator
|
from playhouse.migrate import migrate as migrate_database, SqliteMigrator
|
||||||
|
|
||||||
latest_version = 5 # 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
|
database = (
|
||||||
|
DatabaseProxy()
|
||||||
|
) # use a proxy object, as we will init real db client inside meshchat.py
|
||||||
migrator = SqliteMigrator(database)
|
migrator = SqliteMigrator(database)
|
||||||
|
|
||||||
|
|
||||||
# migrates the database
|
# migrates the database
|
||||||
def migrate(current_version):
|
def migrate(current_version):
|
||||||
|
|
||||||
# migrate to version 2
|
# migrate to version 2
|
||||||
if current_version < 2:
|
if current_version < 2:
|
||||||
migrate_database(
|
migrate_database(
|
||||||
migrator.add_column("lxmf_messages", 'delivery_attempts', LxmfMessage.delivery_attempts),
|
migrator.add_column(
|
||||||
migrator.add_column("lxmf_messages", 'next_delivery_attempt_at', LxmfMessage.next_delivery_attempt_at),
|
"lxmf_messages", "delivery_attempts", LxmfMessage.delivery_attempts
|
||||||
|
),
|
||||||
|
migrator.add_column(
|
||||||
|
"lxmf_messages",
|
||||||
|
"next_delivery_attempt_at",
|
||||||
|
LxmfMessage.next_delivery_attempt_at,
|
||||||
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
# migrate to version 3
|
# migrate to version 3
|
||||||
if current_version < 3:
|
if current_version < 3:
|
||||||
migrate_database(
|
migrate_database(
|
||||||
migrator.add_column("lxmf_messages", 'rssi', LxmfMessage.rssi),
|
migrator.add_column("lxmf_messages", "rssi", LxmfMessage.rssi),
|
||||||
migrator.add_column("lxmf_messages", 'snr', LxmfMessage.snr),
|
migrator.add_column("lxmf_messages", "snr", LxmfMessage.snr),
|
||||||
migrator.add_column("lxmf_messages", 'quality', LxmfMessage.quality),
|
migrator.add_column("lxmf_messages", "quality", LxmfMessage.quality),
|
||||||
)
|
)
|
||||||
|
|
||||||
# migrate to version 4
|
# migrate to version 4
|
||||||
if current_version < 4:
|
if current_version < 4:
|
||||||
migrate_database(
|
migrate_database(
|
||||||
migrator.add_column("lxmf_messages", 'method', LxmfMessage.method),
|
migrator.add_column("lxmf_messages", "method", LxmfMessage.method),
|
||||||
)
|
)
|
||||||
|
|
||||||
# migrate to version 5
|
# migrate to version 5
|
||||||
if current_version < 5:
|
if current_version < 5:
|
||||||
migrate_database(
|
migrate_database(
|
||||||
migrator.add_column("announces", 'rssi', Announce.rssi),
|
migrator.add_column("announces", "rssi", Announce.rssi),
|
||||||
migrator.add_column("announces", 'snr', Announce.snr),
|
migrator.add_column("announces", "snr", Announce.snr),
|
||||||
migrator.add_column("announces", 'quality', Announce.quality),
|
migrator.add_column("announces", "quality", Announce.quality),
|
||||||
)
|
)
|
||||||
|
|
||||||
return latest_version
|
return latest_version
|
||||||
@@ -49,7 +56,6 @@ class BaseModel(Model):
|
|||||||
|
|
||||||
|
|
||||||
class Config(BaseModel):
|
class Config(BaseModel):
|
||||||
|
|
||||||
id = BigAutoField()
|
id = BigAutoField()
|
||||||
key = CharField(unique=True)
|
key = CharField(unique=True)
|
||||||
value = TextField()
|
value = TextField()
|
||||||
@@ -62,12 +68,19 @@ class Config(BaseModel):
|
|||||||
|
|
||||||
|
|
||||||
class Announce(BaseModel):
|
class Announce(BaseModel):
|
||||||
|
|
||||||
id = BigAutoField()
|
id = BigAutoField()
|
||||||
destination_hash = CharField(unique=True) # unique destination hash that was announced
|
destination_hash = CharField(
|
||||||
aspect = TextField(index=True) # aspect is not included in announce, but we want to filter saved announces by aspect
|
unique=True
|
||||||
identity_hash = CharField(index=True) # identity hash that announced the destination
|
) # unique destination hash that was announced
|
||||||
identity_public_key = CharField() # base64 encoded public key, incase we want to recreate the identity manually
|
aspect = TextField(
|
||||||
|
index=True
|
||||||
|
) # aspect is not included in announce, but we want to filter saved announces by aspect
|
||||||
|
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
|
app_data = TextField(null=True) # base64 encoded app data bytes
|
||||||
rssi = IntegerField(null=True)
|
rssi = IntegerField(null=True)
|
||||||
snr = FloatField(null=True)
|
snr = FloatField(null=True)
|
||||||
@@ -82,7 +95,6 @@ class Announce(BaseModel):
|
|||||||
|
|
||||||
|
|
||||||
class CustomDestinationDisplayName(BaseModel):
|
class CustomDestinationDisplayName(BaseModel):
|
||||||
|
|
||||||
id = BigAutoField()
|
id = BigAutoField()
|
||||||
destination_hash = CharField(unique=True) # unique destination hash
|
destination_hash = CharField(unique=True) # unique destination hash
|
||||||
display_name = CharField() # custom display name for the destination hash
|
display_name = CharField() # custom display name for the destination hash
|
||||||
@@ -96,21 +108,30 @@ class CustomDestinationDisplayName(BaseModel):
|
|||||||
|
|
||||||
|
|
||||||
class LxmfMessage(BaseModel):
|
class LxmfMessage(BaseModel):
|
||||||
|
|
||||||
id = BigAutoField()
|
id = BigAutoField()
|
||||||
hash = CharField(unique=True) # unique lxmf message hash
|
hash = CharField(unique=True) # unique lxmf message hash
|
||||||
source_hash = CharField(index=True)
|
source_hash = CharField(index=True)
|
||||||
destination_hash = CharField(index=True)
|
destination_hash = CharField(index=True)
|
||||||
state = CharField() # state is converted from internal int to a human friendly string
|
state = (
|
||||||
|
CharField()
|
||||||
|
) # state is converted from internal int to a human friendly string
|
||||||
progress = FloatField() # progress is converted from internal float 0.00-1.00 to float between 0.00/100 (2 decimal places)
|
progress = FloatField() # progress is converted from internal float 0.00-1.00 to float between 0.00/100 (2 decimal places)
|
||||||
is_incoming = BooleanField() # if true, we should ignore state, it's set to draft by default on incoming messages
|
is_incoming = BooleanField() # if true, we should ignore state, it's set to draft by default on incoming messages
|
||||||
method = CharField(null=True) # what method is being used to send the message, e.g: direct, propagated
|
method = CharField(
|
||||||
delivery_attempts = IntegerField(default=0) # how many times delivery has been attempted for this message
|
null=True
|
||||||
next_delivery_attempt_at = FloatField(null=True) # timestamp of when the message will attempt delivery again
|
) # what method is being used to send the message, e.g: direct, propagated
|
||||||
|
delivery_attempts = IntegerField(
|
||||||
|
default=0
|
||||||
|
) # how many times delivery has been attempted for this message
|
||||||
|
next_delivery_attempt_at = FloatField(
|
||||||
|
null=True
|
||||||
|
) # timestamp of when the message will attempt delivery again
|
||||||
title = TextField()
|
title = TextField()
|
||||||
content = TextField()
|
content = TextField()
|
||||||
fields = TextField() # json string
|
fields = TextField() # json string
|
||||||
timestamp = FloatField() # timestamp of when the message was originally created (before ever being sent)
|
timestamp = (
|
||||||
|
FloatField()
|
||||||
|
) # timestamp of when the message was originally created (before ever being sent)
|
||||||
rssi = IntegerField(null=True)
|
rssi = IntegerField(null=True)
|
||||||
snr = FloatField(null=True)
|
snr = FloatField(null=True)
|
||||||
quality = FloatField(null=True)
|
quality = FloatField(null=True)
|
||||||
@@ -123,7 +144,6 @@ class LxmfMessage(BaseModel):
|
|||||||
|
|
||||||
|
|
||||||
class LxmfConversationReadState(BaseModel):
|
class LxmfConversationReadState(BaseModel):
|
||||||
|
|
||||||
id = BigAutoField()
|
id = BigAutoField()
|
||||||
destination_hash = CharField(unique=True) # unique destination hash
|
destination_hash = CharField(unique=True) # unique destination hash
|
||||||
last_read_at = DateTimeField()
|
last_read_at = DateTimeField()
|
||||||
@@ -137,12 +157,13 @@ class LxmfConversationReadState(BaseModel):
|
|||||||
|
|
||||||
|
|
||||||
class LxmfUserIcon(BaseModel):
|
class LxmfUserIcon(BaseModel):
|
||||||
|
|
||||||
id = BigAutoField()
|
id = BigAutoField()
|
||||||
destination_hash = CharField(unique=True) # unique destination hash
|
destination_hash = CharField(unique=True) # unique destination hash
|
||||||
icon_name = CharField() # material design icon name for the destination hash
|
icon_name = CharField() # material design icon name for the destination hash
|
||||||
foreground_colour = CharField() # hex colour to use for foreground (icon colour)
|
foreground_colour = CharField() # hex colour to use for foreground (icon colour)
|
||||||
background_colour = CharField() # hex colour to use for background (background colour)
|
background_colour = (
|
||||||
|
CharField()
|
||||||
|
) # hex colour to use for background (background colour)
|
||||||
|
|
||||||
created_at = DateTimeField(default=lambda: datetime.now(timezone.utc))
|
created_at = DateTimeField(default=lambda: datetime.now(timezone.utc))
|
||||||
updated_at = DateTimeField(default=lambda: datetime.now(timezone.utc))
|
updated_at = DateTimeField(default=lambda: datetime.now(timezone.utc))
|
||||||
|
|||||||
40
docker-compose.dev.yml
Normal file
40
docker-compose.dev.yml
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
services:
|
||||||
|
reticulum-meshchat:
|
||||||
|
container_name: reticulum-meshchat-dev
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: Dockerfile
|
||||||
|
pull_policy: never
|
||||||
|
restart: unless-stopped
|
||||||
|
user: "1000:1000"
|
||||||
|
# Make the meshchat web interface accessible from the host on port 8000
|
||||||
|
ports:
|
||||||
|
- 0.0.0.0:8000:8000
|
||||||
|
volumes:
|
||||||
|
- meshchat-config:/config:rw
|
||||||
|
- .:/app:delegated
|
||||||
|
- /app/public
|
||||||
|
# Uncomment if you have a USB device connected, such as an RNode
|
||||||
|
# devices:
|
||||||
|
# - /dev/ttyUSB0:/dev/ttyUSB0
|
||||||
|
cap_drop:
|
||||||
|
- ALL
|
||||||
|
cap_add:
|
||||||
|
- NET_ADMIN
|
||||||
|
- NET_RAW
|
||||||
|
deploy:
|
||||||
|
resources:
|
||||||
|
limits:
|
||||||
|
cpus: '1'
|
||||||
|
memory: 512M
|
||||||
|
reservations:
|
||||||
|
cpus: '0.25'
|
||||||
|
memory: 128M
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
meshchat-config:
|
||||||
|
driver: local
|
||||||
|
driver_opts:
|
||||||
|
type: none
|
||||||
|
o: bind
|
||||||
|
device: ${PWD}/config
|
||||||
@@ -1,17 +1,31 @@
|
|||||||
services:
|
services:
|
||||||
reticulum-meshchat:
|
reticulum-meshchat:
|
||||||
container_name: reticulum-meshchat
|
container_name: reticulum-meshchat
|
||||||
image: ghcr.io/liamcottle/reticulum-meshchat:latest
|
image: ghcr.io/sudo-ivan/reticulum-meshchat:latest
|
||||||
pull_policy: always
|
pull_policy: always
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
user: "1000:1000"
|
||||||
# Make the meshchat web interface accessible from the host on port 8000
|
# Make the meshchat web interface accessible from the host on port 8000
|
||||||
ports:
|
ports:
|
||||||
- 0.0.0.0:8000:8000
|
- 0.0.0.0:8000:8000
|
||||||
volumes:
|
volumes:
|
||||||
- meshchat-config:/config
|
- meshchat-config:/config:rw
|
||||||
# Uncomment if you have a USB device connected, such as an RNode
|
# Uncomment if you have a USB device connected, such as an RNode
|
||||||
# devices:
|
# devices:
|
||||||
# - /dev/ttyUSB0:/dev/ttyUSB0
|
# - /dev/ttyUSB0:/dev/ttyUSB0
|
||||||
|
cap_drop:
|
||||||
|
- ALL
|
||||||
|
cap_add:
|
||||||
|
- NET_ADMIN
|
||||||
|
- NET_RAW
|
||||||
|
deploy:
|
||||||
|
resources:
|
||||||
|
limits:
|
||||||
|
cpus: '1'
|
||||||
|
memory: 512M
|
||||||
|
reservations:
|
||||||
|
cpus: '0.25'
|
||||||
|
memory: 128M
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
meshchat-config:
|
meshchat-config:
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ Thank you for considering donating, this helps support my work on this project
|
|||||||
|
|
||||||
## How can I donate?
|
## How can I donate?
|
||||||
|
|
||||||
- Bitcoin: 3FPBfiEwioWHFix3kZqe5bdU9F5o8mG8dh
|
- Bitcoin: bc1qy22smke8n4c54evdxmp7lpy9p0e6m9tavtlg2q
|
||||||
- Ethereum: 0xc64CFbA5D0BF7664158c5671F64d446395b3bF3D
|
- Ethereum: 0xc64CFbA5D0BF7664158c5671F64d446395b3bF3D
|
||||||
- Buy me a Coffee: [https://ko-fi.com/liamcottle](https://ko-fi.com/liamcottle)
|
- Buy me a Coffee: [https://ko-fi.com/liamcottle](https://ko-fi.com/liamcottle)
|
||||||
- Sponsor on GitHub: [https://github.com/sponsors/liamcottle](https://github.com/sponsors/liamcottle)
|
- Sponsor on GitHub: [https://github.com/sponsors/liamcottle](https://github.com/sponsors/liamcottle)
|
||||||
|
|||||||
8
electron/build/reticulum-meshchat.desktop
Normal file
8
electron/build/reticulum-meshchat.desktop
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
[Desktop Entry]
|
||||||
|
Name=Reticulum MeshChat
|
||||||
|
Comment=Decentralized chat over Reticulum networks
|
||||||
|
Exec=reticulum-meshchat
|
||||||
|
Icon=icon
|
||||||
|
Terminal=false
|
||||||
|
Type=Application
|
||||||
|
Categories=Network;Chat;
|
||||||
28
electron/build/reticulum-meshchat.flatpak.json
Normal file
28
electron/build/reticulum-meshchat.flatpak.json
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
{
|
||||||
|
"app-id": "com.liamcottle.reticulummeshchat",
|
||||||
|
"runtime": "org.freedesktop.Platform",
|
||||||
|
"runtime-version": "23.08",
|
||||||
|
"sdk": "org.freedesktop.Sdk",
|
||||||
|
"command": "reticulum-meshchat",
|
||||||
|
"finish-args": [
|
||||||
|
"--share=network",
|
||||||
|
"--socket=x11",
|
||||||
|
"--socket=wayland",
|
||||||
|
"--device=all"
|
||||||
|
],
|
||||||
|
"modules": [
|
||||||
|
{
|
||||||
|
"name": "reticulum-meshchat",
|
||||||
|
"buildsystem": "simple",
|
||||||
|
"build-commands": [
|
||||||
|
"install -Dm755 dist/ReticulumMeshChat-v$FLATPAK_APP_VERSION-linux.AppImage /app/bin/reticulum-meshchat"
|
||||||
|
],
|
||||||
|
"sources": [
|
||||||
|
{
|
||||||
|
"type": "file",
|
||||||
|
"path": "dist/ReticulumMeshChat-v$FLATPAK_APP_VERSION-linux.AppImage"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -48,6 +48,9 @@ ipcMain.handle('showPathInFolder', (event, path) => {
|
|||||||
|
|
||||||
function log(message) {
|
function log(message) {
|
||||||
|
|
||||||
|
// log to stdout of this process
|
||||||
|
console.log(message);
|
||||||
|
|
||||||
// make sure main window exists
|
// make sure main window exists
|
||||||
if(!mainWindow){
|
if(!mainWindow){
|
||||||
return;
|
return;
|
||||||
@@ -58,9 +61,6 @@ function log(message) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// log to electron console
|
|
||||||
console.log(message);
|
|
||||||
|
|
||||||
// log to web console
|
// log to web console
|
||||||
mainWindow.webContents.send('log', message);
|
mainWindow.webContents.send('log', message);
|
||||||
|
|
||||||
@@ -98,50 +98,65 @@ function getDefaultReticulumConfigDir() {
|
|||||||
|
|
||||||
app.whenReady().then(async () => {
|
app.whenReady().then(async () => {
|
||||||
|
|
||||||
// create browser window
|
// get arguments passed to application, and remove the provided application path
|
||||||
mainWindow = new BrowserWindow({
|
const ignoredArguments = ["--no-sandbox"];
|
||||||
width: 1500,
|
const userProvidedArguments = process.argv.slice(1)
|
||||||
height: 800,
|
.filter(arg => !ignoredArguments.includes(arg));
|
||||||
webPreferences: {
|
const shouldLaunchHeadless = userProvidedArguments.includes("--headless");
|
||||||
// used to inject logging over ipc
|
|
||||||
preload: path.join(__dirname, 'preload.js'),
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
// open external links in default web browser instead of electron
|
if(!shouldLaunchHeadless){
|
||||||
mainWindow.webContents.setWindowOpenHandler(({ url }) => {
|
|
||||||
|
|
||||||
var shouldShowInNewElectronWindow = false;
|
// create browser window
|
||||||
|
mainWindow = new BrowserWindow({
|
||||||
|
width: 1500,
|
||||||
|
height: 800,
|
||||||
|
webPreferences: {
|
||||||
|
// used to inject logging over ipc
|
||||||
|
preload: path.join(__dirname, 'preload.js'),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
// we want to open call.html in a new electron window
|
// open external links in default web browser instead of electron
|
||||||
// but all other target="_blank" links should open in the system web browser
|
mainWindow.webContents.setWindowOpenHandler(({ url }) => {
|
||||||
// we don't want /rnode-flasher/index.html to open in electron, otherwise user can't select usb devices...
|
|
||||||
if(url.startsWith("http://localhost") && url.includes("/call.html")){
|
|
||||||
shouldShowInNewElectronWindow = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
// we want to open blob urls in a new electron window
|
var shouldShowInNewElectronWindow = false;
|
||||||
else if(url.startsWith("blob:")) {
|
|
||||||
shouldShowInNewElectronWindow = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
// open in new electron window
|
// we want to open call.html in a new electron window
|
||||||
if(shouldShowInNewElectronWindow){
|
// but all other target="_blank" links should open in the system web browser
|
||||||
|
// we don't want /rnode-flasher/index.html to open in electron, otherwise user can't select usb devices...
|
||||||
|
if(url.startsWith("http://localhost") && url.includes("/call.html")){
|
||||||
|
shouldShowInNewElectronWindow = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// we want to open blob urls in a new electron window
|
||||||
|
else if(url.startsWith("blob:")) {
|
||||||
|
shouldShowInNewElectronWindow = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// open in new electron window
|
||||||
|
if(shouldShowInNewElectronWindow){
|
||||||
|
return {
|
||||||
|
action: "allow",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// fallback to opening any other url in external browser
|
||||||
|
shell.openExternal(url);
|
||||||
return {
|
return {
|
||||||
action: "allow",
|
action: "deny",
|
||||||
};
|
};
|
||||||
|
|
||||||
|
});
|
||||||
|
|
||||||
|
// navigate to loading page
|
||||||
|
await mainWindow.loadFile(path.join(__dirname, 'loading.html'));
|
||||||
|
|
||||||
|
// ask mac users for microphone access for audio calls to work
|
||||||
|
if(process.platform === "darwin"){
|
||||||
|
await systemPreferences.askForMediaAccess('microphone');
|
||||||
}
|
}
|
||||||
|
|
||||||
// fallback to opening any other url in external browser
|
}
|
||||||
shell.openExternal(url);
|
|
||||||
return {
|
|
||||||
action: "deny",
|
|
||||||
};
|
|
||||||
|
|
||||||
});
|
|
||||||
|
|
||||||
// navigate to loading page
|
|
||||||
await mainWindow.loadFile(path.join(__dirname, 'loading.html'));
|
|
||||||
|
|
||||||
// find path to python/cxfreeze reticulum meshchat executable
|
// find path to python/cxfreeze reticulum meshchat executable
|
||||||
const exeName = process.platform === "win32" ? "ReticulumMeshChat.exe" : "ReticulumMeshChat";
|
const exeName = process.platform === "win32" ? "ReticulumMeshChat.exe" : "ReticulumMeshChat";
|
||||||
@@ -152,16 +167,8 @@ app.whenReady().then(async () => {
|
|||||||
exe = path.join(__dirname, '..', `build/exe/${exeName}`);
|
exe = path.join(__dirname, '..', `build/exe/${exeName}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ask mac users for microphone access for audio calls to work
|
|
||||||
if(process.platform === "darwin"){
|
|
||||||
await systemPreferences.askForMediaAccess('microphone');
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
|
||||||
// get arguments passed to application, and remove the provided application path
|
|
||||||
const userProvidedArguments = process.argv.slice(1);
|
|
||||||
|
|
||||||
// arguments we always want to pass in
|
// arguments we always want to pass in
|
||||||
const requiredArguments = [
|
const requiredArguments = [
|
||||||
'--headless', // reticulum meshchat usually launches default web browser, we don't want this when using electron
|
'--headless', // reticulum meshchat usually launches default web browser, we don't want this when using electron
|
||||||
|
|||||||
2840
meshchat.py
2840
meshchat.py
File diff suppressed because it is too large
Load Diff
1768
package-lock.json
generated
1768
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
32
package.json
32
package.json
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "reticulum-meshchat",
|
"name": "reticulum-meshchat",
|
||||||
"version": "1.17.0",
|
"version": "1.23.2",
|
||||||
"description": "",
|
"description": "",
|
||||||
"main": "electron/main.js",
|
"main": "electron/main.js",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
@@ -14,7 +14,7 @@
|
|||||||
},
|
},
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=18"
|
"node": ">=22"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"electron": "^30.0.8",
|
"electron": "^30.0.8",
|
||||||
@@ -70,7 +70,12 @@
|
|||||||
},
|
},
|
||||||
"linux": {
|
"linux": {
|
||||||
"artifactName": "ReticulumMeshChat-v${version}-${os}.${ext}",
|
"artifactName": "ReticulumMeshChat-v${version}-${os}.${ext}",
|
||||||
"target": "AppImage",
|
"target": [
|
||||||
|
"AppImage",
|
||||||
|
"flatpak"
|
||||||
|
],
|
||||||
|
"category": "Network",
|
||||||
|
"icon": "electron/build/icon.png",
|
||||||
"extraFiles": [
|
"extraFiles": [
|
||||||
{
|
{
|
||||||
"from": "build/exe",
|
"from": "build/exe",
|
||||||
@@ -91,27 +96,36 @@
|
|||||||
"artifactName": "ReticulumMeshChat-v${version}-${os}-installer.${ext}",
|
"artifactName": "ReticulumMeshChat-v${version}-${os}-installer.${ext}",
|
||||||
"oneClick": false,
|
"oneClick": false,
|
||||||
"allowToChangeInstallationDirectory": true
|
"allowToChangeInstallationDirectory": true
|
||||||
|
},
|
||||||
|
"flatpak": {
|
||||||
|
"finishArgs": [
|
||||||
|
"--share=network",
|
||||||
|
"--socket=x11",
|
||||||
|
"--socket=wayland",
|
||||||
|
"--device=all"
|
||||||
|
]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@mdi/js": "^7.4.47",
|
"@mdi/js": "^7.4.47",
|
||||||
"@tailwindcss/forms": "^0.5.9",
|
"@tailwindcss/forms": "^0.5.9",
|
||||||
"@vitejs/plugin-vue": "^5.2.1",
|
"@vitejs/plugin-vue": "^5.2.4",
|
||||||
"autoprefixer": "^10.4.20",
|
"autoprefixer": "^10.4.20",
|
||||||
"axios": "^1.7.9",
|
"axios": "^1.9.0",
|
||||||
"click-outside-vue3": "^4.0.1",
|
"click-outside-vue3": "^4.0.1",
|
||||||
"compressorjs": "^1.2.1",
|
"compressorjs": "^1.2.1",
|
||||||
"electron-prompt": "^1.7.0",
|
"electron-prompt": "^1.7.0",
|
||||||
|
"micron-parser": "^1.0.1",
|
||||||
"mitt": "^3.0.1",
|
"mitt": "^3.0.1",
|
||||||
"moment": "^2.30.1",
|
"moment": "^2.30.1",
|
||||||
"postcss": "^8.4.49",
|
"postcss": "^8.4.49",
|
||||||
"protobufjs": "^7.4.0",
|
"protobufjs": "^7.5.1",
|
||||||
"tailwindcss": "^3.4.17",
|
"tailwindcss": "^3.4.17",
|
||||||
"vis-data": "^7.1.9",
|
"vis-data": "^7.1.9",
|
||||||
"vis-network": "^9.1.9",
|
"vis-network": "^9.1.9",
|
||||||
"vite": "^6.0.5",
|
"vite": "^6.3.5",
|
||||||
"vite-plugin-vuetify": "^2.0.4",
|
"vite-plugin-vuetify": "^2.0.4",
|
||||||
"vue-router": "^4.5.0",
|
"vue-router": "^4.5.1",
|
||||||
"vuetify": "^3.7.6"
|
"vuetify": "^3.8.4"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
aiohttp>=3.9.5
|
aiohttp>=3.11.18
|
||||||
cx_freeze>=7.0.0
|
cx_freeze>=7.0.0
|
||||||
lxmf>=0.5.8
|
lxmf>=0.6.3
|
||||||
peewee>=3.17.3
|
peewee>=3.18.1
|
||||||
rns>=0.8.8
|
rns>=0.9.5
|
||||||
websockets>=12.0
|
websockets>=15.0.1
|
||||||
38
setup.py
38
setup.py
@@ -1,44 +1,44 @@
|
|||||||
from cx_Freeze import setup, Executable
|
from cx_Freeze import setup, Executable
|
||||||
|
|
||||||
setup(
|
setup(
|
||||||
name='ReticulumMeshChat',
|
name="ReticulumMeshChat",
|
||||||
version='1.0.0',
|
version="1.0.0",
|
||||||
description='A simple mesh network communications app powered by the Reticulum Network Stack',
|
description="A simple mesh network communications app powered by the Reticulum Network Stack",
|
||||||
executables=[
|
executables=[
|
||||||
Executable(
|
Executable(
|
||||||
script='meshchat.py', # this script to run
|
script="meshchat.py", # this script to run
|
||||||
base=None, # we are running a console application, not a gui
|
base=None, # we are running a console application, not a gui
|
||||||
target_name='ReticulumMeshChat', # creates ReticulumMeshChat.exe
|
target_name="ReticulumMeshChat", # creates ReticulumMeshChat.exe
|
||||||
shortcut_name='ReticulumMeshChat', # name shown in shortcut
|
shortcut_name="ReticulumMeshChat", # name shown in shortcut
|
||||||
shortcut_dir='ProgramMenuFolder', # put the shortcut in windows start menu
|
shortcut_dir="ProgramMenuFolder", # put the shortcut in windows start menu
|
||||||
icon='logo/icon.ico', # set the icon for the exe
|
icon="logo/icon.ico", # set the icon for the exe
|
||||||
copyright='Copyright (c) 2024 Liam Cottle',
|
copyright="Copyright (c) 2024 Liam Cottle",
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
options={
|
options={
|
||||||
'build_exe': {
|
"build_exe": {
|
||||||
# libs that are required
|
# libs that are required
|
||||||
'packages': [
|
"packages": [
|
||||||
# required for dynamic import fix
|
# required for dynamic import fix
|
||||||
# https://github.com/marcelotduarte/cx_Freeze/discussions/2039
|
# https://github.com/marcelotduarte/cx_Freeze/discussions/2039
|
||||||
# https://github.com/marcelotduarte/cx_Freeze/issues/2041
|
# https://github.com/marcelotduarte/cx_Freeze/issues/2041
|
||||||
'RNS',
|
"RNS",
|
||||||
],
|
],
|
||||||
# files that are required
|
# files that are required
|
||||||
'include_files': [
|
"include_files": [
|
||||||
'package.json', # used to determine app version from python
|
"package.json", # used to determine app version from python
|
||||||
'public/', # static files served by web server
|
"public/", # static files served by web server
|
||||||
],
|
],
|
||||||
# slim down the build by excluding these unused libs
|
# slim down the build by excluding these unused libs
|
||||||
'excludes': [
|
"excludes": [
|
||||||
'PIL', # saves ~200MB
|
"PIL", # saves ~200MB
|
||||||
],
|
],
|
||||||
# this has the same effect as the -O command line option when executing CPython directly.
|
# this has the same effect as the -O command line option when executing CPython directly.
|
||||||
# it also prevents assert statements from executing, removes docstrings and sets __debug__ to False.
|
# it also prevents assert statements from executing, removes docstrings and sets __debug__ to False.
|
||||||
# https://stackoverflow.com/a/57948104
|
# https://stackoverflow.com/a/57948104
|
||||||
"optimize": 2,
|
"optimize": 2,
|
||||||
# change where exe is built to
|
# change where exe is built to
|
||||||
'build_exe': 'build/exe',
|
"build_exe": "build/exe",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ import sys
|
|||||||
|
|
||||||
# this class forces stream writes to be flushed immediately
|
# this class forces stream writes to be flushed immediately
|
||||||
class ImmediateFlushingStreamWrapper:
|
class ImmediateFlushingStreamWrapper:
|
||||||
|
|
||||||
def __init__(self, stream):
|
def __init__(self, stream):
|
||||||
self.stream = stream
|
self.stream = stream
|
||||||
|
|
||||||
|
|||||||
@@ -1,16 +1,23 @@
|
|||||||
# an announce handler that forwards announces to a provided callback for the provided aspect filter
|
# an announce handler that forwards announces to a provided callback for the provided aspect filter
|
||||||
# this handler exists so we can have access to the original aspect, as this is not provided in the announce itself
|
# this handler exists so we can have access to the original aspect, as this is not provided in the announce itself
|
||||||
class AnnounceHandler:
|
class AnnounceHandler:
|
||||||
|
|
||||||
def __init__(self, aspect_filter: str, received_announce_callback):
|
def __init__(self, aspect_filter: str, received_announce_callback):
|
||||||
self.aspect_filter = aspect_filter
|
self.aspect_filter = aspect_filter
|
||||||
self.received_announce_callback = received_announce_callback
|
self.received_announce_callback = received_announce_callback
|
||||||
|
|
||||||
# we will just pass the received announce back to the provided callback
|
# we will just pass the received announce back to the provided callback
|
||||||
def received_announce(self, destination_hash, announced_identity, app_data, announce_packet_hash):
|
def received_announce(
|
||||||
|
self, destination_hash, announced_identity, app_data, announce_packet_hash
|
||||||
|
):
|
||||||
try:
|
try:
|
||||||
# handle received announce
|
# handle received announce
|
||||||
self.received_announce_callback(self.aspect_filter, destination_hash, announced_identity, app_data, announce_packet_hash)
|
self.received_announce_callback(
|
||||||
|
self.aspect_filter,
|
||||||
|
destination_hash,
|
||||||
|
announced_identity,
|
||||||
|
app_data,
|
||||||
|
announce_packet_hash,
|
||||||
|
)
|
||||||
except:
|
except:
|
||||||
# ignore failure to handle received announce
|
# ignore failure to handle received announce
|
||||||
pass
|
pass
|
||||||
|
|||||||
23
src/backend/async_utils.py
Normal file
23
src/backend/async_utils.py
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
import asyncio
|
||||||
|
|
||||||
|
|
||||||
|
class AsyncUtils:
|
||||||
|
# this method allows running the provided async coroutine from within a sync function
|
||||||
|
# it will run the async function on the existing event loop if available, otherwise it will start a new event loop
|
||||||
|
@staticmethod
|
||||||
|
def run_async(coroutine):
|
||||||
|
# attempt to get existing event loop
|
||||||
|
existing_event_loop = None
|
||||||
|
try:
|
||||||
|
existing_event_loop = asyncio.get_running_loop()
|
||||||
|
except RuntimeError:
|
||||||
|
# 'RuntimeError: no running event loop'
|
||||||
|
pass
|
||||||
|
|
||||||
|
# if there is an existing event loop running, submit the coroutine to that loop
|
||||||
|
if existing_event_loop and existing_event_loop.is_running():
|
||||||
|
existing_event_loop.create_task(coroutine)
|
||||||
|
return
|
||||||
|
|
||||||
|
# otherwise start a new event loop to run the coroutine
|
||||||
|
asyncio.run(coroutine)
|
||||||
@@ -13,7 +13,6 @@ class CallFailedException(Exception):
|
|||||||
|
|
||||||
|
|
||||||
class AudioCall:
|
class AudioCall:
|
||||||
|
|
||||||
def __init__(self, link: RNS.Link, is_outbound: bool):
|
def __init__(self, link: RNS.Link, is_outbound: bool):
|
||||||
self.link = link
|
self.link = link
|
||||||
self.is_outbound = is_outbound
|
self.is_outbound = is_outbound
|
||||||
@@ -41,21 +40,25 @@ class AudioCall:
|
|||||||
|
|
||||||
# handle packet received over link
|
# handle packet received over link
|
||||||
def on_packet(self, message, packet):
|
def on_packet(self, message, packet):
|
||||||
|
|
||||||
# send audio received from call initiator to all audio packet listeners
|
# send audio received from call initiator to all audio packet listeners
|
||||||
for audio_packet_listener in self.audio_packet_listeners:
|
for audio_packet_listener in self.audio_packet_listeners:
|
||||||
audio_packet_listener(message)
|
audio_packet_listener(message)
|
||||||
|
|
||||||
# send an audio packet over the link
|
# send an audio packet over the link
|
||||||
def send_audio_packet(self, data):
|
def send_audio_packet(self, data):
|
||||||
|
|
||||||
# do nothing if link is not active
|
# do nothing if link is not active
|
||||||
if self.is_active() is False:
|
if self.is_active() is False:
|
||||||
return
|
return
|
||||||
|
|
||||||
# drop audio packet if it is too big to send
|
# drop audio packet if it is too big to send
|
||||||
if len(data) > RNS.Link.MDU:
|
if len(data) > RNS.Link.MDU:
|
||||||
print("[AudioCall] dropping audio packet " + str(len(data)) + " bytes exceeds the link packet MDU of " + str(RNS.Link.MDU) + " bytes")
|
print(
|
||||||
|
"[AudioCall] dropping audio packet "
|
||||||
|
+ str(len(data))
|
||||||
|
+ " bytes exceeds the link packet MDU of "
|
||||||
|
+ str(RNS.Link.MDU)
|
||||||
|
+ " bytes"
|
||||||
|
)
|
||||||
return
|
return
|
||||||
|
|
||||||
# send codec2 audio received from call receiver to call initiator over reticulum link
|
# send codec2 audio received from call receiver to call initiator over reticulum link
|
||||||
@@ -77,9 +80,7 @@ class AudioCall:
|
|||||||
|
|
||||||
|
|
||||||
class AudioCallManager:
|
class AudioCallManager:
|
||||||
|
|
||||||
def __init__(self, identity: RNS.Identity):
|
def __init__(self, identity: RNS.Identity):
|
||||||
|
|
||||||
self.identity = identity
|
self.identity = identity
|
||||||
self.on_incoming_call_callback = None
|
self.on_incoming_call_callback = None
|
||||||
self.on_outgoing_call_callback = None
|
self.on_outgoing_call_callback = None
|
||||||
@@ -91,7 +92,10 @@ class AudioCallManager:
|
|||||||
# announces the audio call destination
|
# announces the audio call destination
|
||||||
def announce(self, app_data=None):
|
def announce(self, app_data=None):
|
||||||
self.audio_call_receiver.destination.announce(app_data)
|
self.audio_call_receiver.destination.announce(app_data)
|
||||||
print("[AudioCallManager] announced destination: " + RNS.prettyhexrep(self.audio_call_receiver.destination.hash))
|
print(
|
||||||
|
"[AudioCallManager] announced destination: "
|
||||||
|
+ RNS.prettyhexrep(self.audio_call_receiver.destination.hash)
|
||||||
|
)
|
||||||
|
|
||||||
# set the callback for incoming calls
|
# set the callback for incoming calls
|
||||||
def register_incoming_call_callback(self, callback):
|
def register_incoming_call_callback(self, callback):
|
||||||
@@ -103,7 +107,6 @@ class AudioCallManager:
|
|||||||
|
|
||||||
# handle incoming calls from audio call receiver
|
# handle incoming calls from audio call receiver
|
||||||
def handle_incoming_call(self, audio_call: AudioCall):
|
def handle_incoming_call(self, audio_call: AudioCall):
|
||||||
|
|
||||||
# remember it
|
# remember it
|
||||||
self.audio_calls.append(audio_call)
|
self.audio_calls.append(audio_call)
|
||||||
|
|
||||||
@@ -113,7 +116,6 @@ class AudioCallManager:
|
|||||||
|
|
||||||
# handle outgoing calls
|
# handle outgoing calls
|
||||||
def handle_outgoing_call(self, audio_call: AudioCall):
|
def handle_outgoing_call(self, audio_call: AudioCall):
|
||||||
|
|
||||||
# remember it
|
# remember it
|
||||||
self.audio_calls.append(audio_call)
|
self.audio_calls.append(audio_call)
|
||||||
|
|
||||||
@@ -145,19 +147,22 @@ class AudioCallManager:
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
# attempts to initiate a call to the provided destination and returns the link hash on success
|
# attempts to initiate a call to the provided destination and returns the link hash on success
|
||||||
async def initiate(self, destination_hash: bytes, timeout_seconds: int = 15) -> AudioCall:
|
async def initiate(
|
||||||
|
self, destination_hash: bytes, timeout_seconds: int = 15
|
||||||
|
) -> AudioCall:
|
||||||
# determine when to timeout
|
# determine when to timeout
|
||||||
timeout_after_seconds = time.time() + timeout_seconds
|
timeout_after_seconds = time.time() + timeout_seconds
|
||||||
|
|
||||||
# check if we have a path to the destination
|
# check if we have a path to the destination
|
||||||
if not RNS.Transport.has_path(destination_hash):
|
if not RNS.Transport.has_path(destination_hash):
|
||||||
|
|
||||||
# we don't have a path, so we need to request it
|
# we don't have a path, so we need to request it
|
||||||
RNS.Transport.request_path(destination_hash)
|
RNS.Transport.request_path(destination_hash)
|
||||||
|
|
||||||
# wait until we have a path, or give up after the configured timeout
|
# wait until we have a path, or give up after the configured timeout
|
||||||
while not RNS.Transport.has_path(destination_hash) and time.time() < timeout_after_seconds:
|
while (
|
||||||
|
not RNS.Transport.has_path(destination_hash)
|
||||||
|
and time.time() < timeout_after_seconds
|
||||||
|
):
|
||||||
await asyncio.sleep(0.1)
|
await asyncio.sleep(0.1)
|
||||||
|
|
||||||
# if we still don't have a path, we can't establish a link, so bail out
|
# if we still don't have a path, we can't establish a link, so bail out
|
||||||
@@ -171,14 +176,16 @@ class AudioCallManager:
|
|||||||
RNS.Destination.OUT,
|
RNS.Destination.OUT,
|
||||||
RNS.Destination.SINGLE,
|
RNS.Destination.SINGLE,
|
||||||
"call",
|
"call",
|
||||||
"audio"
|
"audio",
|
||||||
)
|
)
|
||||||
|
|
||||||
# create link
|
# create link
|
||||||
link = RNS.Link(server_destination)
|
link = RNS.Link(server_destination)
|
||||||
|
|
||||||
# wait until we have established a link, or give up after the configured timeout
|
# wait until we have established a link, or give up after the configured timeout
|
||||||
while link.status is not RNS.Link.ACTIVE and time.time() < timeout_after_seconds:
|
while (
|
||||||
|
link.status is not RNS.Link.ACTIVE and time.time() < timeout_after_seconds
|
||||||
|
):
|
||||||
await asyncio.sleep(0.1)
|
await asyncio.sleep(0.1)
|
||||||
|
|
||||||
# if we still haven't established a link, bail out
|
# if we still haven't established a link, bail out
|
||||||
@@ -198,9 +205,7 @@ class AudioCallManager:
|
|||||||
|
|
||||||
|
|
||||||
class AudioCallReceiver:
|
class AudioCallReceiver:
|
||||||
|
|
||||||
def __init__(self, manager: AudioCallManager):
|
def __init__(self, manager: AudioCallManager):
|
||||||
|
|
||||||
self.manager = manager
|
self.manager = manager
|
||||||
|
|
||||||
# create destination for receiving audio calls
|
# create destination for receiving audio calls
|
||||||
@@ -224,7 +229,6 @@ class AudioCallReceiver:
|
|||||||
|
|
||||||
# client connected to us, set up an audio call instance
|
# client connected to us, set up an audio call instance
|
||||||
def client_connected(self, link: RNS.Link):
|
def client_connected(self, link: RNS.Link):
|
||||||
|
|
||||||
# todo: this can be optional, it's only being sent by default for ui, can be removed
|
# todo: this can be optional, it's only being sent by default for ui, can be removed
|
||||||
link.identify(self.manager.identity)
|
link.identify(self.manager.identity)
|
||||||
|
|
||||||
|
|||||||
@@ -1,10 +1,8 @@
|
|||||||
class ColourUtils:
|
class ColourUtils:
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def hex_colour_to_byte_array(hex_colour):
|
def hex_colour_to_byte_array(hex_colour):
|
||||||
|
|
||||||
# remove leading "#"
|
# remove leading "#"
|
||||||
hex_colour = hex_colour.lstrip('#')
|
hex_colour = hex_colour.lstrip("#")
|
||||||
|
|
||||||
# convert the remaining hex string to bytes
|
# convert the remaining hex string to bytes
|
||||||
return bytes.fromhex(hex_colour)
|
return bytes.fromhex(hex_colour)
|
||||||
|
|||||||
28
src/backend/interface_config_parser.py
Normal file
28
src/backend/interface_config_parser.py
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
import RNS.vendor.configobj
|
||||||
|
|
||||||
|
|
||||||
|
class InterfaceConfigParser:
|
||||||
|
@staticmethod
|
||||||
|
def parse(text):
|
||||||
|
# get lines from provided text
|
||||||
|
lines = text.splitlines()
|
||||||
|
|
||||||
|
# ensure [interfaces] section exists
|
||||||
|
if "[interfaces]" not in lines:
|
||||||
|
lines.insert(0, "[interfaces]")
|
||||||
|
|
||||||
|
# parse lines as rns config object
|
||||||
|
config = RNS.vendor.configobj.ConfigObj(lines)
|
||||||
|
|
||||||
|
# get interfaces from config
|
||||||
|
config_interfaces = config.get("interfaces")
|
||||||
|
|
||||||
|
# process interfaces
|
||||||
|
interfaces = []
|
||||||
|
for interface_name in config_interfaces:
|
||||||
|
# ensure interface has a name
|
||||||
|
interface_config = config_interfaces[interface_name]
|
||||||
|
interface_config["name"] = interface_name
|
||||||
|
interfaces.append(interface_config)
|
||||||
|
|
||||||
|
return interfaces
|
||||||
12
src/backend/interface_editor.py
Normal file
12
src/backend/interface_editor.py
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
class InterfaceEditor:
|
||||||
|
@staticmethod
|
||||||
|
def update_value(interface_details: dict, data: dict, key: str):
|
||||||
|
# update value if provided and not empty
|
||||||
|
value = data.get(key)
|
||||||
|
if value is not None and value != "":
|
||||||
|
interface_details[key] = value
|
||||||
|
return
|
||||||
|
|
||||||
|
# otherwise remove existing value
|
||||||
|
if key in interface_details:
|
||||||
|
del interface_details[key]
|
||||||
132
src/backend/interfaces/WebsocketClientInterface.py
Normal file
132
src/backend/interfaces/WebsocketClientInterface.py
Normal file
@@ -0,0 +1,132 @@
|
|||||||
|
import threading
|
||||||
|
import time
|
||||||
|
|
||||||
|
import RNS
|
||||||
|
from RNS.Interfaces.Interface import Interface
|
||||||
|
from websockets.sync.client import connect
|
||||||
|
from websockets.sync.connection import Connection
|
||||||
|
|
||||||
|
|
||||||
|
class WebsocketClientInterface(Interface):
|
||||||
|
# TODO: required?
|
||||||
|
DEFAULT_IFAC_SIZE = 16
|
||||||
|
|
||||||
|
RECONNECT_DELAY_SECONDS = 5
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return f"WebsocketClientInterface[{self.name}/{self.target_url}]"
|
||||||
|
|
||||||
|
def __init__(self, owner, configuration, websocket: Connection = None):
|
||||||
|
super().__init__()
|
||||||
|
|
||||||
|
self.owner = owner
|
||||||
|
self.parent_interface = None
|
||||||
|
|
||||||
|
self.IN = True
|
||||||
|
self.OUT = False
|
||||||
|
self.HW_MTU = 262144 # 256KiB
|
||||||
|
self.bitrate = 1_000_000_000 # 1Gbps
|
||||||
|
self.mode = RNS.Interfaces.Interface.Interface.MODE_FULL
|
||||||
|
|
||||||
|
# parse config
|
||||||
|
ifconf = Interface.get_config_obj(configuration)
|
||||||
|
self.name = ifconf.get("name")
|
||||||
|
self.target_url = ifconf.get("target_url", None)
|
||||||
|
|
||||||
|
# ensure target url is provided
|
||||||
|
if self.target_url is None:
|
||||||
|
raise SystemError(f"target_url is required for interface '{self.name}'")
|
||||||
|
|
||||||
|
# connect to websocket server if an existing connection was not provided
|
||||||
|
self.websocket = websocket
|
||||||
|
if self.websocket is None:
|
||||||
|
thread = threading.Thread(target=self.connect)
|
||||||
|
thread.daemon = True
|
||||||
|
thread.start()
|
||||||
|
|
||||||
|
# called when a full packet has been received over the websocket
|
||||||
|
def process_incoming(self, data):
|
||||||
|
# do nothing if offline or detached
|
||||||
|
if not self.online or self.detached:
|
||||||
|
return
|
||||||
|
|
||||||
|
# update received bytes counter
|
||||||
|
self.rxb += len(data)
|
||||||
|
|
||||||
|
# update received bytes counter for parent interface
|
||||||
|
if self.parent_interface is not None:
|
||||||
|
self.parent_interface.rxb += len(data)
|
||||||
|
|
||||||
|
# send received data to transport instance
|
||||||
|
self.owner.inbound(data, self)
|
||||||
|
|
||||||
|
# the running reticulum transport instance will call this method whenever the interface must transmit a packet
|
||||||
|
def process_outgoing(self, data):
|
||||||
|
# do nothing if offline or detached
|
||||||
|
if not self.online or self.detached:
|
||||||
|
return
|
||||||
|
|
||||||
|
# send to websocket server
|
||||||
|
try:
|
||||||
|
self.websocket.send(data)
|
||||||
|
except Exception as e:
|
||||||
|
RNS.log(
|
||||||
|
f"Exception occurred while transmitting via {str(self)}", RNS.LOG_ERROR
|
||||||
|
)
|
||||||
|
RNS.log(f"The contained exception was: {str(e)}", RNS.LOG_ERROR)
|
||||||
|
return
|
||||||
|
|
||||||
|
# update sent bytes counter
|
||||||
|
self.txb += len(data)
|
||||||
|
|
||||||
|
# update received bytes counter for parent interface
|
||||||
|
if self.parent_interface is not None:
|
||||||
|
self.parent_interface.txb += len(data)
|
||||||
|
|
||||||
|
# connect to the configured websocket server
|
||||||
|
def connect(self):
|
||||||
|
# do nothing if interface is detached
|
||||||
|
if self.detached:
|
||||||
|
return
|
||||||
|
|
||||||
|
# connect to websocket server
|
||||||
|
try:
|
||||||
|
RNS.log(f"Connecting to Websocket for {str(self)}...", RNS.LOG_DEBUG)
|
||||||
|
self.websocket = connect(
|
||||||
|
f"{self.target_url}", max_size=None, compression=None
|
||||||
|
)
|
||||||
|
RNS.log(f"Connected to Websocket for {str(self)}", RNS.LOG_DEBUG)
|
||||||
|
self.read_loop()
|
||||||
|
except Exception as e:
|
||||||
|
RNS.log(f"{self} failed with error: {e}", RNS.LOG_ERROR)
|
||||||
|
|
||||||
|
# auto reconnect after delay
|
||||||
|
RNS.log(f"Websocket disconnected for {str(self)}...", RNS.LOG_DEBUG)
|
||||||
|
time.sleep(self.RECONNECT_DELAY_SECONDS)
|
||||||
|
self.connect()
|
||||||
|
|
||||||
|
def read_loop(self):
|
||||||
|
self.online = True
|
||||||
|
|
||||||
|
try:
|
||||||
|
for message in self.websocket:
|
||||||
|
self.process_incoming(message)
|
||||||
|
except Exception as e:
|
||||||
|
RNS.log(f"{self} read loop error: {e}", RNS.LOG_ERROR)
|
||||||
|
|
||||||
|
self.online = False
|
||||||
|
|
||||||
|
def detach(self):
|
||||||
|
# mark as offline
|
||||||
|
self.online = False
|
||||||
|
|
||||||
|
# close websocket
|
||||||
|
if self.websocket is not None:
|
||||||
|
self.websocket.close()
|
||||||
|
|
||||||
|
# mark as detached
|
||||||
|
self.detached = True
|
||||||
|
|
||||||
|
|
||||||
|
# set interface class RNS should use when importing this external interface
|
||||||
|
interface_class = WebsocketClientInterface
|
||||||
166
src/backend/interfaces/WebsocketServerInterface.py
Normal file
166
src/backend/interfaces/WebsocketServerInterface.py
Normal file
@@ -0,0 +1,166 @@
|
|||||||
|
import threading
|
||||||
|
import time
|
||||||
|
|
||||||
|
import RNS
|
||||||
|
from RNS.Interfaces.Interface import Interface
|
||||||
|
from websockets.sync.server import Server
|
||||||
|
from websockets.sync.server import serve
|
||||||
|
from websockets.sync.server import ServerConnection
|
||||||
|
|
||||||
|
from src.backend.interfaces.WebsocketClientInterface import WebsocketClientInterface
|
||||||
|
|
||||||
|
|
||||||
|
class WebsocketServerInterface(Interface):
|
||||||
|
# TODO: required?
|
||||||
|
DEFAULT_IFAC_SIZE = 16
|
||||||
|
|
||||||
|
RESTART_DELAY_SECONDS = 5
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return (
|
||||||
|
f"WebsocketServerInterface[{self.name}/{self.listen_ip}:{self.listen_port}]"
|
||||||
|
)
|
||||||
|
|
||||||
|
def __init__(self, owner, configuration):
|
||||||
|
super().__init__()
|
||||||
|
|
||||||
|
self.owner = owner
|
||||||
|
|
||||||
|
self.IN = True
|
||||||
|
self.OUT = False
|
||||||
|
self.HW_MTU = 262144 # 256KiB
|
||||||
|
self.bitrate = 1_000_000_000 # 1Gbps
|
||||||
|
self.mode = RNS.Interfaces.Interface.Interface.MODE_FULL
|
||||||
|
|
||||||
|
self.server: Server | None = None
|
||||||
|
self.spawned_interfaces: [WebsocketClientInterface] = []
|
||||||
|
|
||||||
|
# parse config
|
||||||
|
ifconf = Interface.get_config_obj(configuration)
|
||||||
|
self.name = ifconf.get("name")
|
||||||
|
self.listen_ip = ifconf.get("listen_ip", None)
|
||||||
|
self.listen_port = ifconf.get("listen_port", None)
|
||||||
|
|
||||||
|
# ensure listen ip is provided
|
||||||
|
if self.listen_ip is None:
|
||||||
|
raise SystemError(f"listen_ip is required for interface '{self.name}'")
|
||||||
|
|
||||||
|
# ensure listen port is provided
|
||||||
|
if self.listen_port is None:
|
||||||
|
raise SystemError(f"listen_port is required for interface '{self.name}'")
|
||||||
|
|
||||||
|
# convert listen port to int
|
||||||
|
self.listen_port = int(self.listen_port)
|
||||||
|
|
||||||
|
# run websocket server
|
||||||
|
thread = threading.Thread(target=self.serve)
|
||||||
|
thread.daemon = True
|
||||||
|
thread.start()
|
||||||
|
|
||||||
|
@property
|
||||||
|
def clients(self):
|
||||||
|
return len(self.spawned_interfaces)
|
||||||
|
|
||||||
|
# todo docs
|
||||||
|
def received_announce(self, from_spawned=False):
|
||||||
|
if from_spawned:
|
||||||
|
self.ia_freq_deque.append(time.time())
|
||||||
|
|
||||||
|
# todo docs
|
||||||
|
def sent_announce(self, from_spawned=False):
|
||||||
|
if from_spawned:
|
||||||
|
self.oa_freq_deque.append(time.time())
|
||||||
|
|
||||||
|
# do nothing as the spawned child interface will take care of rx/tx
|
||||||
|
def process_incoming(self, data):
|
||||||
|
pass
|
||||||
|
|
||||||
|
# do nothing as the spawned child interface will take care of rx/tx
|
||||||
|
def process_outgoing(self, data):
|
||||||
|
pass
|
||||||
|
|
||||||
|
def serve(self):
|
||||||
|
# handle new websocket client connections
|
||||||
|
def on_websocket_client_connected(websocket: ServerConnection):
|
||||||
|
# create new child interface
|
||||||
|
RNS.log("Accepting incoming WebSocket connection", RNS.LOG_VERBOSE)
|
||||||
|
spawned_interface = WebsocketClientInterface(
|
||||||
|
self.owner,
|
||||||
|
{
|
||||||
|
"name": f"Client on {self.name}",
|
||||||
|
"target_host": websocket.remote_address[0],
|
||||||
|
"target_port": str(websocket.remote_address[1]),
|
||||||
|
},
|
||||||
|
websocket=websocket,
|
||||||
|
)
|
||||||
|
|
||||||
|
# configure child interface
|
||||||
|
spawned_interface.IN = self.IN
|
||||||
|
spawned_interface.OUT = self.OUT
|
||||||
|
spawned_interface.HW_MTU = self.HW_MTU
|
||||||
|
spawned_interface.bitrate = self.bitrate
|
||||||
|
spawned_interface.mode = self.mode
|
||||||
|
spawned_interface.parent_interface = self
|
||||||
|
spawned_interface.online = True
|
||||||
|
|
||||||
|
# todo implement?
|
||||||
|
spawned_interface.announce_rate_target = None
|
||||||
|
spawned_interface.announce_rate_grace = None
|
||||||
|
spawned_interface.announce_rate_penalty = None
|
||||||
|
|
||||||
|
# todo ifac?
|
||||||
|
# todo announce rates?
|
||||||
|
|
||||||
|
# activate child interface
|
||||||
|
RNS.log(
|
||||||
|
f"Spawned new WebsocketClientInterface: {spawned_interface}",
|
||||||
|
RNS.LOG_VERBOSE,
|
||||||
|
)
|
||||||
|
RNS.Transport.interfaces.append(spawned_interface)
|
||||||
|
|
||||||
|
# associate child interface with this interface
|
||||||
|
while spawned_interface in self.spawned_interfaces:
|
||||||
|
self.spawned_interfaces.remove(spawned_interface)
|
||||||
|
self.spawned_interfaces.append(spawned_interface)
|
||||||
|
|
||||||
|
# run read loop
|
||||||
|
spawned_interface.read_loop()
|
||||||
|
|
||||||
|
# client must have disconnected as the read loop finished, so forget the spawned interface
|
||||||
|
self.spawned_interfaces.remove(spawned_interface)
|
||||||
|
|
||||||
|
# run websocket server
|
||||||
|
try:
|
||||||
|
RNS.log(f"Starting Websocket server for {str(self)}...", RNS.LOG_DEBUG)
|
||||||
|
with serve(
|
||||||
|
on_websocket_client_connected,
|
||||||
|
self.listen_ip,
|
||||||
|
self.listen_port,
|
||||||
|
compression=None,
|
||||||
|
) as server:
|
||||||
|
self.online = True
|
||||||
|
self.server = server
|
||||||
|
server.serve_forever()
|
||||||
|
except Exception as e:
|
||||||
|
RNS.log(f"{self} failed with error: {e}", RNS.LOG_ERROR)
|
||||||
|
|
||||||
|
# websocket server is no longer running, let's restart it
|
||||||
|
self.online = False
|
||||||
|
RNS.log(f"Websocket server stopped for {str(self)}...", RNS.LOG_DEBUG)
|
||||||
|
time.sleep(self.RESTART_DELAY_SECONDS)
|
||||||
|
self.serve()
|
||||||
|
|
||||||
|
def detach(self):
|
||||||
|
# mark as offline
|
||||||
|
self.online = False
|
||||||
|
|
||||||
|
# stop websocket server
|
||||||
|
if self.server is not None:
|
||||||
|
self.server.shutdown()
|
||||||
|
|
||||||
|
# mark as detached
|
||||||
|
self.detached = True
|
||||||
|
|
||||||
|
|
||||||
|
# set interface class RNS should use when importing this external interface
|
||||||
|
interface_class = WebsocketServerInterface
|
||||||
@@ -3,7 +3,6 @@ from typing import List
|
|||||||
|
|
||||||
# helper class for passing around an lxmf audio field
|
# helper class for passing around an lxmf audio field
|
||||||
class LxmfAudioField:
|
class LxmfAudioField:
|
||||||
|
|
||||||
def __init__(self, audio_mode: int, audio_bytes: bytes):
|
def __init__(self, audio_mode: int, audio_bytes: bytes):
|
||||||
self.audio_mode = audio_mode
|
self.audio_mode = audio_mode
|
||||||
self.audio_bytes = audio_bytes
|
self.audio_bytes = audio_bytes
|
||||||
@@ -11,7 +10,6 @@ class LxmfAudioField:
|
|||||||
|
|
||||||
# helper class for passing around an lxmf image field
|
# helper class for passing around an lxmf image field
|
||||||
class LxmfImageField:
|
class LxmfImageField:
|
||||||
|
|
||||||
def __init__(self, image_type: str, image_bytes: bytes):
|
def __init__(self, image_type: str, image_bytes: bytes):
|
||||||
self.image_type = image_type
|
self.image_type = image_type
|
||||||
self.image_bytes = image_bytes
|
self.image_bytes = image_bytes
|
||||||
@@ -19,7 +17,6 @@ class LxmfImageField:
|
|||||||
|
|
||||||
# helper class for passing around an lxmf file attachment
|
# helper class for passing around an lxmf file attachment
|
||||||
class LxmfFileAttachment:
|
class LxmfFileAttachment:
|
||||||
|
|
||||||
def __init__(self, file_name: str, file_bytes: bytes):
|
def __init__(self, file_name: str, file_bytes: bytes):
|
||||||
self.file_name = file_name
|
self.file_name = file_name
|
||||||
self.file_bytes = file_bytes
|
self.file_bytes = file_bytes
|
||||||
@@ -27,7 +24,5 @@ class LxmfFileAttachment:
|
|||||||
|
|
||||||
# helper class for passing around an lxmf file attachments field
|
# helper class for passing around an lxmf file attachments field
|
||||||
class LxmfFileAttachmentsField:
|
class LxmfFileAttachmentsField:
|
||||||
|
|
||||||
def __init__(self, file_attachments: List[LxmfFileAttachment]):
|
def __init__(self, file_attachments: List[LxmfFileAttachment]):
|
||||||
self.file_attachments = file_attachments
|
self.file_attachments = file_attachments
|
||||||
|
|
||||||
|
|||||||
3
src/backend/sideband_commands.py
Normal file
3
src/backend/sideband_commands.py
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
# https://github.com/markqvist/Sideband/blob/e515889e210037f881c201e0d627a7b09a48eb69/sbapp/sideband/sense.py#L11
|
||||||
|
class SidebandCommands:
|
||||||
|
TELEMETRY_REQUEST = 0x01
|
||||||
@@ -14,7 +14,7 @@
|
|||||||
leave-active-class="transition ease-in duration-75"
|
leave-active-class="transition ease-in duration-75"
|
||||||
leave-from-class="transform opacity-100 scale-100"
|
leave-from-class="transform opacity-100 scale-100"
|
||||||
leave-to-class="transform opacity-0 scale-95">
|
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 ]">
|
<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 dark:border-zinc-700" :class="[ dropdownClass ]">
|
||||||
<slot name="items"/>
|
<slot name="items"/>
|
||||||
</div>
|
</div>
|
||||||
</Transition>
|
</Transition>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="cursor-pointer flex p-3 space-x-2 text-sm text-gray-500 hover:bg-gray-100">
|
<div class="cursor-pointer flex p-3 space-x-2 text-sm bg-white text-gray-500 hover:bg-gray-100 dark:bg-zinc-800 dark:text-white dark:hover:bg-zinc-700">
|
||||||
<slot/>
|
<slot/>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<template>
|
<template>
|
||||||
<button type="button" class="p-2 rounded-full text-gray-700 bg-gray-100 hover:bg-gray-200">
|
<button type="button" class="text-gray-700 bg-gray-100 dark:bg-zinc-600 dark:text-white dark:hover:bg-zinc-700 dark:focus-visible:outline-zinc-500 hover:bg-gray-200 p-2 rounded-full">
|
||||||
<slot/>
|
<slot/>
|
||||||
</button>
|
</button>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
<template>
|
<template>
|
||||||
<RouterLink :to="to" v-slot="{ href, route, navigate, isActive, isExactActive }" custom>
|
<RouterLink :to="to" v-slot="{ href, route, navigate, isActive }" custom>
|
||||||
<a
|
<a
|
||||||
:href="href"
|
:href="href"
|
||||||
@click="handleNavigate($event, navigate)"
|
@click="handleNavigate($event, navigate)"
|
||||||
type="button"
|
type="button"
|
||||||
:class="[
|
:class="[
|
||||||
isExactActive
|
isActive
|
||||||
? 'bg-blue-100 text-blue-800 group:text-blue-800 dark:bg-zinc-800 dark:text-blue-300'
|
? 'bg-blue-100 text-blue-800 group:text-blue-800 dark:bg-zinc-800 dark:text-blue-300'
|
||||||
: 'hover:bg-gray-100 dark:hover:bg-zinc-700'
|
: 'hover:bg-gray-100 dark:hover:bg-zinc-700'
|
||||||
]"
|
]"
|
||||||
|
|||||||
10
src/frontend/components/forms/FormLabel.vue
Normal file
10
src/frontend/components/forms/FormLabel.vue
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
<template>
|
||||||
|
<label class="block text-sm font-medium text-gray-900 dark:text-zinc-100">
|
||||||
|
<slot/>
|
||||||
|
</label>
|
||||||
|
</template>
|
||||||
|
<script>
|
||||||
|
export default {
|
||||||
|
name: 'FormLabel',
|
||||||
|
}
|
||||||
|
</script>
|
||||||
10
src/frontend/components/forms/FormSubLabel.vue
Normal file
10
src/frontend/components/forms/FormSubLabel.vue
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
<template>
|
||||||
|
<div class="text-xs text-gray-600 dark:text-zinc-300">
|
||||||
|
<slot/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<script>
|
||||||
|
export default {
|
||||||
|
name: 'FormSubLabel',
|
||||||
|
}
|
||||||
|
</script>
|
||||||
File diff suppressed because it is too large
Load Diff
35
src/frontend/components/interfaces/ExpandingSection.vue
Normal file
35
src/frontend/components/interfaces/ExpandingSection.vue
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
<template>
|
||||||
|
<div class="bg-white rounded shadow divide-y divide-gray-300 dark:divide-zinc-700 dark:bg-zinc-900 overflow-hidden">
|
||||||
|
<div @click="isExpanded = !isExpanded" class="flex p-2 justify-between cursor-pointer hover:bg-gray-50 dark:hover:bg-zinc-800">
|
||||||
|
<div class="my-auto mr-auto">
|
||||||
|
<div class="font-bold dark:text-white">
|
||||||
|
<slot name="title"/>
|
||||||
|
</div>
|
||||||
|
<div class="text-sm text-gray-600 dark:text-gray-300">
|
||||||
|
<slot name="subtitle"/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="my-auto ml-2">
|
||||||
|
<div class="w-5 h-5 text-gray-600 dark:text-gray-300 transform transition-transform duration-200" :class="{ 'rotate-90': isExpanded }">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" class="size-5">
|
||||||
|
<rect width="256" height="256" fill="none"/>
|
||||||
|
<path d="M181.66,122.34l-80-80A8,8,0,0,0,88,48V208a8,8,0,0,0,13.66,5.66l80-80A8,8,0,0,0,181.66,122.34Z" fill="currentColor"/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div v-if="isExpanded" class="divide-y divide-gray-200 dark:text-white">
|
||||||
|
<slot name="content"/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<script>
|
||||||
|
export default {
|
||||||
|
name: 'ExpandingSection',
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
isExpanded: false,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
}
|
||||||
|
</script>
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
<template>
|
<template>
|
||||||
<div v-if="isShowing" class="fixed inset-0 bg-gray-500 bg-opacity-75 transition-opacity flex items-center justify-center">
|
<div v-if="isShowing" class="fixed inset-0 bg-gray-500 bg-opacity-75 transition-opacity flex items-center justify-center">
|
||||||
<div class="flex w-full h-full p-4 overflow-y-auto">
|
<div class="flex w-full h-full p-4 overflow-y-auto">
|
||||||
<div class="my-auto mx-auto w-full bg-white dark:bg-zinc-900 rounded-lg shadow-xl max-w-2xl">
|
<div v-click-outside="dismiss" class="my-auto mx-auto w-full bg-white dark:bg-zinc-900 rounded-lg shadow-xl max-w-2xl">
|
||||||
|
|
||||||
<!-- title -->
|
<!-- title -->
|
||||||
<div class="p-4 border-b dark:border-zinc-700">
|
<div class="p-4 border-b dark:border-zinc-700">
|
||||||
@@ -13,16 +13,14 @@
|
|||||||
|
|
||||||
<!-- file input -->
|
<!-- file input -->
|
||||||
<div class="p-2">
|
<div class="p-2">
|
||||||
<div class="text-sm font-medium text-gray-700 dark:text-zinc-200">Select a Configuration File</div>
|
|
||||||
<div>
|
<div>
|
||||||
<input ref="import-interfaces-file-input" type="file" @change="onFileSelected" accept="*"
|
<input ref="import-interfaces-file-input" type="file" @change="onFileSelected" accept="*" class="w-full text-sm text-gray-500 dark:text-zinc-400">
|
||||||
class="mt-1 block w-full text-sm text-gray-500 dark:text-zinc-400
|
</div>
|
||||||
file:mr-4 file:py-2 file:px-4
|
<div v-if="!selectedFile" class="mt-2 text-sm text-gray-700 dark:text-zinc-200">
|
||||||
file:rounded-md file:border-0
|
<ul class="list-disc list-inside">
|
||||||
file:text-sm file:font-semibold
|
<li>You can import interfaces from a ~/.reticulum/config file.</li>
|
||||||
file:bg-gray-500 file:text-white
|
<li>You can import interfaces from an exported interfaces file.</li>
|
||||||
hover:file:bg-gray-400
|
</ul>
|
||||||
dark:file:bg-zinc-700 dark:hover:file:bg-zinc-600">
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -35,11 +33,52 @@
|
|||||||
<button @click="deselectAllInterfaces" class="text-sm text-blue-500 hover:underline">Deselect All</button>
|
<button @click="deselectAllInterfaces" class="text-sm text-blue-500 hover:underline">Deselect All</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="p-2 space-y-2 max-h-72 overflow-y-auto">
|
<div class="bg-gray-200 p-2 space-y-2 max-h-80 overflow-y-auto dark:bg-zinc-800">
|
||||||
<div @click="toggleSelectedInterface(iface.name)" v-for="iface in importableInterfaces" :key="iface.name" class="cursor-pointer flex items-center p-2 border rounded dark:border-zinc-700 shadow">
|
<div @click="toggleSelectedInterface(iface.name)" v-for="iface in importableInterfaces" :key="iface.name" class="bg-white cursor-pointer flex items-center p-2 border rounded shadow dark:bg-zinc-900 dark:border-zinc-700">
|
||||||
<div class="mr-auto text-sm text-gray-700 dark:text-zinc-200">
|
<div class="mr-auto text-sm">
|
||||||
<div class="font-semibold">{{ iface.name }}</div>
|
<div class="font-semibold text-gray-700 dark:text-zinc-100">{{ iface.name }}</div>
|
||||||
<div class="text-sm text-gray-500">{{ iface.type }}</div>
|
<div class="text-sm text-gray-500 dark:text-zinc-100">
|
||||||
|
|
||||||
|
<!-- auto interface -->
|
||||||
|
<div v-if="iface.type === 'AutoInterface'">
|
||||||
|
<div>{{ iface.type }}</div>
|
||||||
|
<div>Ethernet and WiFi</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- tcp client interface -->
|
||||||
|
<div v-else-if="iface.type === 'TCPClientInterface'">
|
||||||
|
<div>{{ iface.type }}</div>
|
||||||
|
<div>{{ iface.target_host }}:{{ iface.target_port }}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- tcp server interface -->
|
||||||
|
<div v-else-if="iface.type === 'TCPServerInterface'">
|
||||||
|
<div>{{ iface.type }}</div>
|
||||||
|
<div>{{ iface.listen_ip }}:{{ iface.listen_port }}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- udp interface -->
|
||||||
|
<div v-else-if="iface.type === 'UDPInterface'">
|
||||||
|
<div>{{ iface.type }}</div>
|
||||||
|
<div>Listen: {{ iface.listen_ip }}:{{ iface.listen_port }}</div>
|
||||||
|
<div>Forward: {{ iface.forward_ip }}:{{ iface.forward_port }}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- rnode interface details -->
|
||||||
|
<div v-else-if="iface.type === 'RNodeInterface'">
|
||||||
|
<div>{{ iface.type }}</div>
|
||||||
|
<div>Port: {{ iface.port }}</div>
|
||||||
|
<div>Frequency: {{ formatFrequency(iface.frequency) }}</div>
|
||||||
|
<div>Bandwidth: {{ formatFrequency(iface.bandwidth) }}</div>
|
||||||
|
<div>Spreading Factor: {{ iface.spreadingfactor }}</div>
|
||||||
|
<div>Coding Rate: {{ iface.codingrate }}</div>
|
||||||
|
<div>Transmit Power: {{ iface.txpower }}dBm</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- other interface types -->
|
||||||
|
<div v-else>{{ iface.type }}</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<input @click.stop type="checkbox" v-model="selectedInterfaces" :value="iface.name" class="mx-2 h-4 w-4 text-blue-600 rounded border-gray-300 dark:border-zinc-600">
|
<input @click.stop type="checkbox" v-model="selectedInterfaces" :value="iface.name" class="mx-2 h-4 w-4 text-blue-600 rounded border-gray-300 dark:border-zinc-600">
|
||||||
</div>
|
</div>
|
||||||
@@ -64,6 +103,7 @@
|
|||||||
|
|
||||||
<script>
|
<script>
|
||||||
import DialogUtils from "../../js/DialogUtils";
|
import DialogUtils from "../../js/DialogUtils";
|
||||||
|
import Utils from "../../js/Utils";
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
name: "ImportInterfacesModal",
|
name: "ImportInterfacesModal",
|
||||||
@@ -109,9 +149,9 @@ export default {
|
|||||||
try {
|
try {
|
||||||
|
|
||||||
// fetch preview of interfaces to import
|
// fetch preview of interfaces to import
|
||||||
const formData = new FormData();
|
const response = await window.axios.post('/api/v1/reticulum/interfaces/import-preview', {
|
||||||
formData.append('config', file);
|
config: await file.text(),
|
||||||
const response = await window.axios.post('/api/v1/reticulum/interfaces/preview', formData);
|
});
|
||||||
|
|
||||||
// ensure there are some interfaces available to import
|
// ensure there are some interfaces available to import
|
||||||
if(!response.data.interfaces || response.data.interfaces.length === 0){
|
if(!response.data.interfaces || response.data.interfaces.length === 0){
|
||||||
@@ -172,15 +212,13 @@ export default {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// create form data to send to server
|
|
||||||
const formData = new FormData();
|
|
||||||
formData.append('config', this.selectedFile);
|
|
||||||
formData.append('selected_interfaces', JSON.stringify(this.selectedInterfaces));
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
|
||||||
// import interfaces
|
// import interfaces
|
||||||
await window.axios.post('/api/v1/reticulum/interfaces/import', formData);
|
await window.axios.post('/api/v1/reticulum/interfaces/import', {
|
||||||
|
config: await this.selectedFile.text(),
|
||||||
|
selected_interface_names: this.selectedInterfaces,
|
||||||
|
});
|
||||||
|
|
||||||
// dismiss modal
|
// dismiss modal
|
||||||
this.dismiss();
|
this.dismiss();
|
||||||
@@ -194,6 +232,9 @@ export default {
|
|||||||
console.error(e);
|
console.error(e);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
formatFrequency(hz) {
|
||||||
|
return Utils.formatFrequency(hz);
|
||||||
|
},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="border rounded bg-white shadow overflow-hidden dark:bg-zinc-800 dark:border-zinc-700">
|
<div class="border rounded bg-white shadow dark:bg-zinc-800 dark:border-zinc-700">
|
||||||
|
|
||||||
<!-- IFAC info -->
|
<!-- IFAC info -->
|
||||||
<div v-if="iface._stats?.ifac_signature != null" class="bg-gray-50 p-1 text-sm text-gray-500 space-x-1 border-b dark:bg-zinc-800 dark:border-zinc-700">
|
<div v-if="iface._stats?.ifac_signature != null" class="bg-gray-50 p-1 text-sm text-gray-500 space-x-1 border-b dark:bg-zinc-800 dark:border-zinc-700">
|
||||||
@@ -10,7 +10,7 @@
|
|||||||
</svg>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
<span class="ml-1 my-auto">
|
<span class="ml-1 my-auto">
|
||||||
<span class="text-green-500">{{ iface._stats.ifac_size * 8 }}-bit IFAC</span> with sig <span @click="onIFACSignatureClick(iface._stats.ifac_signature)" class="cursor-pointer"><{{ iface._stats.ifac_signature.slice(0, 6) }}...{{ iface._stats.ifac_signature.slice(-6) }}></span>
|
<span class="text-green-500">{{ iface._stats.ifac_size * 8 }}-bit IFAC</span> <span v-if="iface._stats?.ifac_netname != null">• Network Name: <span class="text-purple-500">{{ iface._stats.ifac_netname }}</span></span> • Signature <span @click="onIFACSignatureClick(iface._stats.ifac_signature)" class="cursor-pointer"><{{ iface._stats.ifac_signature.slice(0, 6) }}...{{ iface._stats.ifac_signature.slice(-6) }}></span>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -36,6 +36,14 @@
|
|||||||
<path d="M252.44,121.34l-48-32A8,8,0,0,0,192,96v24H72V72h33a32,32,0,1,0,0-16H72A16,16,0,0,0,56,72v48H8a8,8,0,0,0,0,16H56v48a16,16,0,0,0,16,16h32v8a16,16,0,0,0,16,16h32a16,16,0,0,0,16-16V176a16,16,0,0,0-16-16H120a16,16,0,0,0-16,16v8H72V136H192v24a8,8,0,0,0,12.44,6.66l48-32a8,8,0,0,0,0-13.32ZM136,48a16,16,0,1,1-16,16A16,16,0,0,1,136,48ZM120,176h32v32H120Zm88-30.95V111l25.58,17Z"></path>
|
<path d="M252.44,121.34l-48-32A8,8,0,0,0,192,96v24H72V72h33a32,32,0,1,0,0-16H72A16,16,0,0,0,56,72v48H8a8,8,0,0,0,0,16H56v48a16,16,0,0,0,16,16h32v8a16,16,0,0,0,16,16h32a16,16,0,0,0,16-16V176a16,16,0,0,0-16-16H120a16,16,0,0,0-16,16v8H72V136H192v24a8,8,0,0,0,12.44,6.66l48-32a8,8,0,0,0,0-13.32ZM136,48a16,16,0,1,1-16,16A16,16,0,0,1,136,48ZM120,176h32v32H120Zm88-30.95V111l25.58,17Z"></path>
|
||||||
</svg>
|
</svg>
|
||||||
|
|
||||||
|
<svg v-else-if="iface.type === 'RNodeMultiInterface'" xmlns="http://www.w3.org/2000/svg" fill="currentColor" viewBox="0 0 256 256" class="size-6 dark:text-white"><path d="M128,88a40,40,0,1,0,40,40A40,40,0,0,0,128,88Zm0,64a24,24,0,1,1,24-24A24,24,0,0,1,128,152Zm73.71,7.14a80,80,0,0,1-14.08,22.2,8,8,0,0,1-11.92-10.67,63.95,63.95,0,0,0,0-85.33,8,8,0,1,1,11.92-10.67,80.08,80.08,0,0,1,14.08,84.47ZM69,103.09a64,64,0,0,0,11.26,67.58,8,8,0,0,1-11.92,10.67,79.93,79.93,0,0,1,0-106.67A8,8,0,1,1,80.29,85.34,63.77,63.77,0,0,0,69,103.09ZM248,128a119.58,119.58,0,0,1-34.29,84,8,8,0,1,1-11.42-11.2,103.9,103.9,0,0,0,0-145.56A8,8,0,1,1,213.71,44,119.58,119.58,0,0,1,248,128ZM53.71,200.78A8,8,0,1,1,42.29,212a119.87,119.87,0,0,1,0-168,8,8,0,1,1,11.42,11.2,103.9,103.9,0,0,0,0,145.56Z"></path></svg>
|
||||||
|
|
||||||
|
<svg v-else-if="iface.type === 'I2PInterface'" xmlns="http://www.w3.org/2000/svg" fill="currentColor" viewBox="0 0 256 256" class="size-6 dark:text-white"><path d="M72,92A12,12,0,1,1,60,80,12,12,0,0,1,72,92Zm56-12a12,12,0,1,0,12,12A12,12,0,0,0,128,80Zm68,24a12,12,0,1,0-12-12A12,12,0,0,0,196,104ZM60,152a12,12,0,1,0,12,12A12,12,0,0,0,60,152Zm68,0a12,12,0,1,0,12,12A12,12,0,0,0,128,152Zm68,0a12,12,0,1,0,12,12A12,12,0,0,0,196,152Z"></path></svg>
|
||||||
|
|
||||||
|
<svg v-else-if="iface.type === 'KISSInterface' || iface.type === 'AX25KISSInterface'" xmlns="http://www.w3.org/2000/svg" fill="currentColor" viewBox="0 0 256 256" class="size-6 dark:text-white"><path d="M104,168a8,8,0,0,1-8,8H64a8,8,0,0,1,0-16H96A8,8,0,0,1,104,168Zm-8-40H64a8,8,0,0,0,0,16H96a8,8,0,0,0,0-16Zm0-32H64a8,8,0,0,0,0,16H96a8,8,0,0,0,0-16ZM232,80V192a16,16,0,0,1-16,16H40a16,16,0,0,1-16-16V72a8,8,0,0,1,5.7-7.66l160-48a8,8,0,0,1,4.6,15.33L86.51,64H216A16,16,0,0,1,232,80ZM216,192V80H40V192H216Zm-16-56a40,40,0,1,1-40-40A40,40,0,0,1,200,136Zm-16,0a24,24,0,1,0-24,24A24,24,0,0,0,184,136Z"></path></svg>
|
||||||
|
|
||||||
|
<svg v-else-if="iface.type === 'PipeInterface'" xmlns="http://www.w3.org/2000/svg" fill="currentColor" viewBox="0 0 256 256" class="size-6 dark:text-white"><path d="M128,128a8,8,0,0,1-3,6.25l-40,32a8,8,0,1,1-10-12.5L107.19,128,75,102.25a8,8,0,1,1,10-12.5l40,32A8,8,0,0,1,128,128Zm48,24H136a8,8,0,0,0,0,16h40a8,8,0,0,0,0-16Zm56-96V200a16,16,0,0,1-16,16H40a16,16,0,0,1-16-16V56A16,16,0,0,1,40,40H216A16,16,0,0,1,232,56ZM216,200V56H40V200H216Z"></path></svg>
|
||||||
|
|
||||||
<svg v-else xmlns="http://www.w3.org/2000/svg" fill="currentColor" viewBox="0 0 256 256" class="size-6 dark:text-white">
|
<svg v-else xmlns="http://www.w3.org/2000/svg" fill="currentColor" viewBox="0 0 256 256" class="size-6 dark:text-white">
|
||||||
<path d="M140,180a12,12,0,1,1-12-12A12,12,0,0,1,140,180ZM128,72c-22.06,0-40,16.15-40,36v4a8,8,0,0,0,16,0v-4c0-11,10.77-20,24-20s24,9,24,20-10.77,20-24,20a8,8,0,0,0-8,8v8a8,8,0,0,0,16,0v-.72c18.24-3.35,32-17.9,32-35.28C168,88.15,150.06,72,128,72Zm104,56A104,104,0,1,1,128,24,104.11,104.11,0,0,1,232,128Zm-16,0a88,88,0,1,0-88,88A88.1,88.1,0,0,0,216,128Z"></path>
|
<path d="M140,180a12,12,0,1,1-12-12A12,12,0,0,1,140,180ZM128,72c-22.06,0-40,16.15-40,36v4a8,8,0,0,0,16,0v-4c0-11,10.77-20,24-20s24,9,24,20-10.77,20-24,20a8,8,0,0,0-8,8v8a8,8,0,0,0,16,0v-.72c18.24-3.35,32-17.9,32-35.28C168,88.15,150.06,72,128,72Zm104,56A104,104,0,1,1,128,24,104.11,104.11,0,0,1,232,128Zm-16,0a88,88,0,1,0-88,88A88.1,88.1,0,0,0,216,128Z"></path>
|
||||||
</svg>
|
</svg>
|
||||||
@@ -48,34 +56,22 @@
|
|||||||
<div class="text-sm flex space-x-1 dark:text-zinc-100">
|
<div class="text-sm flex space-x-1 dark:text-zinc-100">
|
||||||
|
|
||||||
<!-- auto interface -->
|
<!-- auto interface -->
|
||||||
<span v-if="iface.type === 'AutoInterface'">
|
<div v-if="iface.type === 'AutoInterface'">
|
||||||
{{ iface.type }} • Ethernet and WiFi
|
{{ iface.type }} • Ethernet and WiFi
|
||||||
</span>
|
</div>
|
||||||
|
|
||||||
<!-- tcp client interface -->
|
<!-- tcp client interface -->
|
||||||
<span v-else-if="iface.type === 'TCPClientInterface'">
|
<div v-else-if="iface.type === 'TCPClientInterface'">
|
||||||
{{ iface.type }} • {{ iface.target_host }}:{{ iface.target_port }}
|
{{ iface.type }} • {{ iface.target_host }}:{{ iface.target_port }}
|
||||||
</span>
|
</div>
|
||||||
|
|
||||||
<!-- tcp server interface -->
|
<!-- tcp server interface -->
|
||||||
<span v-else-if="iface.type === 'TCPServerInterface'">
|
<div v-else-if="iface.type === 'TCPServerInterface'">
|
||||||
{{ iface.type }} • {{ iface.listen_ip }}:{{ iface.listen_port }}
|
{{ iface.type }} • {{ iface.listen_ip }}:{{ iface.listen_port }}
|
||||||
</span>
|
</div>
|
||||||
|
|
||||||
<!-- udp interface -->
|
<!-- other interface types -->
|
||||||
<span v-else-if="iface.type === 'UDPInterface'">
|
<div v-else>{{ iface.type }}</div>
|
||||||
{{ iface.type }} • {{ iface.listen_ip }}:{{ iface.listen_port }} • {{ iface.forward_ip }}:{{ iface.forward_port }}
|
|
||||||
</span>
|
|
||||||
|
|
||||||
<!-- rnode interface details -->
|
|
||||||
<span v-else-if="iface.type === 'RNodeInterface'">
|
|
||||||
{{ iface.type }} • {{ iface.port }} • freq={{ iface.frequency }} • bw={{ iface.bandwidth }} • power={{ iface.txpower }}dBm • sf={{ iface.spreadingfactor }} • cr={{ iface.codingrate }}
|
|
||||||
</span>
|
|
||||||
|
|
||||||
<!-- unknown interface types -->
|
|
||||||
<span v-else>
|
|
||||||
{{ iface.type ?? 'Unknown Interface Type' }}
|
|
||||||
</span>
|
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -96,7 +92,7 @@
|
|||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
<button v-else @click="enableInterface" type="button" class="cursor-pointer">
|
<button v-else @click="enableInterface" type="button" class="cursor-pointer">
|
||||||
<span class="flex text-gray-700 bg-gray-100 dark:bg-zinc-600 dark:text-white dark:hover:bg-zinc-700 dark:focus-visible:outline-zinc-500 hover:bg-gray-200 p-2 dark:bg-zinc-600 dark:text-white dark:hover:bg-zinc-700 dark:focus-visible:outline-zinc-500 rounded-full">
|
<span class="flex text-gray-700 bg-gray-100 dark:bg-zinc-600 dark:text-white dark:hover:bg-zinc-700 dark:focus-visible:outline-zinc-500 hover:bg-gray-200 p-2 rounded-full">
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="size-5">
|
<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="M5.636 5.636a9 9 0 1 0 12.728 0M12 3v9" />
|
<path stroke-linecap="round" stroke-linejoin="round" d="M5.636 5.636a9 9 0 1 0 12.728 0M12 3v9" />
|
||||||
</svg>
|
</svg>
|
||||||
@@ -104,31 +100,91 @@
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- edit interface button -->
|
|
||||||
<div class="my-auto mr-1">
|
|
||||||
<button @click="editInterface" type="button" class="cursor-pointer">
|
|
||||||
<span class="flex text-gray-700 bg-gray-100 hover:bg-gray-200 p-2 dark:bg-zinc-600 dark:text-white dark:hover:bg-zinc-700 dark:focus-visible:outline-zinc-500 rounded-full ">
|
|
||||||
<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="m16.862 4.487 1.687-1.688a1.875 1.875 0 1 1 2.652 2.652L6.832 19.82a4.5 4.5 0 0 1-1.897 1.13l-2.685.8.8-2.685a4.5 4.5 0 0 1 1.13-1.897L16.863 4.487Zm0 0L19.5 7.125" />
|
|
||||||
</svg>
|
|
||||||
</span>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- delete interface button -->
|
|
||||||
<div class="my-auto mr-2">
|
<div class="my-auto mr-2">
|
||||||
<button @click="deleteInterface" type="button" class="cursor-pointer">
|
<DropDownMenu>
|
||||||
<span class="flex text-gray-700 bg-gray-100 hover:bg-gray-200 p-2 dark:bg-zinc-600 dark:text-white dark:hover:bg-zinc-700 dark:focus-visible:outline-zinc-500 rounded-full">
|
<template v-slot:button>
|
||||||
<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">
|
<IconButton>
|
||||||
<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 xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="size-5">
|
||||||
</svg>
|
<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" />
|
||||||
</span>
|
</svg>
|
||||||
</button>
|
</IconButton>
|
||||||
|
</template>
|
||||||
|
<template v-slot:items>
|
||||||
|
|
||||||
|
<!-- enable/disable interface button -->
|
||||||
|
<div class="border-b dark:border-zinc-700">
|
||||||
|
|
||||||
|
<!-- enable interface button -->
|
||||||
|
<DropDownMenuItem v-if="isInterfaceEnabled(iface)" @click="disableInterface">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" class="size-5">
|
||||||
|
<path fill-rule="evenodd" d="M12 2.25a.75.75 0 0 1 .75.75v9a.75.75 0 0 1-1.5 0V3a.75.75 0 0 1 .75-.75ZM6.166 5.106a.75.75 0 0 1 0 1.06 8.25 8.25 0 1 0 11.668 0 .75.75 0 1 1 1.06-1.06c3.808 3.807 3.808 9.98 0 13.788-3.807 3.808-9.98 3.808-13.788 0-3.808-3.807-3.808-9.98 0-13.788a.75.75 0 0 1 1.06 0Z" clip-rule="evenodd" />
|
||||||
|
</svg>
|
||||||
|
<span>Disable Interface</span>
|
||||||
|
</DropDownMenuItem>
|
||||||
|
|
||||||
|
<DropDownMenuItem v-else @click="enableInterface">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" class="size-5">
|
||||||
|
<path fill-rule="evenodd" d="M12 2.25a.75.75 0 0 1 .75.75v9a.75.75 0 0 1-1.5 0V3a.75.75 0 0 1 .75-.75ZM6.166 5.106a.75.75 0 0 1 0 1.06 8.25 8.25 0 1 0 11.668 0 .75.75 0 1 1 1.06-1.06c3.808 3.807 3.808 9.98 0 13.788-3.807 3.808-9.98 3.808-13.788 0-3.808-3.807-3.808-9.98 0-13.788a.75.75 0 0 1 1.06 0Z" clip-rule="evenodd" />
|
||||||
|
</svg>
|
||||||
|
<span>Enable Interface</span>
|
||||||
|
</DropDownMenuItem>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- edit interface button -->
|
||||||
|
<DropDownMenuItem @click="editInterface">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" class="size-5">
|
||||||
|
<path d="M21.731 2.269a2.625 2.625 0 0 0-3.712 0l-1.157 1.157 3.712 3.712 1.157-1.157a2.625 2.625 0 0 0 0-3.712ZM19.513 8.199l-3.712-3.712-12.15 12.15a5.25 5.25 0 0 0-1.32 2.214l-.8 2.685a.75.75 0 0 0 .933.933l2.685-.8a5.25 5.25 0 0 0 2.214-1.32L19.513 8.2Z" />
|
||||||
|
</svg>
|
||||||
|
<span>Edit Interface</span>
|
||||||
|
</DropDownMenuItem>
|
||||||
|
|
||||||
|
<!-- export interface button -->
|
||||||
|
<DropDownMenuItem @click="exportInterface">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" class="size-5">
|
||||||
|
<path fill-rule="evenodd" d="M12 2.25a.75.75 0 0 1 .75.75v11.69l3.22-3.22a.75.75 0 1 1 1.06 1.06l-4.5 4.5a.75.75 0 0 1-1.06 0l-4.5-4.5a.75.75 0 1 1 1.06-1.06l3.22 3.22V3a.75.75 0 0 1 .75-.75Zm-9 13.5a.75.75 0 0 1 .75.75v2.25a1.5 1.5 0 0 0 1.5 1.5h13.5a1.5 1.5 0 0 0 1.5-1.5V16.5a.75.75 0 0 1 1.5 0v2.25a3 3 0 0 1-3 3H5.25a3 3 0 0 1-3-3V16.5a.75.75 0 0 1 .75-.75Z" clip-rule="evenodd" />
|
||||||
|
</svg>
|
||||||
|
<span>Export Interface</span>
|
||||||
|
</DropDownMenuItem>
|
||||||
|
|
||||||
|
<!-- delete interface button -->
|
||||||
|
<div class="border-t dark:border-zinc-700">
|
||||||
|
<DropDownMenuItem @click="deleteInterface">
|
||||||
|
<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 Interface</span>
|
||||||
|
</DropDownMenuItem>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</template>
|
||||||
|
</DropDownMenu>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex bg-gray-50 p-1 text-sm text-gray-500 space-x-1 border-t dark:bg-zinc-800 dark:text-white dark:border-zinc-700">
|
<!-- extra interface details -->
|
||||||
|
<div v-if="['UDPInterface', 'RNodeInterface'].includes(iface.type)" class="p-1 text-sm border-t dark:text-zinc-100 dark:border-zinc-700">
|
||||||
|
|
||||||
|
<!-- udp interface -->
|
||||||
|
<div v-if="iface.type === 'UDPInterface'">
|
||||||
|
<div>Listen: {{ iface.listen_ip }}:{{ iface.listen_port }}</div>
|
||||||
|
<div>Forward: {{ iface.forward_ip }}:{{ iface.forward_port }}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- rnode interface details -->
|
||||||
|
<div v-else-if="iface.type === 'RNodeInterface'">
|
||||||
|
<div>Port: {{ iface.port }}</div>
|
||||||
|
<div>Frequency: {{ formatFrequency(iface.frequency) }}</div>
|
||||||
|
<div>Bandwidth: {{ formatFrequency(iface.bandwidth) }}</div>
|
||||||
|
<div>Spreading Factor: {{ iface.spreadingfactor }}</div>
|
||||||
|
<div>Coding Rate: {{ iface.codingrate }}</div>
|
||||||
|
<div>Transmit Power: {{ iface.txpower }}dBm</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex bg-gray-50 p-1 text-sm text-gray-500 space-x-1 border-t rounded-b dark:bg-zinc-800 dark:text-white dark:border-zinc-700">
|
||||||
|
|
||||||
<!-- status -->
|
<!-- status -->
|
||||||
<div v-if="iface._stats?.status === true" class="text-sm text-green-500">Connected</div>
|
<div v-if="iface._stats?.status === true" class="text-sm text-green-500">Connected</div>
|
||||||
@@ -138,7 +194,11 @@
|
|||||||
<div>• Bitrate: {{ formatBitsPerSecond(iface._stats?.bitrate ?? 0) }}</div>
|
<div>• Bitrate: {{ formatBitsPerSecond(iface._stats?.bitrate ?? 0) }}</div>
|
||||||
<div>• TX: {{ formatBytes(iface._stats?.txb ?? 0) }}</div>
|
<div>• TX: {{ formatBytes(iface._stats?.txb ?? 0) }}</div>
|
||||||
<div>• RX: {{ formatBytes(iface._stats?.rxb ?? 0) }}</div>
|
<div>• RX: {{ formatBytes(iface._stats?.rxb ?? 0) }}</div>
|
||||||
<div v-if="iface._stats?.clients">• Clients: {{ iface._stats?.clients }}</div>
|
<div v-if="iface.type === 'RNodeInterface'">• Noise Floor: {{
|
||||||
|
iface._stats?.noise_floor
|
||||||
|
}} dBm
|
||||||
|
</div>
|
||||||
|
<div v-if="iface._stats?.clients != null">• Clients: {{ iface._stats?.clients }}</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -148,9 +208,17 @@
|
|||||||
<script>
|
<script>
|
||||||
import DialogUtils from "../../js/DialogUtils";
|
import DialogUtils from "../../js/DialogUtils";
|
||||||
import Utils from "../../js/Utils";
|
import Utils from "../../js/Utils";
|
||||||
|
import DropDownMenuItem from "../DropDownMenuItem.vue";
|
||||||
|
import IconButton from "../IconButton.vue";
|
||||||
|
import DropDownMenu from "../DropDownMenu.vue";
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
name: 'Interface',
|
name: 'Interface',
|
||||||
|
components: {
|
||||||
|
DropDownMenu,
|
||||||
|
IconButton,
|
||||||
|
DropDownMenuItem,
|
||||||
|
},
|
||||||
props: {
|
props: {
|
||||||
iface: Object,
|
iface: Object,
|
||||||
},
|
},
|
||||||
@@ -175,6 +243,9 @@ export default {
|
|||||||
editInterface() {
|
editInterface() {
|
||||||
this.$emit("edit");
|
this.$emit("edit");
|
||||||
},
|
},
|
||||||
|
exportInterface() {
|
||||||
|
this.$emit("export");
|
||||||
|
},
|
||||||
deleteInterface() {
|
deleteInterface() {
|
||||||
this.$emit("delete");
|
this.$emit("delete");
|
||||||
},
|
},
|
||||||
@@ -184,6 +255,9 @@ export default {
|
|||||||
formatBytes: function(bytes) {
|
formatBytes: function(bytes) {
|
||||||
return Utils.formatBytes(bytes);
|
return Utils.formatBytes(bytes);
|
||||||
},
|
},
|
||||||
|
formatFrequency(hz) {
|
||||||
|
return Utils.formatFrequency(hz);
|
||||||
|
},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
<template>
|
<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 flex-1 overflow-hidden min-w-full sm:min-w-[500px] dark:bg-zinc-950">
|
||||||
<div class="overflow-y-auto p-2 space-y-2">
|
<div class="overflow-y-auto p-2 space-y-2">
|
||||||
|
|
||||||
<!-- warning - keeping orange-500 for warning visibility in both modes -->
|
<!-- warning - keeping orange-500 for warning visibility in both modes -->
|
||||||
<div class="flex bg-orange-500 p-2 text-sm font-semibold leading-6 text-white rounded shadow">
|
<div class="flex bg-orange-500 p-2 text-sm font-semibold leading-6 text-white rounded shadow">
|
||||||
<div class="my-auto">
|
<div class="my-auto">
|
||||||
@@ -59,6 +60,7 @@
|
|||||||
@enable="enableInterface(iface._name)"
|
@enable="enableInterface(iface._name)"
|
||||||
@disable="disableInterface(iface._name)"
|
@disable="disableInterface(iface._name)"
|
||||||
@edit="editInterface(iface._name)"
|
@edit="editInterface(iface._name)"
|
||||||
|
@export="exportInterface(iface._name)"
|
||||||
@delete="deleteInterface(iface._name)"/>
|
@delete="deleteInterface(iface._name)"/>
|
||||||
|
|
||||||
<!-- disabled interfaces -->
|
<!-- disabled interfaces -->
|
||||||
@@ -69,7 +71,9 @@
|
|||||||
@enable="enableInterface(iface._name)"
|
@enable="enableInterface(iface._name)"
|
||||||
@disable="disableInterface(iface._name)"
|
@disable="disableInterface(iface._name)"
|
||||||
@edit="editInterface(iface._name)"
|
@edit="editInterface(iface._name)"
|
||||||
|
@export="exportInterface(iface._name)"
|
||||||
@delete="deleteInterface(iface._name)"/>
|
@delete="deleteInterface(iface._name)"/>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -84,6 +88,7 @@ import ElectronUtils from "../../js/ElectronUtils";
|
|||||||
import Interface from "./Interface.vue";
|
import Interface from "./Interface.vue";
|
||||||
import Utils from "../../js/Utils";
|
import Utils from "../../js/Utils";
|
||||||
import ImportInterfacesModal from "./ImportInterfacesModal.vue";
|
import ImportInterfacesModal from "./ImportInterfacesModal.vue";
|
||||||
|
import DownloadUtils from "../../js/DownloadUtils";
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
name: 'InterfacesPage',
|
name: 'InterfacesPage',
|
||||||
@@ -136,57 +141,13 @@ export default {
|
|||||||
// update data
|
// update data
|
||||||
const interfaces = response.data.interface_stats?.interfaces ?? [];
|
const interfaces = response.data.interface_stats?.interfaces ?? [];
|
||||||
for(const iface of interfaces){
|
for(const iface of interfaces){
|
||||||
this.interfaceStats[iface.name] = iface;
|
this.interfaceStats[iface.short_name] = iface;
|
||||||
}
|
}
|
||||||
|
|
||||||
} catch(e) {
|
} catch(e) {
|
||||||
// do nothing if failed to load interfaces
|
// do nothing if failed to load interfaces
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
findInterfaceStats(interfaceName) {
|
|
||||||
const interfaceDescription = this.getInterfaceDescription(interfaceName);
|
|
||||||
return this.interfaceStats[interfaceDescription];
|
|
||||||
},
|
|
||||||
getInterfaceDescription(interfaceName) {
|
|
||||||
|
|
||||||
// the interface-stats api returns interface names like the following;
|
|
||||||
//
|
|
||||||
// "AutoInterface[Default Interface]"
|
|
||||||
// "RNodeInterface[RNode LoRa Interface Fast]"
|
|
||||||
// "TCPInterface[RNS Testnet Amsterdam/amsterdam.connect.reticulum.network:4965]"
|
|
||||||
//
|
|
||||||
// however, the interfaces api just returns;
|
|
||||||
// "Default Interface"
|
|
||||||
// "RNode LoRa Interface Fast"
|
|
||||||
// "RNS Testnet Amsterdam"
|
|
||||||
//
|
|
||||||
// so we need to map the basic interface name to the former, so we can lookup stats for the interface
|
|
||||||
const iface = this.interfaces[interfaceName];
|
|
||||||
if(iface){
|
|
||||||
switch(iface.type){
|
|
||||||
case "TCPClientInterface": {
|
|
||||||
// yes, this is meant to be passed as TCPInterface, even though the interface type includes client...
|
|
||||||
// example: "TCPInterface[RNS Testnet Amsterdam/amsterdam.connect.reticulum.network:4965]";
|
|
||||||
return `TCPInterface[${interfaceName}/${iface.target_host}:${iface.target_port}]`;
|
|
||||||
}
|
|
||||||
case "TCPServerInterface": {
|
|
||||||
// example: "TCPServerInterface[TCP Server Interface/0.0.0.0:4242]";
|
|
||||||
return `TCPServerInterface[${interfaceName}/${iface.listen_ip}:${iface.listen_port}]`;
|
|
||||||
}
|
|
||||||
case "UDPInterface": {
|
|
||||||
// example: "UDPInterface[UDP Interface/0.0.0.0:1234]";
|
|
||||||
return `UDPInterface[${interfaceName}/${iface.listen_ip}:${iface.listen_port}]`;
|
|
||||||
}
|
|
||||||
default: {
|
|
||||||
// example: "RNodeInterface[RNode LoRa Interface Fast]",
|
|
||||||
return `${iface.type}[${interfaceName}]`;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
|
||||||
|
|
||||||
},
|
|
||||||
async enableInterface(interfaceName) {
|
async enableInterface(interfaceName) {
|
||||||
|
|
||||||
// enable interface
|
// enable interface
|
||||||
@@ -250,22 +211,36 @@ export default {
|
|||||||
},
|
},
|
||||||
async exportInterfaces() {
|
async exportInterfaces() {
|
||||||
try {
|
try {
|
||||||
const response = await window.axios.get('/api/v1/reticulum/interfaces/export', {
|
|
||||||
responseType: 'blob'
|
// fetch exported interfaces
|
||||||
});
|
const response = await window.axios.post('/api/v1/reticulum/interfaces/export');
|
||||||
|
|
||||||
const url = window.URL.createObjectURL(new Blob([response.data]));
|
// download file to browser
|
||||||
const link = document.createElement('a');
|
DownloadUtils.downloadFile("meshchat_interfaces.txt", new Blob([response.data]));
|
||||||
link.href = url;
|
|
||||||
link.setAttribute('download', 'reticulum_interfaces');
|
|
||||||
document.body.appendChild(link);
|
|
||||||
link.click();
|
|
||||||
link.remove();
|
|
||||||
} catch(e) {
|
} catch(e) {
|
||||||
DialogUtils.alert("Failed to export interfaces");
|
DialogUtils.alert("Failed to export interfaces");
|
||||||
console.error(e);
|
console.error(e);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
async exportInterface(interfaceName) {
|
||||||
|
try {
|
||||||
|
|
||||||
|
// fetch exported interfaces
|
||||||
|
const response = await window.axios.post('/api/v1/reticulum/interfaces/export', {
|
||||||
|
selected_interface_names: [
|
||||||
|
interfaceName,
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
// download file to browser
|
||||||
|
DownloadUtils.downloadFile(`${interfaceName}.txt`, new Blob([response.data]));
|
||||||
|
|
||||||
|
} catch(e) {
|
||||||
|
DialogUtils.alert("Failed to export interface");
|
||||||
|
console.error(e);
|
||||||
|
}
|
||||||
|
},
|
||||||
showImportInterfacesModal() {
|
showImportInterfacesModal() {
|
||||||
this.$refs["import-interfaces-modal"].show();
|
this.$refs["import-interfaces-modal"].show();
|
||||||
},
|
},
|
||||||
@@ -282,7 +257,7 @@ export default {
|
|||||||
const results = [];
|
const results = [];
|
||||||
for(const [interfaceName, iface] of Object.entries(this.interfaces)){
|
for(const [interfaceName, iface] of Object.entries(this.interfaces)){
|
||||||
iface._name = interfaceName;
|
iface._name = interfaceName;
|
||||||
iface._stats = this.findInterfaceStats(interfaceName);
|
iface._stats = this.interfaceStats[interfaceName];
|
||||||
results.push(iface);
|
results.push(iface);
|
||||||
}
|
}
|
||||||
return results;
|
return results;
|
||||||
|
|||||||
@@ -72,15 +72,11 @@
|
|||||||
|
|
||||||
<!-- close button -->
|
<!-- close button -->
|
||||||
<div class="my-auto mr-2">
|
<div class="my-auto mr-2">
|
||||||
<div @click="close" class="cursor-pointer">
|
<IconButton @click="close">
|
||||||
<div class="flex text-gray-700 bg-gray-100 hover:bg-gray-200 p-2 rounded-full">
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" class="w-5 h-5">
|
||||||
<div>
|
<path d="M6.28 5.22a.75.75 0 0 0-1.06 1.06L8.94 10l-3.72 3.72a.75.75 0 1 0 1.06 1.06L10 11.06l3.72 3.72a.75.75 0 1 0 1.06-1.06L11.06 10l3.72-3.72a.75.75 0 0 0-1.06-1.06L10 8.94 6.28 5.22Z" />
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" class="w-5 h-5">
|
</svg>
|
||||||
<path d="M6.28 5.22a.75.75 0 0 0-1.06 1.06L8.94 10l-3.72 3.72a.75.75 0 1 0 1.06 1.06L10 11.06l3.72 3.72a.75.75 0 1 0 1.06-1.06L11.06 10l3.72-3.72a.75.75 0 0 0-1.06-1.06L10 8.94 6.28 5.22Z" />
|
</IconButton>
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
@@ -93,7 +89,7 @@
|
|||||||
<div v-for="chatItem of selectedPeerChatItemsReversed" :key="chatItem.lxmf_message.hash" class="flex flex-col max-w-xl mt-3" :class="{ 'ml-auto pl-4 md:pl-16 items-end': chatItem.is_outbound, 'mr-auto pr-4 md:pr-16 items-start': !chatItem.is_outbound }">
|
<div v-for="chatItem of selectedPeerChatItemsReversed" :key="chatItem.lxmf_message.hash" class="flex flex-col max-w-xl mt-3" :class="{ 'ml-auto pl-4 md:pl-16 items-end': chatItem.is_outbound, 'mr-auto pr-4 md:pr-16 items-start': !chatItem.is_outbound }">
|
||||||
|
|
||||||
<!-- message content -->
|
<!-- message content -->
|
||||||
<div @click="onChatItemClick(chatItem)" class="border border-gray-300 dark:border-zinc-800 rounded-xl shadow overflow-hidden" :class="[ chatItem.lxmf_message.state === 'failed' ? 'bg-red-500 text-white' : chatItem.is_outbound ? 'bg-[#3b82f6] text-white' : 'bg-[#efefef]' ]">
|
<div @click="onChatItemClick(chatItem)" class="border border-gray-300 dark:border-zinc-800 rounded-xl shadow overflow-hidden" :class="[ ['cancelled', 'failed'].includes(chatItem.lxmf_message.state) ? 'bg-red-500 text-white' : chatItem.is_outbound ? 'bg-[#3b82f6] text-white' : 'bg-[#efefef]' ]">
|
||||||
|
|
||||||
<div class="w-full space-y-0.5 px-2.5 py-1">
|
<div class="w-full space-y-0.5 px-2.5 py-1">
|
||||||
|
|
||||||
@@ -167,7 +163,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- message state -->
|
<!-- message state -->
|
||||||
<div v-if="chatItem.is_outbound" class="flex text-right" :class="[ chatItem.lxmf_message.state === 'failed' ? 'text-red-500' : 'text-gray-500' ]">
|
<div v-if="chatItem.is_outbound" class="flex text-right" :class="[ ['cancelled', 'failed'].includes(chatItem.lxmf_message.state) ? 'text-red-500' : 'text-gray-500' ]">
|
||||||
<div class="flex ml-auto space-x-1">
|
<div class="flex ml-auto space-x-1">
|
||||||
|
|
||||||
<!-- state label -->
|
<!-- state label -->
|
||||||
@@ -179,6 +175,7 @@
|
|||||||
<span v-if="chatItem.lxmf_message.state === 'sent' && chatItem.lxmf_message.method === 'propagated'">to propagation node</span>
|
<span v-if="chatItem.lxmf_message.state === 'sent' && chatItem.lxmf_message.method === 'propagated'">to propagation node</span>
|
||||||
<span v-if="chatItem.lxmf_message.state === 'sending'">{{ chatItem.lxmf_message.progress.toFixed(0) }}%</span>
|
<span v-if="chatItem.lxmf_message.state === 'sending'">{{ chatItem.lxmf_message.progress.toFixed(0) }}%</span>
|
||||||
</span>
|
</span>
|
||||||
|
<a v-if="chatItem.lxmf_message.state === 'outbound' || chatItem.lxmf_message.state === 'sending' || chatItem.lxmf_message.state === 'sent'" @click="cancelSendingMessage(chatItem)" class="ml-1 cursor-pointer underline text-blue-500">cancel?</a>
|
||||||
<a v-if="chatItem.lxmf_message.state === 'failed'" @click="retrySendingMessage(chatItem)" class="ml-1 cursor-pointer underline text-blue-500">retry?</a>
|
<a v-if="chatItem.lxmf_message.state === 'failed'" @click="retrySendingMessage(chatItem)" class="ml-1 cursor-pointer underline text-blue-500">retry?</a>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -189,6 +186,13 @@
|
|||||||
</svg>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- cancelled icon -->
|
||||||
|
<div v-else-if="chatItem.lxmf_message.state === 'cancelled'" class="my-auto">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" class="size-5">
|
||||||
|
<path fill-rule="evenodd" d="M12 2.25c-5.385 0-9.75 4.365-9.75 9.75s4.365 9.75 9.75 9.75 9.75-4.365 9.75-9.75S17.385 2.25 12 2.25Zm-1.72 6.97a.75.75 0 1 0-1.06 1.06L10.94 12l-1.72 1.72a.75.75 0 1 0 1.06 1.06L12 13.06l1.72 1.72a.75.75 0 1 0 1.06-1.06L13.06 12l1.72-1.72a.75.75 0 1 0-1.06-1.06L12 10.94l-1.72-1.72Z" clip-rule="evenodd" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- failed icon -->
|
<!-- failed icon -->
|
||||||
<div v-else-if="chatItem.lxmf_message.state === 'failed'" class="my-auto">
|
<div v-else-if="chatItem.lxmf_message.state === 'failed'" class="my-auto">
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" class="w-5 h-5">
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" class="w-5 h-5">
|
||||||
@@ -312,6 +316,7 @@
|
|||||||
|
|
||||||
<!-- text input -->
|
<!-- text input -->
|
||||||
<textarea
|
<textarea
|
||||||
|
ref="message-input"
|
||||||
id="message-input"
|
id="message-input"
|
||||||
:readonly="isSendingMessage"
|
:readonly="isSendingMessage"
|
||||||
v-model="newMessageText"
|
v-model="newMessageText"
|
||||||
@@ -379,6 +384,13 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="font-semibold dark:text-white">No Active Chat</div>
|
<div class="font-semibold dark:text-white">No Active Chat</div>
|
||||||
<div class='dark:text-zinc-300'>Select a Peer to start chatting!</div>
|
<div class='dark:text-zinc-300'>Select a Peer to start chatting!</div>
|
||||||
|
<div class="mx-auto mt-2">
|
||||||
|
<button @click.stop="openLXMFAddress" 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-800 dark:text-white dark:hover:bg-zinc-700 dark:focus-visible:outline-zinc-500">
|
||||||
|
Enter an LXMF Address
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</template>
|
</template>
|
||||||
@@ -395,10 +407,13 @@ import SendMessageButton from "./SendMessageButton.vue";
|
|||||||
import MaterialDesignIcon from "../MaterialDesignIcon.vue";
|
import MaterialDesignIcon from "../MaterialDesignIcon.vue";
|
||||||
import ConversationDropDownMenu from "./ConversationDropDownMenu.vue";
|
import ConversationDropDownMenu from "./ConversationDropDownMenu.vue";
|
||||||
import AddImageButton from "./AddImageButton.vue";
|
import AddImageButton from "./AddImageButton.vue";
|
||||||
|
import IconButton from "../IconButton.vue";
|
||||||
|
import GlobalEmitter from "../../js/GlobalEmitter";
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
name: 'ConversationViewer',
|
name: 'ConversationViewer',
|
||||||
components: {
|
components: {
|
||||||
|
IconButton,
|
||||||
AddImageButton,
|
AddImageButton,
|
||||||
ConversationDropDownMenu,
|
ConversationDropDownMenu,
|
||||||
MaterialDesignIcon,
|
MaterialDesignIcon,
|
||||||
@@ -596,6 +611,9 @@ export default {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
openLXMFAddress() {
|
||||||
|
GlobalEmitter.emit("compose-new-message");
|
||||||
|
},
|
||||||
onLxmfMessageReceived(lxmfMessage) {
|
onLxmfMessageReceived(lxmfMessage) {
|
||||||
|
|
||||||
// add inbound message to ui
|
// add inbound message to ui
|
||||||
@@ -1106,6 +1124,38 @@ export default {
|
|||||||
this.isSendingMessage = false;
|
this.isSendingMessage = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
},
|
||||||
|
async cancelSendingMessage(chatItem) {
|
||||||
|
|
||||||
|
// get lxmf message hash else do nothing
|
||||||
|
const lxmfMessageHash = chatItem.lxmf_message.hash;
|
||||||
|
if(!lxmfMessageHash){
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
|
||||||
|
// cancel sending lxmf message
|
||||||
|
const response = await window.axios.post(`/api/v1/lxmf-messages/${lxmfMessageHash}/cancel`);
|
||||||
|
|
||||||
|
// get lxmf message from response
|
||||||
|
const lxmfMessage = response.data.lxmf_message;
|
||||||
|
if(!lxmfMessage){
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// update lxmf message in ui
|
||||||
|
this.onLxmfMessageUpdated(lxmfMessage);
|
||||||
|
|
||||||
|
} catch(e) {
|
||||||
|
|
||||||
|
// show error
|
||||||
|
const message = e.response?.data?.message ?? "failed to cancel message";
|
||||||
|
DialogUtils.alert(message);
|
||||||
|
console.log(e);
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
},
|
},
|
||||||
async retrySendingMessage(chatItem) {
|
async retrySendingMessage(chatItem) {
|
||||||
|
|
||||||
@@ -1353,7 +1403,22 @@ export default {
|
|||||||
});
|
});
|
||||||
},
|
},
|
||||||
addNewLine: function() {
|
addNewLine: function() {
|
||||||
this.newMessageText += "\n";
|
|
||||||
|
// get cursor position for message input
|
||||||
|
const input = this.$refs["message-input"];
|
||||||
|
const cursorPosition = input.selectionStart;
|
||||||
|
|
||||||
|
// insert a newline character after the cursor position
|
||||||
|
const text = this.newMessageText;
|
||||||
|
this.newMessageText = text.slice(0, cursorPosition) + '\n' + text.slice(cursorPosition);
|
||||||
|
|
||||||
|
// move cursor to the position after the added newline
|
||||||
|
const newCursorPosition = cursorPosition + 1;
|
||||||
|
this.$nextTick(() => {
|
||||||
|
input.selectionStart = newCursorPosition;
|
||||||
|
input.selectionEnd = newCursorPosition;
|
||||||
|
});
|
||||||
|
|
||||||
},
|
},
|
||||||
onEnterPressed: function() {
|
onEnterPressed: function() {
|
||||||
|
|
||||||
|
|||||||
@@ -15,7 +15,7 @@
|
|||||||
:my-lxmf-address-hash="config?.lxmf_address_hash"
|
:my-lxmf-address-hash="config?.lxmf_address_hash"
|
||||||
:selected-peer="selectedPeer"
|
:selected-peer="selectedPeer"
|
||||||
:conversations="conversations"
|
:conversations="conversations"
|
||||||
@close="selectedPeer = null"
|
@close="onCloseConversationViewer"
|
||||||
@reload-conversations="getConversations"/>
|
@reload-conversations="getConversations"/>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
@@ -38,6 +38,9 @@ export default {
|
|||||||
ConversationViewer,
|
ConversationViewer,
|
||||||
MessagesSidebar,
|
MessagesSidebar,
|
||||||
},
|
},
|
||||||
|
props: {
|
||||||
|
destinationHash: String,
|
||||||
|
},
|
||||||
data() {
|
data() {
|
||||||
return {
|
return {
|
||||||
|
|
||||||
@@ -76,6 +79,11 @@ export default {
|
|||||||
this.getConversations();
|
this.getConversations();
|
||||||
}, 5000);
|
}, 5000);
|
||||||
|
|
||||||
|
// compose message if a destination hash was provided on page load
|
||||||
|
if(this.destinationHash){
|
||||||
|
this.onComposeNewMessage(this.destinationHash);
|
||||||
|
}
|
||||||
|
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
async onComposeNewMessage(destinationHash) {
|
async onComposeNewMessage(destinationHash) {
|
||||||
@@ -88,6 +96,14 @@ export default {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// if user provided an address with an "lxmf@" prefix, lets remove that to get the raw destination hash
|
||||||
|
if(destinationHash.startsWith("lxmf@")){
|
||||||
|
destinationHash = destinationHash.replace("lxmf@", "");
|
||||||
|
}
|
||||||
|
|
||||||
|
// fetch updated announce as we might be composing new message before we loaded the announces list
|
||||||
|
await this.getLxmfDeliveryAnnounce(destinationHash);
|
||||||
|
|
||||||
// attempt to find existing peer so we can show their name
|
// attempt to find existing peer so we can show their name
|
||||||
const existingPeer = this.peers[destinationHash];
|
const existingPeer = this.peers[destinationHash];
|
||||||
if(existingPeer){
|
if(existingPeer){
|
||||||
@@ -160,6 +176,28 @@ export default {
|
|||||||
console.log(e);
|
console.log(e);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
async getLxmfDeliveryAnnounce(destinationHash) {
|
||||||
|
try {
|
||||||
|
|
||||||
|
// fetch announce for destination hash
|
||||||
|
const response = await window.axios.get(`/api/v1/announces`, {
|
||||||
|
params: {
|
||||||
|
destination_hash: destinationHash,
|
||||||
|
limit: 1,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// update ui
|
||||||
|
const lxmfDeliveryAnnounces = response.data.announces;
|
||||||
|
for(const lxmfDeliveryAnnounce of lxmfDeliveryAnnounces){
|
||||||
|
this.updatePeerFromAnnounce(lxmfDeliveryAnnounce);
|
||||||
|
}
|
||||||
|
|
||||||
|
} catch(e) {
|
||||||
|
// do nothing if failed to load announce
|
||||||
|
console.log(e);
|
||||||
|
}
|
||||||
|
},
|
||||||
async getConversations() {
|
async getConversations() {
|
||||||
try {
|
try {
|
||||||
const response = await window.axios.get(`/api/v1/lxmf/conversations`);
|
const response = await window.axios.get(`/api/v1/lxmf/conversations`);
|
||||||
@@ -173,7 +211,18 @@ export default {
|
|||||||
this.peers[announce.destination_hash] = announce;
|
this.peers[announce.destination_hash] = announce;
|
||||||
},
|
},
|
||||||
onPeerClick: function(peer) {
|
onPeerClick: function(peer) {
|
||||||
|
|
||||||
|
// update selected peer
|
||||||
this.selectedPeer = peer;
|
this.selectedPeer = peer;
|
||||||
|
|
||||||
|
// update current route
|
||||||
|
this.$router.replace({
|
||||||
|
name: "messages",
|
||||||
|
params: {
|
||||||
|
destinationHash: peer.destination_hash,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
},
|
},
|
||||||
onConversationClick: function(conversation) {
|
onConversationClick: function(conversation) {
|
||||||
|
|
||||||
@@ -184,6 +233,17 @@ export default {
|
|||||||
this.$refs["conversation-viewer"].markConversationAsRead(conversation);
|
this.$refs["conversation-viewer"].markConversationAsRead(conversation);
|
||||||
|
|
||||||
},
|
},
|
||||||
|
onCloseConversationViewer: function() {
|
||||||
|
|
||||||
|
// clear selected peer
|
||||||
|
this.selectedPeer = null;
|
||||||
|
|
||||||
|
// update current route
|
||||||
|
this.$router.replace({
|
||||||
|
name: "messages",
|
||||||
|
});
|
||||||
|
|
||||||
|
},
|
||||||
},
|
},
|
||||||
watch: {
|
watch: {
|
||||||
conversations() {
|
conversations() {
|
||||||
|
|||||||
@@ -165,6 +165,60 @@ export default {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// handle double click on a node
|
||||||
|
this.network.on("doubleClick", (params) => {
|
||||||
|
|
||||||
|
// get clicked node id
|
||||||
|
const clickedNodeId = params.nodes[0];
|
||||||
|
if(!clickedNodeId){
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// find node by id
|
||||||
|
const node = this.network.body.nodes[clickedNodeId];
|
||||||
|
if(!node){
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// handle double click on an announce node
|
||||||
|
if(node.options.group === "announce"){
|
||||||
|
|
||||||
|
// get announce
|
||||||
|
const announce = node.options._announce;
|
||||||
|
if(!announce) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// handle double click on lxmf.delivery node
|
||||||
|
if(announce.aspect === "lxmf.delivery"){
|
||||||
|
|
||||||
|
// go to messages page for this destination hash
|
||||||
|
this.$router.push({
|
||||||
|
name: "messages",
|
||||||
|
params: {
|
||||||
|
destinationHash: announce.destination_hash,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
// handle double click on nomadnetwork.node node
|
||||||
|
if(announce.aspect === "nomadnetwork.node"){
|
||||||
|
|
||||||
|
// go to nomadnetwork page for this destination hash
|
||||||
|
this.$router.push({
|
||||||
|
name: "nomadnetwork",
|
||||||
|
params: {
|
||||||
|
destinationHash: announce.destination_hash,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
});
|
||||||
|
|
||||||
// update network
|
// update network
|
||||||
await this.update();
|
await this.update();
|
||||||
|
|
||||||
@@ -358,6 +412,9 @@ export default {
|
|||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// attach announce to this node
|
||||||
|
node._announce = announce;
|
||||||
|
|
||||||
// add node
|
// add node
|
||||||
nodes.push(node);
|
nodes.push(node);
|
||||||
|
|
||||||
|
|||||||
@@ -19,7 +19,7 @@
|
|||||||
|
|
||||||
<!-- close button -->
|
<!-- close button -->
|
||||||
<div class="my-auto ml-auto mr-2">
|
<div class="my-auto ml-auto mr-2">
|
||||||
<div @click="selectedNode = null" class="cursor-pointer">
|
<div @click="onCloseNodeViewer" class="cursor-pointer">
|
||||||
<div class="flex text-gray-700 dark:text-gray-300 bg-gray-100 dark:bg-zinc-800 hover:bg-gray-200 dark:hover:bg-zinc-700 p-1 rounded-full">
|
<div class="flex text-gray-700 dark:text-gray-300 bg-gray-100 dark:bg-zinc-800 hover:bg-gray-200 dark:hover:bg-zinc-700 p-1 rounded-full">
|
||||||
<div>
|
<div>
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" class="w-5 h-5">
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" class="w-5 h-5">
|
||||||
@@ -33,7 +33,7 @@
|
|||||||
|
|
||||||
<!-- browser navigation -->
|
<!-- browser navigation -->
|
||||||
<div class="flex w-full border-gray-300 dark:border-zinc-800 border-b p-2">
|
<div class="flex w-full border-gray-300 dark:border-zinc-800 border-b p-2">
|
||||||
<button @click="loadNodePage(selectedNode.destination_hash, '/page/index.mu')" type="button" class="my-auto text-gray-500 dark:text-gray-300 bg-gray-200 dark:bg-zinc-800 hover:bg-gray-300 dark:hover:bg-zinc-700 rounded p-1 cursor-pointer">
|
<button @click="loadNodePage(selectedNode.destination_hash, defaultNodePagePath)" type="button" class="my-auto text-gray-500 dark:text-gray-300 bg-gray-200 dark:bg-zinc-800 hover:bg-gray-300 dark:hover:bg-zinc-700 rounded p-1 cursor-pointer">
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" class="w-5 h-5">
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" class="w-5 h-5">
|
||||||
<path fill-rule="evenodd" d="M9.293 2.293a1 1 0 0 1 1.414 0l7 7A1 1 0 0 1 17 11h-1v6a1 1 0 0 1-1 1h-2a1 1 0 0 1-1-1v-3a1 1 0 0 0-1-1H9a1 1 0 0 0-1 1v3a1 1 0 0 1-1 1H5a1 1 0 0 1-1-1v-6H3a1 1 0 0 1-.707-1.707l7-7Z" clip-rule="evenodd" />
|
<path fill-rule="evenodd" d="M9.293 2.293a1 1 0 0 1 1.414 0l7 7A1 1 0 0 1 17 11h-1v6a1 1 0 0 1-1 1h-2a1 1 0 0 1-1-1v-3a1 1 0 0 0-1-1H9a1 1 0 0 0-1 1v3a1 1 0 0 1-1 1H5a1 1 0 0 1-1-1v-6H3a1 1 0 0 1-.707-1.707l7-7Z" clip-rule="evenodd" />
|
||||||
</svg>
|
</svg>
|
||||||
@@ -43,6 +43,11 @@
|
|||||||
<path fill-rule="evenodd" d="M15.312 11.424a5.5 5.5 0 0 1-9.201 2.466l-.312-.311h2.433a.75.75 0 0 0 0-1.5H3.989a.75.75 0 0 0-.75.75v4.242a.75.75 0 0 0 1.5 0v-2.43l.31.31a7 7 0 0 0 11.712-3.138.75.75 0 0 0-1.449-.39Zm1.23-3.723a.75.75 0 0 0 .219-.53V2.929a.75.75 0 0 0-1.5 0V5.36l-.31-.31A7 7 0 0 0 3.239 8.188a.75.75 0 1 0 1.448.389A5.5 5.5 0 0 1 13.89 6.11l.311.31h-2.432a.75.75 0 0 0 0 1.5h4.243a.75.75 0 0 0 .53-.219Z" clip-rule="evenodd" />
|
<path fill-rule="evenodd" d="M15.312 11.424a5.5 5.5 0 0 1-9.201 2.466l-.312-.311h2.433a.75.75 0 0 0 0-1.5H3.989a.75.75 0 0 0-.75.75v4.242a.75.75 0 0 0 1.5 0v-2.43l.31.31a7 7 0 0 0 11.712-3.138.75.75 0 0 0-1.449-.39Zm1.23-3.723a.75.75 0 0 0 .219-.53V2.929a.75.75 0 0 0-1.5 0V5.36l-.31-.31A7 7 0 0 0 3.239 8.188a.75.75 0 1 0 1.448.389A5.5 5.5 0 0 1 13.89 6.11l.311.31h-2.432a.75.75 0 0 0 0 1.5h4.243a.75.75 0 0 0 .53-.219Z" clip-rule="evenodd" />
|
||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
|
<button @click="toggleNodePageSource" type="button" title="Toggle Source Code" class="ml-1 my-auto text-gray-500 dark:text-gray-300 rounded p-1 cursor-pointer" :class="[ isShowingNodePageSource ? 'bg-green-500 hover:bg-green-600 text-white' : 'bg-gray-200 dark:bg-zinc-800 hover:bg-gray-300 dark:hover:bg-zinc-700' ]">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="size-5">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" d="M17.25 6.75 22.5 12l-5.25 5.25m-10.5 0L1.5 12l5.25-5.25m7.5-3-4.5 16.5" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
<button @click="loadPreviousNodePage" type="button" :disabled="nodePagePathHistory.length === 0" :class="[ nodePagePathHistory.length > 0 ? 'text-gray-500 dark:text-gray-300 bg-gray-200 dark:bg-zinc-800 hover:bg-gray-300 dark:hover:bg-zinc-700' : 'text-gray-400 dark:text-gray-500 bg-gray-100 dark:bg-zinc-900']" class="ml-1 my-auto rounded p-1 cursor-pointer">
|
<button @click="loadPreviousNodePage" type="button" :disabled="nodePagePathHistory.length === 0" :class="[ nodePagePathHistory.length > 0 ? 'text-gray-500 dark:text-gray-300 bg-gray-200 dark:bg-zinc-800 hover:bg-gray-300 dark:hover:bg-zinc-700' : 'text-gray-400 dark:text-gray-500 bg-gray-100 dark:bg-zinc-900']" class="ml-1 my-auto rounded p-1 cursor-pointer">
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" class="w-5 h-5">
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" class="w-5 h-5">
|
||||||
<path fill-rule="evenodd" d="M17 10a.75.75 0 0 1-.75.75H5.612l4.158 3.96a.75.75 0 1 1-1.04 1.08l-5.5-5.25a.75.75 0 0 1 0-1.08l5.5-5.25a.75.75 0 1 1 1.04 1.08L5.612 9.25H16.25A.75.75 0 0 1 17 10Z" clip-rule="evenodd" />
|
<path fill-rule="evenodd" d="M17 10a.75.75 0 0 1-.75.75H5.612l4.158 3.96a.75.75 0 1 1-1.04 1.08l-5.5-5.25a.75.75 0 0 1 0-1.08l5.5-5.25a.75.75 0 1 1 1.04 1.08L5.612 9.25H16.25A.75.75 0 0 1 17 10Z" clip-rule="evenodd" />
|
||||||
@@ -69,7 +74,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="my-auto">Loading {{ nodePageProgress }}%</div>
|
<div class="my-auto">Loading {{ nodePageProgress }}%</div>
|
||||||
</div>
|
</div>
|
||||||
<pre v-else v-html="nodePageContent" class="h-full text-wrap"></pre>
|
<pre v-else v-html="renderedNodePageContent()" class="h-full break-words whitespace-pre-wrap"></pre>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- file download bottom bar -->
|
<!-- file download bottom bar -->
|
||||||
@@ -93,6 +98,13 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="font-semibold">No Active Node</div>
|
<div class="font-semibold">No Active Node</div>
|
||||||
<div>Select a Node to start browsing!</div>
|
<div>Select a Node to start browsing!</div>
|
||||||
|
<div class="mx-auto mt-2">
|
||||||
|
<button @click.stop="openUrl" 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-800 dark:text-white dark:hover:bg-zinc-700 dark:focus-visible:outline-zinc-500">
|
||||||
|
Open a Nomadnet URL
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -123,7 +135,7 @@ pre a:hover {
|
|||||||
|
|
||||||
<script>
|
<script>
|
||||||
|
|
||||||
import MicronParser from "../../js/MicronParser";
|
import MicronParser from "micron-parser";
|
||||||
import DialogUtils from "../../js/DialogUtils";
|
import DialogUtils from "../../js/DialogUtils";
|
||||||
import WebSocketConnection from "../../js/WebSocketConnection";
|
import WebSocketConnection from "../../js/WebSocketConnection";
|
||||||
import NomadNetworkSidebar from "./NomadNetworkSidebar.vue";
|
import NomadNetworkSidebar from "./NomadNetworkSidebar.vue";
|
||||||
@@ -134,6 +146,9 @@ export default {
|
|||||||
components: {
|
components: {
|
||||||
NomadNetworkSidebar,
|
NomadNetworkSidebar,
|
||||||
},
|
},
|
||||||
|
props: {
|
||||||
|
destinationHash: String,
|
||||||
|
},
|
||||||
data() {
|
data() {
|
||||||
return {
|
return {
|
||||||
|
|
||||||
@@ -142,6 +157,8 @@ export default {
|
|||||||
selectedNodePath: null,
|
selectedNodePath: null,
|
||||||
|
|
||||||
isLoadingNodePage: false,
|
isLoadingNodePage: false,
|
||||||
|
isShowingNodePageSource: false,
|
||||||
|
defaultNodePagePath: "/page/index.mu",
|
||||||
nodePageRequestSequence: 0,
|
nodePageRequestSequence: 0,
|
||||||
nodePagePath: null,
|
nodePagePath: null,
|
||||||
nodePagePathUrlInput: null,
|
nodePagePathUrlInput: null,
|
||||||
@@ -150,7 +167,6 @@ export default {
|
|||||||
nodePagePathHistory: [],
|
nodePagePathHistory: [],
|
||||||
nodePageCache: {},
|
nodePageCache: {},
|
||||||
|
|
||||||
|
|
||||||
isDownloadingNodeFile: false,
|
isDownloadingNodeFile: false,
|
||||||
nodeFilePath: null,
|
nodeFilePath: null,
|
||||||
nodeFileProgress: 0,
|
nodeFileProgress: 0,
|
||||||
@@ -161,23 +177,51 @@ export default {
|
|||||||
};
|
};
|
||||||
},
|
},
|
||||||
beforeUnmount() {
|
beforeUnmount() {
|
||||||
|
|
||||||
// stop listening for websocket messages
|
// stop listening for websocket messages
|
||||||
WebSocketConnection.off("message", this.onWebsocketMessage);
|
WebSocketConnection.off("message", this.onWebsocketMessage);
|
||||||
|
|
||||||
|
// stop listening for element clicks
|
||||||
|
window.document.removeEventListener('click', this.onElementClick);
|
||||||
|
|
||||||
},
|
},
|
||||||
mounted() {
|
mounted() {
|
||||||
|
|
||||||
// listen for websocket messages
|
// listen for websocket messages
|
||||||
WebSocketConnection.on("message", this.onWebsocketMessage);
|
WebSocketConnection.on("message", this.onWebsocketMessage);
|
||||||
|
|
||||||
// fixme: this is called by the micron-parser.js
|
// listen for element clicks
|
||||||
window.onNodePageUrlClick = (url, options = null) => {
|
window.document.addEventListener('click', this.onElementClick);
|
||||||
this.onNodePageUrlClick(url, options);
|
|
||||||
};
|
// load nomadnetwork node if a destination hash was provided on page load
|
||||||
|
if(this.destinationHash){
|
||||||
|
(async () => {
|
||||||
|
// fetch updated announce as we are probably loading node page before we loaded the announces list
|
||||||
|
await this.getNomadnetworkNodeAnnounce(this.destinationHash);
|
||||||
|
await this.onNodePageUrlClick(`${this.destinationHash}:${this.defaultNodePagePath}`);
|
||||||
|
})();
|
||||||
|
}
|
||||||
|
|
||||||
this.getNomadnetworkNodeAnnounces();
|
this.getNomadnetworkNodeAnnounces();
|
||||||
|
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
|
onElementClick(event) {
|
||||||
|
|
||||||
|
// find the closest ancestor (or the clicked element itself) with data-action="openNode"
|
||||||
|
const element = event.target.closest('[data-action="openNode"]');
|
||||||
|
if(!element){
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// get the destination and fields
|
||||||
|
const destination = element.getAttribute("data-destination");
|
||||||
|
const fields = element.getAttribute("data-fields");
|
||||||
|
|
||||||
|
// navigate to destination
|
||||||
|
this.onNodePageUrlClick(destination, fields);
|
||||||
|
|
||||||
|
},
|
||||||
async onWebsocketMessage(message) {
|
async onWebsocketMessage(message) {
|
||||||
const json = JSON.parse(message.data);
|
const json = JSON.parse(message.data);
|
||||||
switch(json.type){
|
switch(json.type){
|
||||||
@@ -286,11 +330,53 @@ export default {
|
|||||||
console.log(e);
|
console.log(e);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
async getNomadnetworkNodeAnnounce(destinationHash) {
|
||||||
|
try {
|
||||||
|
|
||||||
|
// fetch announces for "nomadnetwork.node" aspect
|
||||||
|
const response = await window.axios.get(`/api/v1/announces`, {
|
||||||
|
params: {
|
||||||
|
destination_hash: destinationHash,
|
||||||
|
limit: 1,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// update ui
|
||||||
|
const nodeAnnounces = response.data.announces;
|
||||||
|
for(const nodeAnnounce of nodeAnnounces){
|
||||||
|
this.updateNodeFromAnnounce(nodeAnnounce);
|
||||||
|
}
|
||||||
|
|
||||||
|
} catch(e) {
|
||||||
|
// do nothing if failed to load announce
|
||||||
|
console.log(e);
|
||||||
|
}
|
||||||
|
},
|
||||||
updateNodeFromAnnounce: function(announce) {
|
updateNodeFromAnnounce: function(announce) {
|
||||||
this.nodes[announce.destination_hash] = announce;
|
this.nodes[announce.destination_hash] = announce;
|
||||||
},
|
},
|
||||||
|
async openUrl() {
|
||||||
|
|
||||||
|
// ask for url
|
||||||
|
const url = await DialogUtils.prompt("Enter a Nomadnet URL");
|
||||||
|
if(!url){
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// navigate to the url
|
||||||
|
await this.onNodePageUrlClick(url);
|
||||||
|
|
||||||
|
},
|
||||||
async loadNodePage(destinationHash, pagePath, fieldData = null, addToHistory = true, loadFromCache = true) {
|
async loadNodePage(destinationHash, pagePath, fieldData = null, addToHistory = true, loadFromCache = true) {
|
||||||
|
|
||||||
|
// update current route
|
||||||
|
this.$router.replace({
|
||||||
|
name: "nomadnetwork",
|
||||||
|
params: {
|
||||||
|
destinationHash: destinationHash,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
// get new sequence for this page load
|
// get new sequence for this page load
|
||||||
const seq = ++this.nodePageRequestSequence;
|
const seq = ++this.nodePageRequestSequence;
|
||||||
|
|
||||||
@@ -324,6 +410,7 @@ export default {
|
|||||||
// if page is cache, we can just return it now
|
// if page is cache, we can just return it now
|
||||||
if(cachedNodePageContent != null){
|
if(cachedNodePageContent != null){
|
||||||
this.nodePageContent = cachedNodePageContent;
|
this.nodePageContent = cachedNodePageContent;
|
||||||
|
this.renderPageContent(pagePath, cachedNodePageContent);
|
||||||
this.isLoadingNodePage = false;
|
this.isLoadingNodePage = false;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -332,31 +419,21 @@ export default {
|
|||||||
|
|
||||||
this.downloadNomadNetPage(destinationHash, pagePath, fieldData, (pageContent) => {
|
this.downloadNomadNetPage(destinationHash, pagePath, fieldData, (pageContent) => {
|
||||||
|
|
||||||
const muParser = new MicronParser();
|
|
||||||
|
|
||||||
// do nothing if callback is for a previous request
|
// do nothing if callback is for a previous request
|
||||||
if(seq !== this.nodePageRequestSequence){
|
if(seq !== this.nodePageRequestSequence){
|
||||||
console.log("ignoring page content callback for previous page request")
|
console.log("ignoring page content callback for previous page request")
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// check if page url ends with .mu but remove page data first
|
// update page content
|
||||||
// address:/page/index.mu`Data=123
|
this.nodePageContent = pageContent;
|
||||||
const [ pagePathWithoutData, pageData ] = pagePath.split("`");
|
|
||||||
|
|
||||||
// convert micron to html if page ends with .mu extension
|
|
||||||
// otherwise, we will just serve the content as is
|
|
||||||
if(pagePathWithoutData.endsWith(".mu")){
|
|
||||||
this.nodePageContent = muParser.convertMicronToHtml(pageContent);
|
|
||||||
} else {
|
|
||||||
this.nodePageContent = pageContent;
|
|
||||||
}
|
|
||||||
|
|
||||||
// update cache
|
// update cache
|
||||||
const nodePagePathCacheKey = `${destinationHash}:${pagePath}`;
|
const nodePagePathCacheKey = `${destinationHash}:${pagePath}`;
|
||||||
this.nodePageCache[nodePagePathCacheKey] = this.nodePageContent;
|
this.nodePageCache[nodePagePathCacheKey] = this.nodePageContent;
|
||||||
|
|
||||||
// update page content
|
// update page content
|
||||||
|
this.renderPageContent(pagePath, pageContent);
|
||||||
this.isLoadingNodePage = false;
|
this.isLoadingNodePage = false;
|
||||||
|
|
||||||
// update node path
|
// update node path
|
||||||
@@ -390,6 +467,35 @@ export default {
|
|||||||
|
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
renderPageContent(path, content) {
|
||||||
|
|
||||||
|
// render page content if we aren't viewing source
|
||||||
|
if(!this.isShowingNodePageSource){
|
||||||
|
|
||||||
|
// check if page url ends with .mu but remove page data first
|
||||||
|
// address:/page/index.mu`Data=123
|
||||||
|
const [ pagePathWithoutData ] = path.split("`");
|
||||||
|
|
||||||
|
// convert micron to html if page ends with .mu extension
|
||||||
|
if(pagePathWithoutData.endsWith(".mu")){
|
||||||
|
const muParser = new MicronParser();
|
||||||
|
return muParser.convertMicronToHtml(content);
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
// otherwise, we will just serve the raw content, making sure to prevent injecting html
|
||||||
|
return content
|
||||||
|
.replace(/&/g, "&")
|
||||||
|
.replace(/</g, "<")
|
||||||
|
.replace(/>/g, ">")
|
||||||
|
.replace(/"/g, """)
|
||||||
|
.replace(/'/g, "'");
|
||||||
|
|
||||||
|
},
|
||||||
|
toggleNodePageSource() {
|
||||||
|
this.isShowingNodePageSource = !this.isShowingNodePageSource;
|
||||||
|
},
|
||||||
async reloadNodePage() {
|
async reloadNodePage() {
|
||||||
|
|
||||||
// reload current node page without adding to history and without using cache
|
// reload current node page without adding to history and without using cache
|
||||||
@@ -416,9 +522,9 @@ export default {
|
|||||||
// remove leading ":"
|
// remove leading ":"
|
||||||
var path = url.substring(1);
|
var path = url.substring(1);
|
||||||
|
|
||||||
// if page path is empty we should load "/page/index.mu"
|
// if page path is empty we should load default page path
|
||||||
if(path === ""){
|
if(path === ""){
|
||||||
path = "/page/index.mu";
|
path = this.defaultNodePagePath;
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -448,7 +554,7 @@ export default {
|
|||||||
if(url.length === 32){
|
if(url.length === 32){
|
||||||
return {
|
return {
|
||||||
destination_hash: url,
|
destination_hash: url,
|
||||||
path: "/page/index.mu",
|
path: this.defaultNodePagePath,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -520,8 +626,12 @@ export default {
|
|||||||
if(url.startsWith("lxmf@")){
|
if(url.startsWith("lxmf@")){
|
||||||
const destinationHash = url.replace("lxmf@", "");
|
const destinationHash = url.replace("lxmf@", "");
|
||||||
if(destinationHash.length === 32){
|
if(destinationHash.length === 32){
|
||||||
await this.$router.push({ name: "messages" });
|
await this.$router.push({
|
||||||
GlobalEmitter.emit("compose-new-message", destinationHash);
|
name: "messages",
|
||||||
|
params: {
|
||||||
|
destinationHash: destinationHash,
|
||||||
|
},
|
||||||
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -620,8 +730,24 @@ export default {
|
|||||||
|
|
||||||
},
|
},
|
||||||
onNodeClick: function(node) {
|
onNodeClick: function(node) {
|
||||||
|
|
||||||
|
// update selected node
|
||||||
this.selectedNode = node;
|
this.selectedNode = node;
|
||||||
this.loadNodePage(node.destination_hash, "/page/index.mu");
|
|
||||||
|
// load default node page
|
||||||
|
this.loadNodePage(node.destination_hash, this.defaultNodePagePath);
|
||||||
|
|
||||||
|
},
|
||||||
|
onCloseNodeViewer: function() {
|
||||||
|
|
||||||
|
// clear selected node
|
||||||
|
this.selectedNode = null;
|
||||||
|
|
||||||
|
// update current route
|
||||||
|
this.$router.replace({
|
||||||
|
name: "nomadnetwork",
|
||||||
|
});
|
||||||
|
|
||||||
},
|
},
|
||||||
getNomadnetPageDownloadCallbackKey: function(destinationHash, pagePath) {
|
getNomadnetPageDownloadCallbackKey: function(destinationHash, pagePath) {
|
||||||
return `${destinationHash}:${pagePath}`;
|
return `${destinationHash}:${pagePath}`;
|
||||||
@@ -694,6 +820,9 @@ export default {
|
|||||||
console.error(e);
|
console.error(e);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
renderedNodePageContent() {
|
||||||
|
return this.renderPageContent(this.nodePagePath, this.nodePageContent);
|
||||||
|
},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -19,6 +19,25 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- transport mode -->
|
||||||
|
<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">Transport Mode</div>
|
||||||
|
<div class="divide-y divide-gray-300 dark:divide-zinc-700 text-gray-900 dark:text-gray-100">
|
||||||
|
|
||||||
|
<div class="p-2">
|
||||||
|
<div class="flex items-start">
|
||||||
|
<div class="flex items-center h-5">
|
||||||
|
<input v-model="config.is_transport_enabled" @change="onIsTransportEnabledChange" type="checkbox" class="w-4 h-4 border border-gray-300 dark:border-zinc-600 rounded bg-gray-50 dark:bg-zinc-700 focus:ring-3 focus:ring-blue-300 dark:focus:ring-blue-600">
|
||||||
|
</div>
|
||||||
|
<label class="ml-2 text-sm font-medium text-gray-900 dark:text-gray-100">Enable Transport Mode</label>
|
||||||
|
</div>
|
||||||
|
<div class="text-sm text-gray-700 dark:text-gray-300">When enabled, MeshChat will route traffic for other peers, respond to path requests and pass announces over your interfaces.</div>
|
||||||
|
<div class="text-sm text-gray-700 dark:text-gray-300">Changing this setting requires you to restart MeshChat.</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- interfaces -->
|
<!-- interfaces -->
|
||||||
<div class="bg-white dark:bg-zinc-800 rounded shadow">
|
<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">Interfaces</div>
|
<div class="flex border-b border-gray-300 dark:border-zinc-700 text-gray-700 dark:text-gray-200 p-2 font-semibold">Interfaces</div>
|
||||||
@@ -150,6 +169,7 @@
|
|||||||
<script>
|
<script>
|
||||||
import Utils from "../../js/Utils";
|
import Utils from "../../js/Utils";
|
||||||
import WebSocketConnection from "../../js/WebSocketConnection";
|
import WebSocketConnection from "../../js/WebSocketConnection";
|
||||||
|
import DialogUtils from "../../js/DialogUtils";
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
name: 'SettingsPage',
|
name: 'SettingsPage',
|
||||||
@@ -247,6 +267,25 @@ export default {
|
|||||||
"lxmf_preferred_propagation_node_auto_sync_interval_seconds": this.config.lxmf_preferred_propagation_node_auto_sync_interval_seconds,
|
"lxmf_preferred_propagation_node_auto_sync_interval_seconds": this.config.lxmf_preferred_propagation_node_auto_sync_interval_seconds,
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
async onIsTransportEnabledChange() {
|
||||||
|
if(this.config.is_transport_enabled){
|
||||||
|
try {
|
||||||
|
const response = await window.axios.post("/api/v1/reticulum/enable-transport");
|
||||||
|
DialogUtils.alert(response.data.message);
|
||||||
|
} catch(e) {
|
||||||
|
DialogUtils.alert("Failed to enable transport mode!");
|
||||||
|
console.log(e);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
try {
|
||||||
|
const response = await window.axios.post("/api/v1/reticulum/disable-transport");
|
||||||
|
DialogUtils.alert(response.data.message);
|
||||||
|
} catch(e) {
|
||||||
|
DialogUtils.alert("Failed to disable transport mode!");
|
||||||
|
console.log(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
formatSecondsAgo: function(seconds) {
|
formatSecondsAgo: function(seconds) {
|
||||||
return Utils.formatSecondsAgo(seconds);
|
return Utils.formatSecondsAgo(seconds);
|
||||||
},
|
},
|
||||||
|
|||||||
28
src/frontend/js/DownloadUtils.js
Normal file
28
src/frontend/js/DownloadUtils.js
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
class DownloadUtils {
|
||||||
|
|
||||||
|
static downloadFile(filename, blob) {
|
||||||
|
|
||||||
|
// create object url for blob
|
||||||
|
const objectUrl = URL.createObjectURL(blob);
|
||||||
|
|
||||||
|
// create hidden link element to download blob
|
||||||
|
const link = document.createElement('a');
|
||||||
|
link.href = objectUrl;
|
||||||
|
link.download = filename;
|
||||||
|
link.style.display = "none";
|
||||||
|
document.body.append(link);
|
||||||
|
|
||||||
|
// click link to download file in browser
|
||||||
|
link.click();
|
||||||
|
|
||||||
|
// link element is no longer needed
|
||||||
|
link.remove();
|
||||||
|
|
||||||
|
// revoke object url to clear memory
|
||||||
|
setTimeout(() => URL.revokeObjectURL(objectUrl), 10000);
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
export default DownloadUtils;
|
||||||
@@ -143,7 +143,7 @@ class Utils {
|
|||||||
|
|
||||||
static isInterfaceEnabled(iface) {
|
static isInterfaceEnabled(iface) {
|
||||||
const rawValue = iface.enabled ?? iface.interface_enabled;
|
const rawValue = iface.enabled ?? iface.interface_enabled;
|
||||||
const value = rawValue?.toLowerCase();
|
const value = rawValue?.toString()?.toLowerCase();
|
||||||
return value === "on" || value === "yes" || value === "true";
|
return value === "on" || value === "yes" || value === "true";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3,8 +3,18 @@ import mitt from 'mitt';
|
|||||||
class WebSocketConnection {
|
class WebSocketConnection {
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
|
|
||||||
this.emitter = mitt();
|
this.emitter = mitt();
|
||||||
this.reconnect();
|
this.reconnect();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ping websocket server every 30 seconds
|
||||||
|
* this helps to prevent the underlying tcp connection from going stale when there's no traffic for a long time
|
||||||
|
*/
|
||||||
|
setInterval(() => {
|
||||||
|
this.ping();
|
||||||
|
}, 30000);
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// add event listener
|
// add event listener
|
||||||
@@ -47,6 +57,16 @@ class WebSocketConnection {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
ping() {
|
||||||
|
try {
|
||||||
|
this.send(JSON.stringify({
|
||||||
|
"type": "ping",
|
||||||
|
}));
|
||||||
|
} catch(e) {
|
||||||
|
// ignore error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export default new WebSocketConnection();
|
export default new WebSocketConnection();
|
||||||
|
|||||||
@@ -46,7 +46,8 @@ const router = createRouter({
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "messages",
|
name: "messages",
|
||||||
path: '/messages',
|
path: '/messages/:destinationHash?',
|
||||||
|
props: true,
|
||||||
component: defineAsyncComponent(() => import("./components/messages/MessagesPage.vue")),
|
component: defineAsyncComponent(() => import("./components/messages/MessagesPage.vue")),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -56,7 +57,8 @@ const router = createRouter({
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "nomadnetwork",
|
name: "nomadnetwork",
|
||||||
path: '/nomadnetwork',
|
path: '/nomadnetwork/:destinationHash?',
|
||||||
|
props: true,
|
||||||
component: defineAsyncComponent(() => import("./components/nomadnetwork/NomadNetworkPage.vue")),
|
component: defineAsyncComponent(() => import("./components/nomadnetwork/NomadNetworkPage.vue")),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -110,7 +110,7 @@
|
|||||||
<div class="border-t px-2 py-1 text-sm">
|
<div class="border-t px-2 py-1 text-sm">
|
||||||
<div>
|
<div>
|
||||||
<span>Download Firmware</span>
|
<span>Download Firmware</span>
|
||||||
<span v-if="selectedProduct && selectedModel && recommendedFirmwareFilename">: {{ recommendedFirmwareFilename }}</span>
|
<span v-if="selectedProduct && selectedModel && recommendedFirmwareFilename">: <a target="_blank" :href="`https://github.com/markqvist/RNode_Firmware/releases/latest/download/${recommendedFirmwareFilename}`" class="text-blue-500 hover:underline">{{ recommendedFirmwareFilename }}</a></span>
|
||||||
</div>
|
</div>
|
||||||
<div class="space-x-1">
|
<div class="space-x-1">
|
||||||
<a target="_blank" href="https://github.com/markqvist/RNode_Firmware/releases" class="text-blue-500 hover:underline">Official Firmware</a>
|
<a target="_blank" href="https://github.com/markqvist/RNode_Firmware/releases" class="text-blue-500 hover:underline">Official Firmware</a>
|
||||||
@@ -312,6 +312,48 @@
|
|||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="border bg-gray-50 rounded shadow">
|
||||||
|
|
||||||
|
<div class="border-b px-2 py-1">
|
||||||
|
Configure Display (optional)
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="p-3 space-y-2">
|
||||||
|
|
||||||
|
<div class="flex space-x-1">
|
||||||
|
<div class="my-auto">Rotation</div>
|
||||||
|
<button @click="setDisplayRotation(0)" class="border border-gray-500 px-2 bg-gray-100 hover:bg-gray-200 rounded">
|
||||||
|
0
|
||||||
|
</button>
|
||||||
|
<button @click="setDisplayRotation(1)" class="border border-gray-500 px-2 bg-gray-100 hover:bg-gray-200 rounded">
|
||||||
|
1
|
||||||
|
</button>
|
||||||
|
<button @click="setDisplayRotation(2)" class="border border-gray-500 px-2 bg-gray-100 hover:bg-gray-200 rounded">
|
||||||
|
2
|
||||||
|
</button>
|
||||||
|
<button @click="setDisplayRotation(3)" class="border border-gray-500 px-2 bg-gray-100 hover:bg-gray-200 rounded">
|
||||||
|
3
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex space-x-1">
|
||||||
|
<div class="my-auto">Reconditioning</div>
|
||||||
|
<button @click="startDisplayReconditioning" class="border border-gray-500 px-2 bg-gray-100 hover:bg-gray-200 rounded">
|
||||||
|
Start
|
||||||
|
</button>
|
||||||
|
<button @click="reboot" class="border border-gray-500 px-2 bg-gray-100 hover:bg-gray-200 rounded">
|
||||||
|
Stop
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="border-t px-2 py-1 text-sm">
|
||||||
|
<div>Setting display rotation requires firmware v1.80+</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- setup web-serial-polyfill -->
|
<!-- setup web-serial-polyfill -->
|
||||||
@@ -334,6 +376,8 @@
|
|||||||
data() {
|
data() {
|
||||||
return {
|
return {
|
||||||
|
|
||||||
|
rnode: null,
|
||||||
|
|
||||||
isFlashing: false,
|
isFlashing: false,
|
||||||
flashingProgress: 0,
|
flashingProgress: 0,
|
||||||
|
|
||||||
@@ -397,6 +441,22 @@
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: "Heltec T114",
|
||||||
|
id: ROM.PRODUCT_HELTEC_T114,
|
||||||
|
platform: ROM.PLATFORM_NRF52,
|
||||||
|
models: [
|
||||||
|
{
|
||||||
|
id: ROM.MODEL_C6,
|
||||||
|
name: "470-510 MHz (HT-n5262-LF)",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: ROM.MODEL_C7,
|
||||||
|
name: "863-928 MHz (HT-n5262-HF)",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
firmware_filename: "rnode_firmware_heltec_t114.zip",
|
||||||
|
},
|
||||||
{
|
{
|
||||||
name: "LilyGO LoRa32 v1.0",
|
name: "LilyGO LoRa32 v1.0",
|
||||||
id: ROM.PRODUCT_T32_10,
|
id: ROM.PRODUCT_T32_10,
|
||||||
@@ -587,6 +647,21 @@
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
id: ROM.MODEL_AC,
|
||||||
|
name: "2.4 GHz (with SX1280 chip)",
|
||||||
|
firmware_filename: "rnode_firmware_t3s3_sx1280_pa.zip",
|
||||||
|
flash_config: {
|
||||||
|
flash_size: "4MB",
|
||||||
|
flash_files: {
|
||||||
|
"0xe000": "rnode_firmware_t3s3_sx1280_pa.boot_app0",
|
||||||
|
"0x0": "rnode_firmware_t3s3_sx1280_pa.bootloader",
|
||||||
|
"0x10000": "rnode_firmware_t3s3_sx1280_pa.bin",
|
||||||
|
"0x210000": "console_image.bin",
|
||||||
|
"0x8000": "rnode_firmware_t3s3_sx1280_pa.partitions",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -714,11 +789,11 @@
|
|||||||
platform: ROM.PLATFORM_NRF52,
|
platform: ROM.PLATFORM_NRF52,
|
||||||
models: [
|
models: [
|
||||||
{
|
{
|
||||||
id: ROM.MODEL_T4,
|
id: ROM.MODEL_16,
|
||||||
name: "433 MHz",
|
name: "433 MHz",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: ROM.MODEL_T9,
|
id: ROM.MODEL_17,
|
||||||
name: "868 MHz / 915 MHz / 923 MHz",
|
name: "868 MHz / 915 MHz / 923 MHz",
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
@@ -858,11 +933,37 @@
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// close any existing rnode connection
|
||||||
|
if(this.rnode){
|
||||||
|
await this.rnode.close();
|
||||||
|
this.rnode = null;
|
||||||
|
}
|
||||||
|
|
||||||
// ask user to select device
|
// ask user to select device
|
||||||
return await navigator.serial.requestPort({
|
return await navigator.serial.requestPort({
|
||||||
filters: [],
|
filters: [],
|
||||||
});
|
});
|
||||||
|
|
||||||
|
},
|
||||||
|
async askForRNode() {
|
||||||
|
|
||||||
|
// ask for serial port
|
||||||
|
const serialPort = await this.askForSerialPort();
|
||||||
|
if(!serialPort){
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// check if device is an rnode
|
||||||
|
this.rnode = await RNode.fromSerialPort(serialPort);
|
||||||
|
const isRNode = await this.rnode.detect();
|
||||||
|
if(!isRNode){
|
||||||
|
await this.rnode.close();
|
||||||
|
alert("Selected device is not an RNode!");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.rnode;
|
||||||
|
|
||||||
},
|
},
|
||||||
async enterDfuMode() {
|
async enterDfuMode() {
|
||||||
|
|
||||||
@@ -1069,17 +1170,9 @@
|
|||||||
},
|
},
|
||||||
async detect() {
|
async detect() {
|
||||||
|
|
||||||
// ask for serial port
|
// ask for rnode
|
||||||
const serialPort = await this.askForSerialPort();
|
const rnode = await this.askForRNode();
|
||||||
if(!serialPort){
|
if(!rnode){
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// check if device is an rnode
|
|
||||||
const rnode = await RNode.fromSerialPort(serialPort);
|
|
||||||
const isRNode = await rnode.detect();
|
|
||||||
if(!isRNode){
|
|
||||||
alert("Selected device is not an RNode!");
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1122,17 +1215,9 @@
|
|||||||
},
|
},
|
||||||
async reboot() {
|
async reboot() {
|
||||||
|
|
||||||
// ask for serial port
|
// ask for rnode
|
||||||
const serialPort = await this.askForSerialPort();
|
const rnode = await this.askForRNode();
|
||||||
if(!serialPort){
|
if(!rnode){
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// check if device is an rnode
|
|
||||||
const rnode = await RNode.fromSerialPort(serialPort);
|
|
||||||
const isRNode = await rnode.detect();
|
|
||||||
if(!isRNode){
|
|
||||||
alert("Selected device is not an RNode!");
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1210,17 +1295,9 @@
|
|||||||
},
|
},
|
||||||
async readDisplay() {
|
async readDisplay() {
|
||||||
|
|
||||||
// ask for serial port
|
// ask for rnode
|
||||||
const serialPort = await this.askForSerialPort();
|
const rnode = await this.askForRNode();
|
||||||
if(!serialPort){
|
if(!rnode){
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// check if device is an rnode
|
|
||||||
const rnode = await RNode.fromSerialPort(serialPort);
|
|
||||||
const isRNode = await rnode.detect();
|
|
||||||
if(!isRNode){
|
|
||||||
alert("Selected device is not an RNode!");
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1236,17 +1313,9 @@
|
|||||||
},
|
},
|
||||||
async dumpEeprom() {
|
async dumpEeprom() {
|
||||||
|
|
||||||
// ask for serial port
|
// ask for rnode
|
||||||
const serialPort = await this.askForSerialPort();
|
const rnode = await this.askForRNode();
|
||||||
if(!serialPort){
|
if(!rnode){
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// check if device is an rnode
|
|
||||||
const rnode = await RNode.fromSerialPort(serialPort);
|
|
||||||
const isRNode = await rnode.detect();
|
|
||||||
if(!isRNode){
|
|
||||||
alert("Selected device is not an RNode!");
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1264,22 +1333,15 @@
|
|||||||
},
|
},
|
||||||
async wipeEeprom() {
|
async wipeEeprom() {
|
||||||
|
|
||||||
// ask for serial port
|
// ask for rnode
|
||||||
const serialPort = await this.askForSerialPort();
|
const rnode = await this.askForRNode();
|
||||||
if(!serialPort){
|
if(!rnode){
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ask user to confirm
|
// ask user to confirm
|
||||||
if(!confirm("Are you sure you want to wipe the eeprom on this device? This will take about 30 seconds. An alert will show when the eeprom wipe has finished.")){
|
if(!confirm("Are you sure you want to wipe the eeprom on this device? This will take about 30 seconds. An alert will show when the eeprom wipe has finished.")){
|
||||||
return;
|
await rnode.close();
|
||||||
}
|
|
||||||
|
|
||||||
// check if device is an rnode
|
|
||||||
const rnode = await RNode.fromSerialPort(serialPort);
|
|
||||||
const isRNode = await rnode.detect();
|
|
||||||
if(!isRNode){
|
|
||||||
alert("Selected device is not an RNode!");
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1298,18 +1360,9 @@
|
|||||||
},
|
},
|
||||||
async provision() {
|
async provision() {
|
||||||
|
|
||||||
// ask for serial port
|
// ask for rnode
|
||||||
const serialPort = await this.askForSerialPort();
|
const rnode = await this.askForRNode();
|
||||||
if(!serialPort){
|
if(!rnode){
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// check if device is an rnode
|
|
||||||
const rnode = await RNode.fromSerialPort(serialPort);
|
|
||||||
const isRNode = await rnode.detect();
|
|
||||||
if(!isRNode){
|
|
||||||
alert("Selected device is not an RNode!");
|
|
||||||
await rnode.close();
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1489,17 +1542,9 @@
|
|||||||
},
|
},
|
||||||
async setFirmwareHash() {
|
async setFirmwareHash() {
|
||||||
|
|
||||||
// ask for serial port
|
// ask for rnode
|
||||||
const serialPort = await this.askForSerialPort();
|
const rnode = await this.askForRNode();
|
||||||
if(!serialPort){
|
if(!rnode){
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// check if device is an rnode
|
|
||||||
const rnode = await RNode.fromSerialPort(serialPort);
|
|
||||||
const isRNode = await rnode.detect();
|
|
||||||
if(!isRNode){
|
|
||||||
alert("Selected device is not an RNode!");
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1546,17 +1591,9 @@
|
|||||||
},
|
},
|
||||||
async enableTncMode() {
|
async enableTncMode() {
|
||||||
|
|
||||||
// ask for serial port
|
// ask for rnode
|
||||||
const serialPort = await this.askForSerialPort();
|
const rnode = await this.askForRNode();
|
||||||
if(!serialPort){
|
if(!rnode){
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// check if device is an rnode
|
|
||||||
const rnode = await RNode.fromSerialPort(serialPort);
|
|
||||||
const isRNode = await rnode.detect();
|
|
||||||
if(!isRNode){
|
|
||||||
alert("Selected device is not an RNode!");
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1602,17 +1639,9 @@
|
|||||||
},
|
},
|
||||||
async disableTncMode() {
|
async disableTncMode() {
|
||||||
|
|
||||||
// ask for serial port
|
// ask for rnode
|
||||||
const serialPort = await this.askForSerialPort();
|
const rnode = await this.askForRNode();
|
||||||
if(!serialPort){
|
if(!rnode){
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// check if device is an rnode
|
|
||||||
const rnode = await RNode.fromSerialPort(serialPort);
|
|
||||||
const isRNode = await rnode.detect();
|
|
||||||
if(!isRNode){
|
|
||||||
alert("Selected device is not an RNode!");
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1643,17 +1672,9 @@
|
|||||||
},
|
},
|
||||||
async enableBluetooth() {
|
async enableBluetooth() {
|
||||||
|
|
||||||
// ask for serial port
|
// ask for rnode
|
||||||
const serialPort = await this.askForSerialPort();
|
const rnode = await this.askForRNode();
|
||||||
if(!serialPort){
|
if(!rnode){
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// check if device is an rnode
|
|
||||||
const rnode = await RNode.fromSerialPort(serialPort);
|
|
||||||
const isRNode = await rnode.detect();
|
|
||||||
if(!isRNode){
|
|
||||||
alert("Selected device is not an RNode!");
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1671,26 +1692,18 @@
|
|||||||
await rnode.enableBluetooth();
|
await rnode.enableBluetooth();
|
||||||
console.log("enabling bluetooth: done");
|
console.log("enabling bluetooth: done");
|
||||||
|
|
||||||
await Utils.sleepMillis(1000);
|
alert("Bluetooth has been enabled!");
|
||||||
|
|
||||||
// done
|
// done
|
||||||
|
await Utils.sleepMillis(1000);
|
||||||
await rnode.close();
|
await rnode.close();
|
||||||
alert("Bluetooth has been enabled!");
|
|
||||||
|
|
||||||
},
|
},
|
||||||
async disableBluetooth() {
|
async disableBluetooth() {
|
||||||
|
|
||||||
// ask for serial port
|
// ask for rnode
|
||||||
const serialPort = await this.askForSerialPort();
|
const rnode = await this.askForRNode();
|
||||||
if(!serialPort){
|
if(!rnode){
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// check if device is an rnode
|
|
||||||
const rnode = await RNode.fromSerialPort(serialPort);
|
|
||||||
const isRNode = await rnode.detect();
|
|
||||||
if(!isRNode){
|
|
||||||
alert("Selected device is not an RNode!");
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1707,27 +1720,18 @@
|
|||||||
console.log("disabling bluetooth");
|
console.log("disabling bluetooth");
|
||||||
await rnode.disableBluetooth();
|
await rnode.disableBluetooth();
|
||||||
console.log("disabling bluetooth: done");
|
console.log("disabling bluetooth: done");
|
||||||
|
alert("Bluetooth has been disabled!");
|
||||||
await Utils.sleepMillis(1000);
|
|
||||||
|
|
||||||
// done
|
// done
|
||||||
|
await Utils.sleepMillis(1000);
|
||||||
await rnode.close();
|
await rnode.close();
|
||||||
alert("Bluetooth has been disabled!");
|
|
||||||
|
|
||||||
},
|
},
|
||||||
async startBluetoothPairing() {
|
async startBluetoothPairing() {
|
||||||
|
|
||||||
// ask for serial port
|
// ask for rnode
|
||||||
const serialPort = await this.askForSerialPort();
|
const rnode = await this.askForRNode();
|
||||||
if(!serialPort){
|
if(!rnode){
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// check if device is an rnode
|
|
||||||
const rnode = await RNode.fromSerialPort(serialPort);
|
|
||||||
const isRNode = await rnode.detect();
|
|
||||||
if(!isRNode){
|
|
||||||
alert("Selected device is not an RNode!");
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1743,13 +1747,72 @@
|
|||||||
// start bluetooth pairing
|
// start bluetooth pairing
|
||||||
try {
|
try {
|
||||||
console.log("start bluetooth pairing");
|
console.log("start bluetooth pairing");
|
||||||
const pin = await rnode.startBluetoothPairing();
|
await rnode.startBluetoothPairing(async (pin) => {
|
||||||
|
alert("Bluetooth Pairing Pin: " + pin);
|
||||||
|
await rnode.close();
|
||||||
|
});
|
||||||
console.log("start bluetooth pairing: done");
|
console.log("start bluetooth pairing: done");
|
||||||
} catch(error) {
|
} catch(error) {
|
||||||
alert(error);
|
alert(error);
|
||||||
}
|
}
|
||||||
|
|
||||||
alert("RNode should now be in Bluetooth Pairing mode. A pin will be shown on the screen when you pair with it from Android bluetooth settings.");
|
// tell user device is in pairing mode, and how to pair
|
||||||
|
alert([
|
||||||
|
"- RNode is in Bluetooth Pairing Mode for 30 seconds.",
|
||||||
|
"- Close this alert before performing the next steps.",
|
||||||
|
"- Open bluetooth settings on your Android device.",
|
||||||
|
"- Click pair on the RNode device that shows up.",
|
||||||
|
"- Bluetooth pin will shown on your RNode screen and on this page.",
|
||||||
|
].join("\n"));
|
||||||
|
|
||||||
|
},
|
||||||
|
async setDisplayRotation(rotation) {
|
||||||
|
|
||||||
|
// ask for rnode
|
||||||
|
const rnode = await this.askForRNode();
|
||||||
|
if(!rnode){
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// check if device has been provisioned
|
||||||
|
const rom = await rnode.getRomAsObject();
|
||||||
|
const details = rom.parse();
|
||||||
|
if(!details || !details.is_provisioned){
|
||||||
|
alert("Eeprom is not provisioned. You must do this first!");
|
||||||
|
await rnode.close();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// configure
|
||||||
|
console.log("setting display rotation");
|
||||||
|
await rnode.setDisplayRotation(rotation);
|
||||||
|
console.log("setting display rotation: done");
|
||||||
|
|
||||||
|
// done
|
||||||
|
await rnode.close();
|
||||||
|
|
||||||
|
},
|
||||||
|
async startDisplayReconditioning() {
|
||||||
|
|
||||||
|
// ask for rnode
|
||||||
|
const rnode = await this.askForRNode();
|
||||||
|
if(!rnode){
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// check if device has been provisioned
|
||||||
|
const rom = await rnode.getRomAsObject();
|
||||||
|
const details = rom.parse();
|
||||||
|
if(!details || !details.is_provisioned){
|
||||||
|
alert("Eeprom is not provisioned. You must do this first!");
|
||||||
|
await rnode.close();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// configure
|
||||||
|
console.log("starting display reconditioning");
|
||||||
|
await rnode.startDisplayReconditioning();
|
||||||
|
console.log("starting display reconditioning: done");
|
||||||
|
|
||||||
// done
|
// done
|
||||||
await rnode.close();
|
await rnode.close();
|
||||||
|
|||||||
@@ -79,6 +79,8 @@ class RNode {
|
|||||||
ROM_UNLOCK_BYTE = 0xF8;
|
ROM_UNLOCK_BYTE = 0xF8;
|
||||||
CMD_HASHES = 0x60;
|
CMD_HASHES = 0x60;
|
||||||
CMD_FW_UPD = 0x61;
|
CMD_FW_UPD = 0x61;
|
||||||
|
CMD_DISP_ROT = 0x67;
|
||||||
|
CMD_DISP_RCND = 0x68;
|
||||||
|
|
||||||
CMD_BT_CTRL = 0x46;
|
CMD_BT_CTRL = 0x46;
|
||||||
CMD_BT_PIN = 0x62;
|
CMD_BT_PIN = 0x62;
|
||||||
@@ -121,8 +123,10 @@ class RNode {
|
|||||||
|
|
||||||
constructor(serialPort) {
|
constructor(serialPort) {
|
||||||
this.serialPort = serialPort;
|
this.serialPort = serialPort;
|
||||||
this.readable = serialPort.readable;
|
this.reader = serialPort.readable.getReader();
|
||||||
this.writable = serialPort.writable;
|
this.writable = serialPort.writable;
|
||||||
|
this.callbacks = {};
|
||||||
|
this.readLoop();
|
||||||
}
|
}
|
||||||
|
|
||||||
static async fromSerialPort(serialPort) {
|
static async fromSerialPort(serialPort) {
|
||||||
@@ -137,11 +141,21 @@ class RNode {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async close() {
|
async close() {
|
||||||
|
|
||||||
|
// release reader lock
|
||||||
|
try {
|
||||||
|
this.reader.releaseLock();
|
||||||
|
} catch(e) {
|
||||||
|
//console.log("failed to release lock on serial port readable, ignoring...", e);
|
||||||
|
}
|
||||||
|
|
||||||
|
// close serial port
|
||||||
try {
|
try {
|
||||||
await this.serialPort.close();
|
await this.serialPort.close();
|
||||||
} catch(e) {
|
} catch(e) {
|
||||||
console.log("failed to close serial port, ignoring...", e);
|
//console.log("failed to close serial port, ignoring...", e);
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async write(bytes) {
|
async write(bytes) {
|
||||||
@@ -153,79 +167,100 @@ class RNode {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async readFromSerialPort(timeoutMillis) {
|
async readLoop() {
|
||||||
return new Promise(async (resolve, reject) => {
|
try {
|
||||||
|
let buffer = [];
|
||||||
|
let inFrame = false;
|
||||||
|
while(true){
|
||||||
|
|
||||||
// create reader
|
// read kiss frames until reader indicates it's done
|
||||||
const reader = this.readable.getReader();
|
const { value, done } = await this.reader.read();
|
||||||
|
if(done){
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
// timeout after provided millis
|
// read kiss frames
|
||||||
if(timeoutMillis != null){
|
for(const byte of value){
|
||||||
setTimeout(() => {
|
if(byte === this.KISS_FEND){
|
||||||
reader.releaseLock();
|
if(inFrame){
|
||||||
reject("timeout");
|
// End of frame
|
||||||
}, timeoutMillis);
|
const decodedFrame = this.decodeKissFrame(buffer);
|
||||||
}
|
if(decodedFrame){
|
||||||
|
this.onCommandReceived(decodedFrame);
|
||||||
// attempt to read kiss frame
|
} else {
|
||||||
try {
|
console.warn("Invalid frame ignored.");
|
||||||
let buffer = [];
|
|
||||||
while(true){
|
|
||||||
const { value, done } = await reader.read();
|
|
||||||
if(done){
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
if(value){
|
|
||||||
for(let byte of value){
|
|
||||||
buffer.push(byte);
|
|
||||||
if(byte === this.KISS_FEND){
|
|
||||||
if(buffer.length > 1){
|
|
||||||
resolve(this.handleKISSFrame(buffer));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
buffer = [this.KISS_FEND]; // Start new frame
|
|
||||||
}
|
}
|
||||||
|
buffer = [];
|
||||||
}
|
}
|
||||||
|
inFrame = !inFrame;
|
||||||
|
} else if(inFrame) {
|
||||||
|
buffer.push(byte);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (error) {
|
|
||||||
console.error('Error reading from serial port: ', error);
|
}
|
||||||
} finally {
|
} catch(error) {
|
||||||
reader.releaseLock();
|
|
||||||
|
// ignore error if reader was released
|
||||||
|
if(error instanceof TypeError){
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
});
|
console.error('Error reading from serial port: ', error);
|
||||||
|
|
||||||
|
} finally {
|
||||||
|
this.reader.releaseLock();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
handleKISSFrame(frame) {
|
onCommandReceived(data) {
|
||||||
|
try {
|
||||||
|
|
||||||
let data = [];
|
// get received command and bytes from data
|
||||||
|
const [ command, ...bytes ] = data;
|
||||||
|
console.log("onCommandReceived", "0x" + command.toString(16), bytes);
|
||||||
|
|
||||||
|
// find callback for received command
|
||||||
|
const callback = this.callbacks[command];
|
||||||
|
if(!callback){
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// fire callback
|
||||||
|
callback(bytes);
|
||||||
|
|
||||||
|
// forget callback
|
||||||
|
delete this.callbacks[command];
|
||||||
|
|
||||||
|
} catch(e) {
|
||||||
|
console.log("failed to handle received command", data, e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
decodeKissFrame(frame) {
|
||||||
|
|
||||||
|
const data = [];
|
||||||
let escaping = false;
|
let escaping = false;
|
||||||
|
|
||||||
// Skip the initial 0xC0 and process the rest
|
for(const byte of frame){
|
||||||
for(let i = 1; i < frame.length; i++){
|
if(escaping){
|
||||||
let byte = frame[i];
|
if(byte === this.KISS_TFEND){
|
||||||
if (escaping) {
|
|
||||||
if (byte === this.KISS_TFEND) {
|
|
||||||
data.push(this.KISS_FEND);
|
data.push(this.KISS_FEND);
|
||||||
} else if (byte === this.KISS_TFESC) {
|
} else if(byte === this.KISS_TFESC) {
|
||||||
data.push(this.KISS_FESC);
|
data.push(this.KISS_FESC);
|
||||||
|
} else {
|
||||||
|
return null; // Invalid escape sequence
|
||||||
}
|
}
|
||||||
escaping = false;
|
escaping = false;
|
||||||
|
} else if(byte === this.KISS_FESC) {
|
||||||
|
escaping = true;
|
||||||
} else {
|
} else {
|
||||||
if (byte === this.KISS_FESC) {
|
data.push(byte);
|
||||||
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 null if incomplete escape at end
|
||||||
return data;
|
return escaping ? null : data;
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -248,6 +283,28 @@ class RNode {
|
|||||||
await this.write(this.createKissFrame(data));
|
await this.write(this.createKissFrame(data));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// sends a command to the rnode, and resolves the promise with the result
|
||||||
|
async sendCommand(command, data) {
|
||||||
|
return new Promise(async (resolve, reject) => {
|
||||||
|
try {
|
||||||
|
|
||||||
|
// listen for response
|
||||||
|
this.callbacks[command] = (response) => {
|
||||||
|
resolve(response);
|
||||||
|
};
|
||||||
|
|
||||||
|
// send command
|
||||||
|
await this.sendKissCommand([
|
||||||
|
command,
|
||||||
|
...data,
|
||||||
|
]);
|
||||||
|
|
||||||
|
} catch(e) {
|
||||||
|
reject(e);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
async reset() {
|
async reset() {
|
||||||
await this.sendKissCommand([
|
await this.sendKissCommand([
|
||||||
this.CMD_RESET,
|
this.CMD_RESET,
|
||||||
@@ -256,30 +313,42 @@ class RNode {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async detect() {
|
async detect() {
|
||||||
|
return new Promise(async (resolve) => {
|
||||||
|
try {
|
||||||
|
|
||||||
// ask if device is rnode
|
// timeout after provided millis
|
||||||
await this.sendKissCommand([
|
const timeout = setTimeout(() => {
|
||||||
this.CMD_DETECT,
|
resolve(false);
|
||||||
this.DETECT_REQ,
|
}, 2000);
|
||||||
]);
|
|
||||||
|
|
||||||
// read response from device
|
// detect rnode
|
||||||
const [ command, responseByte ] = await this.readFromSerialPort();
|
const response = await this.sendCommand(this.CMD_DETECT, [
|
||||||
|
this.DETECT_REQ,
|
||||||
|
]);
|
||||||
|
|
||||||
// device is an rnode if response is as expected
|
// we no longer want to timeout
|
||||||
return command === this.CMD_DETECT && responseByte === this.DETECT_RESP;
|
clearTimeout(timeout);
|
||||||
|
|
||||||
|
// device is an rnode if response is as expected
|
||||||
|
const [ responseByte ] = response;
|
||||||
|
const isRnode = responseByte === this.DETECT_RESP;
|
||||||
|
resolve(isRnode);
|
||||||
|
|
||||||
|
} catch(e) {
|
||||||
|
resolve(false);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async getFirmwareVersion() {
|
async getFirmwareVersion() {
|
||||||
|
|
||||||
await this.sendKissCommand([
|
const response = await this.sendCommand(this.CMD_FW_VERSION, [
|
||||||
this.CMD_FW_VERSION,
|
|
||||||
0x00,
|
0x00,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// read response from device
|
// read response from device
|
||||||
var [ command, majorVersion, minorVersion ] = await this.readFromSerialPort();
|
var [ majorVersion, minorVersion ] = response;
|
||||||
if(minorVersion.length === 1){
|
if(minorVersion.length === 1){
|
||||||
minorVersion = "0" + minorVersion;
|
minorVersion = "0" + minorVersion;
|
||||||
}
|
}
|
||||||
@@ -291,99 +360,91 @@ class RNode {
|
|||||||
|
|
||||||
async getPlatform() {
|
async getPlatform() {
|
||||||
|
|
||||||
await this.sendKissCommand([
|
const response = await this.sendCommand(this.CMD_PLATFORM, [
|
||||||
this.CMD_PLATFORM,
|
|
||||||
0x00,
|
0x00,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// read response from device
|
// read response from device
|
||||||
const [ command, platformByte ] = await this.readFromSerialPort();
|
const [ platformByte ] = response;
|
||||||
return platformByte;
|
return platformByte;
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async getMcu() {
|
async getMcu() {
|
||||||
|
|
||||||
await this.sendKissCommand([
|
const response = await this.sendCommand(this.CMD_MCU, [
|
||||||
this.CMD_MCU,
|
|
||||||
0x00,
|
0x00,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// read response from device
|
// read response from device
|
||||||
const [ command, mcuByte ] = await this.readFromSerialPort();
|
const [ mcuByte ] = response;
|
||||||
return mcuByte;
|
return mcuByte;
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async getBoard() {
|
async getBoard() {
|
||||||
|
|
||||||
await this.sendKissCommand([
|
const response = await this.sendCommand(this.CMD_BOARD, [
|
||||||
this.CMD_BOARD,
|
|
||||||
0x00,
|
0x00,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// read response from device
|
// read response from device
|
||||||
const [ command, boardByte ] = await this.readFromSerialPort();
|
const [ boardByte ] = response;
|
||||||
return boardByte;
|
return boardByte;
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async getDeviceHash() {
|
async getDeviceHash() {
|
||||||
|
|
||||||
await this.sendKissCommand([
|
const response = await this.sendCommand(this.CMD_DEV_HASH, [
|
||||||
this.CMD_DEV_HASH,
|
|
||||||
0x01, // anything != 0x00
|
0x01, // anything != 0x00
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// read response from device
|
// read response from device
|
||||||
const [ command, ...deviceHash ] = await this.readFromSerialPort();
|
const [ ...deviceHash ] = response;
|
||||||
return deviceHash;
|
return deviceHash;
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async getTargetFirmwareHash() {
|
async getTargetFirmwareHash() {
|
||||||
|
|
||||||
await this.sendKissCommand([
|
const response = await this.sendCommand(this.CMD_HASHES, [
|
||||||
this.CMD_HASHES,
|
|
||||||
this.HASH_TYPE_TARGET_FIRMWARE,
|
this.HASH_TYPE_TARGET_FIRMWARE,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// read response from device
|
// read response from device
|
||||||
const [ command, hashType, ...targetFirmwareHash ] = await this.readFromSerialPort();
|
const [ hashType, ...targetFirmwareHash ] = response;
|
||||||
return targetFirmwareHash;
|
return targetFirmwareHash;
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async getFirmwareHash() {
|
async getFirmwareHash() {
|
||||||
|
|
||||||
await this.sendKissCommand([
|
const response = await this.sendCommand(this.CMD_HASHES, [
|
||||||
this.CMD_HASHES,
|
|
||||||
this.HASH_TYPE_FIRMWARE,
|
this.HASH_TYPE_FIRMWARE,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// read response from device
|
// read response from device
|
||||||
const [ command, hashType, ...firmwareHash ] = await this.readFromSerialPort();
|
const [ hashType, ...firmwareHash ] = response;
|
||||||
return firmwareHash;
|
return firmwareHash;
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async getRom() {
|
async getRom() {
|
||||||
|
|
||||||
await this.sendKissCommand([
|
const response = await this.sendCommand(this.CMD_ROM_READ, [
|
||||||
this.CMD_ROM_READ,
|
|
||||||
0x00,
|
0x00,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// read response from device
|
// read response from device
|
||||||
const [ command, ...eepromBytes ] = await this.readFromSerialPort();
|
const [ ...eepromBytes ] = response;
|
||||||
return eepromBytes;
|
return eepromBytes;
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async getFrequency() {
|
async getFrequency() {
|
||||||
|
|
||||||
await this.sendKissCommand([
|
const response = await this.sendCommand(this.CMD_FREQUENCY, [
|
||||||
this.CMD_FREQUENCY,
|
|
||||||
// request frequency by sending zero as 4 bytes
|
// request frequency by sending zero as 4 bytes
|
||||||
0x00,
|
0x00,
|
||||||
0x00,
|
0x00,
|
||||||
@@ -392,7 +453,7 @@ class RNode {
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
// read response from device
|
// read response from device
|
||||||
const [ command, ...frequencyBytes ] = await this.readFromSerialPort();
|
const [ ...frequencyBytes ] = response;
|
||||||
|
|
||||||
// convert 4 bytes to 32bit integer representing frequency in hertz
|
// convert 4 bytes to 32bit integer representing frequency in hertz
|
||||||
const frequencyInHz = frequencyBytes[0] << 24 | frequencyBytes[1] << 16 | frequencyBytes[2] << 8 | frequencyBytes[3];
|
const frequencyInHz = frequencyBytes[0] << 24 | frequencyBytes[1] << 16 | frequencyBytes[2] << 8 | frequencyBytes[3];
|
||||||
@@ -402,8 +463,7 @@ class RNode {
|
|||||||
|
|
||||||
async getBandwidth() {
|
async getBandwidth() {
|
||||||
|
|
||||||
await this.sendKissCommand([
|
const response = await this.sendCommand(this.CMD_BANDWIDTH, [
|
||||||
this.CMD_BANDWIDTH,
|
|
||||||
// request bandwidth by sending zero as 4 bytes
|
// request bandwidth by sending zero as 4 bytes
|
||||||
0x00,
|
0x00,
|
||||||
0x00,
|
0x00,
|
||||||
@@ -412,7 +472,7 @@ class RNode {
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
// read response from device
|
// read response from device
|
||||||
const [ command, ...bandwidthBytes ] = await this.readFromSerialPort();
|
const [ ...bandwidthBytes ] = response;
|
||||||
|
|
||||||
// convert 4 bytes to 32bit integer representing bandwidth in hertz
|
// convert 4 bytes to 32bit integer representing bandwidth in hertz
|
||||||
const bandwidthInHz = bandwidthBytes[0] << 24 | bandwidthBytes[1] << 16 | bandwidthBytes[2] << 8 | bandwidthBytes[3];
|
const bandwidthInHz = bandwidthBytes[0] << 24 | bandwidthBytes[1] << 16 | bandwidthBytes[2] << 8 | bandwidthBytes[3];
|
||||||
@@ -422,69 +482,60 @@ class RNode {
|
|||||||
|
|
||||||
async getTxPower() {
|
async getTxPower() {
|
||||||
|
|
||||||
await this.sendKissCommand([
|
const response = await this.sendCommand(this.CMD_TXPOWER, [
|
||||||
this.CMD_TXPOWER,
|
|
||||||
0xFF, // request tx power
|
0xFF, // request tx power
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// read response from device
|
// read response from device
|
||||||
const [ command, txPower ] = await this.readFromSerialPort();
|
const [ txPower ] = response;
|
||||||
|
|
||||||
return txPower;
|
return txPower;
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async getSpreadingFactor() {
|
async getSpreadingFactor() {
|
||||||
|
|
||||||
await this.sendKissCommand([
|
const response = await this.sendCommand(this.CMD_SF, [
|
||||||
this.CMD_SF,
|
|
||||||
0xFF, // request spreading factor
|
0xFF, // request spreading factor
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// read response from device
|
// read response from device
|
||||||
const [ command, spreadingFactor ] = await this.readFromSerialPort();
|
const [ spreadingFactor ] = response;
|
||||||
|
|
||||||
return spreadingFactor;
|
return spreadingFactor;
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async getCodingRate() {
|
async getCodingRate() {
|
||||||
|
|
||||||
await this.sendKissCommand([
|
const response = await this.sendCommand(this.CMD_CR, [
|
||||||
this.CMD_CR,
|
|
||||||
0xFF, // request coding rate
|
0xFF, // request coding rate
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// read response from device
|
// read response from device
|
||||||
const [ command, codingRate ] = await this.readFromSerialPort();
|
const [ codingRate ] = response;
|
||||||
|
|
||||||
return codingRate;
|
return codingRate;
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async getRadioState() {
|
async getRadioState() {
|
||||||
|
|
||||||
await this.sendKissCommand([
|
const response = await this.sendCommand(this.CMD_RADIO_STATE, [
|
||||||
this.CMD_RADIO_STATE,
|
|
||||||
0xFF, // request radio state
|
0xFF, // request radio state
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// read response from device
|
// read response from device
|
||||||
const [ command, radioState ] = await this.readFromSerialPort();
|
const [ radioState ] = response;
|
||||||
|
|
||||||
return radioState;
|
return radioState;
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async getRxStat() {
|
async getRxStat() {
|
||||||
|
|
||||||
await this.sendKissCommand([
|
const response = await this.sendCommand(this.CMD_STAT_RX, [
|
||||||
this.CMD_STAT_RX,
|
|
||||||
0x00,
|
0x00,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// read response from device
|
// read response from device
|
||||||
const [ command, ...statBytes ] = await this.readFromSerialPort();
|
const [ ...statBytes ] = response;
|
||||||
|
|
||||||
// convert 4 bytes to 32bit integer
|
// convert 4 bytes to 32bit integer
|
||||||
const stat = statBytes[0] << 24 | statBytes[1] << 16 | statBytes[2] << 8 | statBytes[3];
|
const stat = statBytes[0] << 24 | statBytes[1] << 16 | statBytes[2] << 8 | statBytes[3];
|
||||||
@@ -494,13 +545,12 @@ class RNode {
|
|||||||
|
|
||||||
async getTxStat() {
|
async getTxStat() {
|
||||||
|
|
||||||
await this.sendKissCommand([
|
const response = await this.sendCommand(this.CMD_STAT_TX, [
|
||||||
this.CMD_STAT_TX,
|
|
||||||
0x00,
|
0x00,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// read response from device
|
// read response from device
|
||||||
const [ command, ...statBytes ] = await this.readFromSerialPort();
|
const [ ...statBytes ] = response;
|
||||||
|
|
||||||
// convert 4 bytes to 32bit integer
|
// convert 4 bytes to 32bit integer
|
||||||
const stat = statBytes[0] << 24 | statBytes[1] << 16 | statBytes[2] << 8 | statBytes[3];
|
const stat = statBytes[0] << 24 | statBytes[1] << 16 | statBytes[2] << 8 | statBytes[3];
|
||||||
@@ -510,14 +560,12 @@ class RNode {
|
|||||||
|
|
||||||
async getRssiStat() {
|
async getRssiStat() {
|
||||||
|
|
||||||
await this.sendKissCommand([
|
const response = await this.sendCommand(this.CMD_STAT_RSSI, [
|
||||||
this.CMD_STAT_RSSI,
|
|
||||||
0x00,
|
0x00,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// read response from device
|
// read response from device
|
||||||
const [ command, rssi ] = await this.readFromSerialPort();
|
const [ rssi ] = response;
|
||||||
|
|
||||||
return rssi;
|
return rssi;
|
||||||
|
|
||||||
}
|
}
|
||||||
@@ -536,7 +584,23 @@ class RNode {
|
|||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
async startBluetoothPairing() {
|
async startBluetoothPairing(pinCallback) {
|
||||||
|
|
||||||
|
// listen for bluetooth pin
|
||||||
|
// pin will be available once the user has initiated pairing from an Android device
|
||||||
|
this.callbacks[this.CMD_BT_PIN] = (response) => {
|
||||||
|
|
||||||
|
// read response from device
|
||||||
|
const [ ...pinBytes ] = response;
|
||||||
|
|
||||||
|
// convert 4 bytes to 32bit integer
|
||||||
|
const pin = pinBytes[0] << 24 | pinBytes[1] << 16 | pinBytes[2] << 8 | pinBytes[3];
|
||||||
|
|
||||||
|
// tell user what the bluetooth pin is
|
||||||
|
console.log("Bluetooth Pairing Pin: " + pin);
|
||||||
|
pinCallback(pin);
|
||||||
|
|
||||||
|
};
|
||||||
|
|
||||||
// enable pairing
|
// enable pairing
|
||||||
await this.sendKissCommand([
|
await this.sendKissCommand([
|
||||||
@@ -544,43 +608,16 @@ class RNode {
|
|||||||
0x02, // enable pairing
|
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() {
|
async readDisplay() {
|
||||||
|
|
||||||
await this.sendKissCommand([
|
const response = await this.sendCommand(this.CMD_DISP_READ, [
|
||||||
this.CMD_DISP_READ,
|
|
||||||
0x01,
|
0x01,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// read response from device
|
// read response from device
|
||||||
const [ command, ...displayBuffer ] = await this.readFromSerialPort();
|
const [ ...displayBuffer ] = response;
|
||||||
|
|
||||||
return displayBuffer;
|
return displayBuffer;
|
||||||
|
|
||||||
}
|
}
|
||||||
@@ -715,6 +752,20 @@ class RNode {
|
|||||||
return new ROM(rom);
|
return new ROM(rom);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async setDisplayRotation(rotation) {
|
||||||
|
await this.sendKissCommand([
|
||||||
|
this.CMD_DISP_ROT,
|
||||||
|
rotation & 0xFF,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
async startDisplayReconditioning() {
|
||||||
|
await this.sendKissCommand([
|
||||||
|
this.CMD_DISP_RCND,
|
||||||
|
0x01,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
class ROM {
|
class ROM {
|
||||||
@@ -743,6 +794,7 @@ class ROM {
|
|||||||
static MODEL_A7 = 0xA7
|
static MODEL_A7 = 0xA7
|
||||||
static MODEL_A5 = 0xA5;
|
static MODEL_A5 = 0xA5;
|
||||||
static MODEL_AA = 0xAA;
|
static MODEL_AA = 0xAA;
|
||||||
|
static MODEL_AC = 0xAC;
|
||||||
|
|
||||||
static PRODUCT_T32_10 = 0xB2
|
static PRODUCT_T32_10 = 0xB2
|
||||||
static MODEL_BA = 0xBA
|
static MODEL_BA = 0xBA
|
||||||
@@ -766,6 +818,10 @@ class ROM {
|
|||||||
static MODEL_C5 = 0xC5
|
static MODEL_C5 = 0xC5
|
||||||
static MODEL_CA = 0xCA
|
static MODEL_CA = 0xCA
|
||||||
|
|
||||||
|
static PRODUCT_HELTEC_T114 = 0xC2
|
||||||
|
static MODEL_C6 = 0xC6
|
||||||
|
static MODEL_C7 = 0xC7
|
||||||
|
|
||||||
static PRODUCT_TBEAM = 0xE0
|
static PRODUCT_TBEAM = 0xE0
|
||||||
static MODEL_E4 = 0xE4
|
static MODEL_E4 = 0xE4
|
||||||
static MODEL_E9 = 0xE9
|
static MODEL_E9 = 0xE9
|
||||||
@@ -781,8 +837,8 @@ class ROM {
|
|||||||
static MODEL_D9 = 0xD9;
|
static MODEL_D9 = 0xD9;
|
||||||
|
|
||||||
static PRODUCT_TECHO = 0x15;
|
static PRODUCT_TECHO = 0x15;
|
||||||
static MODEL_T4 = 0x16;
|
static MODEL_16 = 0x16;
|
||||||
static MODEL_T9 = 0x17;
|
static MODEL_17 = 0x17;
|
||||||
|
|
||||||
static PRODUCT_HMBRW = 0xF0
|
static PRODUCT_HMBRW = 0xF0
|
||||||
static MODEL_FF = 0xFF
|
static MODEL_FF = 0xFF
|
||||||
|
|||||||
Reference in New Issue
Block a user