lots of fixes, changes, styling, fixing outbound calls, rnode-flasher.
Some checks failed
CI / test-backend (push) Successful in 4s
CI / build-frontend (push) Successful in 1m49s
CI / test-lang (push) Successful in 1m47s
CI / test-backend (pull_request) Successful in 24s
Build and Publish Docker Image / build (pull_request) Has been skipped
CI / test-lang (pull_request) Successful in 52s
OSV-Scanner PR Scan / scan-pr (pull_request) Successful in 24s
CI / lint (push) Failing after 5m14s
CI / lint (pull_request) Failing after 5m8s
Tests / test (push) Failing after 9m17s
CI / build-frontend (pull_request) Successful in 9m48s
Benchmarks / benchmark (push) Successful in 14m52s
Benchmarks / benchmark (pull_request) Successful in 15m9s
Build and Publish Docker Image / build-dev (pull_request) Successful in 13m47s
Tests / test (pull_request) Failing after 25m50s
Build Test / Build and Test (pull_request) Successful in 53m37s
Build Test / Build and Test (push) Successful in 56m30s

This commit is contained in:
2026-01-04 15:57:49 -06:00
parent f3ec20b14e
commit c4674992e0
34 changed files with 6540 additions and 286 deletions

View File

@@ -8,6 +8,11 @@ Season 1 Episode 1 - A MASSIVE REFACTOR
### New Features
- **Banishment System (formerly Blocked):**
- Renamed all instances of "Blocked" to **"Banished"**, you can now banish people to the shadow realm.
- **Blackhole Integration:** Automatically blackholes identities at the RNS transport layer when they are banished in MeshChatX. This prevents their traffic from being relayed through your node and publishes the update to your interfaces (trusted interfaces will pull and enforce the banishment).
- Integrated RNS 1.1.0 Blackhole to display publishing status, sources, and current blackhole counts in the RNStatus page.
- **RNPath Management Tool:** New UI tool to manage the Reticulum path table, monitor announce rates (with rate-limit detection), and perform manual path requests or purges directly from the app.
- **Maps:** You can now draw and doodle directly on the map to mark locations or plan routes.
- **Calls & Audio:**
- Added support for custom ringtones and a brand-new ringtone editor.

View File

