From 1046baf7413e135bde552d3ce1c86179e40870b7 Mon Sep 17 00:00:00 2001
From: Jack Kingsman
Date: Fri, 3 Apr 2026 14:43:52 -0700
Subject: [PATCH] Add auto-resend option for not-heard-repeated messages.
Closes #154.
---
app/database.py | 3 +-
app/migrations.py | 21 ++++
app/models.py | 7 ++
app/repository/settings.py | 14 ++-
app/routers/settings.py | 8 ++
app/services/message_send.py | 113 +++++++++++++++++-
.../settings/SettingsRadioSection.tsx | 20 ++++
frontend/src/test/settingsModal.test.tsx | 1 +
frontend/src/types.ts | 2 +
tests/test_migrations.py | 32 ++---
10 files changed, 202 insertions(+), 19 deletions(-)
diff --git a/app/database.py b/app/database.py
index 6da61bb..0834ced 100644
--- a/app/database.py
+++ b/app/database.py
@@ -105,7 +105,8 @@ CREATE TABLE IF NOT EXISTS app_settings (
blocked_keys TEXT DEFAULT '[]',
blocked_names TEXT DEFAULT '[]',
discovery_blocked_types TEXT DEFAULT '[]',
- tracked_telemetry_repeaters TEXT DEFAULT '[]'
+ tracked_telemetry_repeaters TEXT DEFAULT '[]',
+ auto_resend_channel INTEGER DEFAULT 0
);
INSERT OR IGNORE INTO app_settings (id) VALUES (1);
diff --git a/app/migrations.py b/app/migrations.py
index ecd9df0..35b4cb1 100644
--- a/app/migrations.py
+++ b/app/migrations.py
@@ -407,6 +407,12 @@ async def run_migrations(conn: aiosqlite.Connection) -> int:
await set_version(conn, 53)
applied += 1
+ if version < 54:
+ logger.info("Applying migration 54: add auto_resend_channel to app_settings")
+ await _migrate_054_auto_resend_channel(conn)
+ await set_version(conn, 54)
+ applied += 1
+
if applied > 0:
logger.info(
"Applied %d migration(s), schema now at version %d", applied, await get_version(conn)
@@ -3192,3 +3198,18 @@ async def _migrate_053_tracked_telemetry_repeaters(conn: aiosqlite.Connection) -
"ALTER TABLE app_settings ADD COLUMN tracked_telemetry_repeaters TEXT DEFAULT '[]'"
)
await conn.commit()
+
+
+async def _migrate_054_auto_resend_channel(conn: aiosqlite.Connection) -> None:
+ """Add auto_resend_channel boolean column to app_settings."""
+ tables_cursor = await conn.execute("SELECT name FROM sqlite_master WHERE type='table'")
+ if "app_settings" not in {row[0] for row in await tables_cursor.fetchall()}:
+ await conn.commit()
+ return
+ col_cursor = await conn.execute("PRAGMA table_info(app_settings)")
+ columns = {row[1] for row in await col_cursor.fetchall()}
+ if "auto_resend_channel" not in columns:
+ await conn.execute(
+ "ALTER TABLE app_settings ADD COLUMN auto_resend_channel INTEGER DEFAULT 0"
+ )
+ await conn.commit()
diff --git a/app/models.py b/app/models.py
index f962e55..ec9e99c 100644
--- a/app/models.py
+++ b/app/models.py
@@ -843,6 +843,13 @@ class AppSettings(BaseModel):
default_factory=list,
description="Public keys of repeaters opted into periodic telemetry collection (max 8)",
)
+ auto_resend_channel: bool = Field(
+ default=False,
+ description=(
+ "When enabled, outgoing channel messages that receive no echo within 2 seconds "
+ "are automatically byte-perfect resent once (within the 30-second dedup window)"
+ ),
+ )
class FanoutConfig(BaseModel):
diff --git a/app/repository/settings.py b/app/repository/settings.py
index 7516ea6..df4fb1b 100644
--- a/app/repository/settings.py
+++ b/app/repository/settings.py
@@ -30,7 +30,7 @@ class AppSettingsRepository:
last_message_times, preferences_migrated,
advert_interval, last_advert_time, flood_scope,
blocked_keys, blocked_names, discovery_blocked_types,
- tracked_telemetry_repeaters
+ tracked_telemetry_repeaters, auto_resend_channel
FROM app_settings WHERE id = 1
"""
)
@@ -99,6 +99,12 @@ class AppSettingsRepository:
except (json.JSONDecodeError, TypeError, KeyError):
tracked_telemetry_repeaters = []
+ # Parse auto_resend_channel boolean
+ try:
+ auto_resend_channel = bool(row["auto_resend_channel"])
+ except (KeyError, TypeError):
+ auto_resend_channel = False
+
return AppSettings(
max_radio_contacts=row["max_radio_contacts"],
favorites=favorites,
@@ -112,6 +118,7 @@ class AppSettingsRepository:
blocked_names=blocked_names,
discovery_blocked_types=discovery_blocked_types,
tracked_telemetry_repeaters=tracked_telemetry_repeaters,
+ auto_resend_channel=auto_resend_channel,
)
@staticmethod
@@ -128,6 +135,7 @@ class AppSettingsRepository:
blocked_names: list[str] | None = None,
discovery_blocked_types: list[int] | None = None,
tracked_telemetry_repeaters: list[str] | None = None,
+ auto_resend_channel: bool | None = None,
) -> AppSettings:
"""Update app settings. Only provided fields are updated."""
updates = []
@@ -182,6 +190,10 @@ class AppSettingsRepository:
updates.append("tracked_telemetry_repeaters = ?")
params.append(json.dumps(tracked_telemetry_repeaters))
+ if auto_resend_channel is not None:
+ updates.append("auto_resend_channel = ?")
+ params.append(1 if auto_resend_channel else 0)
+
if updates:
query = f"UPDATE app_settings SET {', '.join(updates)} WHERE id = 1"
await db.conn.execute(query, params)
diff --git a/app/routers/settings.py b/app/routers/settings.py
index 0ddc157..62e4835 100644
--- a/app/routers/settings.py
+++ b/app/routers/settings.py
@@ -53,6 +53,10 @@ class AppSettingsUpdate(BaseModel):
"advertisements should not create new contacts"
),
)
+ auto_resend_channel: bool | None = Field(
+ default=None,
+ description="Auto-resend channel messages once if no echo heard within 2 seconds",
+ )
class BlockKeyRequest(BaseModel):
@@ -142,6 +146,10 @@ async def update_settings(update: AppSettingsUpdate) -> AppSettings:
valid = [t for t in update.discovery_blocked_types if t in (1, 2, 3, 4)]
kwargs["discovery_blocked_types"] = sorted(set(valid))
+ # Auto-resend channel
+ if update.auto_resend_channel is not None:
+ kwargs["auto_resend_channel"] = update.auto_resend_channel
+
# Flood scope
flood_scope_changed = False
if update.flood_scope is not None:
diff --git a/app/services/message_send.py b/app/services/message_send.py
index 5672334..232ac15 100644
--- a/app/services/message_send.py
+++ b/app/services/message_send.py
@@ -2,6 +2,7 @@
import asyncio
import logging
+import time as _time
from collections.abc import Callable
from typing import Any
@@ -9,8 +10,14 @@ from fastapi import HTTPException
from meshcore import EventType
from app.models import ResendChannelMessageResponse
+from app.radio import RadioOperationBusyError
from app.region_scope import normalize_region_scope
-from app.repository import AppSettingsRepository, ContactRepository, MessageRepository
+from app.repository import (
+ AppSettingsRepository,
+ ChannelRepository,
+ ContactRepository,
+ MessageRepository,
+)
from app.services import dm_ack_tracker
from app.services.messages import (
broadcast_message,
@@ -33,6 +40,15 @@ NowFn = Callable[[], float]
OutgoingReservationKey = tuple[str, str, str]
RetryTaskScheduler = Callable[[Any], Any]
+# Channel echo watchdog: delay before checking for echoes
+ECHO_WATCHDOG_DELAY_SECONDS = 2.0
+
+# Byte-perfect resend window (must match router's RESEND_WINDOW_SECONDS)
+RESEND_WINDOW_SECONDS = 30
+
+# Temp radio slot used by the router for channel sends
+WATCHDOG_TEMP_RADIO_SLOT = 0
+
_pending_outgoing_timestamp_reservations: dict[OutgoingReservationKey, set[int]] = {}
_outgoing_timestamp_reservations_lock = asyncio.Lock()
@@ -620,6 +636,85 @@ async def send_direct_message_to_contact(
return message
+async def _channel_echo_watchdog(
+ message_id: int,
+ radio_manager,
+ broadcast_fn: BroadcastFn,
+ error_broadcast_fn: BroadcastFn,
+) -> None:
+ """One-shot watchdog: if no echo heard after delay, attempt one byte-perfect resend.
+
+ Spawned as a fire-and-forget task after a channel send when auto_resend_channel is enabled.
+ Uses non-blocking radio lock so it never stalls user actions.
+ """
+ try:
+ await asyncio.sleep(ECHO_WATCHDOG_DELAY_SECONDS)
+
+ msg = await MessageRepository.get_by_id(message_id)
+ if not msg:
+ return
+ if msg.acked > 0:
+ logger.debug(
+ "Echo watchdog: message %d already has %d echo(s), skipping", message_id, msg.acked
+ )
+ return
+ if msg.sender_timestamp is None:
+ return
+
+ elapsed = int(_time.time()) - msg.sender_timestamp
+ if elapsed > RESEND_WINDOW_SECONDS:
+ logger.debug(
+ "Echo watchdog: message %d outside resend window (%ds)", message_id, elapsed
+ )
+ return
+
+ channel = await ChannelRepository.get_by_key(msg.conversation_key)
+ if not channel:
+ return
+
+ logger.info(
+ "Echo watchdog: no echo for message %d after %.0fs, attempting byte-perfect resend",
+ message_id,
+ ECHO_WATCHDOG_DELAY_SECONDS,
+ )
+
+ try:
+ key_bytes = bytes.fromhex(msg.conversation_key)
+ except ValueError:
+ return
+
+ timestamp_bytes = msg.sender_timestamp.to_bytes(4, "little")
+
+ # Strip sender name prefix to get the raw text for the radio
+ async with radio_manager.radio_operation("echo_watchdog_resend", blocking=False) as mc:
+ radio_name = mc.self_info.get("name", "") if mc.self_info else ""
+ text_to_send = msg.text
+ if radio_name and text_to_send.startswith(f"{radio_name}: "):
+ text_to_send = text_to_send[len(f"{radio_name}: ") :]
+
+ result = await send_channel_message_with_effective_scope(
+ mc=mc,
+ channel=channel,
+ channel_key=msg.conversation_key,
+ key_bytes=key_bytes,
+ text=text_to_send,
+ timestamp_bytes=timestamp_bytes,
+ action_label="echo watchdog resend",
+ radio_manager=radio_manager,
+ temp_radio_slot=WATCHDOG_TEMP_RADIO_SLOT,
+ error_broadcast_fn=error_broadcast_fn,
+ )
+ if result is not None and result.type != EventType.ERROR:
+ logger.info("Echo watchdog: resent message %d successfully", message_id)
+ else:
+ logger.debug("Echo watchdog: resend got no/error result for message %d", message_id)
+
+ except RadioOperationBusyError:
+ logger.debug("Echo watchdog: radio busy, skipping resend for message %d", message_id)
+ except Exception:
+ logger.debug("Echo watchdog: resend failed for message %d", message_id, exc_info=True)
+
+
async def send_channel_message_to_channel(
*,
channel,
@@ -728,6 +823,22 @@ async def send_channel_message_to_channel(
message_repository=message_repository,
)
broadcast_message(message=outgoing_message, broadcast_fn=broadcast_fn)
+
+ # Spawn echo watchdog if auto-resend is enabled
+ try:
+ settings = await AppSettingsRepository.get()
+ if settings.auto_resend_channel:
+ asyncio.create_task(
+ _channel_echo_watchdog(
+ message_id=outgoing_message.id,
+ radio_manager=radio_manager,
+ broadcast_fn=broadcast_fn,
+ error_broadcast_fn=error_broadcast_fn,
+ )
+ )
+ except Exception:
+ pass # Never let watchdog setup failure break the send
+
return outgoing_message
diff --git a/frontend/src/components/settings/SettingsRadioSection.tsx b/frontend/src/components/settings/SettingsRadioSection.tsx
index dadd413..2d5dc73 100644
--- a/frontend/src/components/settings/SettingsRadioSection.tsx
+++ b/frontend/src/components/settings/SettingsRadioSection.tsx
@@ -702,6 +702,26 @@ export function SettingsRadioSection({
+
+
+
+ onSaveAppSettings({ auto_resend_channel: checked === true })
+ }
+ className="mt-0.5"
+ />
+
+
Auto-Resend Unheard Channel Messages
+
+ When enabled, outgoing channel messages that receive no echo within 2 seconds are
+ automatically resent once (byte-perfect, within the 30-second dedup window). Repeaters
+ that already heard the original will ignore the duplicate. This functionality will NOT
+ create double-sent/duplicate messages.
+
+
+
diff --git a/frontend/src/test/settingsModal.test.tsx b/frontend/src/test/settingsModal.test.tsx
index 30b4d34..7eca899 100644
--- a/frontend/src/test/settingsModal.test.tsx
+++ b/frontend/src/test/settingsModal.test.tsx
@@ -70,6 +70,7 @@ const baseSettings: AppSettings = {
blocked_names: [],
discovery_blocked_types: [],
tracked_telemetry_repeaters: [],
+ auto_resend_channel: false,
};
function renderModal(overrides?: {
diff --git a/frontend/src/types.ts b/frontend/src/types.ts
index 6a93b1c..53ba5ec 100644
--- a/frontend/src/types.ts
+++ b/frontend/src/types.ts
@@ -337,12 +337,14 @@ export interface AppSettings {
blocked_names: string[];
discovery_blocked_types: number[];
tracked_telemetry_repeaters: string[];
+ auto_resend_channel: boolean;
}
export interface AppSettingsUpdate {
max_radio_contacts?: number;
auto_decrypt_dm_on_advert?: boolean;
advert_interval?: number;
+ auto_resend_channel?: boolean;
flood_scope?: string;
blocked_keys?: string[];
blocked_names?: string[];
diff --git a/tests/test_migrations.py b/tests/test_migrations.py
index 859517f..d09eb03 100644
--- a/tests/test_migrations.py
+++ b/tests/test_migrations.py
@@ -1249,8 +1249,8 @@ class TestMigration039:
applied = await run_migrations(conn)
- assert applied == 15
- assert await get_version(conn) == 53
+ assert applied == 16
+ assert await get_version(conn) == 54
cursor = await conn.execute(
"""
@@ -1321,8 +1321,8 @@ class TestMigration039:
applied = await run_migrations(conn)
- assert applied == 15
- assert await get_version(conn) == 53
+ assert applied == 16
+ assert await get_version(conn) == 54
cursor = await conn.execute(
"""
@@ -1388,8 +1388,8 @@ class TestMigration039:
applied = await run_migrations(conn)
- assert applied == 9
- assert await get_version(conn) == 53
+ assert applied == 10
+ assert await get_version(conn) == 54
cursor = await conn.execute(
"""
@@ -1441,8 +1441,8 @@ class TestMigration040:
applied = await run_migrations(conn)
- assert applied == 14
- assert await get_version(conn) == 53
+ assert applied == 15
+ assert await get_version(conn) == 54
await conn.execute(
"""
@@ -1503,8 +1503,8 @@ class TestMigration041:
applied = await run_migrations(conn)
- assert applied == 13
- assert await get_version(conn) == 53
+ assert applied == 14
+ assert await get_version(conn) == 54
await conn.execute(
"""
@@ -1556,8 +1556,8 @@ class TestMigration042:
applied = await run_migrations(conn)
- assert applied == 12
- assert await get_version(conn) == 53
+ assert applied == 13
+ assert await get_version(conn) == 54
await conn.execute(
"""
@@ -1696,8 +1696,8 @@ class TestMigration046:
applied = await run_migrations(conn)
- assert applied == 8
- assert await get_version(conn) == 53
+ assert applied == 9
+ assert await get_version(conn) == 54
cursor = await conn.execute(
"""
@@ -1790,8 +1790,8 @@ class TestMigration047:
applied = await run_migrations(conn)
- assert applied == 7
- assert await get_version(conn) == 53
+ assert applied == 8
+ assert await get_version(conn) == 54
cursor = await conn.execute(
"""