diff --git a/app.py b/app.py
index 8302d91..71d0200 100644
--- a/app.py
+++ b/app.py
@@ -85,16 +85,32 @@ def start_gateway(reticulum):
thread.start()
-def _transport_settings_match(config_file, desired_host, desired_port):
- """Check if existing config transport settings match desired values."""
+def _config_settings_match(config_file, desired_host, desired_port):
+ """Check if existing config transport and LoRa settings match desired values."""
import configparser
try:
config = configparser.ConfigParser()
config.read(config_file)
- if config.has_section("TCP Transport"):
- existing_host = config.get("TCP Transport", "target_host")
- existing_port = config.get("TCP Transport", "target_port")
- return existing_host == desired_host and existing_port == str(desired_port)
+ # Check TCP transport
+ tcp_enabled = get_setting("tcp_enabled", "1") == "1"
+ has_tcp = config.has_section("TCP Transport")
+ if tcp_enabled != has_tcp:
+ return False
+ if tcp_enabled and has_tcp:
+ if (config.get("TCP Transport", "target_host") != desired_host or
+ config.get("TCP Transport", "target_port") != str(desired_port)):
+ return False
+ # Check LoRa
+ lora_enabled = get_setting("lora_enabled", "0") == "1"
+ has_lora = config.has_section("RNode LoRa")
+ if lora_enabled != has_lora:
+ return False
+ if lora_enabled and has_lora:
+ if config.get("RNode LoRa", "port", fallback="") != get_setting("lora_port", ""):
+ return False
+ if config.get("RNode LoRa", "frequency", fallback="") != get_setting("lora_frequency", "867200000"):
+ return False
+ return True
except Exception:
pass
return False
@@ -111,9 +127,41 @@ def ensure_rns_config(config_dir, transport_host=None, transport_port=None):
transport_port = int(get_setting("transport_port", str(DEFAULT_TRANSPORT_PORT)))
if os.path.exists(config_file):
- if _transport_settings_match(config_file, transport_host, transport_port):
+ if _config_settings_match(config_file, transport_host, transport_port):
return
+ # Build optional interface blocks
+ tcp_block = ""
+ if get_setting("tcp_enabled", "1") == "1":
+ tcp_block = f"""
+ [[TCP Transport]]
+ type = TCPClientInterface
+ enabled = yes
+ target_host = {transport_host}
+ target_port = {transport_port}
+"""
+
+ lora_block = ""
+ if get_setting("lora_enabled", "0") == "1":
+ lora_port = get_setting("lora_port", "")
+ if lora_port:
+ lora_frequency = get_setting("lora_frequency", "867200000")
+ lora_bandwidth = get_setting("lora_bandwidth", "125000")
+ lora_txpower = get_setting("lora_txpower", "7")
+ lora_sf = get_setting("lora_sf", "8")
+ lora_cr = get_setting("lora_cr", "5")
+ lora_block = f"""
+ [[RNode LoRa]]
+ type = RNodeInterface
+ enabled = yes
+ port = {lora_port}
+ frequency = {lora_frequency}
+ bandwidth = {lora_bandwidth}
+ txpower = {lora_txpower}
+ spreadingfactor = {lora_sf}
+ codingrate = {lora_cr}
+"""
+
os.makedirs(config_dir, exist_ok=True)
with open(config_file, "w") as f:
f.write(f"""[reticulum]
@@ -127,13 +175,7 @@ def ensure_rns_config(config_dir, transport_host=None, transport_port=None):
[[Default Interface]]
type = AutoInterface
enabled = Yes
-
- [[TCP Transport]]
- type = TCPClientInterface
- enabled = yes
- target_host = {transport_host}
- target_port = {transport_port}
-""")
+{tcp_block}{lora_block}""")
print(f"Created Reticulum config at {config_file}")
diff --git a/handlers.py b/handlers.py
index ab6864f..74f66b7 100644
--- a/handlers.py
+++ b/handlers.py
@@ -758,10 +758,23 @@ def handle_style_form(msg=""):
reranker_checked = " checked" if reranker == "1" else ""
disabled = "" if semantic == "1" else " disabled"
dimmed = ' style="opacity:0.4"' if semantic != "1" else ""
+ tcp_enabled = get_setting("tcp_enabled", "1")
+ tcp_checked = " checked" if tcp_enabled == "1" else ""
+ tcp_disabled = "" if tcp_enabled == "1" else " disabled"
transport_host = get_setting("transport_host", "reticulum.derickphan.com")
transport_port = get_setting("transport_port", "4242")
compress = get_setting("compress_embeddings", "0")
compress_checked = " checked" if compress == "1" else ""
+ lora_enabled = get_setting("lora_enabled", "0")
+ lora_checked = " checked" if lora_enabled == "1" else ""
+ lora_disabled = "" if lora_enabled == "1" else " disabled"
+ lora_dimmed = ' style="opacity:0.4"' if lora_enabled != "1" else ""
+ lora_port = get_setting("lora_port", "")
+ lora_frequency = get_setting("lora_frequency", "867200000")
+ lora_bandwidth = get_setting("lora_bandwidth", "125000")
+ lora_txpower = get_setting("lora_txpower", "7")
+ lora_sf = get_setting("lora_sf", "8")
+ lora_cr = get_setting("lora_cr", "5")
return _respond(
f"
customize "
f"name your search engine "
@@ -773,11 +786,43 @@ def handle_style_form(msg=""):
f" share your site list publicly at /api/sites "
f"Note: pages tagged: private will not be shared. "
f"mesh network "
- f"Connect to a Reticulum transport node to reach other peers.
"
+ f"Choose how to connect to the mesh. You can enable both for maximum reach.
"
+ f"internet "
+ f' '
+ f" connect via internet transport node "
+ f"Reach peers anywhere online. "
+ f' '
+ f"LoRa "
+ f' '
+ f" connect via LoRa radio "
+ f"Reach nearby peers off-grid with an RNode . "
+ f''
+ f'Serial port: '
+ f'advanced radio settings '
+ f'
'
f"search "
f"ai "
f' {esc(err_msg)}'
+ else:
+ status_html = ""
+
+ # Disable sync button while syncing
+ if is_syncing:
+ sync_btn = 'syncing... '
+ else:
+ sync_btn = (
+ f''
+ )
+
cards += (
f''
f'
{esc(s["name"] or "unknown")}
'
f'
{esc(s["dest_hash"])}
'
f'
last sync: {esc(last)}
'
+ f'{status_html}'
f'
'
- f'
browse '
- f'
'
- f'
'
- f'
'
f'
'
f'
'
)
listing = ""
if subs:
+ any_syncing = any(sid in _sync_threads and _sync_threads[sid].is_alive() for sid in [s["id"] for s in subs])
+ syncall_btn = 'syncing... ' if any_syncing else 'sync all '
listing = (
f'{cards}'
f''
+ f'{_csrf_field()}{syncall_btn}'
)
return _respond(
f"subscriptions "
@@ -1188,13 +1270,15 @@ def handle_subscription_pick(body):
return handle_subscriptions(f"Imported {imported} page(s). {errors} error(s).")
-def handle_subscription_sync(sub_id):
+def _sync_subscription(sub_id):
+ """Run a single subscription sync. Designed to run in a background thread."""
+ set_setting(f"sync_status_{sub_id}", "syncing")
db = get_db()
try:
sub = db.execute("SELECT * FROM subscriptions WHERE id = ?", (sub_id,)).fetchone()
if not sub:
- return handle_subscriptions("Subscription not found.")
- # Use last_sync for delta sync if available
+ set_setting(f"sync_status_{sub_id}", "error:Subscription not found.")
+ return
since = sub["last_sync"].replace(" ", "T") if sub["last_sync"] else ""
try:
data = fetch_remote_sites(sub["dest_hash"], since=since)
@@ -1202,11 +1286,12 @@ def handle_subscription_sync(sub_id):
all_urls = data.get("all_urls")
remote_name = data.get("name", sub["name"])
except PermissionError:
- return handle_subscriptions("That instance has sharing disabled.")
- except Exception:
- return handle_subscriptions("Could not sync with that instance.")
+ set_setting(f"sync_status_{sub_id}", "error:That instance has sharing disabled.")
+ return
+ except Exception as e:
+ set_setting(f"sync_status_{sub_id}", f"error:Could not sync \u2014 {e}")
+ return
- # If full sync (all_urls provided), remove pages no longer on remote
if all_urls is not None:
existing = db.execute(
"SELECT id, url FROM remote_pages WHERE subscription_id = ?", (sub_id,)
@@ -1216,7 +1301,6 @@ def handle_subscription_sync(sub_id):
if row["url"] not in remote_url_set:
db.execute("DELETE FROM remote_pages WHERE id = ?", (row["id"],))
- # Upsert changed/new pages
synced = 0
for s in sites:
try:
@@ -1226,7 +1310,6 @@ def handle_subscription_sync(sub_id):
"ON CONFLICT(subscription_id, url) DO UPDATE SET title=excluded.title, note=excluded.note, tags=excluded.tags",
(sub_id, s["url"], s["title"], s.get("note", ""), tags_str),
)
- # Embed remote page for semantic search
if get_setting("semantic_search", "0") == "1":
try:
from embeddings import store_remote_embeddings
@@ -1243,9 +1326,22 @@ def handle_subscription_sync(sub_id):
now = datetime.now().strftime("%Y-%m-%dT%H:%M:%S")
db.execute("UPDATE subscriptions SET last_sync = ?, name = ? WHERE id = ?", (now, remote_name, sub_id))
db.commit()
+ set_setting(f"sync_status_{sub_id}", f"done:{synced}")
+ except Exception as e:
+ set_setting(f"sync_status_{sub_id}", f"error:{e}")
finally:
return_db(db)
- return handle_subscriptions(f"Synced {synced} site(s) from {esc(remote_name)}.")
+
+
+def handle_subscription_sync(sub_id):
+ if sub_id in _sync_threads and _sync_threads[sub_id].is_alive():
+ return _redirect("/subscriptions")
+ # Clear previous status
+ set_setting(f"sync_status_{sub_id}", "syncing")
+ t = threading.Thread(target=_sync_subscription, args=(sub_id,), daemon=True)
+ _sync_threads[sub_id] = t
+ t.start()
+ return _redirect("/subscriptions")
def handle_subscription_autosync(sub_id):
@@ -1277,53 +1373,15 @@ def handle_subscription_syncall():
return_db(db)
if not subs:
return handle_subscriptions("No subscriptions have auto-sync enabled.")
- total = 0
for sub in subs:
- try:
- since = sub["last_sync"].replace(" ", "T") if sub["last_sync"] else ""
- data = fetch_remote_sites(sub["dest_hash"], since=since)
- sites = data.get("sites", [])
- all_urls = data.get("all_urls")
- remote_name = data.get("name", sub["name"])
- db = get_db()
- try:
- if all_urls is not None:
- existing = db.execute(
- "SELECT id, url FROM remote_pages WHERE subscription_id = ?", (sub["id"],)
- ).fetchall()
- remote_url_set = set(all_urls)
- for row in existing:
- if row["url"] not in remote_url_set:
- db.execute("DELETE FROM remote_pages WHERE id = ?", (row["id"],))
- for s in sites:
- try:
- tags_str = ",".join(s.get("tags", []))
- db.execute(
- "INSERT INTO remote_pages (subscription_id, url, title, note, tags) VALUES (?, ?, ?, ?, ?) "
- "ON CONFLICT(subscription_id, url) DO UPDATE SET title=excluded.title, note=excluded.note, tags=excluded.tags",
- (sub["id"], s["url"], s["title"], s.get("note", ""), tags_str),
- )
- if get_setting("semantic_search", "0") == "1":
- try:
- from embeddings import store_remote_embeddings
- rp_id = db.execute(
- "SELECT id FROM remote_pages WHERE subscription_id = ? AND url = ?",
- (sub["id"], s["url"]),
- ).fetchone()["id"]
- store_remote_embeddings(rp_id, s["title"], s.get("note", ""), db)
- except Exception:
- pass
- except Exception:
- pass
- now = datetime.now().strftime("%Y-%m-%dT%H:%M:%S")
- db.execute("UPDATE subscriptions SET last_sync = ?, name = ? WHERE id = ?", (now, remote_name, sub["id"]))
- db.commit()
- finally:
- return_db(db)
- total += 1
- except Exception:
- pass
- return handle_subscriptions(f"Synced {total} subscription(s).")
+ sub_id = sub["id"]
+ if sub_id in _sync_threads and _sync_threads[sub_id].is_alive():
+ continue
+ set_setting(f"sync_status_{sub_id}", "syncing")
+ t = threading.Thread(target=_sync_subscription, args=(sub_id,), daemon=True)
+ _sync_threads[sub_id] = t
+ t.start()
+ return _redirect("/subscriptions")
# --- Reindex (semantic search) ---
diff --git a/rns_client.py b/rns_client.py
index dbc0af5..98df406 100644
--- a/rns_client.py
+++ b/rns_client.py
@@ -1,9 +1,15 @@
+import json
import time
import RNS
APP_NAME = "tinyweb"
ASPECTS = ["server"]
-REQUEST_TIMEOUT = 30
+
+# 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=""):
@@ -11,18 +17,40 @@ 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 < 15:
- time.sleep(0.5)
- elapsed += 0.5
+ 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}")
+ 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:
@@ -39,15 +67,16 @@ def fetch_remote_sites(dest_hash_hex, since=""):
# Establish link
link = RNS.Link(destination)
elapsed = 0
- while link.status == RNS.Link.PENDING and elapsed < 15:
- time.sleep(0.25)
- elapsed += 0.25
+ 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}")
+ raise ConnectionError(
+ f"Could not establish link to {dest_hash_hex} ({timeouts['link']}s timeout)"
+ )
try:
- # Request /api/sites
query = {"since": [since]} if since else {}
request_data = {
"method": "GET",
@@ -57,13 +86,14 @@ def fetch_remote_sites(dest_hash_hex, since=""):
"gateway_host": "",
}
- receipt = link.request("/tinyweb", data=request_data, timeout=REQUEST_TIMEOUT)
+ 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 < REQUEST_TIMEOUT:
- time.sleep(0.5)
- elapsed += 0.5
+ 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()
@@ -71,9 +101,10 @@ def fetch_remote_sites(dest_hash_hex, since=""):
raise PermissionError("That instance has sharing disabled.")
if resp["status"] != 200:
raise ConnectionError(f"Remote returned status {resp['status']}")
- import json
return json.loads(resp["body"])
else:
- raise ConnectionError(f"Request failed or timed out")
+ raise ConnectionError(
+ f"Request failed or timed out ({req_timeout}s timeout)"
+ )
finally:
link.teardown()