@@ -9,7 +9,7 @@ For issues contact me over LXMF: `73
[![Build](https://git.quad4.io/RNS-Things/MeshChatX/actions/workflows/build.yml/badge.svg?branch=master)](https://git.quad4.io/RNS-Things/MeshChatX/actions/workflows/build.yml)
[![Docker](https://git.quad4.io/RNS-Things/MeshChatX/actions/workflows/docker.yml/badge.svg?branch=master)](https://git.quad4.io/RNS-Things/MeshChatX/actions/workflows/docker.yml)
A [Reticulum MeshChat](https://github.com/liamcottle/reticulum-meshchat) fork from the future.
A [Reticulum MeshChat](https://git.quad4.io/Reticulum/Reticulum) fork from the future.
<video src="https://strg.0rbitzer0.net/raw/62926a2a-0a9a-4f44-a5f6-000dd60deac1.mp4" controls="controls" style="max-width: 100%;"></video>

View File

File diff suppressed because it is too large Load Diff

View File

@@ -350,7 +350,7 @@ app.whenReady().then(async () => {
"style-src 'self' 'unsafe-inline'",
"img-src 'self' data: blob: https://*.tile.openstreetmap.org https://tile.openstreetmap.org",
"font-src 'self' data:",
"connect-src 'self' http://localhost:9337 https://localhost:9337 ws://localhost:* wss://localhost:* blob: https://*.tile.openstreetmap.org https://tile.openstreetmap.org https://nominatim.openstreetmap.org https://api.github.com https://objects.githubusercontent.com https://github.com",
"connect-src 'self' http://localhost:9337 https://localhost:9337 ws://localhost:* wss://localhost:* blob: https://*.tile.openstreetmap.org https://tile.openstreetmap.org https://nominatim.openstreetmap.org https://git.quad4.io",
"media-src 'self' blob:",
"worker-src 'self' blob:",
"frame-src 'self'",

View File

@@ -1756,8 +1756,8 @@ class ReticulumMeshChat:
timestamp=time.time(),
)
# Trigger missed call notification if it was an incoming call that ended while ringing
if is_incoming and status_code == 4:
# Trigger missed call notification if it was an incoming call that ended without being established
if is_incoming and not self.telephone_manager.call_was_established:
# Check if we should suppress the notification/websocket message
# If DND was on, we still record it but maybe skip the noisy websocket?
# Actually, persistent notification is good.
@@ -1812,7 +1812,9 @@ class ReticulumMeshChat:
target_name = None
if target_hash:
try:
contact = ctx.database.contacts.get_contact_by_hash(target_hash)
contact = ctx.database.contacts.get_contact_by_identity_hash(
target_hash
)
if contact:
target_name = contact.name
except Exception: # noqa: S110
@@ -2264,7 +2266,7 @@ class ReticulumMeshChat:
return web.json_response({"error": "URL is required"}, status=400)
# Restrict to GitHub for safety
if not url.startswith("https://github.com/") and not url.startswith(
if not url.startswith("https://git.quad4.io/") and not url.startswith(
"https://objects.githubusercontent.com/"
):
return web.json_response({"error": "Invalid download URL"}, status=403)
@@ -3330,9 +3332,48 @@ class ReticulumMeshChat:
# update docs
@routes.post("/api/v1/docs/update")
async def docs_update(request):
success = self.docs_manager.update_docs()
version = request.query.get("version", "latest")
success = self.docs_manager.update_docs(version=version)
return web.json_response({"success": success})
# upload docs zip
@routes.post("/api/v1/docs/upload")
async def docs_upload(request):
try:
reader = await request.multipart()
field = await reader.next()
if field.name != "file":
return web.json_response(
{"error": "No file field in multipart request"}, status=400
)
version = request.query.get("version")
if not version:
# use timestamp if no version provided
version = f"upload-{int(time.time())}"
zip_data = await field.read()
success = self.docs_manager.upload_zip(zip_data, version)
return web.json_response({"success": success, "version": version})
except Exception as e:
return web.json_response({"error": str(e)}, status=500)
# switch docs version
@routes.post("/api/v1/docs/switch")
async def docs_switch(request):
try:
data = await request.json()
version = data.get("version")
if not version:
return web.json_response(
{"error": "No version provided"}, status=400
)
success = self.docs_manager.switch_version(version)
return web.json_response({"success": success})
except Exception as e:
return web.json_response({"error": str(e)}, status=500)
# search docs
@routes.get("/api/v1/docs/search")
async def docs_search(request):
@@ -3875,7 +3916,7 @@ class ReticulumMeshChat:
initiation_target_name = None
if initiation_target_hash:
try:
contact = self.database.contacts.get_contact_by_hash(
contact = self.database.contacts.get_contact_by_identity_hash(
initiation_target_hash
)
if contact:
@@ -4230,7 +4271,9 @@ class ReticulumMeshChat:
"greeting.opus",
)
if os.path.exists(filepath):
return web.FileResponse(filepath)
return web.FileResponse(
filepath, headers={"Content-Type": "audio/opus"}
)
return web.json_response(
{"message": "Greeting audio not found"},
status=404,
@@ -4240,6 +4283,16 @@ class ReticulumMeshChat:
@routes.get("/api/v1/telephone/voicemails/{id}/audio")
async def telephone_voicemail_audio(request):
voicemail_id = request.match_info.get("id")
try:
voicemail_id = int(voicemail_id)
except (ValueError, TypeError):
return web.json_response({"message": "Invalid voicemail ID"}, status=400)
if not self.voicemail_manager:
return web.json_response(
{"message": "Voicemail manager not available"}, status=503
)
voicemail = self.database.voicemails.get_voicemail(voicemail_id)
if voicemail:
filepath = os.path.join(
@@ -4247,7 +4300,10 @@ class ReticulumMeshChat:
voicemail["filename"],
)
if os.path.exists(filepath):
return web.FileResponse(filepath)
# Browsers might need a proper content type for .opus files
return web.FileResponse(
filepath, headers={"Content-Type": "audio/opus"}
)
RNS.log(
f"Voicemail: Recording file missing for ID {voicemail_id}: {filepath}",
RNS.LOG_ERROR,
@@ -4288,6 +4344,13 @@ class ReticulumMeshChat:
@routes.get("/api/v1/telephone/recordings/{id}/audio/{side}")
async def telephone_recording_audio(request):
recording_id = request.match_info.get("id")
try:
recording_id = int(recording_id)
except (ValueError, TypeError):
return web.json_response(
{"message": "Invalid recording ID"}, status=400
)
side = request.match_info.get("side") # rx or tx
recording = self.database.telephone.get_call_recording(recording_id)
if recording:
@@ -4302,7 +4365,9 @@ class ReticulumMeshChat:
filename,
)
if os.path.exists(filepath):
return web.FileResponse(filepath)
return web.FileResponse(
filepath, headers={"Content-Type": "audio/opus"}
)
return web.json_response({"message": "Recording not found"}, status=404)
@@ -7105,6 +7170,14 @@ class ReticulumMeshChat:
response.headers["Content-Type"] = "application/wasm"
elif path.endswith(".html"):
response.headers["Content-Type"] = "text/html; charset=utf-8"
elif path.endswith(".opus"):
response.headers["Content-Type"] = "audio/opus"
elif path.endswith(".ogg"):
response.headers["Content-Type"] = "audio/ogg"
elif path.endswith(".wav"):
response.headers["Content-Type"] = "audio/wav"
elif path.endswith(".mp3"):
response.headers["Content-Type"] = "audio/mpeg"
return response
# security headers middleware
@@ -7135,7 +7208,7 @@ class ReticulumMeshChat:
"style-src 'self' 'unsafe-inline'; "
"img-src 'self' data: blob: https://*.tile.openstreetmap.org https://tile.openstreetmap.org; "
"font-src 'self' data:; "
"connect-src 'self' ws://localhost:* wss://localhost:* blob: https://*.tile.openstreetmap.org https://tile.openstreetmap.org https://nominatim.openstreetmap.org https://api.github.com https://objects.githubusercontent.com https://github.com; "
"connect-src 'self' ws://localhost:* wss://localhost:* blob: https://*.tile.openstreetmap.org https://tile.openstreetmap.org https://nominatim.openstreetmap.org https://git.quad4.io; "
"media-src 'self' blob:; "
"worker-src 'self' blob:; "
"frame-src 'self'; "
@@ -7335,7 +7408,7 @@ class ReticulumMeshChat:
ctx.message_router.announce_propagation_node()
# send announce for telephone
ctx.telephone_manager.announce()
ctx.telephone_manager.announce(display_name=ctx.config.display_name.get())
# tell websocket clients we just announced
await self.send_announced_to_websocket_clients(context=ctx)
@@ -8629,7 +8702,7 @@ class ReticulumMeshChat:
announce["app_data"],
)
elif announce["aspect"] == "lxst.telephony":
display_name = announce.get("display_name") or "Anonymous Peer"
display_name = parse_lxmf_display_name(announce["app_data"])
# Try to find associated LXMF destination hash if this is a telephony announce
lxmf_destination_hash = None
@@ -9452,7 +9525,8 @@ class ReticulumMeshChat:
print(
"Received an announce from "
+ RNS.prettyhexrep(destination_hash)
+ " for [lxst.telephony]",
+ " for [lxst.telephony]"
+ (f" ({display_name})" if (display_name := parse_lxmf_display_name(base64.b64encode(app_data).decode() if app_data else None, None)) else "")
)
# track announce timestamp

View File

@@ -19,29 +19,33 @@ class DocsManager:
self.storage_dir = storage_dir
# Determine docs directories
# If storage_dir is provided, we prefer using it for documentation storage
# to avoid Read-only file system errors in environments like AppImages.
if self.storage_dir:
self.docs_dir = os.path.join(self.storage_dir, "reticulum-docs")
self.docs_base_dir = os.path.join(self.storage_dir, "reticulum-docs")
self.meshchatx_docs_dir = os.path.join(self.storage_dir, "meshchatx-docs")
else:
self.docs_dir = os.path.join(self.public_dir, "reticulum-docs")
self.docs_base_dir = os.path.join(self.public_dir, "reticulum-docs")
self.meshchatx_docs_dir = os.path.join(self.public_dir, "meshchatx-docs")
# The actual docs are served from this directory
# We will use a 'current' subdirectory for the active version
self.docs_dir = os.path.join(self.docs_base_dir, "current")
self.versions_dir = os.path.join(self.docs_base_dir, "versions")
self.download_status = "idle"
self.download_progress = 0
self.last_error = None
# Ensure docs directories exist
try:
if not os.path.exists(self.docs_dir):
os.makedirs(self.docs_dir)
for d in [self.docs_base_dir, self.versions_dir, self.meshchatx_docs_dir]:
if not os.path.exists(d):
os.makedirs(d)
# If 'current' doesn't exist but we have versions, pick the latest one
if not os.path.exists(self.docs_dir) or not os.listdir(self.docs_dir):
self._update_current_link()
if not os.path.exists(self.meshchatx_docs_dir):
os.makedirs(self.meshchatx_docs_dir)
except OSError as e:
# If we still fail (e.g. storage_dir was not provided and public_dir is read-only)
# we log it but don't crash the whole app. Emergency mode can still run.
logging.error(f"Failed to create documentation directories: {e}")
self.last_error = str(e)
@@ -51,6 +55,75 @@ class DocsManager:
):
self.populate_meshchatx_docs()
def _update_current_link(self, version=None):
"""Updates the 'current' directory to point to the specified version or the latest one."""
if not os.path.exists(self.versions_dir):
return
versions = self.get_available_versions()
if not versions:
return
target_version = version
if not target_version:
# Pick latest version (alphabetically)
target_version = versions[-1]
version_path = os.path.join(self.versions_dir, target_version)
if not os.path.exists(version_path):
return
# On some systems symlinks might fail or be restricted, so we use a directory copy or move
# but for now let's try to just use the path directly if possible.
# However, meshchat.py uses self.docs_dir for the static route.
# To make it simple and robust across platforms, we'll clear 'current' and copy the version
if os.path.exists(self.docs_dir):
if os.path.islink(self.docs_dir):
os.unlink(self.docs_dir)
else:
shutil.rmtree(self.docs_dir)
try:
# Try symlink first as it's efficient
os.symlink(version_path, self.docs_dir)
except (OSError, AttributeError):
# Fallback to copy
shutil.copytree(version_path, self.docs_dir)
def get_available_versions(self):
if not os.path.exists(self.versions_dir):
return []
versions = [
d
for d in os.listdir(self.versions_dir)
if os.path.isdir(os.path.join(self.versions_dir, d))
]
return sorted(versions)
def get_current_version(self):
if not os.path.exists(self.docs_dir):
return None
if os.path.islink(self.docs_dir):
return os.path.basename(os.readlink(self.docs_dir))
# If it's a copy, we might need a metadata file to know which version it is
version_file = os.path.join(self.docs_dir, ".version")
if os.path.exists(version_file):
try:
with open(version_file, "r") as f:
return f.read().strip()
except OSError:
pass
return "unknown"
def switch_version(self, version):
if version in self.get_available_versions():
self._update_current_link(version)
return True
return False
def populate_meshchatx_docs(self):
"""Populates meshchatx-docs from the project's docs folder."""
# Try to find docs folder in several places
@@ -134,6 +207,8 @@ class DocsManager:
"last_error": self.last_error,
"has_docs": self.has_docs(),
"has_meshchatx_docs": self.has_meshchatx_docs(),
"versions": self.get_available_versions(),
"current_version": self.get_current_version(),
}
def has_meshchatx_docs(self):
@@ -340,32 +415,36 @@ class DocsManager:
return results
def has_docs(self):
# Check if index.html exists in the docs folder or if config says so
# Check if index.html exists in the docs folder or if we have any versions
if self.config.docs_downloaded.get():
return True
return os.path.exists(os.path.join(self.docs_dir, "index.html"))
return (
os.path.exists(os.path.join(self.docs_dir, "index.html"))
or len(self.get_available_versions()) > 0
)
def update_docs(self):
def update_docs(self, version="latest"):
if (
self.download_status == "downloading"
or self.download_status == "extracting"
):
return False
thread = threading.Thread(target=self._download_task)
thread = threading.Thread(target=self._download_task, args=(version,))
thread.daemon = True
thread.start()
return True
def _download_task(self):
def _download_task(self, version="latest"):
self.download_status = "downloading"
self.download_progress = 0
self.last_error = None
try:
# We use the reticulum_website repository which contains the built HTML docs
url = "https://github.com/markqvist/reticulum_website/archive/refs/heads/main.zip"
zip_path = os.path.join(self.docs_dir, "website.zip")
# Default to git.quad4.io as requested
url = "https://git.quad4.io/Reticulum/reticulum_website/archive/main.zip"
zip_path = os.path.join(self.docs_base_dir, "website.zip")
# Download ZIP
response = requests.get(url, stream=True, timeout=60)
@@ -386,7 +465,13 @@ class DocsManager:
# Extract
self.download_status = "extracting"
self._extract_docs(zip_path)
# For automatic downloads from git, we'll use a timestamp as version if none provided
if version == "latest":
import time
version = f"git-{int(time.time())}"
self._extract_docs(zip_path, version)
# Cleanup
if os.path.exists(zip_path):
@@ -395,50 +480,104 @@ class DocsManager:
self.config.docs_downloaded.set(True)
self.download_progress = 100
self.download_status = "completed"
# Switch to the new version
self.switch_version(version)
except Exception as e:
self.last_error = str(e)
self.download_status = "error"
logging.exception(f"Failed to update docs: {e}")
def _extract_docs(self, zip_path):
def upload_zip(self, zip_bytes, version):
self.download_status = "extracting"
self.download_progress = 0
self.last_error = None
try:
zip_path = os.path.join(self.docs_base_dir, "uploaded.zip")
with open(zip_path, "wb") as f:
f.write(zip_bytes)
self._extract_docs(zip_path, version)
if os.path.exists(zip_path):
os.remove(zip_path)
self.download_status = "completed"
self.download_progress = 100
self.switch_version(version)
return True
except Exception as e:
self.last_error = str(e)
self.download_status = "error"
logging.exception(f"Failed to upload docs: {e}")
return False
def _extract_docs(self, zip_path, version):
# Target dir for this version
version_dir = os.path.join(self.versions_dir, version)
if os.path.exists(version_dir):
shutil.rmtree(version_dir)
os.makedirs(version_dir)
# Temp dir for extraction
temp_extract = os.path.join(self.docs_dir, "temp_extract")
temp_extract = os.path.join(self.docs_base_dir, "temp_extract")
if os.path.exists(temp_extract):
shutil.rmtree(temp_extract)
with zipfile.ZipFile(zip_path, "r") as zip_ref:
# GitHub zips have a root folder like reticulum_website-main/
# We want the contents of reticulum_website-main/docs/
root_folder = zip_ref.namelist()[0].split("/")[0]
# Gitea/GitHub zips have a root folder
namelist = zip_ref.namelist()
if not namelist:
raise Exception("Zip file is empty")
root_folder = namelist[0].split("/")[0]
# Check if it's the reticulum_website repo (has docs/ folder)
docs_prefix = f"{root_folder}/docs/"
has_docs_subfolder = any(m.startswith(docs_prefix) for m in namelist)
members_to_extract = [
m for m in zip_ref.namelist() if m.startswith(docs_prefix)
]
if has_docs_subfolder:
members_to_extract = [m for m in namelist if m.startswith(docs_prefix)]
for member in members_to_extract:
zip_ref.extract(member, temp_extract)
src_path = os.path.join(temp_extract, root_folder, "docs")
# Clear existing docs except for the temp folder
for item in os.listdir(self.docs_dir):
item_path = os.path.join(self.docs_dir, item)
if item != "temp_extract" and item != "website.zip":
if os.path.isdir(item_path):
shutil.rmtree(item_path)
else:
os.remove(item_path)
# Move files from extracted docs to docs_dir
if os.path.exists(src_path):
# Move files from extracted docs to version_dir
for item in os.listdir(src_path):
s = os.path.join(src_path, item)
d = os.path.join(self.docs_dir, item)
d = os.path.join(version_dir, item)
if os.path.isdir(s):
shutil.copytree(s, d)
else:
shutil.copy2(s, d)
else:
# Just extract everything directly to version_dir, but remove root folder if exists
zip_ref.extractall(temp_extract)
src_path = os.path.join(temp_extract, root_folder)
if os.path.exists(src_path) and os.path.isdir(src_path):
for item in os.listdir(src_path):
s = os.path.join(src_path, item)
d = os.path.join(version_dir, item)
if os.path.isdir(s):
shutil.copytree(s, d)
else:
shutil.copy2(s, d)
else:
# Fallback if no root folder
for item in os.listdir(temp_extract):
s = os.path.join(temp_extract, item)
d = os.path.join(version_dir, item)
if os.path.isdir(s):
shutil.copytree(s, d)
else:
shutil.copy2(s, d)
# Create a metadata file with the version name
with open(os.path.join(version_dir, ".version"), "w") as f:
f.write(version)
# Cleanup temp
if os.path.exists(temp_extract):
shutil.rmtree(temp_extract)

View File

@@ -1,4 +1,5 @@
import asyncio
import base64
import os
import time
@@ -61,6 +62,7 @@ class TelephoneManager:
self.call_start_time = None
self.call_status_at_end = None
self.call_is_incoming = False
self.call_was_established = False
# Manual mute overrides in case LXST internal muting is buggy
self.transmit_muted = False
@@ -82,6 +84,8 @@ class TelephoneManager:
self.telephone = Telephone(self.identity)
# Disable busy tone played on caller side when remote side rejects, or doesn't answer
self.telephone.set_busy_tone_time(0)
# Increase connection timeout for slower networks
self.telephone.set_connect_timeout(30)
# Set initial profile from config
if self.config_manager:
@@ -109,12 +113,14 @@ class TelephoneManager:
def on_telephone_ringing(self, caller_identity: RNS.Identity):
self.call_start_time = time.time()
self.call_is_incoming = True
self.call_was_established = False
if self.on_ringing_callback:
self.on_ringing_callback(caller_identity)
def on_telephone_call_established(self, caller_identity: RNS.Identity):
# Update start time to when it was actually established for duration calculation
self.call_start_time = time.time()
self.call_was_established = True
# Recording disabled for now due to stability issues with LXST
# if self.config_manager and self.config_manager.call_recording_enabled.get():
@@ -139,8 +145,18 @@ class TelephoneManager:
# Disabled for now
pass
def announce(self, attached_interface=None):
def announce(self, attached_interface=None, display_name=None):
if self.telephone:
if display_name:
import RNS.vendor.umsgpack as msgpack
# Pack display name in LXMF-compatible app data format
app_data = msgpack.packb([display_name, None, None])
self.telephone.destination.announce(
app_data=app_data, attached_interface=attached_interface
)
self.telephone.last_announce = time.time()
else:
self.telephone.announce(attached_interface=attached_interface)
def _update_initiation_status(self, status, target_hash=None):
@@ -171,15 +187,36 @@ class TelephoneManager:
self._update_initiation_status("Resolving identity...", destination_hash_hex)
try:
# Find destination identity
destination_identity = RNS.Identity.recall(destination_hash)
# If identity not found, check if it's a destination hash in our announces
if destination_identity is None and self.db:
announce = self.db.announces.get_announce_by_hash(destination_hash_hex)
def resolve_identity(target_hash_hex):
target_hash = bytes.fromhex(target_hash_hex)
# 1. Try RNS recall
ident = RNS.Identity.recall(target_hash)
if ident:
return ident
# 2. Check DB announces
if self.db:
announce = self.db.announces.get_announce_by_hash(target_hash_hex)
if announce:
# Try recalling identity hash from announce
identity_hash = bytes.fromhex(announce["identity_hash"])
destination_identity = RNS.Identity.recall(identity_hash)
ident = RNS.Identity.recall(identity_hash)
if ident:
return ident
# Try reconstructing from public key if recall failed
if announce.get("identity_public_key"):
try:
return RNS.Identity.from_bytes(
base64.b64decode(announce["identity_public_key"])
)
except Exception:
pass
return None
# Find destination identity
destination_identity = resolve_identity(destination_hash_hex)
if destination_identity is None:
self._update_initiation_status("Discovering path/identity...")
@@ -189,17 +226,7 @@ class TelephoneManager:
start_wait = time.time()
while time.time() - start_wait < timeout_seconds:
await asyncio.sleep(0.5)
destination_identity = RNS.Identity.recall(destination_hash)
if destination_identity:
break
if self.db:
announce = self.db.announces.get_announce_by_hash(
destination_hash_hex
)
if announce:
identity_hash = bytes.fromhex(announce["identity_hash"])
destination_identity = RNS.Identity.recall(identity_hash)
destination_identity = resolve_identity(destination_hash_hex)
if destination_identity:
break
@@ -212,6 +239,13 @@ class TelephoneManager:
self._update_initiation_status("Requesting path...")
RNS.Transport.request_path(destination_hash)
# Wait up to 10s for path discovery
path_wait_start = time.time()
while time.time() - path_wait_start < min(timeout_seconds, 10):
if RNS.Transport.has_path(destination_hash):
break
await asyncio.sleep(0.5)
self._update_initiation_status("Dialing...")
self.call_start_time = time.time()
self.call_is_incoming = False

View File

@@ -402,9 +402,6 @@ class VoicemailManager:
threading.Thread(target=session_job, daemon=True).start()
def start_recording(self, caller_identity):
# Disabled for now
return
telephone = self.telephone_manager.telephone
if not telephone or not telephone.active_call:
return
@@ -454,6 +451,9 @@ class VoicemailManager:
# Save to database if long enough
if duration >= 1:
filepath = os.path.join(self.recordings_dir, self.recording_filename)
self._fix_recording(filepath)
remote_name = self.get_name_for_identity_hash(
self.recording_remote_identity.hash.hex(),
)
@@ -491,10 +491,51 @@ class VoicemailManager:
RNS.log(f"Error stopping recording: {e}", RNS.LOG_ERROR)
self.is_recording = False
def start_greeting_recording(self):
# Disabled for now
def _fix_recording(self, filepath):
"""Ensures the recording is a valid OGG/Opus file using ffmpeg."""
if not self.has_ffmpeg or not os.path.exists(filepath):
return
temp_path = filepath + ".fix"
try:
# We assume it might be raw opus packets or a slightly broken ogg
# ffmpeg can often fix this by just re-wrapping it.
# We try to detect if it's already a valid format first.
cmd = [
self.ffmpeg_path,
"-y",
"-i",
filepath,
"-c:a",
"libopus",
"-b:a",
"16k",
"-ar",
"48000",
"-ac",
"1",
temp_path,
]
result = subprocess.run(
cmd, capture_output=True, text=True, check=False
) # noqa: S603
if result.returncode == 0 and os.path.exists(temp_path):
os.remove(filepath)
os.rename(temp_path, filepath)
RNS.log(f"Voicemail: Fixed recording format for {filepath}", RNS.LOG_DEBUG)
else:
RNS.log(
f"Voicemail: ffmpeg failed to fix {filepath}: {result.stderr}",
RNS.LOG_WARNING,
)
except Exception as e:
RNS.log(f"Voicemail: Error fixing recording {filepath}: {e}", RNS.LOG_ERROR)
finally:
if os.path.exists(temp_path):
os.remove(temp_path)
def start_greeting_recording(self):
telephone = self.telephone_manager.telephone
if not telephone:
return

View File

@@ -50,7 +50,7 @@
{{ $t("app.custom_fork_by") }}
<a
target="_blank"
href="https://github.com/Sudo-Ivan"
href="https://git.quad4.io/Sudo-Ivan"
class="text-blue-500 dark:text-blue-300 hover:underline"
>Sudo-Ivan</a
>

View File

@@ -112,7 +112,7 @@
? 'text-red-600 dark:text-red-400 animate-pulse'
: activeCall.is_voicemail
? 'text-red-600 dark:text-red-400 animate-pulse'
: activeCall.status === 6
: activeCall && activeCall.status === 6
? 'text-green-600 dark:text-green-400'
: 'text-gray-600 dark:text-zinc-400',
]"
@@ -123,17 +123,17 @@
<MaterialDesignIcon icon-name="record" class="size-4" />
{{ $t("call.recording_voicemail") }}
</span>
<span v-else-if="activeCall.is_incoming && activeCall.status === 4">{{
<span v-else-if="activeCall && activeCall.is_incoming && activeCall.status === 4">{{
$t("call.incoming_call")
}}</span>
<span v-else-if="activeCall.status === 0">{{ $t("call.busy") }}</span>
<span v-else-if="activeCall.status === 1">{{ $t("call.rejected") }}</span>
<span v-else-if="activeCall.status === 2">{{ $t("call.calling") }}</span>
<span v-else-if="activeCall.status === 3">{{ $t("call.available") }}</span>
<span v-else-if="activeCall.status === 4">{{ $t("call.ringing") }}</span>
<span v-else-if="activeCall.status === 5">{{ $t("call.connecting") }}</span>
<span v-else-if="activeCall.status === 6">{{ $t("call.connected") }}</span>
<span v-else>{{ $t("call.status") }}: {{ activeCall.status }}</span>
<span v-else-if="activeCall && activeCall.status === 0">{{ $t("call.busy") }}</span>
<span v-else-if="activeCall && activeCall.status === 1">{{ $t("call.rejected") }}</span>
<span v-else-if="activeCall && activeCall.status === 2">{{ $t("call.calling") }}</span>
<span v-else-if="activeCall && activeCall.status === 3">{{ $t("call.available") }}</span>
<span v-else-if="activeCall && activeCall.status === 4">{{ $t("call.ringing") }}</span>
<span v-else-if="activeCall && activeCall.status === 5">{{ $t("call.connecting") }}</span>
<span v-else-if="activeCall && activeCall.status === 6">{{ $t("call.connected") }}</span>
<span v-else-if="activeCall">{{ $t("call.status") }}: {{ activeCall.status }}</span>
</div>
<div
v-else-if="initiationStatus"
@@ -154,7 +154,7 @@
<!-- Stats (only when connected and not minimized) -->
<div
v-if="activeCall.status === 6 && !isEnded"
v-if="activeCall && activeCall.status === 6 && !isEnded"
class="mb-4 p-2 bg-gray-50 dark:bg-zinc-800/50 rounded-lg text-[10px] text-gray-500 dark:text-zinc-400 grid grid-cols-2 gap-1"
>
<div class="flex items-center space-x-1">
@@ -203,7 +203,7 @@
<button
type="button"
:title="
activeCall.is_incoming && activeCall.status === 4
activeCall && activeCall.is_incoming && activeCall.status === 4
? $t('call.decline_call')
: $t('call.hangup_call')
"
@@ -215,7 +215,7 @@
<!-- Send to Voicemail (if incoming) -->
<button
v-if="activeCall.is_incoming && activeCall.status === 4"
v-if="activeCall && activeCall.is_incoming && activeCall.status === 4"
type="button"
:title="$t('call.send_to_voicemail')"
class="p-2.5 rounded-full bg-blue-600 text-white hover:bg-blue-700 shadow-lg shadow-blue-600/30 transition-all duration-200"
@@ -226,7 +226,7 @@
<!-- Answer (if incoming) -->
<button
v-if="activeCall.is_incoming && activeCall.status === 4"
v-if="activeCall && activeCall.is_incoming && activeCall.status === 4"
type="button"
:title="$t('call.answer_call')"
class="p-2.5 rounded-full bg-green-600 text-white hover:bg-green-700 shadow-lg shadow-green-600/30 animate-bounce"
@@ -239,7 +239,7 @@
<!-- Ended State Voicemail Playback -->
<div
v-if="isEnded && activeCall.is_voicemail && voicemailStatus && voicemailStatus.latest_id"
v-if="isEnded && activeCall && activeCall.is_voicemail && voicemailStatus && voicemailStatus.latest_id"
class="px-4 pb-4"
>
<AudioWaveformPlayer :src="`/api/v1/telephone/voicemails/${voicemailStatus.latest_id}/audio`" />

View File

@@ -191,7 +191,7 @@
{{ $t("call.recording_voicemail") }}
</span>
<span
v-else-if="activeCall.is_incoming && activeCall.status === 4"
v-else-if="activeCall && activeCall.is_incoming && activeCall.status === 4"
class="text-blue-600 dark:text-blue-400 font-bold text-sm animate-bounce"
>{{ $t("call.incoming_call") }}</span
>
@@ -199,31 +199,31 @@
v-else
class="text-gray-700 dark:text-zinc-300 font-bold text-sm flex items-center gap-2"
>
<span v-if="activeCall.status === 0">Busy...</span>
<span v-else-if="activeCall.status === 1" class="text-red-500"
<span v-if="activeCall && activeCall.status === 0">Busy...</span>
<span v-else-if="activeCall && activeCall.status === 1" class="text-red-500"
>Rejected</span
>
<span v-else-if="activeCall.status === 2" class="animate-pulse"
<span v-else-if="activeCall && activeCall.status === 2" class="animate-pulse"
>Calling...</span
>
<span v-else-if="activeCall.status === 3">Available</span>
<span v-else-if="activeCall.status === 4" class="animate-pulse"
<span v-else-if="activeCall && activeCall.status === 3">Available</span>
<span v-else-if="activeCall && activeCall.status === 4" class="animate-pulse"
>Ringing...</span
>
<span v-else-if="activeCall.status === 5">Connecting...</span>
<span v-else-if="activeCall && activeCall.status === 5">Connecting...</span>
<span
v-else-if="activeCall.status === 6"
v-else-if="activeCall && activeCall.status === 6"
class="text-green-500 flex items-center gap-2"
>
<span class="size-2 bg-green-500 rounded-full animate-ping"></span>
Connected
</span>
<span v-else>Status: {{ activeCall.status }}</span>
<span v-else-if="activeCall">Status: {{ activeCall.status }}</span>
</span>
<!-- Duration -->
<div
v-if="activeCall.status === 6 && elapsedTime"
v-if="activeCall && activeCall.status === 6 && elapsedTime"
class="text-xs font-mono text-gray-400 dark:text-zinc-500 mt-1"
>
{{ elapsedTime }}
@@ -336,7 +336,7 @@
<div class="flex gap-3">
<!-- answer call -->
<button
v-if="activeCall.is_incoming && activeCall.status === 4"
v-if="activeCall && activeCall.is_incoming && activeCall.status === 4"
type="button"
class="flex-1 flex items-center justify-center gap-2 rounded-2xl bg-green-600 py-4 text-sm font-bold text-white shadow-xl shadow-green-600/20 hover:bg-green-500 transition-all duration-200"
@click="answerCall"
@@ -347,7 +347,7 @@
<!-- send to voicemail -->
<button
v-if="activeCall.is_incoming && activeCall.status === 4"
v-if="activeCall && activeCall.is_incoming && activeCall.status === 4"
type="button"
class="flex-1 flex items-center justify-center gap-2 rounded-2xl bg-blue-600 py-4 text-sm font-bold text-white shadow-xl shadow-blue-600/20 hover:bg-blue-500 transition-all duration-200"
@click="sendToVoicemail"
@@ -365,7 +365,7 @@
>
<MaterialDesignIcon icon-name="phone-hangup" class="size-5 rotate-[135deg]" />
<span>{{
activeCall.is_incoming && activeCall.status === 4
activeCall && activeCall.is_incoming && activeCall.status === 4
? $t("call.decline")
: $t("call.hangup")
}}</span>

View File

@@ -85,6 +85,71 @@
<!-- Actions Section -->
<div class="flex items-center space-x-1 md:space-x-2 ml-auto shrink-0">
<!-- Version Selector -->
<div
v-if="activeTab === 'reticulum' && (status.has_docs || status.versions.length > 0)"
class="relative"
>
<button
v-click-outside="() => (showVersions = false)"
class="p-1.5 text-gray-500 hover:bg-gray-100 dark:hover:bg-zinc-800 rounded-lg transition-colors flex items-center gap-1.5"
:class="{ 'bg-gray-100 dark:bg-zinc-800': showVersions }"
@click="showVersions = !showVersions"
>
<MaterialDesignIcon icon-name="history" class="w-4 h-4 md:w-5 md:h-5" />
<span class="hidden xl:inline text-[10px] font-bold uppercase">{{
status.current_version || "Default"
}}</span>
</button>
<div
v-if="showVersions"
class="absolute right-0 mt-2 w-48 bg-white dark:bg-zinc-800 border border-gray-200 dark:border-zinc-700 rounded-xl shadow-xl z-50 overflow-hidden"
>
<div
class="p-2 border-b border-gray-100 dark:border-zinc-700 bg-gray-50/50 dark:bg-zinc-800/50"
>
<span class="text-[10px] font-bold text-gray-400 uppercase tracking-wider">Versions</span>
</div>
<div class="max-h-64 overflow-y-auto py-1">
<button
v-for="version in status.versions"
:key="version"
class="w-full px-4 py-2 text-left text-[11px] hover:bg-blue-50 dark:hover:bg-blue-900/20 transition-colors flex items-center justify-between"
:class="
status.current_version === version
? 'text-blue-600 dark:text-blue-400 font-bold'
: 'text-gray-700 dark:text-zinc-300'
"
@click="switchVersion(version)"
>
<span>{{ version }}</span>
<MaterialDesignIcon
v-if="status.current_version === version"
icon-name="check"
class="w-3.5 h-3.5"
/>
</button>
<div
v-if="status.versions.length === 0"
class="px-4 py-3 text-center text-gray-500 text-[10px]"
>
No versions available
</div>
</div>
<div
class="p-2 border-t border-gray-100 dark:border-zinc-700 bg-gray-50/50 dark:bg-zinc-800/50"
>
<label
class="flex items-center justify-center gap-2 px-3 py-1.5 bg-blue-600 hover:bg-blue-700 text-white rounded-lg cursor-pointer transition-colors text-[10px] font-bold uppercase"
>
<MaterialDesignIcon icon-name="upload" class="w-3.5 h-3.5" />
<span>Upload ZIP</span>
<input type="file" accept=".zip" class="hidden" @change="handleZipUpload" />
</label>
</div>
</div>
</div>
<!-- Language Selector -->
<div v-if="activeTab === 'reticulum' && status.has_docs" class="relative">
<button
@@ -456,9 +521,12 @@ export default {
last_error: null,
has_docs: false,
has_meshchatx_docs: false,
versions: [],
current_version: null,
},
statusInterval: null,
showLanguages: false,
showVersions: false,
searchQuery: "",
searchResults: [],
isSearching: false,
@@ -568,6 +636,44 @@ export default {
console.error("Failed to trigger docs update:", error);
}
},
async switchVersion(version) {
try {
await window.axios.post("/api/v1/docs/switch", { version });
this.showVersions = false;
this.fetchStatus();
// reload iframe if in reticulum tab
if (this.activeTab === "reticulum") {
const iframe = this.$refs.docsIframe;
if (iframe) {
iframe.contentWindow.location.reload();
}
}
} catch (error) {
console.error("Failed to switch docs version:", error);
}
},
async handleZipUpload(event) {
const file = event.target.files[0];
if (!file) return;
const version = prompt("Enter version name for this upload:", `upload-${Date.now()}`);
if (!version) return;
const formData = new FormData();
formData.append("file", file);
try {
await window.axios.post(`/api/v1/docs/upload?version=${encodeURIComponent(version)}`, formData, {
headers: {
"Content-Type": "multipart/form-data",
},
});
this.fetchStatus();
} catch (error) {
console.error("Failed to upload docs zip:", error);
alert("Failed to upload docs zip: " + (error.response?.data?.error || error.message));
}
},
async exportDocs() {
window.location.href = "/api/v1/docs/export";
},

View File

@@ -108,6 +108,9 @@ export default {
this.stopPlayback();
const response = await fetch(this.src);
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const arrayBuffer = await response.arrayBuffer();
if (!this.audioContext) {

View File

@@ -15,7 +15,7 @@
{{ $t("tools.micron_editor.title") }}
</h1>
<a
href="https://github.com/RFnexus"
href="https://git.quad4.io/RFnexus"
target="_blank"
rel="noopener noreferrer"
class="text-[10px] text-gray-500 hover:text-teal-500 transition-colors hidden sm:block leading-tight"

View File

@@ -396,13 +396,13 @@
<div class="flex items-center gap-4">
<a
target="_blank"
href="https://github.com/liamcottle/rnode-flasher"
href="https://git.quad4.io/Reticulum/rnode-flasher"
class="text-blue-500 hover:underline text-sm font-bold"
>RNode Flasher GH</a
>
<a
target="_blank"
href="https://github.com/markqvist/RNode_Firmware"
href="https://git.quad4.io/Reticulum/RNode_Firmware"
class="text-blue-500 hover:underline text-sm font-bold"
>RNode Firmware GH</a
>
@@ -472,7 +472,9 @@ export default {
methods: {
async fetchLatestRelease() {
try {
const response = await fetch("https://api.github.com/repos/markqvist/RNode_Firmware/releases/latest");
const response = await fetch(
"https://git.quad4.io/api/v1/repos/Reticulum/RNode_Firmware/releases/latest"
);
if (response.ok) {
this.latestRelease = await response.json();
}
@@ -726,6 +728,7 @@ export default {
terminal: {
writeLine: console.log,
write: console.log,
clean: () => {},
},
});
@@ -738,7 +741,11 @@ export default {
calculateMD5Hash: (img) => window.CryptoJS.MD5(window.CryptoJS.enc.Latin1.parse(img)),
reportProgress: (idx, written, total) => {
this.flashingProgress = Math.floor((written / total) * 100);
this.flashingStatus = `File ${idx + 1}/${filesToFlash.length}: ${this.flashingProgress}%`;
this.flashingStatus = this.$t("tools.rnode_flasher.flashing_file_progress", {
current: idx + 1,
total: filesToFlash.length,
percentage: this.flashingProgress,
});
},
});
@@ -762,7 +769,7 @@ export default {
const rnode = await this.askForRNode();
if (!rnode) return;
const ver = await rnode.getFirmwareVersion();
ToastUtils.success(`RNode v${ver} detected`);
ToastUtils.success(this.$t("tools.rnode_flasher.alerts.rnode_detected", { version: ver }));
await rnode.close();
} catch {
ToastUtils.error(this.$t("tools.rnode_flasher.errors.failed_detect"));
@@ -775,8 +782,8 @@ export default {
await rnode.reset();
await rnode.close();
ToastUtils.success(this.$t("tools.rnode_flasher.alerts.rebooting"));
} catch {
// ignore
} catch (e) {
ToastUtils.error(this.$t("tools.rnode_flasher.errors.failed_reboot", { error: e.message || e }));
}
},
async readDisplay() {
@@ -786,8 +793,8 @@ export default {
const buffer = await rnode.readDisplay();
await rnode.close();
this.rnodeDisplayImage = this.rnodeDisplayBufferToPng(buffer);
} catch {
// ignore
} catch (e) {
ToastUtils.error(this.$t("tools.rnode_flasher.errors.failed_read_display", { error: e.message || e }));
}
},
rnodeDisplayBufferToPng(displayBuffer) {
@@ -837,9 +844,9 @@ export default {
const eeprom = await rnode.getRom();
console.log(RNodeUtils.bytesToHex(eeprom));
await rnode.close();
ToastUtils.success("EEPROM dumped to console");
} catch {
// ignore
ToastUtils.success(this.$t("tools.rnode_flasher.alerts.eeprom_dumped"));
} catch (e) {
ToastUtils.error(this.$t("tools.rnode_flasher.errors.failed_dump_eeprom", { error: e.message || e }));
}
},
async wipeEeprom() {
@@ -854,8 +861,8 @@ export default {
await rnode.reset();
await rnode.close();
ToastUtils.success(this.$t("tools.rnode_flasher.alerts.eeprom_wiped"));
} catch {
// ignore
} catch (e) {
ToastUtils.error(this.$t("tools.rnode_flasher.errors.failed_wipe_eeprom", { error: e.message || e }));
}
},
async provision() {
@@ -904,8 +911,8 @@ export default {
await rnode.reset();
await rnode.close();
ToastUtils.success(this.$t("tools.rnode_flasher.alerts.provision_success"));
} catch {
ToastUtils.error(this.$t("tools.rnode_flasher.errors.failed_provision"));
} catch (e) {
ToastUtils.error(this.$t("tools.rnode_flasher.errors.failed_provision", { error: e.message || e }));
} finally {
this.isProvisioning = false;
}
@@ -920,7 +927,6 @@ export default {
await rnode.close();
return;
}
this.isSettingFirmwareHash = true;
const hash = await rnode.getFirmwareHash();
await rnode.setFirmwareHash(hash);
@@ -930,8 +936,8 @@ export default {
});
await rnode.close();
ToastUtils.success(this.$t("tools.rnode_flasher.alerts.hash_success"));
} catch {
ToastUtils.error(this.$t("tools.rnode_flasher.errors.failed_set_hash"));
} catch (e) {
ToastUtils.error(this.$t("tools.rnode_flasher.errors.failed_set_hash", { error: e.message || e }));
} finally {
this.isSettingFirmwareHash = false;
}
@@ -953,8 +959,8 @@ export default {
await rnode.reset();
await rnode.close();
ToastUtils.success(this.$t("tools.rnode_flasher.alerts.tnc_enabled"));
} catch {
// ignore
} catch (e) {
ToastUtils.error(this.$t("tools.rnode_flasher.errors.failed_enable_tnc", { error: e.message || e }));
}
},
async disableTncMode() {
@@ -966,8 +972,8 @@ export default {
await rnode.reset();
await rnode.close();
ToastUtils.success(this.$t("tools.rnode_flasher.alerts.tnc_disabled"));
} catch {
// ignore
} catch (e) {
ToastUtils.error(this.$t("tools.rnode_flasher.errors.failed_disable_tnc", { error: e.message || e }));
}
},
async enableBluetooth() {
@@ -978,8 +984,10 @@ export default {
await RNodeUtils.sleepMillis(1000);
await rnode.close();
ToastUtils.success(this.$t("tools.rnode_flasher.alerts.bluetooth_enabled"));
} catch {
// ignore
} catch (e) {
ToastUtils.error(
this.$t("tools.rnode_flasher.errors.failed_enable_bluetooth", { error: e.message || e })
);
}
},
async disableBluetooth() {
@@ -990,8 +998,10 @@ export default {
await RNodeUtils.sleepMillis(1000);
await rnode.close();
ToastUtils.success(this.$t("tools.rnode_flasher.alerts.bluetooth_disabled"));
} catch {
// ignore
} catch (e) {
ToastUtils.error(
this.$t("tools.rnode_flasher.errors.failed_disable_bluetooth", { error: e.message || e })
);
}
},
async startBluetoothPairing() {
@@ -1001,9 +1011,9 @@ export default {
await rnode.startBluetoothPairing((pin) => {
ToastUtils.success(this.$t("tools.rnode_flasher.alerts.bluetooth_pairing_pin", { pin }));
});
ToastUtils.success("Pairing mode started (30s)");
} catch {
// ignore
ToastUtils.success(this.$t("tools.rnode_flasher.alerts.bluetooth_pairing_started"));
} catch (e) {
ToastUtils.error(this.$t("tools.rnode_flasher.errors.failed_start_pairing", { error: e.message || e }));
}
},
async setDisplayRotation(rot) {
@@ -1012,9 +1022,9 @@ export default {
if (!rnode) return;
await rnode.setDisplayRotation(rot);
await rnode.close();
ToastUtils.success("Rotation updated");
} catch {
// ignore
ToastUtils.success(this.$t("tools.rnode_flasher.alerts.rotation_updated"));
} catch (e) {
ToastUtils.error(this.$t("tools.rnode_flasher.errors.failed_set_rotation", { error: e.message || e }));
}
},
async startDisplayReconditioning() {
@@ -1023,9 +1033,11 @@ export default {
if (!rnode) return;
await rnode.startDisplayReconditioning();
await rnode.close();
ToastUtils.success("Reconditioning started");
} catch {
// ignore
ToastUtils.success(this.$t("tools.rnode_flasher.alerts.reconditioning_started"));
} catch (e) {
ToastUtils.error(
this.$t("tools.rnode_flasher.errors.failed_start_reconditioning", { error: e.message || e })
);
}
},
async readAsBinaryString(blob) {

View File

@@ -5,11 +5,15 @@ class WebSocketConnection {
this.emitter = mitt();
this.ws = null;
this.pingInterval = null;
this.reconnectTimeout = null;
this.initialized = false;
this.destroyed = false;
this.connect();
}
async connect() {
this.destroyed = false;
if (typeof window === "undefined" || !window.axios) {
setTimeout(() => this.connect(), 100);
return;
@@ -17,6 +21,7 @@ class WebSocketConnection {
this.initialized = true;
this.reconnect();
if (this.pingInterval) clearInterval(this.pingInterval);
this.pingInterval = setInterval(() => {
this.ping();
}, 30000);
@@ -38,17 +43,18 @@ class WebSocketConnection {
}
reconnect() {
if (!this.initialized) {
if (!this.initialized || this.destroyed || typeof window === "undefined" || !window.location) {
return;
}
// connect to websocket
const wsUrl = location.origin.replace(/^https/, "wss").replace(/^http/, "ws") + "/ws";
const wsUrl = window.location.origin.replace(/^https/, "wss").replace(/^http/, "ws") + "/ws";
this.ws = new WebSocket(wsUrl);
// auto reconnect when websocket closes
this.ws.addEventListener("close", () => {
setTimeout(() => {
if (this.destroyed) return;
this.reconnectTimeout = setTimeout(() => {
this.reconnect();
}, 1000);
});
@@ -59,6 +65,23 @@ class WebSocketConnection {
};
}
destroy() {
this.destroyed = true;
this.initialized = false;
if (this.pingInterval) {
clearInterval(this.pingInterval);
this.pingInterval = null;
}
if (this.reconnectTimeout) {
clearTimeout(this.reconnectTimeout);
this.reconnectTimeout = null;
}
if (this.ws) {
this.ws.close();
this.ws = null;
}
}
send(message) {
if (this.ws != null && this.ws.readyState === WebSocket.OPEN) {
this.ws.send(message);

View File

@@ -0,0 +1,263 @@
export default class Nrf52DfuFlasher {
DFU_TOUCH_BAUD = 1200;
SERIAL_PORT_OPEN_WAIT_TIME = 0.1;
TOUCH_RESET_WAIT_TIME = 1.5;
FLASH_BAUD = 115200;
HEX_TYPE_APPLICATION = 4;
DFU_INIT_PACKET = 1;
DFU_START_PACKET = 3;
DFU_DATA_PACKET = 4;
DFU_STOP_DATA_PACKET = 5;
DATA_INTEGRITY_CHECK_PRESENT = 1;
RELIABLE_PACKET = 1;
HCI_PACKET_TYPE = 14;
FLASH_PAGE_SIZE = 4096;
FLASH_PAGE_ERASE_TIME = 0.0897;
FLASH_WORD_WRITE_TIME = 0.0001;
FLASH_PAGE_WRITE_TIME = (this.FLASH_PAGE_SIZE / 4) * this.FLASH_WORD_WRITE_TIME;
// The DFU packet max size
DFU_PACKET_MAX_SIZE = 512;
constructor(serialPort) {
this.serialPort = serialPort;
this.sequenceNumber = 0;
this.sd_size = 0;
this.total_size = 0;
}
/**
* Waits for the provided milliseconds, and then resolves.
* @param millis
* @returns {Promise<void>}
*/
async sleepMillis(millis) {
await new Promise((resolve) => {
setTimeout(resolve, millis);
});
}
/**
* Writes the provided data to the Serial Port.
* @param data
* @returns {Promise<void>}
*/
async sendPacket(data) {
const writer = this.serialPort.writable.getWriter();
try {
await writer.write(new Uint8Array(data));
} finally {
writer.releaseLock();
}
}
/**
* Puts an nRF52 board into DFU mode by quickly opening and closing a serial port.
* @returns {Promise<void>}
*/
async enterDfuMode() {
await this.serialPort.open({
baudRate: this.DFU_TOUCH_BAUD,
});
await this.sleepMillis(this.SERIAL_PORT_OPEN_WAIT_TIME * 1000);
await this.serialPort.close();
await this.sleepMillis(this.TOUCH_RESET_WAIT_TIME * 1000);
}
/**
* Flashes the provided firmware zip.
* @param firmwareZipBlob
* @param progressCallback
* @returns {Promise<void>}
*/
async flash(firmwareZipBlob, progressCallback) {
const blobReader = new window.zip.BlobReader(firmwareZipBlob);
const zipReader = new window.zip.ZipReader(blobReader);
const zipEntries = await zipReader.getEntries();
const manifestFile = zipEntries.find((zipEntry) => zipEntry.filename === "manifest.json");
if (!manifestFile) {
throw new Error("manifest.json not found in firmware file!");
}
const text = await manifestFile.getData(new window.zip.TextWriter());
const json = JSON.parse(text);
const manifest = json.manifest;
if (manifest.application) {
await this.dfuSendImage(this.HEX_TYPE_APPLICATION, zipEntries, manifest.application, progressCallback);
}
}
/**
* Sends the firmware image to the device in DFU mode.
* @param programMode
* @param zipEntries
* @param firmwareManifest
* @param progressCallback
* @returns {Promise<void>}
*/
async dfuSendImage(programMode, zipEntries, firmwareManifest, progressCallback) {
await this.serialPort.open({
baudRate: this.FLASH_BAUD,
});
await this.sleepMillis(this.SERIAL_PORT_OPEN_WAIT_TIME * 1000);
var softdeviceSize = 0;
var bootloaderSize = 0;
var applicationSize = 0;
const binFile = zipEntries.find((zipEntry) => zipEntry.filename === firmwareManifest.bin_file);
const firmware = await binFile.getData(new window.zip.Uint8ArrayWriter());
const datFile = zipEntries.find((zipEntry) => zipEntry.filename === firmwareManifest.dat_file);
const init_packet = await datFile.getData(new window.zip.Uint8ArrayWriter());
if (programMode !== this.HEX_TYPE_APPLICATION) {
throw new Error("not implemented");
}
if (programMode === this.HEX_TYPE_APPLICATION) {
applicationSize = firmware.length;
}
await this.sendStartDfu(programMode, softdeviceSize, bootloaderSize, applicationSize);
await this.sendInitPacket(init_packet);
await this.sendFirmware(firmware, progressCallback);
}
calcCrc16(binaryData, crc = 0xffff) {
if (!(binaryData instanceof Uint8Array)) {
throw new Error("calcCrc16 requires Uint8Array input");
}
for (let b of binaryData) {
crc = ((crc >> 8) & 0x00ff) | ((crc << 8) & 0xff00);
crc ^= b;
crc ^= (crc & 0x00ff) >> 4;
crc ^= (crc << 8) << 4;
crc ^= ((crc & 0x00ff) << 4) << 1;
}
return crc & 0xffff;
}
slipEncodeEscChars(dataIn) {
let result = [];
for (let i = 0; i < dataIn.length; i++) {
let char = dataIn[i];
if (char === 0xc0) {
result.push(0xdb);
result.push(0xdc);
} else if (char === 0xdb) {
result.push(0xdb);
result.push(0xdd);
} else {
result.push(char);
}
}
return result;
}
createHciPacketFromFrame(frame) {
this.sequenceNumber = (this.sequenceNumber + 1) % 8;
const slipHeaderBytes = this.createSlipHeader(
this.sequenceNumber,
this.DATA_INTEGRITY_CHECK_PRESENT,
this.RELIABLE_PACKET,
this.HCI_PACKET_TYPE,
frame.length
);
let data = [...slipHeaderBytes, ...frame];
const crc = this.calcCrc16(new Uint8Array(data), 0xffff);
data.push(crc & 0xff);
data.push((crc & 0xff00) >> 8);
return [0xc0, ...this.slipEncodeEscChars(data), 0xc0];
}
getEraseWaitTime() {
return Math.max(0.5, (this.total_size / this.FLASH_PAGE_SIZE + 1) * this.FLASH_PAGE_ERASE_TIME);
}
createImageSizePacket(softdeviceSize = 0, bootloaderSize = 0, appSize = 0) {
return [
...this.int32ToBytes(softdeviceSize),
...this.int32ToBytes(bootloaderSize),
...this.int32ToBytes(appSize),
];
}
async sendStartDfu(mode, softdevice_size = 0, bootloader_size = 0, app_size = 0) {
const frame = [
...this.int32ToBytes(this.DFU_START_PACKET),
...this.int32ToBytes(mode),
...this.createImageSizePacket(softdevice_size, bootloader_size, app_size),
];
await this.sendPacket(this.createHciPacketFromFrame(frame));
this.sd_size = softdevice_size;
this.total_size = softdevice_size + bootloader_size + app_size;
await this.sleepMillis(this.getEraseWaitTime() * 1000);
}
async sendInitPacket(initPacket) {
const frame = [...this.int32ToBytes(this.DFU_INIT_PACKET), ...initPacket, ...this.int16ToBytes(0x0000)];
await this.sendPacket(this.createHciPacketFromFrame(frame));
}
async sendFirmware(firmware, progressCallback) {
const packets = [];
var packetsSent = 0;
for (let i = 0; i < firmware.length; i += this.DFU_PACKET_MAX_SIZE) {
packets.push(
this.createHciPacketFromFrame([
...this.int32ToBytes(this.DFU_DATA_PACKET),
...firmware.slice(i, i + this.DFU_PACKET_MAX_SIZE),
])
);
}
if (progressCallback) {
progressCallback(0);
}
for (var i = 0; i < packets.length; i++) {
await this.sendPacket(packets[i]);
await this.sleepMillis(this.FLASH_PAGE_WRITE_TIME * 1000);
packetsSent++;
if (progressCallback) {
const progress = Math.floor((packetsSent / packets.length) * 100);
progressCallback(progress);
}
}
await this.sendPacket(this.createHciPacketFromFrame([...this.int32ToBytes(this.DFU_STOP_DATA_PACKET)]));
}
createSlipHeader(seq, dip, rp, pktType, pktLen) {
let ints = [0, 0, 0, 0];
ints[0] = seq | (((seq + 1) % 8) << 3) | (dip << 6) | (rp << 7);
ints[1] = pktType | ((pktLen & 0x000f) << 4);
ints[2] = (pktLen & 0x0ff0) >> 4;
ints[3] = (~(ints[0] + ints[1] + ints[2]) + 1) & 0xff;
return new Uint8Array(ints);
}
int32ToBytes(num) {
return [num & 0x000000ff, (num & 0x0000ff00) >> 8, (num & 0x00ff0000) >> 16, (num & 0xff000000) >> 24];
}
int16ToBytes(num) {
return [num & 0x00ff, (num & 0xff00) >> 8];
}
}

View File

@@ -0,0 +1,461 @@
import RNodeUtils from "./RNodeUtils.js";
import ROM from "./ROM.js";
export default class RNode {
KISS_FEND = 0xc0;
KISS_FESC = 0xdb;
KISS_TFEND = 0xdc;
KISS_TFESC = 0xdd;
CMD_FREQUENCY = 0x01;
CMD_BANDWIDTH = 0x02;
CMD_TXPOWER = 0x03;
CMD_SF = 0x04;
CMD_CR = 0x05;
CMD_RADIO_STATE = 0x06;
CMD_STAT_RX = 0x21;
CMD_STAT_TX = 0x22;
CMD_STAT_RSSI = 0x23;
CMD_STAT_SNR = 0x24;
CMD_BOARD = 0x47;
CMD_PLATFORM = 0x48;
CMD_MCU = 0x49;
CMD_RESET = 0x55;
CMD_RESET_BYTE = 0xf8;
CMD_DEV_HASH = 0x56;
CMD_FW_VERSION = 0x50;
CMD_ROM_READ = 0x51;
CMD_ROM_WRITE = 0x52;
CMD_CONF_SAVE = 0x53;
CMD_CONF_DELETE = 0x54;
CMD_FW_HASH = 0x58;
CMD_UNLOCK_ROM = 0x59;
ROM_UNLOCK_BYTE = 0xf8;
CMD_HASHES = 0x60;
CMD_FW_UPD = 0x61;
CMD_DISP_ROT = 0x67;
CMD_DISP_RCND = 0x68;
CMD_BT_CTRL = 0x46;
CMD_BT_PIN = 0x62;
CMD_DISP_READ = 0x66;
CMD_DETECT = 0x08;
DETECT_REQ = 0x73;
DETECT_RESP = 0x46;
RADIO_STATE_OFF = 0x00;
RADIO_STATE_ON = 0x01;
RADIO_STATE_ASK = 0xff;
CMD_ERROR = 0x90;
ERROR_INITRADIO = 0x01;
ERROR_TXFAILED = 0x02;
ERROR_EEPROM_LOCKED = 0x03;
PLATFORM_AVR = 0x90;
PLATFORM_ESP32 = 0x80;
PLATFORM_NRF52 = 0x70;
MCU_1284P = 0x91;
MCU_2560 = 0x92;
MCU_ESP32 = 0x81;
MCU_NRF52 = 0x71;
BOARD_RNODE = 0x31;
BOARD_HMBRW = 0x32;
BOARD_TBEAM = 0x33;
BOARD_HUZZAH32 = 0x34;
BOARD_GENERIC_ESP32 = 0x35;
BOARD_LORA32_V2_0 = 0x36;
BOARD_LORA32_V2_1 = 0x37;
BOARD_RAK4631 = 0x51;
HASH_TYPE_TARGET_FIRMWARE = 0x01;
HASH_TYPE_FIRMWARE = 0x02;
constructor(serialPort) {
this.serialPort = serialPort;
this.reader = serialPort.readable.getReader();
this.writable = serialPort.writable;
this.callbacks = {};
this.readLoop();
}
static async fromSerialPort(serialPort) {
await serialPort.open({
baudRate: 115200,
});
return new RNode(serialPort);
}
async close() {
try {
this.reader.releaseLock();
} catch {
// ignore
}
try {
await this.serialPort.close();
} catch {
// ignore
}
}
async write(bytes) {
const writer = this.writable.getWriter();
try {
await writer.write(new Uint8Array(bytes));
} finally {
writer.releaseLock();
}
}
async readLoop() {
try {
let buffer = [];
let inFrame = false;
while (true) {
const { value, done } = await this.reader.read();
if (done) {
break;
}
for (const byte of value) {
if (byte === this.KISS_FEND) {
if (inFrame) {
const decodedFrame = this.decodeKissFrame(buffer);
if (decodedFrame) {
this.onCommandReceived(decodedFrame);
} else {
console.warn("Invalid frame ignored.");
}
buffer = [];
}
inFrame = !inFrame;
} else if (inFrame) {
buffer.push(byte);
}
}
}
} catch (error) {
if (error instanceof TypeError) {
return;
}
console.error("Error reading from serial port: ", error);
} finally {
try {
this.reader.releaseLock();
} catch {
// ignore
}
}
}
onCommandReceived(data) {
try {
const [command, ...bytes] = data;
const callback = this.callbacks[command];
if (!callback) {
return;
}
callback(bytes);
delete this.callbacks[command];
} catch (e) {
console.log("failed to handle received command", data, e);
}
}
decodeKissFrame(frame) {
const data = [];
let escaping = false;
for (const byte of frame) {
if (escaping) {
if (byte === this.KISS_TFEND) {
data.push(this.KISS_FEND);
} else if (byte === this.KISS_TFESC) {
data.push(this.KISS_FESC);
} else {
return null;
}
escaping = false;
} else if (byte === this.KISS_FESC) {
escaping = true;
} else {
data.push(byte);
}
}
return escaping ? null : data;
}
createKissFrame(data) {
let frame = [this.KISS_FEND];
for (let byte of data) {
if (byte === this.KISS_FEND) {
frame.push(this.KISS_FESC, this.KISS_TFEND);
} else if (byte === this.KISS_FESC) {
frame.push(this.KISS_FESC, this.KISS_TFESC);
} else {
frame.push(byte);
}
}
frame.push(this.KISS_FEND);
return new Uint8Array(frame);
}
async sendKissCommand(data) {
await this.write(this.createKissFrame(data));
}
async sendCommand(command, data) {
return new Promise((resolve, reject) => {
this.callbacks[command] = (response) => {
resolve(response);
};
this.sendKissCommand([command, ...data]).catch((e) => {
reject(e);
});
});
}
async reset() {
await this.sendKissCommand([this.CMD_RESET, this.CMD_RESET_BYTE]);
}
async detect() {
return new Promise((resolve) => {
const timeout = setTimeout(() => {
resolve(false);
}, 2000);
this.sendCommand(this.CMD_DETECT, [this.DETECT_REQ])
.then((response) => {
clearTimeout(timeout);
const [responseByte] = response;
const isRnode = responseByte === this.DETECT_RESP;
resolve(isRnode);
})
.catch(() => {
resolve(false);
});
});
}
async getFirmwareVersion() {
const response = await this.sendCommand(this.CMD_FW_VERSION, [0x00]);
var [majorVersion, minorVersion] = response;
if (minorVersion.length === 1) {
minorVersion = "0" + minorVersion;
}
return majorVersion + "." + minorVersion;
}
async getPlatform() {
const response = await this.sendCommand(this.CMD_PLATFORM, [0x00]);
const [platformByte] = response;
return platformByte;
}
async getMcu() {
const response = await this.sendCommand(this.CMD_MCU, [0x00]);
const [mcuByte] = response;
return mcuByte;
}
async getBoard() {
const response = await this.sendCommand(this.CMD_BOARD, [0x00]);
const [boardByte] = response;
return boardByte;
}
async getDeviceHash() {
const response = await this.sendCommand(this.CMD_DEV_HASH, [0x01]);
const [...deviceHash] = response;
return deviceHash;
}
async getTargetFirmwareHash() {
const response = await this.sendCommand(this.CMD_HASHES, [this.HASH_TYPE_TARGET_FIRMWARE]);
const [, ...targetFirmwareHash] = response;
return targetFirmwareHash;
}
async getFirmwareHash() {
const response = await this.sendCommand(this.CMD_HASHES, [this.HASH_TYPE_FIRMWARE]);
const [, ...firmwareHash] = response;
return firmwareHash;
}
async getRom() {
const response = await this.sendCommand(this.CMD_ROM_READ, [0x00]);
const [...eepromBytes] = response;
return eepromBytes;
}
async getFrequency() {
const response = await this.sendCommand(this.CMD_FREQUENCY, [0x00, 0x00, 0x00, 0x00]);
const [...frequencyBytes] = response;
const frequencyInHz =
(frequencyBytes[0] << 24) | (frequencyBytes[1] << 16) | (frequencyBytes[2] << 8) | frequencyBytes[3];
return frequencyInHz;
}
async getBandwidth() {
const response = await this.sendCommand(this.CMD_BANDWIDTH, [0x00, 0x00, 0x00, 0x00]);
const [...bandwidthBytes] = response;
const bandwidthInHz =
(bandwidthBytes[0] << 24) | (bandwidthBytes[1] << 16) | (bandwidthBytes[2] << 8) | bandwidthBytes[3];
return bandwidthInHz;
}
async getTxPower() {
const response = await this.sendCommand(this.CMD_TXPOWER, [0xff]);
const [txPower] = response;
return txPower;
}
async getSpreadingFactor() {
const response = await this.sendCommand(this.CMD_SF, [0xff]);
const [spreadingFactor] = response;
return spreadingFactor;
}
async getCodingRate() {
const response = await this.sendCommand(this.CMD_CR, [0xff]);
const [codingRate] = response;
return codingRate;
}
async getRadioState() {
const response = await this.sendCommand(this.CMD_RADIO_STATE, [0xff]);
const [radioState] = response;
return radioState;
}
async getRxStat() {
const response = await this.sendCommand(this.CMD_STAT_RX, [0x00]);
const [...statBytes] = response;
const stat = (statBytes[0] << 24) | (statBytes[1] << 16) | (statBytes[2] << 8) | statBytes[3];
return stat;
}
async getTxStat() {
const response = await this.sendCommand(this.CMD_STAT_TX, [0x00]);
const [...statBytes] = response;
const stat = (statBytes[0] << 24) | (statBytes[1] << 16) | (statBytes[2] << 8) | statBytes[3];
return stat;
}
async getRssiStat() {
const response = await this.sendCommand(this.CMD_STAT_RSSI, [0x00]);
const [rssi] = response;
return rssi;
}
async disableBluetooth() {
await this.sendKissCommand([this.CMD_BT_CTRL, 0x00]);
}
async enableBluetooth() {
await this.sendKissCommand([this.CMD_BT_CTRL, 0x01]);
}
async startBluetoothPairing(pinCallback) {
this.callbacks[this.CMD_BT_PIN] = (response) => {
const [...pinBytes] = response;
const pin = (pinBytes[0] << 24) | (pinBytes[1] << 16) | (pinBytes[2] << 8) | pinBytes[3];
pinCallback(pin);
};
await this.sendKissCommand([this.CMD_BT_CTRL, 0x02]);
}
async readDisplay() {
const response = await this.sendCommand(this.CMD_DISP_READ, [0x01]);
const [...displayBuffer] = response;
return displayBuffer;
}
async setFrequency(frequencyInHz) {
const c1 = frequencyInHz >> 24;
const c2 = (frequencyInHz >> 16) & 0xff;
const c3 = (frequencyInHz >> 8) & 0xff;
const c4 = frequencyInHz & 0xff;
await this.sendKissCommand([this.CMD_FREQUENCY, c1, c2, c3, c4]);
}
async setBandwidth(bandwidthInHz) {
const c1 = bandwidthInHz >> 24;
const c2 = (bandwidthInHz >> 16) & 0xff;
const c3 = (bandwidthInHz >> 8) & 0xff;
const c4 = bandwidthInHz & 0xff;
await this.sendKissCommand([this.CMD_BANDWIDTH, c1, c2, c3, c4]);
}
async setTxPower(db) {
await this.sendKissCommand([this.CMD_TXPOWER, db]);
}
async setSpreadingFactor(spreadingFactor) {
await this.sendKissCommand([this.CMD_SF, spreadingFactor]);
}
async setCodingRate(codingRate) {
await this.sendKissCommand([this.CMD_CR, codingRate]);
}
async setRadioStateOn() {
await this.sendKissCommand([this.CMD_RADIO_STATE, this.RADIO_STATE_ON]);
}
async setRadioStateOff() {
await this.sendKissCommand([this.CMD_RADIO_STATE, this.RADIO_STATE_OFF]);
}
async saveConfig() {
await this.sendKissCommand([this.CMD_CONF_SAVE, 0x00]);
}
async deleteConfig() {
await this.sendKissCommand([this.CMD_CONF_DELETE, 0x00]);
}
async indicateFirmwareUpdate() {
await this.sendKissCommand([this.CMD_FW_UPD, 0x01]);
}
async setFirmwareHash(hash) {
await this.sendKissCommand([this.CMD_FW_HASH, ...hash]);
}
async writeRom(address, value) {
await this.sendKissCommand([this.CMD_ROM_WRITE, address, value]);
await RNodeUtils.sleepMillis(85);
}
async wipeRom() {
await this.sendKissCommand([this.CMD_UNLOCK_ROM, this.ROM_UNLOCK_BYTE]);
await RNodeUtils.sleepMillis(30000);
}
async getRomAsObject() {
const rom = await this.getRom();
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]);
}
}

View File

@@ -0,0 +1,49 @@
export default class RNodeUtils {
/**
* Waits for the provided milliseconds, and then resolves.
* @param millis
* @returns {Promise<void>}
*/
static async sleepMillis(millis) {
await new Promise((resolve) => {
setTimeout(resolve, millis);
});
}
static bytesToHex(bytes) {
for (var hex = [], i = 0; i < bytes.length; i++) {
var current = bytes[i] < 0 ? bytes[i] + 256 : bytes[i];
hex.push((current >>> 4).toString(16));
hex.push((current & 0xf).toString(16));
}
return hex.join("");
}
static md5(data) {
// We will use CryptoJS if available on window, or we might need to import it.
// For now, let's assume we will import it or it will be provided.
// In the original it was using CryptoJS.MD5
if (typeof window !== "undefined" && window.CryptoJS) {
var bytes = [];
const hash = window.CryptoJS.MD5(window.CryptoJS.enc.Hex.parse(this.bytesToHex(data)));
for (var i = 0; i < hash.sigBytes; i++) {
bytes.push((hash.words[i >>> 2] >>> (24 - (i % 4) * 8)) & 0xff);
}
return bytes;
}
throw new Error("CryptoJS not found");
}
static packUInt32BE(value) {
const buffer = new ArrayBuffer(4);
const view = new DataView(buffer);
view.setUint32(0, value, false);
return new Uint8Array(buffer);
}
static unpackUInt32BE(byteArray) {
const buffer = new Uint8Array(byteArray).buffer;
const view = new DataView(buffer);
return view.getUint32(0, false);
}
}

View File

@@ -0,0 +1,244 @@
import RNodeUtils from "./RNodeUtils.js";
export default class ROM {
static PLATFORM_AVR = 0x90;
static PLATFORM_ESP32 = 0x80;
static PLATFORM_NRF52 = 0x70;
static MCU_1284P = 0x91;
static MCU_2560 = 0x92;
static MCU_ESP32 = 0x81;
static MCU_NRF52 = 0x71;
static PRODUCT_RAK4631 = 0x10;
static MODEL_11 = 0x11;
static MODEL_12 = 0x12;
static PRODUCT_RNODE = 0x03;
static MODEL_A1 = 0xa1;
static MODEL_A6 = 0xa6;
static MODEL_A4 = 0xa4;
static MODEL_A9 = 0xa9;
static MODEL_A3 = 0xa3;
static MODEL_A8 = 0xa8;
static MODEL_A2 = 0xa2;
static MODEL_A7 = 0xa7;
static MODEL_A5 = 0xa5;
static MODEL_AA = 0xaa;
static MODEL_AC = 0xac;
static PRODUCT_T32_10 = 0xb2;
static MODEL_BA = 0xba;
static MODEL_BB = 0xbb;
static PRODUCT_T32_20 = 0xb0;
static MODEL_B3 = 0xb3;
static MODEL_B8 = 0xb8;
static PRODUCT_T32_21 = 0xb1;
static MODEL_B4 = 0xb4;
static MODEL_B9 = 0xb9;
static MODEL_B4_TCXO = 0x04;
static MODEL_B9_TCXO = 0x09;
static PRODUCT_H32_V2 = 0xc0;
static MODEL_C4 = 0xc4;
static MODEL_C9 = 0xc9;
static PRODUCT_H32_V3 = 0xc1;
static MODEL_C5 = 0xc5;
static MODEL_CA = 0xca;
static PRODUCT_HELTEC_T114 = 0xc2;
static MODEL_C6 = 0xc6;
static MODEL_C7 = 0xc7;
static PRODUCT_TBEAM = 0xe0;
static MODEL_E4 = 0xe4;
static MODEL_E9 = 0xe9;
static MODEL_E3 = 0xe3;
static MODEL_E8 = 0xe8;
static PRODUCT_TBEAM_S_V1 = 0xea;
static MODEL_DB = 0xdb;
static MODEL_DC = 0xdc;
static PRODUCT_TDECK = 0xd0;
static MODEL_D4 = 0xd4;
static MODEL_D9 = 0xd9;
static PRODUCT_TECHO = 0x15;
static MODEL_16 = 0x16;
static MODEL_17 = 0x17;
static PRODUCT_HMBRW = 0xf0;
static MODEL_FF = 0xff;
static MODEL_FE = 0xfe;
static ADDR_PRODUCT = 0x00;
static ADDR_MODEL = 0x01;
static ADDR_HW_REV = 0x02;
static ADDR_SERIAL = 0x03;
static ADDR_MADE = 0x07;
static ADDR_CHKSUM = 0x0b;
static ADDR_SIGNATURE = 0x1b;
static ADDR_INFO_LOCK = 0x9b;
static ADDR_CONF_SF = 0x9c;
static ADDR_CONF_CR = 0x9d;
static ADDR_CONF_TXP = 0x9e;
static ADDR_CONF_BW = 0x9f;
static ADDR_CONF_FREQ = 0xa3;
static ADDR_CONF_OK = 0xa7;
static INFO_LOCK_BYTE = 0x73;
static CONF_OK_BYTE = 0x73;
static BOARD_RNODE = 0x31;
static BOARD_HMBRW = 0x32;
static BOARD_TBEAM = 0x33;
static BOARD_HUZZAH32 = 0x34;
static BOARD_GENERIC_ESP32 = 0x35;
static BOARD_LORA32_V2_0 = 0x36;
static BOARD_LORA32_V2_1 = 0x37;
static BOARD_RAK4631 = 0x51;
static MANUAL_FLASH_MODELS = [ROM.MODEL_A1, ROM.MODEL_A6];
constructor(eeprom) {
this.eeprom = eeprom;
}
getProduct() {
return this.eeprom[ROM.ADDR_PRODUCT];
}
getModel() {
return this.eeprom[ROM.ADDR_MODEL];
}
getHardwareRevision() {
return this.eeprom[ROM.ADDR_HW_REV];
}
getSerialNumber() {
return [
this.eeprom[ROM.ADDR_SERIAL],
this.eeprom[ROM.ADDR_SERIAL + 1],
this.eeprom[ROM.ADDR_SERIAL + 2],
this.eeprom[ROM.ADDR_SERIAL + 3],
];
}
getMade() {
return [
this.eeprom[ROM.ADDR_MADE],
this.eeprom[ROM.ADDR_MADE + 1],
this.eeprom[ROM.ADDR_MADE + 2],
this.eeprom[ROM.ADDR_MADE + 3],
];
}
getChecksum() {
const checksum = [];
for (var i = 0; i < 16; i++) {
checksum.push(this.eeprom[ROM.ADDR_CHKSUM + i]);
}
return checksum;
}
getSignature() {
const signature = [];
for (var i = 0; i < 128; i++) {
signature.push(this.eeprom[ROM.ADDR_SIGNATURE + i]);
}
return signature;
}
getCalculatedChecksum() {
return RNodeUtils.md5([
this.getProduct(),
this.getModel(),
this.getHardwareRevision(),
...this.getSerialNumber(),
...this.getMade(),
]);
}
getConfiguredSpreadingFactor() {
return this.eeprom[ROM.ADDR_CONF_SF];
}
getConfiguredCodingRate() {
return this.eeprom[ROM.ADDR_CONF_CR];
}
getConfiguredTxPower() {
return this.eeprom[ROM.ADDR_CONF_TXP];
}
getConfiguredFrequency() {
return (
(this.eeprom[ROM.ADDR_CONF_FREQ] << 24) |
(this.eeprom[ROM.ADDR_CONF_FREQ + 1] << 16) |
(this.eeprom[ROM.ADDR_CONF_FREQ + 2] << 8) |
this.eeprom[ROM.ADDR_CONF_FREQ + 3]
);
}
getConfiguredBandwidth() {
return (
(this.eeprom[ROM.ADDR_CONF_BW] << 24) |
(this.eeprom[ROM.ADDR_CONF_BW + 1] << 16) |
(this.eeprom[ROM.ADDR_CONF_BW + 2] << 8) |
this.eeprom[ROM.ADDR_CONF_BW + 3]
);
}
isInfoLocked() {
return this.eeprom[ROM.ADDR_INFO_LOCK] === ROM.INFO_LOCK_BYTE;
}
isConfigured() {
return this.eeprom[ROM.ADDR_CONF_OK] === ROM.CONF_OK_BYTE;
}
parse() {
if (!this.isInfoLocked()) {
return null;
}
const checksumHex = RNodeUtils.bytesToHex(this.getChecksum());
const calculatedChecksumHex = RNodeUtils.bytesToHex(this.getCalculatedChecksum());
const signatureHex = RNodeUtils.bytesToHex(this.getSignature());
var details = {
is_provisioned: true,
is_configured: this.isConfigured(),
product: this.getProduct(),
model: this.getModel(),
hardware_revision: this.getHardwareRevision(),
serial_number: RNodeUtils.unpackUInt32BE(this.getSerialNumber()),
made: RNodeUtils.unpackUInt32BE(this.getMade()),
checksum: checksumHex,
calculated_checksum: calculatedChecksumHex,
signature: signatureHex,
};
if (details.is_configured) {
details = {
...details,
configured_spreading_factor: this.getConfiguredSpreadingFactor(),
configured_coding_rate: this.getConfiguredCodingRate(),
configured_tx_power: this.getConfiguredTxPower(),
configured_frequency: this.getConfiguredFrequency(),
configured_bandwidth: this.getConfiguredBandwidth(),
};
}
if (details.checksum !== details.calculated_checksum) {
details.is_provisioned = false;
}
return details;
}
}

View File

@@ -0,0 +1,515 @@
import ROM from "./ROM.js";
export default [
{
name: "Heltec LoRa32 v2",
id: ROM.PRODUCT_H32_V2,
platform: ROM.PLATFORM_ESP32,
models: [
{
id: ROM.MODEL_C4,
name: "433 MHz",
},
{
id: ROM.MODEL_C9,
name: "868 MHz / 915 MHz / 923 MHz",
},
],
firmware_filename: "rnode_firmware_heltec32v2.zip",
flash_config: {
flash_size: "8MB",
flash_files: {
"0xe000": "rnode_firmware_heltec32v2.boot_app0",
"0x1000": "rnode_firmware_heltec32v2.bootloader",
"0x10000": "rnode_firmware_heltec32v2.bin",
"0x210000": "console_image.bin",
"0x8000": "rnode_firmware_heltec32v2.partitions",
},
},
},
{
name: "Heltec LoRa32 v3",
id: ROM.PRODUCT_H32_V3,
platform: ROM.PLATFORM_ESP32,
models: [
{
id: ROM.MODEL_C5,
name: "433 MHz",
},
{
id: ROM.MODEL_CA,
name: "868 MHz / 915 MHz / 923 MHz",
},
],
firmware_filename: "rnode_firmware_heltec32v3.zip",
flash_config: {
flash_size: "8MB",
flash_files: {
"0xe000": "rnode_firmware_heltec32v3.boot_app0",
"0x0": "rnode_firmware_heltec32v3.bootloader",
"0x10000": "rnode_firmware_heltec32v3.bin",
"0x210000": "console_image.bin",
"0x8000": "rnode_firmware_heltec32v3.partitions",
},
},
},
{
name: "Heltec LoRa32 v4",
id: ROM.PRODUCT_H32_V4,
platform: ROM.PLATFORM_ESP32,
models: [
{
id: ROM.MODEL_C8,
name: "868 MHz / 915 MHz / 923 MHz with PA",
},
],
firmware_filename: "rnode_firmware_heltec32v4pa.zip",
flash_config: {
flash_size: "16MB",
flash_files: {
"0xe000": "rnode_firmware_heltec32v4pa.boot_app0",
"0x0": "rnode_firmware_heltec32v4pa.bootloader",
"0x10000": "rnode_firmware_heltec32v4pa.bin",
"0x210000": "console_image.bin",
"0x8000": "rnode_firmware_heltec32v4pa.partitions",
},
},
},
{
name: "Heltec T114",
id: ROM.PRODUCT_HELTEC_T114,
platform: ROM.PLATFORM_NRF52,
models: [
{
id: ROM.MODEL_C6,
name: "470-510 MHz (HT-n5262-LF)",
},
{
id: ROM.MODEL_C7,
name: "863-928 MHz (HT-n5262-HF)",
},
],
firmware_filename: "rnode_firmware_heltec_t114.zip",
},
{
name: "LilyGO LoRa32 v1.0",
id: ROM.PRODUCT_T32_10,
platform: ROM.PLATFORM_ESP32,
models: [
{
id: ROM.MODEL_BA,
name: "433 MHz",
},
{
id: ROM.MODEL_BB,
name: "868 MHz / 915 MHz / 923 MHz",
},
],
firmware_filename: "rnode_firmware_lora32v10.zip",
flash_config: {
flash_size: "4MB",
flash_files: {
"0xe000": "rnode_firmware_lora32v10.boot_app0",
"0x1000": "rnode_firmware_lora32v10.bootloader",
"0x10000": "rnode_firmware_lora32v10.bin",
"0x210000": "console_image.bin",
"0x8000": "rnode_firmware_lora32v10.partitions",
},
},
},
{
name: "LilyGO LoRa32 v2.0",
id: ROM.PRODUCT_T32_20,
platform: ROM.PLATFORM_ESP32,
models: [
{
id: ROM.MODEL_B3,
name: "433 MHz",
},
{
id: ROM.MODEL_B8,
name: "868 MHz / 915 MHz / 923 MHz",
},
],
firmware_filename: "rnode_firmware_lora32v20.zip",
flash_config: {
flash_size: "4MB",
flash_files: {
"0xe000": "rnode_firmware_lora32v20.boot_app0",
"0x1000": "rnode_firmware_lora32v20.bootloader",
"0x10000": "rnode_firmware_lora32v20.bin",
"0x210000": "console_image.bin",
"0x8000": "rnode_firmware_lora32v20.partitions",
},
},
},
{
name: "LilyGO LoRa32 v2.1",
id: ROM.PRODUCT_T32_21,
platform: ROM.PLATFORM_ESP32,
models: [
{
id: ROM.MODEL_B4,
name: "433 MHz",
firmware_filename: "rnode_firmware_lora32v21.zip",
flash_config: {
flash_size: "4MB",
flash_files: {
"0xe000": "rnode_firmware_lora32v21.boot_app0",
"0x1000": "rnode_firmware_lora32v21.bootloader",
"0x10000": "rnode_firmware_lora32v21.bin",
"0x210000": "console_image.bin",
"0x8000": "rnode_firmware_lora32v21.partitions",
},
},
},
{
id: ROM.MODEL_B9,
name: "868/915/923 MHz",
firmware_filename: "rnode_firmware_lora32v21.zip",
flash_config: {
flash_size: "4MB",
flash_files: {
"0xe000": "rnode_firmware_lora32v21.boot_app0",
"0x1000": "rnode_firmware_lora32v21.bootloader",
"0x10000": "rnode_firmware_lora32v21.bin",
"0x210000": "console_image.bin",
"0x8000": "rnode_firmware_lora32v21.partitions",
},
},
},
{
id: ROM.MODEL_B4_TCXO,
mapped_id: ROM.MODEL_B4,
name: "433 MHz, with TCXO",
firmware_filename: "rnode_firmware_lora32v21_tcxo.zip",
flash_config: {
flash_size: "4MB",
flash_files: {
"0xe000": "rnode_firmware_lora32v21_tcxo.boot_app0",
"0x1000": "rnode_firmware_lora32v21_tcxo.bootloader",
"0x10000": "rnode_firmware_lora32v21_tcxo.bin",
"0x210000": "console_image.bin",
"0x8000": "rnode_firmware_lora32v21_tcxo.partitions",
},
},
},
{
id: ROM.MODEL_B9_TCXO,
mapped_id: ROM.MODEL_B9,
name: "868/915/923 MHz, with TCXO",
firmware_filename: "rnode_firmware_lora32v21_tcxo.zip",
flash_config: {
flash_size: "4MB",
flash_files: {
"0xe000": "rnode_firmware_lora32v21_tcxo.boot_app0",
"0x1000": "rnode_firmware_lora32v21_tcxo.bootloader",
"0x10000": "rnode_firmware_lora32v21_tcxo.bin",
"0x210000": "console_image.bin",
"0x8000": "rnode_firmware_lora32v21_tcxo.partitions",
},
},
},
],
},
{
name: "LilyGO LoRa T3S3",
id: ROM.PRODUCT_RNODE,
platform: ROM.PLATFORM_ESP32,
models: [
{
id: ROM.MODEL_A5,
name: "433 MHz (with SX1278 chip)",
firmware_filename: "rnode_firmware_t3s3_sx127x.zip",
flash_config: {
flash_size: "4MB",
flash_files: {
"0xe000": "rnode_firmware_t3s3_sx127x.boot_app0",
"0x0": "rnode_firmware_t3s3_sx127x.bootloader",
"0x10000": "rnode_firmware_t3s3_sx127x.bin",
"0x210000": "console_image.bin",
"0x8000": "rnode_firmware_t3s3_sx127x.partitions",
},
},
},
{
id: ROM.MODEL_AA,
name: "868/915/923 MHz (with SX1276 chip)",
firmware_filename: "rnode_firmware_t3s3_sx127x.zip",
flash_config: {
flash_size: "4MB",
flash_files: {
"0xe000": "rnode_firmware_t3s3_sx127x.boot_app0",
"0x0": "rnode_firmware_t3s3_sx127x.bootloader",
"0x10000": "rnode_firmware_t3s3_sx127x.bin",
"0x210000": "console_image.bin",
"0x8000": "rnode_firmware_t3s3_sx127x.partitions",
},
},
},
{
id: ROM.MODEL_A1,
name: "433 MHz (with SX1268 chip)",
firmware_filename: "rnode_firmware_t3s3.zip",
flash_config: {
flash_size: "4MB",
flash_files: {
"0xe000": "rnode_firmware_t3s3.boot_app0",
"0x0": "rnode_firmware_t3s3.bootloader",
"0x10000": "rnode_firmware_t3s3.bin",
"0x210000": "console_image.bin",
"0x8000": "rnode_firmware_t3s3.partitions",
},
},
},
{
id: ROM.MODEL_A6,
name: "868/915/923 MHz (with SX1262 chip)",
firmware_filename: "rnode_firmware_t3s3.zip",
flash_config: {
flash_size: "4MB",
flash_files: {
"0xe000": "rnode_firmware_t3s3.boot_app0",
"0x0": "rnode_firmware_t3s3.bootloader",
"0x10000": "rnode_firmware_t3s3.bin",
"0x210000": "console_image.bin",
"0x8000": "rnode_firmware_t3s3.partitions",
},
},
},
{
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",
},
},
},
],
},
{
name: "LilyGO T-Beam",
id: ROM.PRODUCT_TBEAM,
platform: ROM.PLATFORM_ESP32,
models: [
{
id: ROM.MODEL_E4,
name: "433 MHz (with SX1278 chip)",
firmware_filename: "rnode_firmware_tbeam.zip",
flash_config: {
flash_size: "4MB",
flash_files: {
"0xe000": "rnode_firmware_tbeam.boot_app0",
"0x1000": "rnode_firmware_tbeam.bootloader",
"0x10000": "rnode_firmware_tbeam.bin",
"0x210000": "console_image.bin",
"0x8000": "rnode_firmware_tbeam.partitions",
},
},
},
{
id: ROM.MODEL_E9,
name: "868/915/923 MHz (with SX1276 chip)",
firmware_filename: "rnode_firmware_tbeam.zip",
flash_config: {
flash_size: "4MB",
flash_files: {
"0xe000": "rnode_firmware_tbeam.boot_app0",
"0x1000": "rnode_firmware_tbeam.bootloader",
"0x10000": "rnode_firmware_tbeam.bin",
"0x210000": "console_image.bin",
"0x8000": "rnode_firmware_tbeam.partitions",
},
},
},
{
id: ROM.MODEL_E3,
name: "433 MHz (with SX1268 chip)",
firmware_filename: "rnode_firmware_tbeam_sx1262.zip",
flash_config: {
flash_size: "4MB",
flash_files: {
"0xe000": "rnode_firmware_tbeam_sx1262.boot_app0",
"0x1000": "rnode_firmware_tbeam_sx1262.bootloader",
"0x10000": "rnode_firmware_tbeam_sx1262.bin",
"0x210000": "console_image.bin",
"0x8000": "rnode_firmware_tbeam_sx1262.partitions",
},
},
},
{
id: ROM.MODEL_E8,
name: "868/915/923 MHz (with SX1262 chip)",
firmware_filename: "rnode_firmware_tbeam_sx1262.zip",
flash_config: {
flash_size: "4MB",
flash_files: {
"0xe000": "rnode_firmware_tbeam_sx1262.boot_app0",
"0x1000": "rnode_firmware_tbeam_sx1262.bootloader",
"0x10000": "rnode_firmware_tbeam_sx1262.bin",
"0x210000": "console_image.bin",
"0x8000": "rnode_firmware_tbeam_sx1262.partitions",
},
},
},
],
},
{
name: "LilyGO T-Beam Supreme",
id: ROM.PRODUCT_TBEAM_S_V1,
platform: ROM.PLATFORM_ESP32,
models: [
{
id: ROM.MODEL_DB,
name: "433 MHz (with SX1268 chip)",
},
{
id: ROM.MODEL_DC,
name: "868/915/923 MHz (with SX1262 chip)",
},
],
firmware_filename: "rnode_firmware_tbeam_supreme.zip",
flash_config: {
flash_size: "4MB",
flash_files: {
"0xe000": "rnode_firmware_tbeam_supreme.boot_app0",
"0x0": "rnode_firmware_tbeam_supreme.bootloader",
"0x10000": "rnode_firmware_tbeam_supreme.bin",
"0x210000": "console_image.bin",
"0x8000": "rnode_firmware_tbeam_supreme.partitions",
},
},
},
{
name: "LilyGO T-Deck",
id: ROM.PRODUCT_TDECK,
platform: ROM.PLATFORM_ESP32,
models: [
{
id: ROM.MODEL_D4,
name: "433 MHz (with SX1268 chip)",
},
{
id: ROM.MODEL_D9,
name: "868/915/923 MHz (with SX1262 chip)",
},
],
firmware_filename: "rnode_firmware_tdeck.zip",
flash_config: {
flash_size: "4MB",
flash_files: {
"0xe000": "rnode_firmware_tdeck.boot_app0",
"0x0": "rnode_firmware_tdeck.bootloader",
"0x10000": "rnode_firmware_tdeck.bin",
"0x210000": "console_image.bin",
"0x8000": "rnode_firmware_tdeck.partitions",
},
},
},
{
name: "LilyGO T-Echo",
id: ROM.PRODUCT_TECHO,
platform: ROM.PLATFORM_NRF52,
models: [
{
id: ROM.MODEL_16,
name: "433 MHz",
},
{
id: ROM.MODEL_17,
name: "868 MHz / 915 MHz / 923 MHz",
},
],
firmware_filename: "rnode_firmware_techo.zip",
},
{
name: "RAK4631",
id: ROM.PRODUCT_RAK4631,
platform: ROM.PLATFORM_NRF52,
models: [
{
id: ROM.MODEL_11,
name: "433 MHz",
},
{
id: ROM.MODEL_12,
name: "868 MHz / 915 MHz / 923 MHz",
},
],
firmware_filename: "rnode_firmware_rak4631.zip",
},
{
name: "RNode",
id: ROM.PRODUCT_RNODE,
platform: ROM.PLATFORM_ESP32,
models: [
{
id: ROM.MODEL_A2,
name: "Handheld v2.1 RNode, 410 - 525 MHz",
firmware_filename: "rnode_firmware_ng21.zip",
flash_config: {
flash_size: "4MB",
flash_files: {
"0xe000": "rnode_firmware_ng21.boot_app0",
"0x1000": "rnode_firmware_ng21.bootloader",
"0x10000": "rnode_firmware_ng21.bin",
"0x210000": "console_image.bin",
"0x8000": "rnode_firmware_ng21.partitions",
},
},
},
{
id: ROM.MODEL_A7,
name: "Handheld v2.1 RNode, 820 - 1020 MHz",
firmware_filename: "rnode_firmware_ng21.zip",
flash_config: {
flash_size: "4MB",
flash_files: {
"0xe000": "rnode_firmware_ng21.boot_app0",
"0x1000": "rnode_firmware_ng21.bootloader",
"0x10000": "rnode_firmware_ng21.bin",
"0x210000": "console_image.bin",
"0x8000": "rnode_firmware_ng21.partitions",
},
},
},
{
id: ROM.MODEL_A1,
name: "Prototype v2.2 RNode, 410 - 525 MHz",
firmware_filename: "rnode_firmware_t3s3.zip",
flash_config: {
flash_size: "4MB",
flash_files: {
"0xe000": "rnode_firmware_t3s3.boot_app0",
"0x0": "rnode_firmware_t3s3.bootloader",
"0x10000": "rnode_firmware_t3s3.bin",
"0x210000": "console_image.bin",
"0x8000": "rnode_firmware_t3s3.partitions",
},
},
},
{
id: ROM.MODEL_A6,
name: "Prototype v2.2 RNode, 820 - 1020 MHz",
firmware_filename: "rnode_firmware_t3s3.zip",
flash_config: {
flash_size: "4MB",
flash_files: {
"0xe000": "rnode_firmware_t3s3.boot_app0",
"0x0": "rnode_firmware_t3s3.bootloader",
"0x10000": "rnode_firmware_t3s3.bin",
"0x210000": "console_image.bin",
"0x8000": "rnode_firmware_t3s3.partitions",
},
},
},
],
},
];

View File

@@ -520,7 +520,129 @@
},
"rnode_flasher": {
"title": "RNode Flasher",
"description": "RNode-Adapter flashen und aktualisieren, ohne die Kommandozeile zu berühren."
"description": "RNode-Adapter flashen und aktualisieren, ohne die Kommandozeile zu berühren.",
"select_device": "1. Wählen Sie Ihr Gerät aus",
"product": "Produkt",
"model": "Modell",
"select_product": "Produkt auswählen",
"select_model": "Modell auswählen",
"enter_dfu_mode": "DFU-Modus aufrufen",
"entering_dfu_mode": "DFU-Modus wird aufgerufen...",
"find_device_issue": "Gerät nicht gefunden? Öffnen Sie ein Ticket auf",
"select_firmware": "2. Firmware zum Flashen auswählen (.zip)",
"flash_now": "Jetzt flashen",
"flashing": "Flashen: {percentage}%",
"flashing_file_progress": "Datei {current}/{total}: {percentage}%",
"connecting_device": "Verbindung zum Gerät wird hergestellt...",
"download_firmware": "Firmware herunterladen",
"official_firmware": "Offizielle Firmware",
"ce_firmware": "CE Firmware",
"transport_node_firmware": "Transport Node Firmware",
"common_issues": "Häufige Probleme",
"hardware_failure": "Hardwarefehler:",
"provision_eeprom_hint": "Sie müssen das EEPROM in Schritt 3 bereitstellen.",
"firmware_corrupt": "Firmware beschädigt:",
"set_firmware_hash_hint": "Sie müssen den Firmware-Hash in Schritt 4 setzen.",
"step_provision": "3. EEPROM bereitstellen",
"provision_description": "Setzt Geräteinfo, Prüfsumme und leere Signatur",
"provision": "Bereitstellen",
"provisioning_wait": "Bereitstellung: Bitte warten...",
"step_set_hash": "4. Firmware-Hash setzen",
"set_hash_description": "Verwendet Hash vom Board",
"set_firmware_hash": "Firmware-Hash setzen",
"setting_hash_wait": "Firmware-Hash wird gesetzt: Bitte warten...",
"step_done": "5. Abschließen",
"download_recommended": "Empfohlene Firmware herunterladen & laden",
"downloading": "Herunterladen...",
"done_description": "Wenn Sie es bis hierher geschafft haben und alle vorherigen Schritte erfolgreich waren, sollte Ihr RNode einsatzbereit sein.",
"meshchat_usage": "Um RNode mit MeshChat zu verwenden, müssen Sie ein RNodeInterface auf der Seite Schnittstellen → Schnittstelle hinzufügen hinzufügen.",
"sideband_usage": "Um RNode mit Sideband zu verwenden, müssen Sie es in Hardware → RNode konfigurieren und Konnektivität → Über RNode verbinden aktivieren.",
"restart_warning": "Sie müssen MeshChat und Sideband neu starten, damit Änderungen an den Schnittstelleneinstellungen wirksam werden, sonst passiert nichts!",
"advanced_tools": "Erweiterte Tools",
"detect_rnode": "RNode erkennen",
"reboot_rnode": "RNode neu starten",
"read_display": "Display lesen",
"dump_eeprom": "EEPROM dumpen",
"wipe_eeprom": "EEPROM löschen",
"eeprom_console_hint": "EEPROM-Dumps werden in der Entwickler-Konsole angezeigt.",
"configure_bluetooth": "Bluetooth konfigurieren (optional)",
"bluetooth_info_1": "Bluetooth wird nicht auf allen Geräten unterstützt.",
"bluetooth_info_2": "Einige Geräte verwenden Bluetooth Classic, andere BLE (Bluetooth Low Energy).",
"bluetooth_info_3": "Versetzen Sie den RNode in den Bluetooth-Kopplungsmodus und verbinden Sie ihn dann über die Android-Bluetooth-Einstellungen.",
"bluetooth_info_4": "Sobald Sie eine Kopplungsanfrage von Android initiiert haben, sollte eine PIN auf dem RNode-Display angezeigt werden.",
"bluetooth_info_5": "In Sideband müssen Sie 'Verbindung über Bluetooth' in Hardware → RNode aktivieren.",
"bluetooth_info_6": "Wenn Ihr Gerät BLE verwendet, müssen Sie auch 'Gerät erfordert BLE' in Hardware → RNode aktivieren.",
"bluetooth_restart_warning": "Vergessen Sie nicht, Sideband neu zu starten, damit die Einstellungen wirksam werden!",
"enable": "Aktivieren",
"disable": "Deaktivieren",
"start_pairing": "Kopplung starten",
"configure_tnc": "TNC-Modus konfigurieren (optional)",
"tnc_info_1": "Der TNC-Modus ermöglicht die Verwendung eines RNode als KISS-kompatibler TNC über die serielle Schnittstelle.",
"tnc_info_2": "Dieser Modus macht ihn nutzbar mit Amateurfunk-Software, die mit einem KISS-TNC über eine serielle Schnittstelle kommunizieren kann.",
"tnc_warning": "Sie müssen den TNC-Modus deaktiviert lassen, wenn Sie RNode mit Apps wie MeshChat oder Sideband verwenden.",
"frequency": "Frequenz (Hz)",
"bandwidth": "Bandbreite",
"tx_power": "Sendeleistung (dBm)",
"spreading_factor": "Spreizfaktor",
"coding_rate": "Coderate",
"configure_display": "Display konfigurieren",
"rotation": "Rotation",
"reconditioning": "Aufbereitung",
"start": "Start",
"stop": "Stopp",
"rotation_v180_warning": "Das Einstellen der Display-Rotation erfordert Firmware v1.80+",
"errors": {
"failed_download": "Fehler beim Herunterladen der Firmware: {error}",
"firmware_not_found_in_release": "Empfohlene Firmware im neuesten Release nicht gefunden.",
"web_serial_not_supported": "Web Serial wird von diesem Browser nicht unterstützt. Bitte verwenden Sie Chrome, Edge oder einen anderen Chromium-basierten Browser.",
"no_device_selected": "Kein Gerät ausgewählt. Bitte wählen Sie einen seriellen Port aus.",
"failed_connect": "Verbindung zum Gerät fehlgeschlagen: {error}",
"not_an_rnode": "Das ausgewählte Gerät ist kein RNode! Bitte stellen Sie sicher, dass Sie den richtigen seriellen Port ausgewählt haben.",
"failed_dfu": "Fehler beim Aufrufen des DFU-Modus: {error}",
"select_firmware_first": "Bitte wählen Sie zuerst eine Firmware-Datei (.zip) aus.",
"failed_flash": "Firmware-Flash fehlgeschlagen: {error}",
"esptool_not_loaded": "esptool-js konnte nicht geladen werden. Bitte laden Sie die Seite neu und versuchen Sie es erneut.",
"no_flash_config": "Flash-Konfiguration für das ausgewählte Gerät nicht verfügbar. Bitte wählen Sie ein anderes Gerät aus oder überprüfen Sie die Firmware-Datei.",
"failed_extract": "Erforderliche Datei \"{file}\" in der Firmware-ZIP-Datei nicht gefunden. Bitte laden Sie die richtige Firmware für Ihr Gerät herunter.",
"failed_detect": "RNode konnte nicht erkannt werden. Stellen Sie sicher, dass das Gerät angeschlossen ist und sich im richtigen Modus befindet.",
"provisioned_already": "EEPROM ist bereits bereitgestellt. Sie müssen es löschen, um es erneut bereitzustellen!",
"select_product_first": "Bitte wählen Sie ein Produkt aus!",
"select_model_first": "Bitte wählen Sie ein Modell aus!",
"not_provisioned": "EEPROM ist nicht bereitgestellt. Sie müssen dies zuerst tun!",
"failed_provision": "Bereitstellung fehlgeschlagen: {error}",
"failed_set_hash": "Fehler beim Setzen des Firmware-Hash: {error}",
"failed_reboot": "Fehler beim Neustart des RNode: {error}",
"failed_read_display": "Fehler beim Lesen des Displays: {error}",
"failed_dump_eeprom": "Fehler beim Dumpen des EEPROM: {error}",
"failed_wipe_eeprom": "Fehler beim Löschen des EEPROM: {error}",
"failed_enable_tnc": "Fehler beim Aktivieren des TNC-Modus: {error}",
"failed_disable_tnc": "Fehler beim Deaktivieren des TNC-Modus: {error}",
"failed_enable_bluetooth": "Fehler beim Aktivieren von Bluetooth: {error}",
"failed_disable_bluetooth": "Fehler beim Deaktivieren von Bluetooth: {error}",
"failed_start_pairing": "Fehler beim Starten der Kopplung: {error}",
"failed_set_rotation": "Fehler beim Einstellen der Display-Rotation: {error}",
"failed_start_reconditioning": "Fehler beim Starten der Display-Aufbereitung: {error}"
},
"alerts": {
"firmware_downloaded": "Empfohlene Firmware wurde heruntergeladen und geladen.",
"dfu_ready": "Das Gerät sollte sich nun im DFU-Modus befinden. Sie können nun mit dem Flashen der Firmware fortfahren.",
"flash_success": "Firmware wurde erfolgreich geflasht!",
"rebooting": "Board startet neu!",
"eeprom_wipe_confirm": "Sind Sie sicher, dass Sie das EEPROM auf diesem Gerät löschen möchten? Dies dauert etwa 30 Sekunden. Eine Benachrichtigung wird angezeigt, wenn das Löschen des EEPROMs abgeschlossen ist.",
"eeprom_wiped": "EEPROM wurde gelöscht!",
"eeprom_dumped": "EEPROM in der Konsole ausgegeben",
"rnode_detected": "RNode v{version} erkannt",
"provision_success": "Gerät wurde bereitgestellt!",
"hash_success": "Firmware-Hash wurde gesetzt!",
"tnc_enabled": "TNC-Modus wurde aktiviert!",
"tnc_disabled": "TNC-Modus wurde deaktiviert!",
"bluetooth_enabled": "Bluetooth wurde aktiviert!",
"bluetooth_disabled": "Bluetooth wurde deaktiviert!",
"bluetooth_pairing_pin": "Bluetooth-Kopplungs-PIN: {pin}",
"bluetooth_pairing_started": "Kopplungsmodus gestartet (30s)",
"rotation_updated": "Rotation aktualisiert",
"reconditioning_started": "Aufbereitung gestartet"
}
},
"micron_editor": {
"title": "Micron Editor",
@@ -536,7 +658,9 @@
"rename_tab": "Tab umbenennen",
"confirm_reset": "Sind Sie sicher, dass Sie den Editor zurücksetzen möchten? Alle Ihre Tabs und Inhalte gehen verloren.",
"confirm_delete_tab": "Sind Sie sicher, dass Sie diesen Tab löschen möchten?",
"main_tab": "Haupt"
"main_tab": "Haupt",
"guide_tab": "NomadNet Leitfaden",
"parser_by": "Micron Parser von"
},
"paper_message": {
"title": "Papiernachricht",
@@ -707,6 +831,7 @@
"unknown": "Unbekannt",
"call_ended": "Anruf beendet",
"call_declined": "Anruf abgelehnt",
"initiation": "Initiiere...",
"send_to_voicemail": "An Sprachnachricht senden",
"incoming_call": "Eingehender Anruf...",
"busy": "Besetzt...",
@@ -820,31 +945,132 @@
"failed_to_play_ringtone": "Klingelton konnte nicht abgespielt werden",
"failed_to_play_voicemail": "Sprachnachricht konnte nicht abgespielt werden"
},
"tutorial": {
"title": "Erste Schritte",
"welcome": "Willkommen bei",
"welcome_desc": "Die Zukunft der netzunabhängigen Kommunikation. Sicher, dezentral und unaufhaltsam.",
"security": "Sicherheit & Leistung",
"security_desc": "Massive Verbesserungen bei Geschwindigkeit, Sicherheit und Crash-Wiederherstellung.",
"security_desc_page": "Massive Verbesserungen bei Geschwindigkeit, Sicherheit und Integrität mit integrierter Crash-Wiederherstellung.",
"maps": "Karten",
"maps_desc": "OpenLayers mit MBTiles-Unterstützung (Export und Offline-Unterstützung) und benutzerdefinierten API-Endpunkten.",
"maps_desc_page": "OpenLayers-Unterstützung mit Offline-MBTiles (Export und Offline-Unterstützung) und benutzerdefinierten API-Endpunkten für Online-Karten.",
"voice": "Vollständige LXST-Stimme",
"voice_desc": "Voicemail, Klingeltöne, Telefonbuch und Kontaktfreigabe.",
"voice_desc_page": "Kristallklare Sprachanrufe über Mesh. Voicemail, benutzerdefinierte Klingeltöne und Telefonbuchsuche.",
"tools": "Erweiterte Werkzeuge",
"tools_desc": "Micron-Editor, NomadNet, RNS-Tools, Dokumentation.",
"tools_desc_page": "Micron-Editor, NomadNet-Knoten, RNS-Tools und integrierte Dokumentation.",
"archiver": "Archivierer",
"archiver_desc": "Manuelle und automatisierte Seitenarchivierung.",
"archiver_desc_page": "Manuelle und automatisierte Seitenarchivierungswerkzeuge für das Offline-Browsing.",
"banishment": "Verbannung",
"banishment_desc": "Verbannen Sie nervige Leute und Knoten, inklusive Unterstützung für RNS-Blackhole-Level-Ignorieren ihrer Ankündigungen.",
"palette": "Befehlspalette + Tastenkombinationen",
"palette_desc": "Navigieren Sie sofort überall hin und passen Sie Tastenkürzel an.",
"palette_desc_page": "Navigieren Sie durch die gesamte Anwendung und passen Sie Ihren Workflow mit sofortigen Tastenkürzeln an.",
"i18n": "i18n-Unterstützung",
"i18n_desc": "Verfügbar in Englisch, Deutsch und Russisch.",
"i18n_desc_page": "Vollständige Internationalisierungsunterstützung für die Sprachen Englisch, Deutsch und Russisch.",
"more_features": "+ viele weitere Funktionen!",
"connect": "Mit dem Mesh verbinden",
"connect_desc": "Um Nachrichten zu senden, müssen Sie sich mit einer Reticulum-Schnittstelle verbinden.",
"connect_desc_page": "Verwenden Sie eines der unten aufgeführten öffentlichen Netzwerke oder fügen Sie eine benutzerdefinierte Schnittstelle über die Seite 'Schnittstellen' hinzu.",
"suggested_networks": "Empfohlene öffentliche Netzwerke",
"suggested_relays": "Empfohlene Relais",
"use": "Verwenden",
"online": "Online",
"custom_interfaces": "Benutzerdefinierte Schnittstellen",
"custom_interfaces_desc": "Möchten Sie eine maßgeschneiderte Schnittstelle hinzufügen? Gehen Sie zur Seite 'Schnittstellen', um eine benutzerdefinierte Verbindung zu konfigurieren.",
"custom_interfaces_desc_page": "Für alle privaten Relais oder Hardware, die Sie selbst verwalten, fügen Sie diese über die dedizierte Seite 'Schnittstellen' hinzu.",
"open_interfaces": "Schnittstellenseite öffnen",
"learn_create": "Lernen & Erstellen",
"learn_create_desc": "Entdecken Sie, wie Sie MeshChatX optimal nutzen können.",
"learn_create_desc_page": "Entdecken Sie, wie Sie MeshChatX optimal nutzen und Ihre eigenen Inhalte erstellen können.",
"documentation": "Dokumentation",
"documentation_desc": "Lesen Sie die offizielle MeshChatX- und Reticulum-Dokumentation.",
"documentation_desc_page": "Umfassende Leitfäden für MeshChatX und den zugrunde liegenden Reticulum Network Stack.",
"meshchatx_docs": "MeshChatX-Dokumentation",
"reticulum_docs": "Reticulum-Dokumentation",
"read_meshchatx_docs": "MeshChatX-Dokumentation lesen",
"reticulum_manual": "Reticulum-Netzwerkhandbuch",
"micron_editor": "Micron-Editor",
"micron_editor_desc": "Werfen Sie einen Blick auf den Micron-Editor für eine Anleitung zum Erstellen von Mesh-nativen Seiten.",
"micron_editor_desc_page": "Erfahren Sie, wie Sie mit der Micron-Markup-Sprache Mesh-native Seiten und interaktive Inhalte erstellen.",
"open_micron_editor": "Micron-Editor öffnen",
"paper_messages": "NomadNet-Knoten erkunden",
"paper_messages_desc": "Dezentrale Seiten und Dienste durchsuchen.",
"send_messages": "Nachrichten senden",
"send_messages_desc": "Starten Sie eine sichere Unterhaltung.",
"explore_nodes": "Knoten visualisieren",
"explore_nodes_desc": "Netzwerkkarte und Topologie anzeigen.",
"voice_calls": "Sprachanrufe",
"voice_calls_desc": "Schauen Sie sich den fantastischen Klingelton-Editor an!",
"ready": "Bereit zum Loslegen!",
"ready_desc": "Alles ist eingerichtet. Sie müssen die Anwendung neu starten, damit die Änderungen wirksam werden.",
"ready_desc_page": "MeshChatX ist jetzt konfiguriert. Sie müssen die Anwendung neu starten, um die Verbindung abzuschließen.",
"docker_note": "Wenn Sie in Docker ausführen, stellen Sie sicher, dass Sie den Container neu starten.",
"restart_required": "Neustart erforderlich",
"restart_desc_page": "Wenn Sie in Docker ausführen, stellen Sie sicher, dass Sie den Container neu starten. Native Apps werden automatisch neu gestartet.",
"back": "Zurück",
"skip": "Überspringen",
"next": "Weiter",
"restart_start": "MeshChatX starten",
"skip_setup": "Setup überspringen",
"continue": "Weiter",
"skip_confirm": "Sind Sie sicher, dass Sie das Setup überspringen möchten? Sie müssen Schnittstellen später manuell hinzufügen."
},
"command_palette": {
"search_placeholder": "Suchen oder Befehl eingeben...",
"no_results": "Keine Ergebnisse gefunden für \"{query}\"",
"nav_messages": "Zu Nachrichten gehen",
"nav_messages_desc": "Öffnen Sie Ihre aktuellen Gespräche",
"nav_nomad": "Zu Nomad Network gehen",
"nav_nomad_desc": "Durchsuchen Sie das verteilte Web",
"nav_map": "Zur Karte gehen",
"nav_map_desc": "Peer-Standorte und Telemetrie anzeigen",
"nav_paper": "Papiernachrichten-Generator",
"nav_paper_desc": "Signierte QR-Code-Nachrichten generieren",
"nav_call": "Zu Anrufen gehen",
"nav_call_desc": "Sprachanrufe und Voicemails",
"nav_settings": "Zu Einstellungen gehen",
"nav_settings_desc": "App-Konfiguration und Einstellungen",
"action_sync": "Nachrichten synchronisieren",
"action_sync_desc": "Nachrichten vom Propagationsknoten anfordern",
"action_compose": "Neue Nachricht verfassen",
"action_compose_desc": "Starten Sie einen neuen Chat per Adresse",
"action_orbit": "Orbit-Modus",
"action_orbit_desc": "Weltraum-Orbit-Visualisierung umschalten",
"group_recent": "Aktuelle Gespräche",
"search_placeholder": "Befehle suchen, navigieren oder Peers finden...",
"no_results": "Keine Ergebnisse für \"{query}\" gefunden",
"group_actions": "Aktionen",
"group_recent": "Kürzliche Peers",
"group_contacts": "Kontakte",
"group_actions": "Schnellaktionen",
"footer_navigate": "Navigieren",
"footer_select": "Auswählen"
"footer_select": "Auswählen",
"nav_messages": "Nachrichten",
"nav_messages_desc": "Nachrichtenseite öffnen",
"nav_nomad": "Nomad Network",
"nav_nomad_desc": "Nomad Network Seiten durchsuchen",
"nav_map": "Karte",
"nav_map_desc": "Netzwerktopologie-Karte anzeigen",
"nav_paper": "Papier-Nachrichten",
"nav_paper_desc": "Offline-Nachrichten erstellen",
"nav_call": "Sprachanrufe",
"nav_call_desc": "Sprachanrufseite öffnen",
"nav_settings": "Einstellungen",
"nav_settings_desc": "Einstellungsseite öffnen",
"nav_ping": "Ping",
"nav_ping_desc": "Netzwerkknoten pingen",
"nav_rnprobe": "RN Probe",
"nav_rnprobe_desc": "Reticulum-Knoten sondieren",
"nav_rncp": "RN CP",
"nav_rncp_desc": "Reticulum Control Protocol",
"nav_rnstatus": "RN Status",
"nav_rnstatus_desc": "Reticulum-Status anzeigen",
"nav_rnpath": "RN Pfad",
"nav_rnpath_desc": "Netzwerkpfade anzeigen",
"nav_translator": "Übersetzer",
"nav_translator_desc": "Nachrichten übersetzen",
"nav_forwarder": "Weiterleiter",
"nav_forwarder_desc": "Nachrichten-Weiterleitungs-Tool",
"nav_documentation": "Dokumentation",
"nav_documentation_desc": "Dokumentation anzeigen",
"nav_micron_editor": "Micron-Editor",
"nav_micron_editor_desc": "Mesh-native Seiten erstellen",
"nav_rnode_flasher": "RNode Flasher",
"nav_rnode_flasher_desc": "RNode-Firmware flashen",
"nav_debug_logs": "Debug-Logs",
"nav_debug_logs_desc": "Debug-Logs anzeigen",
"action_sync": "Nachrichten synchronisieren",
"action_sync_desc": "Mit Propagationsknoten synchronisieren",
"action_compose": "Nachricht verfassen",
"action_compose_desc": "Eine neue Nachricht beginnen",
"action_orbit": "Orbit umschalten",
"action_orbit_desc": "Orbit-Modus umschalten",
"action_getting_started": "Erste Schritte",
"action_getting_started_desc": "Einführungsleitfaden anzeigen",
"action_changelog": "Änderungsprotokoll",
"action_changelog_desc": "Was ist neu anzeigen"
}
}

View File

@@ -23,7 +23,7 @@
"tutorial_connect": "Connect to the Mesh",
"tutorial_finish": "Ready to Roll!",
"tutorial_restart_required": "Restart Required",
"tutorial_docker_note": "If you're running in Docker, ensure your container auto-restarts.",
"tutorial_docker_note": "If you're running in Docker, ensure you restart the container.",
"my_identity": "My Identity",
"identity_hash": "Identity Hash",
"lxmf_address": "LXMF Address",
@@ -520,7 +520,129 @@
},
"rnode_flasher": {
"title": "RNode Flasher",
"description": "Flash and update RNode adapters without touching the command line."
"description": "Flash and update RNode adapters without touching the command line.",
"select_device": "1. Select your device",
"product": "Product",
"model": "Model",
"select_product": "Select a Product",
"select_model": "Select a Model",
"enter_dfu_mode": "Enter DFU Mode",
"entering_dfu_mode": "Entering DFU Mode...",
"find_device_issue": "Can't find your device? Open an issue on",
"select_firmware": "2. Select firmware to flash (.zip)",
"flash_now": "Flash Now",
"flashing": "Flashing: {percentage}%",
"flashing_file_progress": "File {current}/{total}: {percentage}%",
"connecting_device": "Connecting to device...",
"download_firmware": "Download Firmware",
"official_firmware": "Official Firmware",
"ce_firmware": "CE Firmware",
"transport_node_firmware": "Transport Node Firmware",
"common_issues": "Common Issues",
"hardware_failure": "Hardware Failure:",
"provision_eeprom_hint": "You need to provision the eeprom in step 3.",
"firmware_corrupt": "Firmware Corrupt:",
"set_firmware_hash_hint": "You need to set the firmware hash in step 4.",
"step_provision": "3. Provision EEPROM",
"provision_description": "Sets device info, checksum and blank signature",
"provision": "Provision",
"provisioning_wait": "Provisioning: please wait...",
"step_set_hash": "4. Set Firmware Hash",
"set_hash_description": "Uses hash from board",
"set_firmware_hash": "Set Firmware Hash",
"setting_hash_wait": "Setting Firmware Hash: please wait...",
"step_done": "5. Finalize",
"download_recommended": "Download & Load Recommended Firmware",
"downloading": "Downloading...",
"done_description": "If you made it this far, and all previous steps were successful, your RNode should be ready to use.",
"meshchat_usage": "To use RNode with MeshChat, you will need to add an RNodeInterface in the Interfaces → Add Interface page.",
"sideband_usage": "To use RNode with Sideband, you will need to configure it in Hardware → RNode and enable Connectivity → Connect via RNode.",
"restart_warning": "You must restart MeshChat and Sideband for interface setting changes to take effect, otherwise nothing will happen!",
"advanced_tools": "Advanced Tools",
"detect_rnode": "Detect RNode",
"reboot_rnode": "Reboot RNode",
"read_display": "Read Display",
"dump_eeprom": "Dump EEPROM",
"wipe_eeprom": "Wipe EEPROM",
"eeprom_console_hint": "EEPROM dumps are shown in dev tools console.",
"configure_bluetooth": "Configure Bluetooth (optional)",
"bluetooth_info_1": "Bluetooth is not supported on all devices.",
"bluetooth_info_2": "Some devices use Bluetooth Classic, and some use BLE (Bluetooth Low Energy)",
"bluetooth_info_3": "Put the RNode into Bluetooth Pairing mode, then connect to it from Android Bluetooth settings.",
"bluetooth_info_4": "Once you have initiated a pair request from Android, a PIN should show on the RNode display.",
"bluetooth_info_5": "In Sideband you will need to enable Connect using Bluetooth in Hardware → RNode.",
"bluetooth_info_6": "If your device uses BLE you will also need to enable Device requires BLE in Hardware → RNode.",
"bluetooth_restart_warning": "Don't forget to restart Sideband for the setting changes to take effect!",
"enable": "Enable",
"disable": "Disable",
"start_pairing": "Start Pairing",
"configure_tnc": "Configure TNC Mode (optional)",
"tnc_info_1": "TNC mode allows an RNode to be used as a KISS compatible TNC over the Serial Port.",
"tnc_info_2": "This mode makes it usable with amateur radio software that can talk to a KISS TNC over a serial port.",
"tnc_warning": "You must leave TNC mode disabled when using RNode with apps like MeshChat or Sideband.",
"frequency": "Frequency (Hz)",
"bandwidth": "Bandwidth",
"tx_power": "Tx Power (dBm)",
"spreading_factor": "Spreading Factor",
"coding_rate": "Coding Rate",
"configure_display": "Configure Display",
"rotation": "Rotation",
"reconditioning": "Reconditioning",
"start": "Start",
"stop": "Stop",
"rotation_v180_warning": "Setting display rotation requires firmware v1.80+",
"errors": {
"failed_download": "Failed to download firmware: {error}",
"firmware_not_found_in_release": "Recommended firmware not found in the latest release.",
"web_serial_not_supported": "Web Serial is not supported in this browser. Please use Chrome, Edge, or another Chromium-based browser.",
"no_device_selected": "No device selected. Please select a serial port.",
"failed_connect": "Failed to connect to device: {error}",
"not_an_rnode": "Selected device is not an RNode! Please make sure you've selected the correct serial port.",
"failed_dfu": "Failed to enter DFU mode: {error}",
"select_firmware_first": "Please select a firmware file (.zip) first.",
"failed_flash": "Firmware flashing failed: {error}",
"esptool_not_loaded": "esptool-js could not be loaded. Please refresh the page and try again.",
"no_flash_config": "Flash configuration is not available for the selected device. Please select a different device or check the firmware file.",
"failed_extract": "Required file \"{file}\" not found in firmware ZIP file. Please download the correct firmware for your device.",
"failed_detect": "Failed to detect RNode. Make sure the device is connected and in the correct mode.",
"provisioned_already": "Eeprom is already provisioned. You must wipe it to reprovision!",
"select_product_first": "Please select a product!",
"select_model_first": "Please select a model!",
"not_provisioned": "Eeprom is not provisioned. You must do this first!",
"failed_provision": "failed to provision: {error}",
"failed_set_hash": "failed to set firmware hash: {error}",
"failed_reboot": "Failed to reboot RNode: {error}",
"failed_read_display": "Failed to read display: {error}",
"failed_dump_eeprom": "Failed to dump EEPROM: {error}",
"failed_wipe_eeprom": "Failed to wipe EEPROM: {error}",
"failed_enable_tnc": "Failed to enable TNC mode: {error}",
"failed_disable_tnc": "Failed to disable TNC mode: {error}",
"failed_enable_bluetooth": "Failed to enable Bluetooth: {error}",
"failed_disable_bluetooth": "Failed to disable Bluetooth: {error}",
"failed_start_pairing": "Failed to start pairing: {error}",
"failed_set_rotation": "Failed to set display rotation: {error}",
"failed_start_reconditioning": "Failed to start display reconditioning: {error}"
},
"alerts": {
"firmware_downloaded": "Recommended firmware has been downloaded and loaded.",
"dfu_ready": "Device should now be in DFU mode. You can now proceed with flashing firmware.",
"flash_success": "Firmware has been flashed successfully!",
"rebooting": "Board is rebooting!",
"eeprom_wipe_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.",
"eeprom_wiped": "eeprom has been wiped!",
"eeprom_dumped": "EEPROM dumped to console",
"rnode_detected": "RNode v{version} detected",
"provision_success": "device has been provisioned!",
"hash_success": "firmware hash has been set!",
"tnc_enabled": "TNC mode has been enabled!",
"tnc_disabled": "TNC mode has been disabled!",
"bluetooth_enabled": "Bluetooth has been enabled!",
"bluetooth_disabled": "Bluetooth has been disabled!",
"bluetooth_pairing_pin": "Bluetooth Pairing Pin: {pin}",
"bluetooth_pairing_started": "Pairing mode started (30s)",
"rotation_updated": "Rotation updated",
"reconditioning_started": "Reconditioning started"
}
},
"micron_editor": {
"title": "Micron Editor",
@@ -536,7 +658,9 @@
"rename_tab": "Rename Tab",
"confirm_reset": "Are you sure you want to reset the editor? All your tabs and content will be lost.",
"confirm_delete_tab": "Are you sure you want to delete this tab?",
"main_tab": "Main"
"main_tab": "Main",
"guide_tab": "NomadNet Guide",
"parser_by": "Using Micron Parser by"
},
"paper_message": {
"title": "Paper Message",
@@ -707,6 +831,7 @@
"unknown": "Unknown",
"call_ended": "Call Ended",
"call_declined": "Call Declined",
"initiation": "Initiating...",
"send_to_voicemail": "Send to Voicemail",
"incoming_call": "Incoming Call...",
"busy": "Busy...",
@@ -820,31 +945,132 @@
"failed_to_play_ringtone": "Failed to play ringtone",
"failed_to_play_voicemail": "Failed to play voicemail"
},
"tutorial": {
"title": "Getting Started",
"welcome": "Welcome to",
"welcome_desc": "The future of off-grid communication. Secure, decentralized, and unstoppable.",
"security": "Security & Performance",
"security_desc": "Massive improvements in speed, security, and crash recovery.",
"security_desc_page": "Massive improvements in speed, security, and integrity with built-in crash recovery.",
"maps": "Maps",
"maps_desc": "OpenLayers w/ MBTiles support (export and offline support) and custom API endpoints.",
"maps_desc_page": "OpenLayers support with offline MBTiles (export and offline support) and custom API endpoints for online maps.",
"voice": "Full LXST Voice",
"voice_desc": "Voicemail, ringtones, phonebook, and contact sharing.",
"voice_desc_page": "Crystal clear voice calls over mesh. Voicemail, custom ringtones, and phonebook discovery.",
"tools": "Advanced Tools",
"tools_desc": "Micron Editor, NomadNet, RNS tools, Docs.",
"tools_desc_page": "Micron editor, NomadNet nodes, RNS tools, and integrated documentation.",
"archiver": "Archiver",
"archiver_desc": "Manual and Automated page archiving.",
"archiver_desc_page": "Manual and Automated page archiving tools for offline browsing.",
"banishment": "Banishment",
"banishment_desc": "Banish annoying people, nodes and support for RNS blackhole level dropping their announces",
"palette": "Command Palette + Keybindings",
"palette_desc": "Navigate everything instantly and customize shortcuts.",
"palette_desc_page": "Navigate the entire application and customize your workflow with instant shortcuts.",
"i18n": "i18n Support",
"i18n_desc": "Available in English, German, and Russian.",
"i18n_desc_page": "Full internationalization support for English, German, and Russian languages.",
"more_features": "+ many more features!",
"connect": "Connect to the Mesh",
"connect_desc": "To send messages, you need to connect to a Reticulum interface.",
"connect_desc_page": "Use one of the public networks below or add a custom interface from the Interfaces page.",
"suggested_networks": "Suggested Public Networks",
"suggested_relays": "Suggested Relays",
"use": "Use",
"online": "Online",
"custom_interfaces": "Custom Interfaces",
"custom_interfaces_desc": "Want to add a bespoke interface? Head to the Interfaces page to configure a custom connection.",
"custom_interfaces_desc_page": "For any private relays or hardware you manage yourself, add them through the dedicated Interfaces page.",
"open_interfaces": "Open Interfaces page",
"learn_create": "Learn & Create",
"learn_create_desc": "Discover how to use MeshChatX to its full potential.",
"learn_create_desc_page": "Discover how to use MeshChatX to its full potential and create your own content.",
"documentation": "Documentation",
"documentation_desc": "Read the official MeshChatX and Reticulum documentation.",
"documentation_desc_page": "Comprehensive guides for MeshChatX and the underlying Reticulum Network Stack.",
"meshchatx_docs": "MeshChatX Docs",
"reticulum_docs": "Reticulum Docs",
"read_meshchatx_docs": "Read MeshChatX Docs",
"reticulum_manual": "Reticulum Network Manual",
"micron_editor": "Micron Editor",
"micron_editor_desc": "Take a look at the Micron Editor for a guide on creating mesh-native pages.",
"micron_editor_desc_page": "Learn how to create mesh-native pages and interactive content using the Micron markup language.",
"open_micron_editor": "Open Micron Editor",
"paper_messages": "Explore NomadNet Nodes",
"paper_messages_desc": "Browse decentralized pages and services.",
"send_messages": "Send Messages",
"send_messages_desc": "Start a secure conversation.",
"explore_nodes": "Visualize Nodes",
"explore_nodes_desc": "View the network map and topology.",
"voice_calls": "Voice Calls",
"voice_calls_desc": "Check out the awesome ringtone editor!",
"ready": "Ready to Roll!",
"ready_desc": "Everything is set up. You need to restart the application for the changes to take effect.",
"ready_desc_page": "MeshChatX is now configured. You need to restart the application to finalize the connection.",
"docker_note": "If you're running in Docker, make sure you restart the container",
"restart_required": "Restart Required",
"restart_desc_page": "If you're running in Docker, ensure you restart the container. Native apps will relaunch automatically.",
"back": "Back",
"skip": "Skip",
"next": "Next",
"restart_start": "Start MeshChatX",
"skip_setup": "Skip Setup",
"continue": "Continue",
"skip_confirm": "Are you sure you want to skip the setup? You'll need to manually add interfaces later."
},
"command_palette": {
"search_placeholder": "Search or type a command...",
"search_placeholder": "Search commands, navigate, or find peers...",
"no_results": "No results found for \"{query}\"",
"nav_messages": "Go to Messages",
"nav_messages_desc": "Open your recent conversations",
"nav_nomad": "Go to Nomad Network",
"nav_nomad_desc": "Browse the distributed web",
"nav_map": "Go to Map",
"nav_map_desc": "View peer locations and telemetry",
"nav_paper": "Paper Message Generator",
"nav_paper_desc": "Generate signed QR code messages",
"nav_call": "Go to Calls",
"nav_call_desc": "Voice calls and voicemails",
"nav_settings": "Go to Settings",
"nav_settings_desc": "App configuration and preferences",
"action_sync": "Sync Messages",
"action_sync_desc": "Request messages from propagation node",
"action_compose": "Compose New Message",
"action_compose_desc": "Start a new chat by address",
"action_orbit": "Orbit Mode",
"action_orbit_desc": "Toggle space orbit visualization",
"group_recent": "Recent Conversations",
"group_actions": "Actions",
"group_recent": "Recent Peers",
"group_contacts": "Contacts",
"group_actions": "Quick Actions",
"footer_navigate": "Navigate",
"footer_select": "Select"
"footer_select": "Select",
"nav_messages": "Messages",
"nav_messages_desc": "Open messages page",
"nav_nomad": "Nomad Network",
"nav_nomad_desc": "Browse Nomad Network pages",
"nav_map": "Map",
"nav_map_desc": "View network topology map",
"nav_paper": "Paper Messages",
"nav_paper_desc": "Generate offline messages",
"nav_call": "Voice Calls",
"nav_call_desc": "Open voice calls page",
"nav_settings": "Settings",
"nav_settings_desc": "Open settings page",
"nav_ping": "Ping",
"nav_ping_desc": "Ping network nodes",
"nav_rnprobe": "RN Probe",
"nav_rnprobe_desc": "Probe Reticulum nodes",
"nav_rncp": "RN CP",
"nav_rncp_desc": "Reticulum Control Protocol",
"nav_rnstatus": "RN Status",
"nav_rnstatus_desc": "View Reticulum status",
"nav_rnpath": "RN Path",
"nav_rnpath_desc": "View network paths",
"nav_translator": "Translator",
"nav_translator_desc": "Translate messages",
"nav_forwarder": "Forwarder",
"nav_forwarder_desc": "Message forwarding tool",
"nav_documentation": "Documentation",
"nav_documentation_desc": "View documentation",
"nav_micron_editor": "Micron Editor",
"nav_micron_editor_desc": "Create mesh-native pages",
"nav_rnode_flasher": "RNode Flasher",
"nav_rnode_flasher_desc": "Flash RNode firmware",
"nav_debug_logs": "Debug Logs",
"nav_debug_logs_desc": "View debug logs",
"action_sync": "Sync Messages",
"action_sync_desc": "Sync with propagation node",
"action_compose": "Compose Message",
"action_compose_desc": "Start a new message",
"action_orbit": "Toggle Orbit",
"action_orbit_desc": "Toggle orbit mode",
"action_getting_started": "Getting Started",
"action_getting_started_desc": "Show getting started guide",
"action_changelog": "Changelog",
"action_changelog_desc": "View what's new"
}
}

View File

@@ -520,7 +520,129 @@
},
"rnode_flasher": {
"title": "RNode Flasher",
"description": "Прошивка и обновление адаптеров RNode без использования командной строки."
"description": "Прошивка и обновление адаптеров RNode без использования командной строки.",
"select_device": "1. Выберите устройство",
"product": "Продукт",
"model": "Модель",
"select_product": "Выберите продукт",
"select_model": "Выберите модель",
"enter_dfu_mode": "Войти в режим DFU",
"entering_dfu_mode": "Вход в режим DFU...",
"find_device_issue": "Не удается найти устройство? Откройте обращение на",
"select_firmware": "2. Выберите прошивку для установки (.zip)",
"flash_now": "Прошить сейчас",
"flashing": "Прошивка: {percentage}%",
"flashing_file_progress": "Файл {current}/{total}: {percentage}%",
"connecting_device": "Подключение к устройству...",
"download_firmware": "Скачать прошивку",
"official_firmware": "Официальная прошивка",
"ce_firmware": "CE прошивка",
"transport_node_firmware": "Прошивка транспортного узла",
"common_issues": "Общие проблемы",
"hardware_failure": "Аппаратный сбой:",
"provision_eeprom_hint": "Вам необходимо подготовить EEPROM на шаге 3.",
"firmware_corrupt": "Прошивка повреждена:",
"set_firmware_hash_hint": "Вам необходимо установить хеш прошивки на шаге 4.",
"step_provision": "3. Подготовка EEPROM",
"provision_description": "Устанавливает информацию об устройстве, контрольную сумму и пустую подпись",
"provision": "Подготовить",
"provisioning_wait": "Подготовка: пожалуйста, подождите...",
"step_set_hash": "4. Установить хеш прошивки",
"set_hash_description": "Использует хеш с платы",
"set_firmware_hash": "Установить хеш прошивки",
"setting_hash_wait": "Установка хеша прошивки: пожалуйста, подождите...",
"step_done": "5. Завершение",
"download_recommended": "Скачать и загрузить рекомендуемую прошивку",
"downloading": "Загрузка...",
"done_description": "Если вы дошли до этого момента и все предыдущие шаги были успешны, ваш RNode должен быть готов к использованию.",
"meshchat_usage": "Чтобы использовать RNode с MeshChat, вам нужно добавить RNodeInterface на странице Интерфейсы → Добавить интерфейс.",
"sideband_usage": "Чтобы использовать RNode с Sideband, вам нужно настроить его в Оборудование → RNode и включить Подключение → Подключиться через RNode.",
"restart_warning": "Вы должны перезапустить MeshChat и Sideband, чтобы изменения настроек интерфейса вступили в силу, иначе ничего не произойдет!",
"advanced_tools": "Расширенные инструменты",
"detect_rnode": "Определить RNode",
"reboot_rnode": "Перезагрузить RNode",
"read_display": "Прочитать дисплей",
"dump_eeprom": "Дамп EEPROM",
"wipe_eeprom": "Очистить EEPROM",
"eeprom_console_hint": "Дампы EEPROM отображаются в консоли инструментов разработчика.",
"configure_bluetooth": "Настройка Bluetooth (опционально)",
"bluetooth_info_1": "Bluetooth поддерживается не на всех устройствах.",
"bluetooth_info_2": "Некоторые устройства используют Bluetooth Classic, а некоторые — BLE (Bluetooth Low Energy).",
"bluetooth_info_3": "Переведите RNode в режим сопряжения Bluetooth, затем подключитесь к нему в настройках Bluetooth Android.",
"bluetooth_info_4": "После инициирования запроса на сопряжение из Android, PIN-код должен отобразиться на экране RNode.",
"bluetooth_info_5": "В Sideband вам нужно включить 'Подключиться через Bluetooth' в Оборудование → RNode.",
"bluetooth_info_6": "Если ваше устройство использует BLE, вам также нужно включить 'Устройству требуется BLE' в Оборудование → RNode.",
"bluetooth_restart_warning": "Не забудьте перезапустить Sideband, чтобы изменения настроек вступили в силу!",
"enable": "Включить",
"disable": "Выключить",
"start_pairing": "Начать сопряжение",
"configure_tnc": "Настройка режима TNC (опционально)",
"tnc_info_1": "Режим TNC позволяет использовать RNode как KISS-совместимый TNC через последовательный порт.",
"tnc_info_2": "Этот режим делает его пригодным для использования с ПО для радиолюбителей, которое может работать с KISS TNC через последовательный порт.",
"tnc_warning": "Вы должны оставить режим TNC отключенным при использовании RNode с приложениями вроде MeshChat или Sideband.",
"frequency": "Частота (Гц)",
"bandwidth": "Полоса пропускания",
"tx_power": "Мощность передачи (дБм)",
"spreading_factor": "Коэффициент расширения",
"coding_rate": "Скорость кодирования",
"configure_display": "Настройка дисплея",
"rotation": "Поворот",
"reconditioning": "Восстановление",
"start": "Старт",
"stop": "Стоп",
"rotation_v180_warning": "Настройка поворота дисплея требует прошивки v1.80+",
"errors": {
"failed_download": "Не удалось скачать прошивку: {error}",
"firmware_not_found_in_release": "Рекомендуемая прошивка не найдена в последнем выпуске.",
"web_serial_not_supported": "Web Serial не поддерживается в этом браузере. Пожалуйста, используйте Chrome, Edge или другой браузер на базе Chromium.",
"no_device_selected": "Устройство не выбрано. Пожалуйста, выберите последовательный порт.",
"failed_connect": "Не удалось подключиться к устройству: {error}",
"not_an_rnode": "Выбранное устройство не является RNode! Убедитесь, что вы выбрали правильный последовательный порт.",
"failed_dfu": "Не удалось войти в режим DFU: {error}",
"select_firmware_first": "Пожалуйста, сначала выберите файл прошивки (.zip).",
"failed_flash": "Сбой прошивки: {error}",
"esptool_not_loaded": "esptool-js не удалось загрузить. Пожалуйста, обновите страницу и попробуйте снова.",
"no_flash_config": "Конфигурация прошивки недоступна для выбранного устройства. Выберите другое устройство или проверьте файл прошивки.",
"failed_extract": "Требуемый файл \"{file}\" не найден в ZIP-архиве прошивки. Скачайте правильную прошивку для вашего устройства.",
"failed_detect": "Не удалось обнаружить RNode. Убедитесь, что устройство подключено и находится в правильном режиме.",
"provisioned_already": "EEPROM уже подготовлен. Вам нужно очистить его для повторной подготовки!",
"select_product_first": "Пожалуйста, сначала выберите продукт!",
"select_model_first": "Пожалуйста, сначала выберите модель!",
"not_provisioned": "EEPROM не подготовлен. Вы должны сделать это в первую очередь!",
"failed_provision": "не удалось подготовить: {error}",
"failed_set_hash": "не удалось установить хеш прошивки: {error}",
"failed_reboot": "Не удалось перезагрузить RNode: {error}",
"failed_read_display": "Не удалось прочитать дисплей: {error}",
"failed_dump_eeprom": "Не удалось выполнить дамп EEPROM: {error}",
"failed_wipe_eeprom": "Не удалось очистить EEPROM: {error}",
"failed_enable_tnc": "Не удалось включить режим TNC: {error}",
"failed_disable_tnc": "Не удалось выключить режим TNC: {error}",
"failed_enable_bluetooth": "Не удалось включить Bluetooth: {error}",
"failed_disable_bluetooth": "Не удалось выключить Bluetooth: {error}",
"failed_start_pairing": "Не удалось начать сопряжение: {error}",
"failed_set_rotation": "Не удалось установить поворот дисплея: {error}",
"failed_start_reconditioning": "Не удалось запустить восстановление дисплея: {error}"
},
"alerts": {
"firmware_downloaded": "Рекомендуемая прошивка была скачана и загружена.",
"dfu_ready": "Устройство должно находиться в режиме DFU. Теперь вы можете продолжить прошивку.",
"flash_success": "Прошивка успешно завершена!",
"rebooting": "Плата перезагружается!",
"eeprom_wipe_confirm": "Вы уверены, что хотите очистить EEPROM на этом устройстве? Это займет около 30 секунд. Уведомление появится после завершения очистки.",
"eeprom_wiped": "EEPROM очищен!",
"eeprom_dumped": "Дамп EEPROM выведен в консоль",
"rnode_detected": "Обнаружен RNode v{version}",
"provision_success": "устройство подготовлено!",
"hash_success": "хеш прошивки установлен!",
"tnc_enabled": "Режим TNC включен!",
"tnc_disabled": "Режим TNC выключен!",
"bluetooth_enabled": "Bluetooth включен!",
"bluetooth_disabled": "Bluetooth выключен!",
"bluetooth_pairing_pin": "PIN-код сопряжения Bluetooth: {pin}",
"bluetooth_pairing_started": "Режим сопряжения запущен (30с)",
"rotation_updated": "Поворот обновлен",
"reconditioning_started": "Восстановление запущено"
}
},
"micron_editor": {
"title": "Редактор Micron",
@@ -536,7 +658,9 @@
"rename_tab": "Переименовать вкладку",
"confirm_reset": "Вы уверены, что хотите сбросить редактор? Все ваши вкладки и содержимое будут потеряны.",
"confirm_delete_tab": "Вы уверены, что хотите удалить эту вкладку?",
"main_tab": "Основная"
"main_tab": "Основная",
"guide_tab": "Руководство NomadNet",
"parser_by": "Micron Parser от"
},
"paper_message": {
"title": "Бумажное сообщение",
@@ -707,6 +831,7 @@
"unknown": "Неизвестно",
"call_ended": "Звонок завершен",
"call_declined": "Звонок отклонен",
"initiation": "Инициализация...",
"send_to_voicemail": "На голосовую почту",
"incoming_call": "Входящий звонок...",
"busy": "Занято...",
@@ -820,31 +945,132 @@
"failed_to_play_ringtone": "Не удалось воспроизвести рингтон",
"failed_to_play_voicemail": "Не удалось воспроизвести сообщение"
},
"tutorial": {
"title": "Начало работы",
"welcome": "Добро пожаловать в",
"welcome_desc": "Будущее внесетевой связи. Безопасно, децентрализовано и неостановимо.",
"security": "Безопасность и производительность",
"security_desc": "Значительные улучшения скорости, безопасности и восстановления после сбоев.",
"security_desc_page": "Значительные улучшения скорости, безопасности и целостности со встроенным восстановлением после сбоев.",
"maps": "Карты",
"maps_desc": "OpenLayers с поддержкой MBTiles (экспорт и офлайн) и пользовательскими API-точками.",
"maps_desc_page": "Поддержка OpenLayers с офлайн MBTiles (экспорт и офлайн) и пользовательскими API-точками для онлайн-карт.",
"voice": "Полный голос LXST",
"voice_desc": "Голосовая почта, рингтоны, телефонная книга и обмен контактами.",
"voice_desc_page": "Кристально чистые голосовые звонки через mesh. Голосовая почта, свои рингтоны и поиск в телефонной книге.",
"tools": "Продвинутые инструменты",
"tools_desc": "Редактор Micron, NomadNet, инструменты RNS, Документация.",
"tools_desc_page": "Редактор Micron, узлы NomadNet, инструменты RNS и встроенная документация.",
"archiver": "Архиватор",
"archiver_desc": "Ручное и автоматическое архивирование страниц.",
"archiver_desc_page": "Инструменты ручного и автоматического архивирования страниц для офлайн-просмотра.",
"banishment": "Изгнание",
"banishment_desc": "Изгоняйте надоедливых людей и узлы, поддержка блокировки анонсов на уровне RNS blackhole.",
"palette": "Командная палитра + горячие клавиши",
"palette_desc": "Мгновенная навигация и настройка горячих клавиш.",
"palette_desc_page": "Навигация по всему приложению и настройка рабочего процесса с помощью мгновенных горячих клавиш.",
"i18n": "Поддержка i18n",
"i18n_desc": "Доступно на английском, немецком и русском языках.",
"i18n_desc_page": "Полная поддержка интернационализации для английского, немецкого и русского языков.",
"more_features": "+ еще много функций!",
"connect": "Подключение к Mesh",
"connect_desc": "Для отправки сообщений необходимо подключиться к интерфейсу Reticulum.",
"connect_desc_page": "Используйте одну из общедоступных сетей ниже или добавьте свой интерфейс на странице 'Интерфейсы'.",
"suggested_networks": "Предлагаемые публичные сети",
"suggested_relays": "Предлагаемые реле",
"use": "Использовать",
"online": "В сети",
"custom_interfaces": "Свои интерфейсы",
"custom_interfaces_desc": "Хотите добавить свой интерфейс? Перейдите на страницу 'Интерфейсы' для настройки подключения.",
"custom_interfaces_desc_page": "Для любых частных реле или оборудования, которыми вы управляете сами, добавьте их через страницу 'Интерфейсы'.",
"open_interfaces": "Открыть страницу интерфейсов",
"learn_create": "Обучение и создание",
"learn_create_desc": "Узнайте, как использовать MeshChatX на полную мощность.",
"learn_create_desc_page": "Узнайте, как использовать MeshChatX на полную мощность и создавать свой контент.",
"documentation": "Документация",
"documentation_desc": "Читайте официальную документацию MeshChatX и Reticulum.",
"documentation_desc_page": "Подробные руководства по MeshChatX и базовому стеку Reticulum Network.",
"meshchatx_docs": "Документация MeshChatX",
"reticulum_docs": "Документация Reticulum",
"read_meshchatx_docs": "Читать документацию MeshChatX",
"reticulum_manual": "Руководство Reticulum",
"micron_editor": "Редактор Micron",
"micron_editor_desc": "Загляните в редактор Micron, чтобы узнать, как создавать mesh-страницы.",
"micron_editor_desc_page": "Узнайте, как создавать mesh-страницы и интерактивный контент с помощью языка разметки Micron.",
"open_micron_editor": "Открыть редактор Micron",
"paper_messages": "Обзор узлов NomadNet",
"paper_messages_desc": "Просмотр децентрализованных страниц и сервисов.",
"send_messages": "Отправка сообщений",
"send_messages_desc": "Начните защищенный разговор.",
"explore_nodes": "Визуализация узлов",
"explore_nodes_desc": "Просмотр карты и топологии сети.",
"voice_calls": "Голосовые звонки",
"voice_calls_desc": "Посмотрите наш крутой редактор рингтонов!",
"ready": "Все готово!",
"ready_desc": "Все настроено. Вам нужно перезапустить приложение, чтобы изменения вступили в силу.",
"ready_desc_page": "MeshChatX настроен. Вам нужно перезапустить приложение для завершения подключения.",
"docker_note": "Если вы используете Docker, обязательно перезапустите контейнер",
"restart_required": "Требуется перезапуск",
"restart_desc_page": "Если вы используете Docker, обязательно перезапустите контейнер. Нативные приложения перезапустятся автоматически.",
"back": "Назад",
"skip": "Пропустить",
"next": "Далее",
"restart_start": "Запустить MeshChatX",
"skip_setup": "Пропустить настройку",
"continue": "Продолжить",
"skip_confirm": "Вы уверены, что хотите пропустить настройку? Вам придется добавить интерфейсы позже вручную."
},
"command_palette": {
"search_placeholder": "Поиск или ввод команды...",
"no_results": "Ничего не найдено по запросу \"{query}\"",
"nav_messages": "Перейти к сообщениям",
"nav_messages_desc": "Открыть недавние разговоры",
"nav_nomad": "Перейти в Nomad Network",
"nav_nomad_desc": "Просмотр распределенной сети",
"nav_map": "Перейти к карте",
"nav_map_desc": "Просмотр местоположения и телеметрии",
"nav_paper": "Генератор бумажных сообщений",
"nav_paper_desc": "Создание подписанных QR-кодов",
"nav_call": "Перейти к звонкам",
"nav_call_desc": "Голосовые звонки и голосовая почта",
"nav_settings": "Перейти к настройкам",
"nav_settings_desc": "Конфигурация и настройки приложения",
"action_sync": "Синхронизировать сообщения",
"action_sync_desc": "Запросить сообщения с узла",
"action_compose": "Написать новое сообщение",
"action_compose_desc": "Начать новый чат по адресу",
"action_orbit": "Режим орбиты",
"action_orbit_desc": "Визуализация космической орбиты",
"group_recent": "Недавние разговоры",
"search_placeholder": "Поиск команд, навигация или поиск узлов...",
"no_results": "Результатов для \"{query}\" не найдено",
"group_actions": "Действия",
"group_recent": "Недавние узлы",
"group_contacts": "Контакты",
"group_actions": "Быстрые действия",
"footer_navigate": "Навигация",
"footer_select": "Выбрать"
"footer_select": "Выбор",
"nav_messages": "Сообщения",
"nav_messages_desc": "Открыть страницу сообщений",
"nav_nomad": "Nomad Network",
"nav_nomad_desc": "Просмотр страниц Nomad Network",
"nav_map": "Карта",
"nav_map_desc": "Просмотр карты топологии сети",
"nav_paper": "Бумажные сообщения",
"nav_paper_desc": "Создание офлайн-сообщений",
"nav_call": "Голосовые звонки",
"nav_call_desc": "Открыть страницу голосовых звонков",
"nav_settings": "Настройки",
"nav_settings_desc": "Открыть страницу настроек",
"nav_ping": "Ping",
"nav_ping_desc": "Пинг узлов сети",
"nav_rnprobe": "RN Probe",
"nav_rnprobe_desc": "Зондирование узлов Reticulum",
"nav_rncp": "RN CP",
"nav_rncp_desc": "Reticulum Control Protocol",
"nav_rnstatus": "RN Status",
"nav_rnstatus_desc": "Просмотр статуса Reticulum",
"nav_rnpath": "RN Path",
"nav_rnpath_desc": "Просмотр сетевых путей",
"nav_translator": "Переводчик",
"nav_translator_desc": "Перевод сообщений",
"nav_forwarder": "Форвардер",
"nav_forwarder_desc": "Инструмент пересылки сообщений",
"nav_documentation": "Документация",
"nav_documentation_desc": "Просмотр документации",
"nav_micron_editor": "Редактор Micron",
"nav_micron_editor_desc": "Создание mesh-страниц",
"nav_rnode_flasher": "RNode Flasher",
"nav_rnode_flasher_desc": "Прошивка адаптеров RNode",
"nav_debug_logs": "Логи отладки",
"nav_debug_logs_desc": "Просмотр логов отладки",
"action_sync": "Синхронизация",
"action_sync_desc": "Синхронизация с узлом ретрансляции",
"action_compose": "Написать сообщение",
"action_compose_desc": "Начать новое сообщение",
"action_orbit": "Переключить Orbit",
"action_orbit_desc": "Переключить режим Orbit",
"action_getting_started": "Начало работы",
"action_getting_started_desc": "Показать руководство",
"action_changelog": "Список изменений",
"action_changelog_desc": "Просмотр изменений"
}
}

View File

@@ -49,8 +49,10 @@
"@electron/fuses": "^1.8.0",
"@eslint/js": "^9.39.2",
"@rushstack/eslint-patch": "^1.15.0",
"@tailwindcss/typography": "^0.5.19",
"@vue/eslint-config-prettier": "^10.2.0",
"@vue/test-utils": "^2.4.6",
"autoprefixer": "^10.4.23",
"electron": "^39.2.7",
"electron-builder": "^26.0.12",
"electron-builder-squirrel-windows": "^26.0.12",
@@ -61,7 +63,9 @@
"eslint-plugin-vue": "^10.6.2",
"globals": "^16.5.0",
"jsdom": "^26.1.0",
"postcss": "^8.5.6",
"prettier": "^3.7.4",
"tailwindcss": "^3.4.19",
"terser": "^5.44.1",
"vitest": "^3.2.4"
},
@@ -71,7 +75,10 @@
"electron-winstaller",
"esbuild",
"protobufjs"
]
],
"overrides": {
"tmp": ">=0.2.4"
}
},
"build": {
"appId": "com.sudoivan.reticulummeshchat",
@@ -190,7 +197,6 @@
"@mdi/js": "^7.4.47",
"@tailwindcss/forms": "^0.5.11",
"@vitejs/plugin-vue": "^5.2.4",
"autoprefixer": "^10.4.23",
"axios": "^1.13.2",
"click-outside-vue3": "^4.0.1",
"compressorjs": "^1.2.1",
@@ -200,10 +206,8 @@
"micron-parser": "^1.0.2",
"mitt": "^3.0.1",
"ol": "^10.7.0",
"postcss": "^8.5.6",
"protobufjs": "^7.5.4",
"qrcode": "^1.5.4",
"tailwindcss": "^3.4.19",
"vis-data": "^7.1.10",
"vis-network": "^9.1.13",
"vite": "^6.4.1",

59
pnpm-lock.yaml generated
View File

@@ -4,6 +4,9 @@ settings:
autoInstallPeers: true
excludeLinksFromLockfile: false
overrides:
tmp: '>=0.2.4'
importers:
.:
@@ -20,9 +23,6 @@ importers:
'@vitejs/plugin-vue':
specifier: ^5.2.4
version: 5.2.4(vite@6.4.1(@types/node@25.0.3)(jiti@1.21.7)(terser@5.44.1))(vue@3.5.26(typescript@5.9.3))
autoprefixer:
specifier: ^10.4.23
version: 10.4.23(postcss@8.5.6)
axios:
specifier: ^1.13.2
version: 1.13.2
@@ -50,18 +50,12 @@ importers:
ol:
specifier: ^10.7.0
version: 10.7.0
postcss:
specifier: ^8.5.6
version: 8.5.6
protobufjs:
specifier: ^7.5.4
version: 7.5.4
qrcode:
specifier: ^1.5.4
version: 1.5.4
tailwindcss:
specifier: ^3.4.19
version: 3.4.19
vis-data:
specifier: ^7.1.10
version: 7.1.10(uuid@11.1.0)(vis-util@5.0.7(@egjs/hammerjs@2.0.17)(component-emitter@2.0.0))
@@ -123,12 +117,18 @@ importers:
'@rushstack/eslint-patch':
specifier: ^1.15.0
version: 1.15.0
'@tailwindcss/typography':
specifier: ^0.5.19
version: 0.5.19(tailwindcss@3.4.19)
'@vue/eslint-config-prettier':
specifier: ^10.2.0
version: 10.2.0(@types/eslint@9.6.1)(eslint@9.39.2(jiti@1.21.7))(prettier@3.7.4)
'@vue/test-utils':
specifier: ^2.4.6
version: 2.4.6
autoprefixer:
specifier: ^10.4.23
version: 10.4.23(postcss@8.5.6)
electron:
specifier: ^39.2.7
version: 39.2.7
@@ -159,9 +159,15 @@ importers:
jsdom:
specifier: ^26.1.0
version: 26.1.0
postcss:
specifier: ^8.5.6
version: 8.5.6
prettier:
specifier: ^3.7.4
version: 3.7.4
tailwindcss:
specifier: ^3.4.19
version: 3.4.19
terser:
specifier: ^5.44.1
version: 5.44.1
@@ -925,6 +931,11 @@ packages:
peerDependencies:
tailwindcss: '>=3.0.0 || >= 3.0.0-alpha.1 || >= 4.0.0-alpha.20 || >= 4.0.0-beta.1'
'@tailwindcss/typography@0.5.19':
resolution: {integrity: sha512-w31dd8HOx3k9vPtcQh5QHP9GwKcgbMp87j58qi6xgiBnFFtKEAgCWnDw4qUT8aHwkCp8bKvb/KGKWWHedP0AAg==}
peerDependencies:
tailwindcss: '>=3.0.0 || insiders || >=4.0.0-alpha.20 || >=4.0.0-beta.1'
'@tootallnate/once@2.0.0':
resolution: {integrity: sha512-XCuKFP5PS55gnMVu3dty8KPatLqUoy/ZYzDzAGCQ8JNFCkLXzmI7vNHCR+XpbZaMWQK/vQubr7PkYq8g470J/A==}
engines: {node: '>= 10'}
@@ -2745,10 +2756,6 @@ packages:
resolution: {integrity: sha512-5b6Y85tPxZZ7QytO+BQzysW31HJku27cRIlkbAXaNx+BdcVi+LlRFmVXzeF6a7JCwJpyw5c4b+YSVImQIrBpuQ==}
engines: {node: '>=10'}
os-tmpdir@1.0.2:
resolution: {integrity: sha512-D2FR03Vir7FIu45XBY20mTb+/ZSWB00sjU9jdQXt83gDrI4Ztz5Fs7/yy74g2N5SVQY4xY1qDr4rNddwYRVX0g==}
engines: {node: '>=0.10.0'}
p-cancelable@2.1.1:
resolution: {integrity: sha512-BZOr3nRQHOntUjTrH8+Lh54smKHoHyur8We1V8DSMVrl5A2malOOwuJRnKRDjSnkoeBh4at6BwEnb5I7Jl31wg==}
engines: {node: '>=8'}
@@ -2941,6 +2948,10 @@ packages:
peerDependencies:
postcss: ^8.2.14
postcss-selector-parser@6.0.10:
resolution: {integrity: sha512-IQ7TZdoaqbT+LCpShg46jnZVlhWD2w6iQYAcYXfHARZ7X1t/UGhhceQDs5X0cGqKvYlHNOuv7Oa1xmb0oQuA3w==}
engines: {node: '>=4'}
postcss-selector-parser@6.1.2:
resolution: {integrity: sha512-Q8qQfPiZ+THO/3ZrOrO0cJJKfpYCagtMUkXbnEfmgUjwXg6z/WBeOyS9APBBPCTSiDV+s4SwQGu8yFsiMRIudg==}
engines: {node: '>=4'}
@@ -3442,10 +3453,6 @@ packages:
tmp-promise@3.0.3:
resolution: {integrity: sha512-RwM7MoPojPxsOBYnyd2hy0bxtIlVrihNs9pj5SUvY8Zz1sQcQG2tG1hSr8PDxfgEB8RNKDhqbIlroIarSNDNsQ==}
tmp@0.0.33:
resolution: {integrity: sha512-jRCJlojKnZ3addtTOjdIqoRuPEKBvNXcGYqzO6zWZX8KfKEpnGY5jfggJQ3EjKuu8D4bJRr0y+cYJFmYbImXGw==}
engines: {node: '>=0.6.0'}
tmp@0.2.5:
resolution: {integrity: sha512-voyz6MApa1rQGUxT3E+BK7/ROe8itEx7vD8/HEvt4xwXucvQ5G5oeEiHkmHZJuBO21RpOf+YYm9MOivj709jow==}
engines: {node: '>=14.14'}
@@ -4831,6 +4838,11 @@ snapshots:
mini-svg-data-uri: 1.4.4
tailwindcss: 3.4.19
'@tailwindcss/typography@0.5.19(tailwindcss@3.4.19)':
dependencies:
postcss-selector-parser: 6.0.10
tailwindcss: 3.4.19
'@tootallnate/once@2.0.0': {}
'@types/cacheable-request@6.0.3':
@@ -6045,7 +6057,7 @@ snapshots:
dependencies:
chardet: 0.7.0
iconv-lite: 0.4.24
tmp: 0.0.33
tmp: 0.2.5
extract-zip@2.0.1:
dependencies:
@@ -6923,8 +6935,6 @@ snapshots:
strip-ansi: 6.0.1
wcwidth: 1.0.1
os-tmpdir@1.0.2: {}
p-cancelable@2.1.1: {}
p-defer@1.0.0: {}
@@ -7064,6 +7074,11 @@ snapshots:
postcss: 8.5.6
postcss-selector-parser: 6.1.2
postcss-selector-parser@6.0.10:
dependencies:
cssesc: 3.0.0
util-deprecate: 1.0.2
postcss-selector-parser@6.1.2:
dependencies:
cssesc: 3.0.0
@@ -7597,10 +7612,6 @@ snapshots:
dependencies:
tmp: 0.2.5
tmp@0.0.33:
dependencies:
os-tmpdir: 1.0.2
tmp@0.2.5: {}
to-regex-range@5.0.1:

View File

@@ -5,7 +5,6 @@ import json
from unittest.mock import MagicMock, patch, AsyncMock
from meshchatx.meshchat import ReticulumMeshChat
import RNS
import asyncio
@pytest.fixture

View File

@@ -14,7 +14,7 @@ def temp_dir(tmp_path):
def mock_rns_minimal():
with (
patch("RNS.Reticulum") as mock_rns,
patch("RNS.Transport"),
patch("RNS.Transport") as mock_transport,
patch("LXMF.LXMRouter"),
patch("meshchatx.meshchat.get_file_path", return_value="/tmp/mock_path"),
):
@@ -23,6 +23,13 @@ def mock_rns_minimal():
mock_rns_instance.is_connected_to_shared_instance = False
mock_rns_instance.transport_enabled.return_value = True
# Setup RNS.Transport mock constants and tables
mock_transport.path_table = {}
mock_transport.path_states = {}
mock_transport.STATE_UNKNOWN = 0
mock_transport.STATE_RESPONSIVE = 1
mock_transport.STATE_UNRESPONSIVE = 2
# Path management mocks
mock_rns_instance.get_path_table.return_value = []
mock_rns_instance.get_rate_table.return_value = []

View File

@@ -122,7 +122,8 @@ describe("ChangelogModal.vue", () => {
await wrapper.vm.show();
await wrapper.vm.$nextTick();
const closeBtn = wrapper.find(".v-btn");
const closeBtn = wrapper.find("button.v-btn");
expect(closeBtn.exists()).toBe(true);
expect(closeBtn.attributes("class")).toContain("dark:hover:bg-white/10");
expect(closeBtn.attributes("class")).toContain("hover:bg-black/5");
});

View File

@@ -1,11 +1,13 @@
import { mount } from "@vue/test-utils";
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import ConversationViewer from "@/components/messages/ConversationViewer.vue";
import WebSocketConnection from "@/js/WebSocketConnection";
describe("ConversationViewer.vue", () => {
let axiosMock;
beforeEach(() => {
WebSocketConnection.connect();
axiosMock = {
get: vi.fn().mockImplementation((url) => {
if (url.includes("/path")) return Promise.resolve({ data: { path: [] } });
@@ -44,6 +46,7 @@ describe("ConversationViewer.vue", () => {
afterEach(() => {
delete window.axios;
vi.unstubAllGlobals();
WebSocketConnection.destroy();
});
const mountConversationViewer = (props = {}) => {

View File

@@ -32,11 +32,16 @@ describe("DocsPage.vue", () => {
});
afterEach(() => {
delete window.axios;
if (wrapper) {
wrapper.unmount();
}
// Do not delete window.axios, as it might be used by async operations
// and it is globally defined in setup.js anyway.
});
let wrapper;
const mountDocsPage = () => {
return mount(DocsPage, {
wrapper = mount(DocsPage, {
global: {
directives: {
"click-outside": vi.fn(),
@@ -50,6 +55,7 @@ describe("DocsPage.vue", () => {
},
},
});
return wrapper;
};
it("renders download button when no docs are present", async () => {

View File

@@ -62,8 +62,9 @@ describe("MicronEditorPage.vue", () => {
await wrapper.vm.$nextTick();
await wrapper.vm.$nextTick(); // Wait for loadContent
expect(wrapper.vm.tabs.length).toBe(1);
expect(wrapper.vm.tabs.length).toBe(2);
expect(wrapper.vm.tabs[0].name).toBe("tools.micron_editor.main_tab");
expect(wrapper.vm.tabs[1].name).toBe("tools.micron_editor.guide_tab");
expect(wrapper.text()).toContain("tools.micron_editor.title");
});
@@ -88,8 +89,7 @@ describe("MicronEditorPage.vue", () => {
await wrapper.vm.$nextTick();
await wrapper.vm.$nextTick();
// Add a second tab so we can remove one (close button only shows if tabs.length > 1)
await wrapper.vm.addTab();
// Already have 2 tabs (Main + Guide)
expect(wrapper.vm.tabs.length).toBe(2);
// Find close button on the second tab
@@ -105,14 +105,14 @@ describe("MicronEditorPage.vue", () => {
await wrapper.vm.$nextTick();
await wrapper.vm.$nextTick();
await wrapper.vm.addTab();
expect(wrapper.vm.activeTabIndex).toBe(1);
// Click first tab
const tabs = wrapper.findAll(".group.flex.items-center");
await tabs[0].trigger("click");
// Initially on first tab
expect(wrapper.vm.activeTabIndex).toBe(0);
// Click second tab (Guide)
const tabs = wrapper.findAll(".group.flex.items-center");
await tabs[1].trigger("click");
expect(wrapper.vm.activeTabIndex).toBe(1);
});
it("resets all tabs when clicking reset button", async () => {
@@ -120,8 +120,9 @@ describe("MicronEditorPage.vue", () => {
await wrapper.vm.$nextTick();
await wrapper.vm.$nextTick();
const initialTabCount = wrapper.vm.tabs.length;
await wrapper.vm.addTab();
expect(wrapper.vm.tabs.length).toBe(2);
expect(wrapper.vm.tabs.length).toBe(initialTabCount + 1);
// Find reset button
const resetButton = wrapper.find('.mdi-stub[data-icon-name="refresh"]').element.parentElement;
@@ -129,7 +130,7 @@ describe("MicronEditorPage.vue", () => {
expect(window.confirm).toHaveBeenCalled();
expect(micronStorage.clearAll).toHaveBeenCalled();
expect(wrapper.vm.tabs.length).toBe(1);
expect(wrapper.vm.tabs.length).toBe(2); // Resets to Main + Guide
expect(wrapper.vm.activeTabIndex).toBe(0);
});

View File

@@ -4,29 +4,42 @@ import Toast from "@/components/Toast.vue";
import GlobalEmitter from "@/js/GlobalEmitter";
describe("Toast.vue", () => {
let wrapper;
beforeEach(() => {
vi.useFakeTimers();
wrapper = mount(Toast, {
global: {
stubs: {
TransitionGroup: { template: "<div><slot /></div>" },
MaterialDesignIcon: {
name: "MaterialDesignIcon",
template: '<div class="mdi-stub"></div>',
props: ["iconName"],
},
},
},
});
});
afterEach(() => {
if (wrapper) {
wrapper.unmount();
}
vi.useRealTimers();
// Clear all listeners from GlobalEmitter to avoid test pollution
GlobalEmitter.off("toast");
});
it("adds a toast when GlobalEmitter emits 'toast'", async () => {
const wrapper = mount(Toast);
GlobalEmitter.emit("toast", { message: "Test Message", type: "success" });
await wrapper.vm.$nextTick();
expect(wrapper.text()).toContain("Test Message");
expect(wrapper.findComponent({ name: "MaterialDesignIcon" }).props("iconName")).toBe("check-circle");
const icon = wrapper.findComponent({ name: "MaterialDesignIcon" });
expect(icon.exists()).toBe(true);
expect(icon.props("iconName")).toBe("check-circle");
});
it("removes a toast after duration", async () => {
const wrapper = mount(Toast);
GlobalEmitter.emit("toast", { message: "Test Message", duration: 1000 });
await wrapper.vm.$nextTick();
@@ -39,8 +52,6 @@ describe("Toast.vue", () => {
});
it("removes a toast when clicking the close button", async () => {
const wrapper = mount(Toast);
GlobalEmitter.emit("toast", { message: "Test Message", duration: 0 });
await wrapper.vm.$nextTick();
@@ -48,13 +59,12 @@ describe("Toast.vue", () => {
const closeButton = wrapper.find("button");
await closeButton.trigger("click");
await wrapper.vm.$nextTick();
expect(wrapper.text()).not.toContain("Test Message");
});
it("assigns correct classes for different toast types", async () => {
const wrapper = mount(Toast);
GlobalEmitter.emit("toast", { message: "Success", type: "success" });
GlobalEmitter.emit("toast", { message: "Error", type: "error" });
await wrapper.vm.$nextTick();

83
tests/frontend/setup.js Normal file
View File

@@ -0,0 +1,83 @@
import { vi } from "vitest";
import { config } from "@vue/test-utils";
// Global mocks
global.performance.mark = vi.fn();
global.performance.measure = vi.fn();
global.performance.getEntriesByName = vi.fn(() => []);
global.performance.clearMarks = vi.fn();
global.performance.clearMeasures = vi.fn();
// Mock window.axios by default to prevent TypeErrors
global.axios = {
get: vi.fn().mockResolvedValue({ data: {} }),
post: vi.fn().mockResolvedValue({ data: {} }),
put: vi.fn().mockResolvedValue({ data: {} }),
patch: vi.fn().mockResolvedValue({ data: {} }),
delete: vi.fn().mockResolvedValue({ data: {} }),
};
window.axios = global.axios;
// Stub all Vuetify components to avoid warnings and CSS issues
config.global.stubs = {
MaterialDesignIcon: { template: '<div class="mdi-stub"><slot /></div>' },
RouterLink: { template: "<a><slot /></a>" },
RouterView: { template: "<div><slot /></div>" },
// Common Vuetify components
"v-app": true,
"v-main": true,
"v-container": true,
"v-row": true,
"v-col": true,
"v-btn": true,
"v-icon": true,
"v-card": true,
"v-card-title": true,
"v-card-text": true,
"v-card-actions": true,
"v-dialog": true,
"v-text-field": true,
"v-textarea": true,
"v-select": true,
"v-switch": true,
"v-checkbox": true,
"v-list": true,
"v-list-item": true,
"v-list-item-title": true,
"v-list-item-subtitle": true,
"v-menu": true,
"v-divider": true,
"v-spacer": true,
"v-progress-circular": true,
"v-progress-linear": true,
"v-tabs": true,
"v-tab": true,
"v-window": true,
"v-window-item": true,
"v-expansion-panels": true,
"v-expansion-panel": true,
"v-expansion-panel-title": true,
"v-expansion-panel-text": true,
"v-chip": true,
"v-toolbar": true,
"v-toolbar-title": true,
"v-tooltip": true,
"v-alert": true,
"v-snackbar": true,
"v-badge": true,
};
// Mock window.matchMedia
Object.defineProperty(window, "matchMedia", {
writable: true,
value: vi.fn().mockImplementation((query) => ({
matches: false,
media: query,
onchange: null,
addListener: vi.fn(), // deprecated
removeListener: vi.fn(), // deprecated
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
dispatchEvent: vi.fn(),
})),
});