diff --git a/AGENTS.md b/AGENTS.md
index 01c7ab4..bc351a4 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -268,6 +268,7 @@ All endpoints are prefixed with `/api` (e.g., `/api/health`).
| GET | `/api/messages` | List with filters |
| POST | `/api/messages/direct` | Send direct message |
| POST | `/api/messages/channel` | Send channel message |
+| POST | `/api/messages/channel/{message_id}/resend` | Resend an outgoing channel message (within 30 seconds) |
| GET | `/api/packets/undecrypted/count` | Count of undecrypted packets |
| POST | `/api/packets/decrypt/historical` | Decrypt stored packets |
| POST | `/api/packets/maintenance` | Delete old packets and vacuum |
@@ -359,8 +360,8 @@ mc.subscribe(EventType.ACK, handler)
| `MESHCORE_BLE_PIN` | *(required with BLE)* | BLE PIN code |
| `MESHCORE_DATABASE_PATH` | `data/meshcore.db` | SQLite database location |
-**Note:** Runtime app settings are stored in the database (`app_settings` table), not environment variables. These include `max_radio_contacts`, `experimental_channel_double_send`, `auto_decrypt_dm_on_advert`, `sidebar_sort_order`, `advert_interval`, `last_advert_time`, `favorites`, `last_message_times`, and `bots`. They are configured via `GET/PATCH /api/settings` (and related settings endpoints).
+**Note:** Runtime app settings are stored in the database (`app_settings` table), not environment variables. These include `max_radio_contacts`, `auto_decrypt_dm_on_advert`, `sidebar_sort_order`, `advert_interval`, `last_advert_time`, `favorites`, `last_message_times`, and `bots`. They are configured via `GET/PATCH /api/settings` (and related settings endpoints).
-`experimental_channel_double_send` is an opt-in experimental setting: when enabled, channel sends perform a second byte-perfect resend after a 3-second delay.
+Byte-perfect channel retries are user-triggered via `POST /api/messages/channel/{message_id}/resend` and are allowed for 30 seconds after the original send.
**Transport mutual exclusivity:** Only one of `MESHCORE_SERIAL_PORT`, `MESHCORE_TCP_HOST`, or `MESHCORE_BLE_ADDRESS` may be set. If none are set, serial auto-detection is used.
diff --git a/app/AGENTS.md b/app/AGENTS.md
index 8b78a77..7d052ee 100644
--- a/app/AGENTS.md
+++ b/app/AGENTS.md
@@ -121,6 +121,7 @@ app/
- `GET /messages`
- `POST /messages/direct`
- `POST /messages/channel`
+- `POST /messages/channel/{message_id}/resend`
### Packets
- `GET /packets/undecrypted/count`
@@ -164,7 +165,6 @@ Main tables:
`app_settings` fields in active model:
- `max_radio_contacts`
-- `experimental_channel_double_send`
- `favorites`
- `auto_decrypt_dm_on_advert`
- `sidebar_sort_order`
diff --git a/app/migrations.py b/app/migrations.py
index b866b32..0782fd7 100644
--- a/app/migrations.py
+++ b/app/migrations.py
@@ -149,6 +149,13 @@ async def run_migrations(conn: aiosqlite.Connection) -> int:
await set_version(conn, 16)
applied += 1
+ # Migration 17: Drop experimental_channel_double_send column (replaced by user-triggered resend)
+ if version < 17:
+ logger.info("Applying migration 17: drop experimental_channel_double_send column")
+ await _migrate_017_drop_experimental_channel_double_send(conn)
+ await set_version(conn, 17)
+ applied += 1
+
if applied > 0:
logger.info(
"Applied %d migration(s), schema now at version %d", applied, await get_version(conn)
@@ -1021,3 +1028,29 @@ async def _migrate_016_add_experimental_channel_double_send(conn: aiosqlite.Conn
raise
await conn.commit()
+
+
+async def _migrate_017_drop_experimental_channel_double_send(conn: aiosqlite.Connection) -> None:
+ """
+ Drop experimental_channel_double_send column from app_settings.
+
+ This feature is replaced by a user-triggered resend button.
+ SQLite 3.35.0+ supports ALTER TABLE DROP COLUMN. For older versions,
+ we silently skip (the column will remain but is unused).
+ """
+ try:
+ await conn.execute("ALTER TABLE app_settings DROP COLUMN experimental_channel_double_send")
+ logger.debug("Dropped experimental_channel_double_send from app_settings")
+ except aiosqlite.OperationalError as e:
+ error_msg = str(e).lower()
+ if "no such column" in error_msg:
+ logger.debug("app_settings.experimental_channel_double_send already dropped, skipping")
+ elif "syntax error" in error_msg or "drop column" in error_msg:
+ logger.debug(
+ "SQLite doesn't support DROP COLUMN, "
+ "experimental_channel_double_send column will remain"
+ )
+ else:
+ raise
+
+ await conn.commit()
diff --git a/app/models.py b/app/models.py
index a3613bb..e31442f 100644
--- a/app/models.py
+++ b/app/models.py
@@ -265,13 +265,6 @@ class AppSettings(BaseModel):
"(favorite contacts first, then recent non-repeaters)"
),
)
- experimental_channel_double_send: bool = Field(
- default=False,
- description=(
- "Experimental: when enabled, channel messages are sent twice with a 3-second delay, "
- "reusing the same timestamp bytes"
- ),
- )
favorites: list[Favorite] = Field(
default_factory=list, description="List of favorited conversations"
)
diff --git a/app/repository.py b/app/repository.py
index 69974fe..b4859be 100644
--- a/app/repository.py
+++ b/app/repository.py
@@ -522,6 +522,36 @@ class MessageRepository:
return 0, None
return row["acked"], MessageRepository._parse_paths(row["paths"])
+ @staticmethod
+ async def get_by_id(message_id: int) -> "Message | None":
+ """Look up a message by its ID."""
+ cursor = await db.conn.execute(
+ """
+ SELECT id, type, conversation_key, text, sender_timestamp, received_at,
+ paths, txt_type, signature, outgoing, acked
+ FROM messages
+ WHERE id = ?
+ """,
+ (message_id,),
+ )
+ row = await cursor.fetchone()
+ if not row:
+ return None
+
+ return Message(
+ id=row["id"],
+ type=row["type"],
+ conversation_key=row["conversation_key"],
+ text=row["text"],
+ sender_timestamp=row["sender_timestamp"],
+ received_at=row["received_at"],
+ paths=MessageRepository._parse_paths(row["paths"]),
+ txt_type=row["txt_type"],
+ signature=row["signature"],
+ outgoing=bool(row["outgoing"]),
+ acked=row["acked"],
+ )
+
@staticmethod
async def get_by_content(
msg_type: str,
@@ -800,8 +830,7 @@ class AppSettingsRepository:
"""
cursor = await db.conn.execute(
"""
- SELECT max_radio_contacts, experimental_channel_double_send,
- favorites, auto_decrypt_dm_on_advert,
+ SELECT max_radio_contacts, favorites, auto_decrypt_dm_on_advert,
sidebar_sort_order, last_message_times, preferences_migrated,
advert_interval, last_advert_time, bots
FROM app_settings WHERE id = 1
@@ -860,7 +889,6 @@ class AppSettingsRepository:
return AppSettings(
max_radio_contacts=row["max_radio_contacts"],
- experimental_channel_double_send=bool(row["experimental_channel_double_send"]),
favorites=favorites,
auto_decrypt_dm_on_advert=bool(row["auto_decrypt_dm_on_advert"]),
sidebar_sort_order=sort_order,
@@ -874,7 +902,6 @@ class AppSettingsRepository:
@staticmethod
async def update(
max_radio_contacts: int | None = None,
- experimental_channel_double_send: bool | None = None,
favorites: list[Favorite] | None = None,
auto_decrypt_dm_on_advert: bool | None = None,
sidebar_sort_order: str | None = None,
@@ -892,10 +919,6 @@ class AppSettingsRepository:
updates.append("max_radio_contacts = ?")
params.append(max_radio_contacts)
- if experimental_channel_double_send is not None:
- updates.append("experimental_channel_double_send = ?")
- params.append(1 if experimental_channel_double_send else 0)
-
if favorites is not None:
updates.append("favorites = ?")
favorites_json = json.dumps([f.model_dump() for f in favorites])
diff --git a/app/routers/messages.py b/app/routers/messages.py
index fab95cc..5ce7f10 100644
--- a/app/routers/messages.py
+++ b/app/routers/messages.py
@@ -158,7 +158,6 @@ async def send_direct_message(request: SendDirectMessageRequest) -> Message:
# Temporary radio slot used for sending channel messages
TEMP_RADIO_SLOT = 0
-EXPERIMENTAL_CHANNEL_DOUBLE_SEND_DELAY_SECONDS = 3
@router.post("/channel", response_model=Message)
@@ -168,14 +167,13 @@ async def send_channel_message(request: SendChannelMessageRequest) -> Message:
# Get channel info from our database
from app.decoder import calculate_channel_hash
- from app.repository import AppSettingsRepository, ChannelRepository
+ from app.repository import ChannelRepository
db_channel = await ChannelRepository.get_by_key(request.channel_key)
if not db_channel:
raise HTTPException(
status_code=404, detail=f"Channel {request.channel_key} not found in database"
)
- app_settings = await AppSettingsRepository.get()
# Convert channel key hex to bytes
try:
@@ -234,8 +232,8 @@ async def send_channel_message(request: SendChannelMessageRequest) -> Message:
if result.type == EventType.ERROR:
raise HTTPException(status_code=500, detail=f"Failed to send message: {result.payload}")
- # Store outgoing immediately after the first successful send to avoid a race where
- # our own echo lands before persistence (especially with delayed duplicate sends).
+ # Store outgoing immediately after send to avoid a race where
+ # our own echo lands before persistence.
message_id = await MessageRepository.create(
msg_type="CHAN",
text=text_with_sender,
@@ -250,9 +248,9 @@ async def send_channel_message(request: SendChannelMessageRequest) -> Message:
detail="Failed to store outgoing message - unexpected duplicate",
)
- # Broadcast immediately so all connected clients see the message before any
- # double-send delay. This also ensures the message is in the frontend's state
- # when echo-driven `message_acked` events arrive during the sleep below.
+ # Broadcast immediately so all connected clients see the message promptly.
+ # This ensures the message exists in frontend state when echo-driven
+ # `message_acked` events arrive.
broadcast_event(
"message",
Message(
@@ -267,25 +265,6 @@ async def send_channel_message(request: SendChannelMessageRequest) -> Message:
).model_dump(),
)
- # Experimental: byte-perfect resend after a delay to improve delivery reliability.
- # This intentionally holds the radio operation lock for the full delay — it is an
- # opt-in experimental feature where blocking other radio operations is acceptable.
- if app_settings.experimental_channel_double_send:
- logger.debug(
- "Experimental channel double-send enabled; waiting %ds before byte-perfect duplicate",
- EXPERIMENTAL_CHANNEL_DOUBLE_SEND_DELAY_SECONDS,
- )
- await asyncio.sleep(EXPERIMENTAL_CHANNEL_DOUBLE_SEND_DELAY_SECONDS)
- duplicate_result = await mc.commands.send_chan_msg(
- chan=TEMP_RADIO_SLOT,
- msg=request.text,
- timestamp=timestamp_bytes,
- )
- if duplicate_result.type == EventType.ERROR:
- logger.warning(
- "Experimental duplicate channel send failed: %s", duplicate_result.payload
- )
-
if message_id is None or now is None:
raise HTTPException(status_code=500, detail="Failed to store outgoing message")
@@ -321,3 +300,79 @@ async def send_channel_message(request: SendChannelMessageRequest) -> Message:
)
return message
+
+
+RESEND_WINDOW_SECONDS = 30
+
+
+@router.post("/channel/{message_id}/resend")
+async def resend_channel_message(message_id: int) -> dict:
+ """Resend a channel message within 30 seconds of original send.
+
+ Performs a byte-perfect resend using the same timestamp bytes as the original.
+ """
+ mc = require_connected()
+
+ from app.repository import ChannelRepository
+
+ msg = await MessageRepository.get_by_id(message_id)
+ if not msg:
+ raise HTTPException(status_code=404, detail="Message not found")
+
+ if not msg.outgoing:
+ raise HTTPException(status_code=400, detail="Can only resend outgoing messages")
+
+ if msg.type != "CHAN":
+ raise HTTPException(status_code=400, detail="Can only resend channel messages")
+
+ if msg.sender_timestamp is None:
+ raise HTTPException(status_code=400, detail="Message has no timestamp")
+
+ elapsed = int(time.time()) - msg.sender_timestamp
+ if elapsed > RESEND_WINDOW_SECONDS:
+ raise HTTPException(status_code=400, detail="Resend window has expired (30 seconds)")
+
+ db_channel = await ChannelRepository.get_by_key(msg.conversation_key)
+ if not db_channel:
+ raise HTTPException(status_code=404, detail=f"Channel {msg.conversation_key} not found")
+
+ # Reconstruct timestamp bytes
+ timestamp_bytes = msg.sender_timestamp.to_bytes(4, "little")
+
+ # Strip sender prefix: DB stores "RadioName: message" but radio needs "message"
+ 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}: ") :]
+
+ try:
+ key_bytes = bytes.fromhex(msg.conversation_key)
+ except ValueError:
+ raise HTTPException(
+ status_code=400, detail=f"Invalid channel key format: {msg.conversation_key}"
+ ) from None
+
+ async with radio_manager.radio_operation("resend_channel_message"):
+ set_result = await mc.commands.set_channel(
+ channel_idx=TEMP_RADIO_SLOT,
+ channel_name=db_channel.name,
+ channel_secret=key_bytes,
+ )
+ if set_result.type == EventType.ERROR:
+ raise HTTPException(
+ status_code=500,
+ detail="Failed to configure channel on radio before resending",
+ )
+
+ result = await mc.commands.send_chan_msg(
+ chan=TEMP_RADIO_SLOT,
+ msg=text_to_send,
+ timestamp=timestamp_bytes,
+ )
+ if result.type == EventType.ERROR:
+ raise HTTPException(
+ status_code=500, detail=f"Failed to resend message: {result.payload}"
+ )
+
+ logger.info("Resent channel message %d to %s", message_id, db_channel.name)
+ return {"status": "ok", "message_id": message_id}
diff --git a/app/routers/settings.py b/app/routers/settings.py
index 4c5e5c4..ce02db6 100644
--- a/app/routers/settings.py
+++ b/app/routers/settings.py
@@ -41,13 +41,6 @@ class AppSettingsUpdate(BaseModel):
"Maximum contacts to keep on radio (favorites first, then recent non-repeaters)"
),
)
- experimental_channel_double_send: bool | None = Field(
- default=None,
- description=(
- "Experimental: always send channel messages twice with a 3-second delay using "
- "identical timestamp bytes"
- ),
- )
auto_decrypt_dm_on_advert: bool | None = Field(
default=None,
description="Whether to attempt historical DM decryption on new contact advertisement",
@@ -109,13 +102,6 @@ async def update_settings(update: AppSettingsUpdate) -> AppSettings:
logger.info("Updating max_radio_contacts to %d", update.max_radio_contacts)
kwargs["max_radio_contacts"] = update.max_radio_contacts
- if update.experimental_channel_double_send is not None:
- logger.info(
- "Updating experimental_channel_double_send to %s",
- update.experimental_channel_double_send,
- )
- kwargs["experimental_channel_double_send"] = update.experimental_channel_double_send
-
if update.auto_decrypt_dm_on_advert is not None:
logger.info("Updating auto_decrypt_dm_on_advert to %s", update.auto_decrypt_dm_on_advert)
kwargs["auto_decrypt_dm_on_advert"] = update.auto_decrypt_dm_on_advert
diff --git a/frontend/AGENTS.md b/frontend/AGENTS.md
index 59ef669..cb153a2 100644
--- a/frontend/AGENTS.md
+++ b/frontend/AGENTS.md
@@ -96,6 +96,7 @@ Specialized logic is delegated to hooks:
- Outgoing sends are optimistic in UI and persisted server-side.
- Backend also emits WS `message` for outgoing sends so other clients stay in sync.
- ACK/repeat updates arrive as `message_acked` events.
+- Outgoing channel messages show a 30-second resend control; resend calls `POST /api/messages/channel/{message_id}/resend`.
## WebSocket (`useWebSocket.ts`)
@@ -149,7 +150,6 @@ LocalStorage migration helpers for favorites; canonical favorites are server-sid
`AppSettings` currently includes:
- `max_radio_contacts`
-- `experimental_channel_double_send`
- `favorites`
- `auto_decrypt_dm_on_advert`
- `sidebar_sort_order`
diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx
index 200a232..6389498 100644
--- a/frontend/src/App.tsx
+++ b/frontend/src/App.tsx
@@ -666,6 +666,18 @@ export function App() {
}
}, []);
+ // Handle resend channel message
+ const handleResendChannelMessage = useCallback(async (messageId: number) => {
+ try {
+ await api.resendChannelMessage(messageId);
+ toast.success('Message resent');
+ } catch (err) {
+ toast.error('Failed to resend', {
+ description: err instanceof Error ? err.message : 'Unknown error',
+ });
+ }
+ }, []);
+
// Handle sender click to add mention
const handleSenderClick = useCallback((sender: string) => {
messageInputRef.current?.appendText(`@[${sender}] `);
@@ -1182,6 +1194,9 @@ export function App() {
activeConversation.type === 'channel' ? handleSenderClick : undefined
}
onLoadOlder={fetchOlderMessages}
+ onResendChannelMessage={
+ activeConversation.type === 'channel' ? handleResendChannelMessage : undefined
+ }
radioName={config?.name}
config={config}
/>
diff --git a/frontend/src/api.ts b/frontend/src/api.ts
index b947c5a..11d964d 100644
--- a/frontend/src/api.ts
+++ b/frontend/src/api.ts
@@ -166,6 +166,10 @@ export const api = {
method: 'POST',
body: JSON.stringify({ channel_key: channelKey, text }),
}),
+ resendChannelMessage: (messageId: number) =>
+ fetchJson<{ status: string; message_id: number }>(`/messages/channel/${messageId}/resend`, {
+ method: 'POST',
+ }),
// Packets
getUndecryptedPacketCount: () => fetchJson<{ count: number }>('/packets/undecrypted/count'),
diff --git a/frontend/src/components/MessageList.tsx b/frontend/src/components/MessageList.tsx
index 21f421d..6978545 100644
--- a/frontend/src/components/MessageList.tsx
+++ b/frontend/src/components/MessageList.tsx
@@ -23,6 +23,7 @@ interface MessageListProps {
hasOlderMessages?: boolean;
onSenderClick?: (sender: string) => void;
onLoadOlder?: () => void;
+ onResendChannelMessage?: (messageId: number) => void;
radioName?: string;
config?: RadioConfig | null;
}
@@ -134,6 +135,8 @@ function HopCountBadge({ paths, onClick, variant }: HopCountBadgeProps) {
);
}
+const RESEND_WINDOW_SECONDS = 30;
+
export function MessageList({
messages,
contacts,
@@ -142,6 +145,7 @@ export function MessageList({
hasOlderMessages = false,
onSenderClick,
onLoadOlder,
+ onResendChannelMessage,
radioName,
config,
}: MessageListProps) {
@@ -153,6 +157,8 @@ export function MessageList({
paths: MessagePath[];
senderInfo: SenderInfo;
} | null>(null);
+ const [resendableIds, setResendableIds] = useState>(new Set());
+ const resendTimersRef = useRef
-
-
- Experimental: Adds a duplicate channel send after a 3-second
- delay, using the exact same timestamp bytes.
-
-
-
- This increases channel airtime and adds a 3-second second-attempt delay. Most
- clients deduplicate repeats by payload and timestamp, but behavior can vary by
- firmware/client.
-