feat(telemetry): implement telemetry tracking and path tracing features

- Added telemetry tracking capabilities, allowing users to toggle tracking for specific peers and retrieve tracked peers.
- Introduced RNPathTraceHandler for tracing paths to destination hashes.
- Enhanced database schema to support telemetry tracking and added related fields in contacts.
- Updated configuration management to include telemetry settings.
- Implemented API endpoints for downloading database backups and snapshots, as well as for telemetry-related functionalities.
- Improved error handling and response messages for telemetry requests and path tracing.
This commit is contained in:
2026-01-07 19:13:08 -06:00
parent ce568c2965
commit df306cc67b
11 changed files with 991 additions and 135 deletions

View File

@@ -26,6 +26,7 @@ import traceback
import webbrowser
from datetime import UTC, datetime, timedelta
from logging.handlers import RotatingFileHandler
from urllib.parse import urlparse
import aiohttp
import bcrypt
@@ -435,6 +436,17 @@ class ReticulumMeshChat:
if self.current_context:
self.current_context.rnpath_handler = value
@property
def rnpath_trace_handler(self):
return (
self.current_context.rnpath_trace_handler if self.current_context else None
)
@rnpath_trace_handler.setter
def rnpath_trace_handler(self, value):
if self.current_context:
self.current_context.rnpath_trace_handler = value
@property
def rnprobe_handler(self):
return self.current_context.rnprobe_handler if self.current_context else None
@@ -793,7 +805,6 @@ class ReticulumMeshChat:
# Wait another moment for sockets to definitely be released by OS
# Also give some time for the RPC listener port to settle
print("Waiting for ports to settle...")
# We add a settle time here similar to Sideband's logic
await asyncio.sleep(4)
# Detect RPC type from reticulum instance if possible, otherwise default to both
@@ -2292,6 +2303,64 @@ class ReticulumMeshChat:
status=500,
)
@routes.get("/api/v1/database/backups/{filename}/download")
async def download_db_backup(request):
try:
filename = request.match_info.get("filename")
if not filename.endswith(".zip"):
filename += ".zip"
backup_dir = os.path.join(self.storage_dir, "database-backups")
full_path = os.path.join(backup_dir, filename)
if not os.path.exists(full_path) or not full_path.startswith(
backup_dir,
):
return web.json_response(
{"status": "error", "message": "Backup not found"},
status=404,
)
return web.FileResponse(
path=full_path,
headers={
"Content-Disposition": f'attachment; filename="{filename}"',
},
)
except Exception as e:
return web.json_response(
{"status": "error", "message": str(e)},
status=500,
)
@routes.get("/api/v1/database/snapshots/{filename}/download")
async def download_db_snapshot(request):
try:
filename = request.match_info.get("filename")
if not filename.endswith(".zip"):
filename += ".zip"
snapshot_dir = os.path.join(self.storage_dir, "snapshots")
full_path = os.path.join(snapshot_dir, filename)
if not os.path.exists(full_path) or not full_path.startswith(
snapshot_dir,
):
return web.json_response(
{"status": "error", "message": "Snapshot not found"},
status=404,
)
return web.FileResponse(
path=full_path,
headers={
"Content-Disposition": f'attachment; filename="{filename}"',
},
)
except Exception as e:
return web.json_response(
{"status": "error", "message": str(e)},
status=500,
)
@routes.get("/api/v1/status")
async def status(request):
return web.json_response(
@@ -3366,6 +3435,8 @@ class ReticulumMeshChat:
# Get total paths
total_paths = 0
is_connected_to_shared_instance = False
shared_instance_address = None
if hasattr(self, "reticulum") and self.reticulum:
try:
path_table = self.reticulum.get_path_table()
@@ -3373,6 +3444,70 @@ class ReticulumMeshChat:
except Exception: # noqa: S110
pass
is_connected_to_shared_instance = getattr(
self.reticulum,
"is_connected_to_shared_instance",
False,
)
if is_connected_to_shared_instance:
# Try to find the shared instance address from active connections
try:
for conn in process.connections(kind="all"):
if conn.status == psutil.CONN_ESTABLISHED and conn.raddr:
# Check for common Reticulum shared instance ports or UNIX sockets
if (
isinstance(conn.raddr, tuple)
and conn.raddr[1] == 37428
):
shared_instance_address = (
f"{conn.raddr[0]}:{conn.raddr[1]}"
)
break
if (
isinstance(conn.raddr, str)
and (
"rns" in conn.raddr or "reticulum" in conn.raddr
)
and ".sock" in conn.raddr
):
shared_instance_address = conn.raddr
break
except Exception: # noqa: S110
pass
# Fallback to reading config if not found via connections
if not shared_instance_address:
try:
config_dir = getattr(self, "reticulum_config_dir", None)
if not config_dir:
config_dir = getattr(
RNS.Reticulum,
"configdir",
os.path.expanduser("~/.reticulum"),
)
config_path = os.path.join(config_dir, "config")
if os.path.isfile(config_path):
cp = configparser.ConfigParser()
cp.read(config_path)
if cp.has_section("reticulum"):
shared_port = cp.getint(
"reticulum",
"shared_instance_port",
fallback=37428,
)
shared_bind = cp.get(
"reticulum",
"shared_instance_bind",
fallback="127.0.0.1",
)
shared_instance_address = (
f"{shared_bind}:{shared_port}"
)
except Exception: # noqa: S110
pass
# Calculate announce rates
current_time = time.time()
announces_per_second = len(
@@ -3453,15 +3588,8 @@ class ReticulumMeshChat:
if hasattr(self, "reticulum") and self.reticulum
else None
),
"is_connected_to_shared_instance": (
getattr(
self.reticulum,
"is_connected_to_shared_instance",
False,
)
if hasattr(self, "reticulum") and self.reticulum
else False
),
"is_connected_to_shared_instance": is_connected_to_shared_instance,
"shared_instance_address": shared_instance_address,
"is_transport_enabled": (
self.reticulum.transport_enabled()
if hasattr(self, "reticulum") and self.reticulum
@@ -5245,6 +5373,7 @@ class ReticulumMeshChat:
lxst_address = data.get("lxst_address")
preferred_ringtone_id = data.get("preferred_ringtone_id")
custom_image = data.get("custom_image")
is_telemetry_trusted = data.get("is_telemetry_trusted", 0)
if not name:
return web.json_response(
@@ -5278,6 +5407,7 @@ class ReticulumMeshChat:
lxst_address=lxst_address,
preferred_ringtone_id=preferred_ringtone_id,
custom_image=custom_image,
is_telemetry_trusted=is_telemetry_trusted,
)
return web.json_response({"message": "Contact added"})
@@ -5292,6 +5422,7 @@ class ReticulumMeshChat:
preferred_ringtone_id = data.get("preferred_ringtone_id")
custom_image = data.get("custom_image")
clear_image = data.get("clear_image", False)
is_telemetry_trusted = data.get("is_telemetry_trusted")
self.database.contacts.update_contact(
contact_id,
@@ -5302,6 +5433,7 @@ class ReticulumMeshChat:
preferred_ringtone_id=preferred_ringtone_id,
custom_image=custom_image,
clear_image=clear_image,
is_telemetry_trusted=is_telemetry_trusted,
)
return web.json_response({"message": "Contact updated"})
@@ -6374,6 +6506,31 @@ class ReticulumMeshChat:
except Exception as e:
return web.json_response({"message": str(e)}, status=500)
@routes.get("/api/v1/rnpath/trace/{destination_hash}")
async def rnpath_trace(request):
destination_hash = request.match_info.get("destination_hash")
if not destination_hash:
return web.json_response(
{"error": "destination_hash is required"},
status=400,
)
try:
if not self.rnpath_trace_handler:
return web.json_response(
{
"error": "RNPathTraceHandler not initialized for current context",
},
status=503,
)
result = await self.rnpath_trace_handler.trace_path(destination_hash)
return web.json_response(result)
except Exception as e:
import traceback
error_msg = f"Trace route failed: {e}\n{traceback.format_exc()}"
print(error_msg)
return web.json_response({"error": error_msg}, status=500)
@routes.post("/api/v1/rnprobe")
async def rnprobe(request):
data = await request.json()
@@ -7219,6 +7376,9 @@ class ReticulumMeshChat:
"contact_image": contact_image,
"destination_hash": other_user_hash,
"is_unread": is_unread,
"is_tracking": self.database.telemetry.is_tracking(
other_user_hash,
),
"failed_messages_count": row["failed_count"],
"has_attachments": message_fields_have_attachments(
row["fields"],
@@ -7866,10 +8026,40 @@ class ReticulumMeshChat:
if r["physical_link"]
else None,
"updated_at": r["updated_at"],
"is_tracking": self.database.telemetry.is_tracking(
r["destination_hash"],
),
},
)
return web.json_response({"telemetry": telemetry_list})
@routes.get("/api/v1/telemetry/trusted-peers")
async def telemetry_trusted_peers_get(request):
# get all contacts that are telemetry trusted
contacts = self.database.provider.fetchall(
"SELECT * FROM contacts WHERE is_telemetry_trusted = 1 ORDER BY name ASC",
)
return web.json_response({"trusted_peers": [dict(c) for c in contacts]})
# toggle telemetry tracking for a destination
@routes.post("/api/v1/telemetry/tracking/{destination_hash}/toggle")
async def toggle_telemetry_tracking(request):
destination_hash = request.match_info["destination_hash"]
data = await request.json()
is_tracking = data.get("is_tracking")
new_status = self.database.telemetry.toggle_tracking(
destination_hash,
is_tracking,
)
return web.json_response({"status": "ok", "is_tracking": new_status})
# get all tracked peers
@routes.get("/api/v1/telemetry/tracking")
async def get_tracked_peers(request):
results = self.database.telemetry.get_tracked_peers()
return web.json_response({"tracked_peers": results})
# get telemetry history for a destination
@routes.get("/api/v1/telemetry/history/{destination_hash}")
async def get_telemetry_history(request):
@@ -8088,10 +8278,7 @@ class ReticulumMeshChat:
response.headers["X-XSS-Protection"] = "1; mode=block"
response.headers["Referrer-Policy"] = "strict-origin-when-cross-origin"
# CSP: allow localhost for development and Electron, websockets, and blob URLs
# Add 'unsafe-inline' and 'unsafe-eval' for some legacy doc scripts if needed,
# and allow framing ourselves for the docs page.
gitea_url = "https://git.quad4.io"
# CSP base configuration
connect_sources = [
"'self'",
"ws://localhost:*",
@@ -8100,13 +8287,47 @@ class ReticulumMeshChat:
"https://*.tile.openstreetmap.org",
"https://tile.openstreetmap.org",
"https://nominatim.openstreetmap.org",
"https://*.cartocdn.com",
]
img_sources = [
"'self'",
"data:",
"blob:",
"https://*.tile.openstreetmap.org",
"https://tile.openstreetmap.org",
"https://*.cartocdn.com",
]
frame_sources = [
"'self'",
"https://reticulum.network",
]
script_sources = ["'self'", "'unsafe-inline'", "'unsafe-eval'"]
style_sources = ["'self'", "'unsafe-inline'"]
if self.current_context and self.current_context.config:
# Helper to add domain from URL
def add_domain_from_url(url, target_list):
if not url:
return None
try:
parsed = urlparse(url)
if parsed.netloc:
domain = f"{parsed.scheme}://{parsed.netloc}"
if domain not in target_list:
target_list.append(domain)
return domain
except Exception: # noqa: S110
pass
return None
# Add configured Gitea base URL
gitea_url = self.current_context.config.gitea_base_url.get()
if gitea_url not in connect_sources:
connect_sources.append(gitea_url)
add_domain_from_url(
self.current_context.config.gitea_base_url.get(),
connect_sources,
)
# Add configured docs download URLs domains
docs_urls_str = self.current_context.config.docs_download_urls.get()
@@ -8116,33 +8337,67 @@ class ReticulumMeshChat:
if u.strip()
]
for url in docs_urls:
try:
from urllib.parse import urlparse
domain = add_domain_from_url(url, connect_sources)
if domain and "github.com" in domain:
content_domain = "https://objects.githubusercontent.com"
if content_domain not in connect_sources:
connect_sources.append(content_domain)
parsed = urlparse(url)
if parsed.netloc:
domain = f"{parsed.scheme}://{parsed.netloc}"
if domain not in connect_sources:
connect_sources.append(domain)
# Add map tile server domain
map_tile_url = self.current_context.config.map_tile_server_url.get()
add_domain_from_url(map_tile_url, img_sources)
add_domain_from_url(map_tile_url, connect_sources)
# If GitHub is used, also allow objects.githubusercontent.com for redirects
if "github.com" in domain:
content_domain = "https://objects.githubusercontent.com"
if content_domain not in connect_sources:
connect_sources.append(content_domain)
except Exception: # noqa: S110
pass
# Add nominatim API domain
nominatim_url = self.current_context.config.map_nominatim_api_url.get()
add_domain_from_url(nominatim_url, connect_sources)
# Add custom CSP sources from config
def add_extra_sources(extra_str, target_list):
if not extra_str:
return
sources = [
s.strip()
for s in extra_str.replace("\n", ",")
.replace(";", ",")
.split(",")
if s.strip()
]
for s in sources:
if s not in target_list:
target_list.append(s)
add_extra_sources(
self.current_context.config.csp_extra_connect_src.get(),
connect_sources,
)
add_extra_sources(
self.current_context.config.csp_extra_img_src.get(),
img_sources,
)
add_extra_sources(
self.current_context.config.csp_extra_frame_src.get(),
frame_sources,
)
add_extra_sources(
self.current_context.config.csp_extra_script_src.get(),
script_sources,
)
add_extra_sources(
self.current_context.config.csp_extra_style_src.get(),
style_sources,
)
csp = (
"default-src 'self'; "
"script-src 'self' 'unsafe-inline' 'unsafe-eval'; "
"style-src 'self' 'unsafe-inline'; "
"img-src 'self' data: blob: https://*.tile.openstreetmap.org https://tile.openstreetmap.org; "
f"script-src {' '.join(script_sources)}; "
f"style-src {' '.join(style_sources)}; "
f"img-src {' '.join(img_sources)}; "
"font-src 'self' data:; "
f"connect-src {' '.join(connect_sources)}; "
"media-src 'self' blob:; "
"worker-src 'self' blob:; "
"frame-src 'self' https://reticulum.network; "
f"frame-src {' '.join(frame_sources)}; "
"object-src 'none'; "
"base-uri 'self';"
)
@@ -8329,6 +8584,47 @@ class ReticulumMeshChat:
# Sleep for 12 hours
await asyncio.sleep(12 * 3600)
async def telemetry_tracking_loop(self, session_id, context=None):
ctx = context or self.current_context
if not ctx:
return
while self.running and ctx.running and ctx.session_id == session_id:
try:
# Only run if telemetry is enabled globally
if not ctx.config.telemetry_enabled.get():
await asyncio.sleep(60)
continue
# Get all tracked peers
tracked_peers = ctx.database.telemetry.get_tracked_peers()
now = time.time()
for peer in tracked_peers:
dest_hash = peer["destination_hash"]
interval = peer.get("interval_seconds", 60)
last_req = peer.get("last_request_at")
if last_req is None or now - last_req >= interval:
print(f"Sending telemetry request to tracked peer: {dest_hash}")
# Send telemetry request
await self.send_message(
destination_hash=dest_hash,
content="",
commands=[{SidebandCommands.TELEMETRY_REQUEST: 0}],
delivery_method="opportunistic",
no_display=False,
context=ctx,
)
# Update last request time
ctx.database.telemetry.update_last_request_at(dest_hash, now)
except Exception as e:
print(f"Telemetry tracking loop error: {e}")
# Check every 10 seconds
await asyncio.sleep(10)
# handle announcing
async def announce(self, context=None):
ctx = context or self.current_context
@@ -8590,6 +8886,24 @@ class ReticulumMeshChat:
if "map_nominatim_api_url" in data:
self.config.map_nominatim_api_url.set(data["map_nominatim_api_url"])
# update location settings
if "location_source" in data:
self.config.location_source.set(data["location_source"])
if "location_manual_lat" in data:
self.config.location_manual_lat.set(str(data["location_manual_lat"]))
if "location_manual_lon" in data:
self.config.location_manual_lon.set(str(data["location_manual_lon"]))
if "location_manual_alt" in data:
self.config.location_manual_alt.set(str(data["location_manual_alt"]))
if "telemetry_enabled" in data:
self.config.telemetry_enabled.set(
self._parse_bool(data["telemetry_enabled"])
)
# update banishment settings
if "banished_effect_enabled" in data:
self.config.banished_effect_enabled.set(
@@ -8644,6 +8958,18 @@ class ReticulumMeshChat:
value = self._parse_bool(data["blackhole_integration_enabled"])
self.config.blackhole_integration_enabled.set(value)
# update csp extra sources
if "csp_extra_connect_src" in data:
self.config.csp_extra_connect_src.set(data["csp_extra_connect_src"])
if "csp_extra_img_src" in data:
self.config.csp_extra_img_src.set(data["csp_extra_img_src"])
if "csp_extra_frame_src" in data:
self.config.csp_extra_frame_src.set(data["csp_extra_frame_src"])
if "csp_extra_script_src" in data:
self.config.csp_extra_script_src.set(data["csp_extra_script_src"])
if "csp_extra_style_src" in data:
self.config.csp_extra_style_src.set(data["csp_extra_style_src"])
# update voicemail settings
if "voicemail_enabled" in data:
self.config.voicemail_enabled.set(
@@ -9604,8 +9930,18 @@ class ReticulumMeshChat:
"desktop_open_calls_in_separate_window": ctx.config.desktop_open_calls_in_separate_window.get(),
"desktop_hardware_acceleration_enabled": ctx.config.desktop_hardware_acceleration_enabled.get(),
"blackhole_integration_enabled": ctx.config.blackhole_integration_enabled.get(),
"csp_extra_connect_src": ctx.config.csp_extra_connect_src.get(),
"csp_extra_img_src": ctx.config.csp_extra_img_src.get(),
"csp_extra_frame_src": ctx.config.csp_extra_frame_src.get(),
"csp_extra_script_src": ctx.config.csp_extra_script_src.get(),
"csp_extra_style_src": ctx.config.csp_extra_style_src.get(),
"telephone_tone_generator_enabled": ctx.config.telephone_tone_generator_enabled.get(),
"telephone_tone_generator_volume": ctx.config.telephone_tone_generator_volume.get(),
"location_source": ctx.config.location_source.get(),
"location_manual_lat": ctx.config.location_manual_lat.get(),
"location_manual_lon": ctx.config.location_manual_lon.get(),
"location_manual_alt": ctx.config.location_manual_alt.get(),
"telemetry_enabled": ctx.config.telemetry_enabled.get(),
}
# try and get a name for the provided identity hash
@@ -9938,18 +10274,82 @@ class ReticulumMeshChat:
print(f"Rejecting LXMF message from blocked source: {source_hash}")
return
# check if this lxmf message contains a telemetry request command from sideband
is_sideband_telemetry_request = False
lxmf_fields = lxmf_message.get_fields()
# check both standard LXMF.FIELD_COMMANDS (9) and FIELD_COMMANDS (1)
commands = []
if LXMF.FIELD_COMMANDS in lxmf_fields:
for command in lxmf_fields[LXMF.FIELD_COMMANDS]:
if SidebandCommands.TELEMETRY_REQUEST in command:
val = lxmf_fields[LXMF.FIELD_COMMANDS]
if isinstance(val, list):
commands.extend(val)
elif isinstance(val, dict):
commands.append(val)
if 0x01 in lxmf_fields and 0x01 != LXMF.FIELD_COMMANDS:
val = lxmf_fields[0x01]
if isinstance(val, list):
commands.extend(val)
elif isinstance(val, dict):
commands.append(val)
if commands:
for command in commands:
if (
(
isinstance(command, dict)
and (
SidebandCommands.TELEMETRY_REQUEST in command
or str(SidebandCommands.TELEMETRY_REQUEST) in command
or f"0x{SidebandCommands.TELEMETRY_REQUEST:02x}"
in command
)
)
or (
isinstance(command, (list, tuple))
and SidebandCommands.TELEMETRY_REQUEST in command
)
or command == SidebandCommands.TELEMETRY_REQUEST
or str(command) == str(SidebandCommands.TELEMETRY_REQUEST)
):
is_sideband_telemetry_request = True
# respond to telemetry requests from sideband
# respond to telemetry requests
if is_sideband_telemetry_request:
print(f"Responding to telemetry request from {source_hash}")
self.handle_telemetry_request(source_hash)
# Check if telemetry is enabled globally
if not ctx.config.telemetry_enabled.get():
print(f"Telemetry is disabled, ignoring request from {source_hash}")
else:
# Check if peer is trusted
contact = ctx.database.contacts.get_contact_by_identity_hash(
source_hash
)
if not contact or not contact.get("is_telemetry_trusted"):
print(
f"Telemetry request from untrusted peer {source_hash}, ignoring"
)
else:
print(f"Responding to telemetry request from {source_hash}")
self.handle_telemetry_request(source_hash)
self.db_upsert_lxmf_message(lxmf_message, context=ctx)
# broadcast notification
AsyncUtils.run_async(
self.websocket_broadcast(
json.dumps(
{
"type": "lxmf.delivery",
"remote_identity_name": source_hash[:8],
"lxmf_message": convert_db_lxmf_message_to_dict(
ctx.database.messages.get_lxmf_message_by_hash(
lxmf_message.hash.hex()
),
include_attachments=False,
),
},
),
),
)
return
# check for spam keywords
@@ -9989,47 +10389,39 @@ class ReticulumMeshChat:
# handle telemetry
try:
message_fields = lxmf_message.get_fields()
# Single telemetry entry
if LXMF.FIELD_TELEMETRY in message_fields:
telemetry_data = message_fields[LXMF.FIELD_TELEMETRY]
# unpack to get timestamp
unpacked = Telemeter.from_packed(telemetry_data)
if unpacked and "time" in unpacked:
timestamp = unpacked["time"]["utc"]
self.process_incoming_telemetry(
source_hash,
message_fields[LXMF.FIELD_TELEMETRY],
lxmf_message,
context=ctx,
)
# physical link info
physical_link = {
"rssi": self.reticulum.get_packet_rssi(lxmf_message.hash)
if hasattr(self, "reticulum") and self.reticulum
else None,
"snr": self.reticulum.get_packet_snr(lxmf_message.hash)
if hasattr(self, "reticulum") and self.reticulum
else None,
"q": self.reticulum.get_packet_q(lxmf_message.hash)
if hasattr(self, "reticulum") and self.reticulum
else None,
}
ctx.database.telemetry.upsert_telemetry(
destination_hash=source_hash,
timestamp=timestamp,
data=telemetry_data,
received_from=ctx.local_lxmf_destination.hexhash,
physical_link=physical_link,
)
# broadcast telemetry update via websocket
AsyncUtils.run_async(
self.websocket_broadcast(
json.dumps(
{
"type": "lxmf.telemetry",
"destination_hash": source_hash,
"timestamp": timestamp,
"telemetry": unpacked,
},
),
),
)
# Telemetry stream (multiple entries)
if (
hasattr(LXMF, "FIELD_TELEMETRY_STREAM")
and LXMF.FIELD_TELEMETRY_STREAM in message_fields
):
stream = message_fields[LXMF.FIELD_TELEMETRY_STREAM]
if isinstance(stream, (list, tuple)):
for entry in stream:
if isinstance(entry, (list, tuple)) and len(entry) >= 3:
entry_source = (
entry[0].hex()
if isinstance(entry[0], bytes)
else entry[0]
)
entry_timestamp = entry[1]
entry_data = entry[2]
self.process_incoming_telemetry(
entry_source,
entry_data,
lxmf_message,
timestamp_override=entry_timestamp,
context=ctx,
)
except Exception as e:
print(f"Failed to handle telemetry in LXMF message: {e}")
@@ -10050,9 +10442,7 @@ class ReticulumMeshChat:
source_hash = lxmf_message.source_hash.hex()
# ignore our own icon and empty payloads to avoid overwriting peers with our appearance
if source_hash and local_hash and source_hash == local_hash:
pass
elif (
if (source_hash and local_hash and source_hash == local_hash) or (
not icon_name or not foreground_colour or not background_colour
):
pass
@@ -10560,6 +10950,66 @@ class ReticulumMeshChat:
data = f"{name}|{fg}|{bg}"
return hashlib.sha256(data.encode()).hexdigest()
def process_incoming_telemetry(
self,
source_hash,
telemetry_data,
lxmf_message,
timestamp_override=None,
context=None,
):
ctx = context or self.current_context
if not ctx:
return
try:
unpacked = Telemeter.from_packed(telemetry_data)
if unpacked:
timestamp = timestamp_override or (
unpacked["time"]["utc"] if "time" in unpacked else int(time.time())
)
# physical link info
physical_link = {
"rssi": self.reticulum.get_packet_rssi(lxmf_message.hash)
if hasattr(self, "reticulum") and self.reticulum
else None,
"snr": self.reticulum.get_packet_snr(lxmf_message.hash)
if hasattr(self, "reticulum") and self.reticulum
else None,
"q": self.reticulum.get_packet_q(lxmf_message.hash)
if hasattr(self, "reticulum") and self.reticulum
else None,
}
ctx.database.telemetry.upsert_telemetry(
destination_hash=source_hash,
timestamp=timestamp,
data=telemetry_data,
received_from=ctx.local_lxmf_destination.hexhash,
physical_link=physical_link,
)
# broadcast telemetry update via websocket
AsyncUtils.run_async(
self.websocket_broadcast(
json.dumps(
{
"type": "lxmf.telemetry",
"destination_hash": source_hash,
"timestamp": timestamp,
"telemetry": unpacked,
"physical_link": physical_link,
"is_tracking": ctx.database.telemetry.is_tracking(
source_hash,
),
},
),
),
)
except Exception as e:
print(f"Error processing incoming telemetry: {e}")
def handle_telemetry_request(self, to_addr_hash: str):
# get our location from config
lat = self.database.config.get("map_default_lat")
@@ -10584,15 +11034,13 @@ class ReticulumMeshChat:
telemetry_data = Telemeter.pack(location=location)
# send as an LXMF message with no content, only telemetry field
# use no_display=True to avoid showing in chat UI
AsyncUtils.run_async(
self.send_message(
destination_hash=to_addr_hash,
content="",
telemetry_data=telemetry_data,
delivery_method="opportunistic",
no_display=True,
no_display=False,
),
)
except Exception as e:
@@ -10672,7 +11120,7 @@ class ReticulumMeshChat:
f" ({display_name})"
if (
display_name := parse_lxmf_display_name(
base64.b64encode(app_data).decode() if app_data else None,
app_data,
None,
)
)