tinyweb/rns_client.py
lichenblankie e3aadf3947 added LoRa sync with settings UI
- Progressive retry in rns_client.py: fast timeout (15s) then slow (60s+)
  for LoRa/multi-hop links, with automatic fallback
- Background sync threads so subscriptions page returns immediately
  with syncing/error status indicators per subscription
- LoRa RNode configuration in settings page with serial port and
  expandable advanced radio settings (frequency, bandwidth, etc.)
- Internet transport now toggleable alongside LoRa — users can
  enable one, the other, or both
- Reticulum config auto-generated from settings on startup
2026-06-05 05:29:36 +00:00

110 lines
3.6 KiB
Python

import json
import time
import RNS
APP_NAME = "tinyweb"
ASPECTS = ["server"]
# Two-tier timeout profiles: fast first, then slow for LoRa/multi-hop links
_TIMEOUT_TIERS = [
{"path": 15, "link": 15, "request": 30, "poll": 0.25},
{"path": 60, "link": 60, "request": 120, "poll": 1.0},
]
def fetch_remote_sites(dest_hash_hex, since=""):
"""
Connect to a remote TinyWeb instance over Reticulum and fetch its
shared sites. Returns the response dict from /api/sites, or raises
an exception on failure. Pass `since` as ISO timestamp for delta sync.
Uses progressive timeouts: tries fast first, then retries with longer
timeouts for slow links (LoRa, multi-hop).
"""
last_error = None
for tier in _TIMEOUT_TIERS:
try:
return _fetch(dest_hash_hex, since, tier)
except PermissionError:
raise # Don't retry permission errors
except Exception as e:
last_error = e
continue
raise ConnectionError(
f"Could not reach {dest_hash_hex} after {len(_TIMEOUT_TIERS)} attempts: {last_error}"
)
def _fetch(dest_hash_hex, since, timeouts):
"""Single fetch attempt with the given timeout profile."""
dest_hash = bytes.fromhex(dest_hash_hex)
poll = timeouts["poll"]
# Resolve path if needed
if not RNS.Transport.has_path(dest_hash):
RNS.Transport.request_path(dest_hash)
elapsed = 0
while not RNS.Transport.has_path(dest_hash) and elapsed < timeouts["path"]:
time.sleep(poll)
elapsed += poll
if not RNS.Transport.has_path(dest_hash):
raise ConnectionError(
f"Could not find path to {dest_hash_hex} ({timeouts['path']}s timeout)"
)
server_identity = RNS.Identity.recall(dest_hash)
if server_identity is None:
raise ConnectionError(f"Could not recall identity for {dest_hash_hex}")
destination = RNS.Destination(
server_identity,
RNS.Destination.OUT,
RNS.Destination.SINGLE,
APP_NAME,
*ASPECTS,
)
# Establish link
link = RNS.Link(destination)
elapsed = 0
while link.status == RNS.Link.PENDING and elapsed < timeouts["link"]:
time.sleep(poll)
elapsed += poll
if link.status != RNS.Link.ACTIVE:
raise ConnectionError(
f"Could not establish link to {dest_hash_hex} ({timeouts['link']}s timeout)"
)
try:
query = {"since": [since]} if since else {}
request_data = {
"method": "GET",
"path": "/api/sites",
"query": query,
"body": {},
"gateway_host": "",
}
req_timeout = timeouts["request"]
receipt = link.request("/tinyweb", data=request_data, timeout=req_timeout)
elapsed = 0
done = (RNS.RequestReceipt.READY, RNS.RequestReceipt.DELIVERED, RNS.RequestReceipt.FAILED)
while receipt.get_status() not in done and elapsed < req_timeout:
time.sleep(poll)
elapsed += poll
if receipt.get_status() in (RNS.RequestReceipt.READY, RNS.RequestReceipt.DELIVERED):
resp = receipt.get_response()
if resp["status"] == 403:
raise PermissionError("That instance has sharing disabled.")
if resp["status"] != 200:
raise ConnectionError(f"Remote returned status {resp['status']}")
return json.loads(resp["body"])
else:
raise ConnectionError(
f"Request failed or timed out ({req_timeout}s timeout)"
)
finally:
link.teardown()