From a157390fb73640c269e77fe92aeafeed8d08dc1a Mon Sep 17 00:00:00 2001
From: Jack Kingsman
Date: Tue, 10 Feb 2026 20:33:14 -0800
Subject: [PATCH] Add experimental double send
---
AGENTS.md | 4 +-
app/AGENTS.md | 8 +-
app/migrations.py | 28 ++++++
app/models.py | 7 ++
app/repository.py | 9 +-
app/routers/messages.py | 71 +++++++++-----
app/routers/settings.py | 14 +++
frontend/AGENTS.md | 4 +-
frontend/src/components/SettingsModal.tsx | 32 ++++++-
frontend/src/test/appFavorites.test.tsx | 1 +
frontend/src/test/settingsModal.test.tsx | 23 ++++-
frontend/src/types.ts | 2 +
tests/e2e/helpers/api.ts | 1 +
tests/test_api.py | 3 +-
tests/test_migrations.py | 20 ++--
tests/test_repository.py | 2 +
tests/test_send_messages.py | 107 +++++++++++++++++++++-
tests/test_settings_router.py | 9 +-
18 files changed, 300 insertions(+), 45 deletions(-)
diff --git a/AGENTS.md b/AGENTS.md
index 0ae35af..0e28099 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -356,6 +356,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:** `max_radio_contacts` is a runtime setting stored in the database (`app_settings` table), not an environment variable. It is configured via `PATCH /api/settings`.
+**Note:** `max_radio_contacts` and `experimental_channel_double_send` are runtime settings stored in the database (`app_settings` table), not environment variables. They are configured via `PATCH /api/settings`.
+
+`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.
**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 7888741..d92ee03 100644
--- a/app/AGENTS.md
+++ b/app/AGENTS.md
@@ -36,7 +36,7 @@ app/
├── messages.py # Message list and send (direct/channel)
├── packets.py # Raw packet endpoints, historical decryption
├── read_state.py # Read state: unread counts, mark-all-read
- ├── settings.py # App settings (max_radio_contacts)
+ ├── settings.py # App settings (max_radio_contacts, experimental_channel_double_send, etc.)
└── ws.py # WebSocket endpoint at /api/ws
```
@@ -65,6 +65,7 @@ await RawPacketRepository.mark_decrypted(packet_id, message_id)
# App settings (single-row pattern)
settings = await AppSettingsRepository.get()
await AppSettingsRepository.update(auto_decrypt_dm_on_advert=True)
+await AppSettingsRepository.update(experimental_channel_double_send=True)
await AppSettingsRepository.add_favorite("contact", public_key)
```
@@ -252,6 +253,7 @@ raw_packets (
app_settings (
id INTEGER PRIMARY KEY CHECK (id = 1), -- Single-row pattern
max_radio_contacts INTEGER DEFAULT 200,
+ experimental_channel_double_send INTEGER DEFAULT 0, -- Experimental delayed byte-perfect channel resend
favorites TEXT DEFAULT '[]', -- JSON array of {type, id}
auto_decrypt_dm_on_advert INTEGER DEFAULT 0,
sidebar_sort_order TEXT DEFAULT 'recent', -- 'recent' or 'alpha'
@@ -527,7 +529,7 @@ All endpoints are prefixed with `/api`.
### Messages
- `GET /api/messages?type=&conversation_key=&limit=&offset=` - List with filters
- `POST /api/messages/direct` - Send direct message
-- `POST /api/messages/channel` - Send channel message
+- `POST /api/messages/channel` - Send channel message (stores outgoing immediately; response includes current ack count)
### Packets
- `GET /api/packets/undecrypted/count` - Count of undecrypted packets
@@ -535,7 +537,7 @@ All endpoints are prefixed with `/api`.
### Settings
- `GET /api/settings` - Get all app settings
-- `PATCH /api/settings` - Update settings (max_radio_contacts, auto_decrypt_dm_on_advert, sidebar_sort_order)
+- `PATCH /api/settings` - Update settings (max_radio_contacts, experimental_channel_double_send, auto_decrypt_dm_on_advert, sidebar_sort_order)
- `POST /api/settings/favorites` - Add a favorite
- `DELETE /api/settings/favorites` - Remove a favorite
- `POST /api/settings/favorites/toggle` - Toggle favorite status
diff --git a/app/migrations.py b/app/migrations.py
index d3cb5b5..b866b32 100644
--- a/app/migrations.py
+++ b/app/migrations.py
@@ -142,6 +142,13 @@ async def run_migrations(conn: aiosqlite.Connection) -> int:
await set_version(conn, 15)
applied += 1
+ # Migration 16: Add experimental_channel_double_send setting
+ if version < 16:
+ logger.info("Applying migration 16: add experimental_channel_double_send column")
+ await _migrate_016_add_experimental_channel_double_send(conn)
+ await set_version(conn, 16)
+ applied += 1
+
if applied > 0:
logger.info(
"Applied %d migration(s), schema now at version %d", applied, await get_version(conn)
@@ -993,3 +1000,24 @@ async def _migrate_015_fix_null_sender_timestamp(conn: aiosqlite.Connection) ->
)
await conn.commit()
+
+
+async def _migrate_016_add_experimental_channel_double_send(conn: aiosqlite.Connection) -> None:
+ """
+ Add experimental_channel_double_send column to app_settings table.
+
+ When enabled, channel sends perform an immediate byte-perfect duplicate send
+ using the same timestamp bytes.
+ """
+ try:
+ await conn.execute(
+ "ALTER TABLE app_settings ADD COLUMN experimental_channel_double_send INTEGER DEFAULT 0"
+ )
+ logger.debug("Added experimental_channel_double_send column to app_settings")
+ except aiosqlite.OperationalError as e:
+ if "duplicate column" in str(e).lower():
+ logger.debug("experimental_channel_double_send column already exists, skipping")
+ else:
+ raise
+
+ await conn.commit()
diff --git a/app/models.py b/app/models.py
index 019ff13..c4e9a03 100644
--- a/app/models.py
+++ b/app/models.py
@@ -263,6 +263,13 @@ 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 4d4050c..4eb00e4 100644
--- a/app/repository.py
+++ b/app/repository.py
@@ -737,7 +737,8 @@ class AppSettingsRepository:
"""
cursor = await db.conn.execute(
"""
- SELECT max_radio_contacts, favorites, auto_decrypt_dm_on_advert,
+ SELECT max_radio_contacts, experimental_channel_double_send,
+ 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
@@ -796,6 +797,7 @@ 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,
@@ -809,6 +811,7 @@ 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,
@@ -826,6 +829,10 @@ 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 0bc9438..30a6753 100644
--- a/app/routers/messages.py
+++ b/app/routers/messages.py
@@ -144,6 +144,7 @@ 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)
@@ -153,13 +154,14 @@ 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 ChannelRepository
+ from app.repository import AppSettingsRepository, 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:
@@ -177,6 +179,11 @@ async def send_channel_message(request: SendChannelMessageRequest) -> Message:
TEMP_RADIO_SLOT,
expected_hash,
)
+ channel_key_upper = request.channel_key.upper()
+ radio_name = mc.self_info.get("name", "") if mc.self_info else ""
+ text_with_sender = f"{radio_name}: {request.text}" if radio_name else request.text
+ message_id: int | None = None
+ now: int | None = None
async with radio_manager.radio_operation("send_channel_message"):
# Load the channel to a temporary radio slot before sending
@@ -202,35 +209,53 @@ async def send_channel_message(request: SendChannelMessageRequest) -> Message:
# and the database. This ensures the echo's timestamp matches our stored message
# for proper deduplication.
now = int(time.time())
+ timestamp_bytes = now.to_bytes(4, "little")
result = await mc.commands.send_chan_msg(
chan=TEMP_RADIO_SLOT,
msg=request.text,
- timestamp=now.to_bytes(4, "little"), # Pass as bytes for compatibility
+ timestamp=timestamp_bytes,
)
- if result.type == EventType.ERROR:
- raise HTTPException(status_code=500, detail=f"Failed to send message: {result.payload}")
+ if result.type == EventType.ERROR:
+ raise HTTPException(status_code=500, detail=f"Failed to send message: {result.payload}")
- # Store outgoing message with sender prefix (to match echo format)
- # The radio includes "SenderName: " prefix when broadcasting, so we store it the same way
- # to enable proper deduplication when the echo comes back
- channel_key_upper = request.channel_key.upper()
- radio_name = mc.self_info.get("name", "") if mc.self_info else ""
- text_with_sender = f"{radio_name}: {request.text}" if radio_name else request.text
- message_id = await MessageRepository.create(
- msg_type="CHAN",
- text=text_with_sender,
- conversation_key=channel_key_upper,
- sender_timestamp=now,
- received_at=now,
- outgoing=True,
- )
- if message_id is None:
- raise HTTPException(
- status_code=500,
- detail="Failed to store outgoing message - unexpected duplicate",
+ # Store outgoing immediately after the first successful send to avoid a race where
+ # our own echo lands before persistence (especially with delayed duplicate sends).
+ message_id = await MessageRepository.create(
+ msg_type="CHAN",
+ text=text_with_sender,
+ conversation_key=channel_key_upper,
+ sender_timestamp=now,
+ received_at=now,
+ outgoing=True,
)
+ if message_id is None:
+ raise HTTPException(
+ status_code=500,
+ detail="Failed to store outgoing message - unexpected duplicate",
+ )
+
+ 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")
+
+ acked_count = await MessageRepository.get_ack_count(message_id)
message = Message(
id=message_id,
@@ -240,7 +265,7 @@ async def send_channel_message(request: SendChannelMessageRequest) -> Message:
sender_timestamp=now,
received_at=now,
outgoing=True,
- acked=0,
+ acked=acked_count,
)
# Trigger bots for outgoing channel messages (runs in background, doesn't block response)
diff --git a/app/routers/settings.py b/app/routers/settings.py
index ce02db6..4c5e5c4 100644
--- a/app/routers/settings.py
+++ b/app/routers/settings.py
@@ -41,6 +41,13 @@ 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",
@@ -102,6 +109,13 @@ 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 6208565..725394d 100644
--- a/frontend/AGENTS.md
+++ b/frontend/AGENTS.md
@@ -51,7 +51,7 @@ frontend/
│ │ ├── MapView.tsx # Leaflet map showing node locations
│ │ ├── CrackerPanel.tsx # WebGPU channel key cracker (lazy-loads wordlist)
│ │ ├── NewMessageModal.tsx
-│ │ └── SettingsModal.tsx # Unified settings: radio config, identity, serial, database, advertise
+│ │ └── SettingsModal.tsx # Unified settings: radio config, identity, connectivity, database, advertise
│ └── test/
│ ├── setup.ts # Test setup (jsdom, matchers)
│ ├── messageParser.test.ts
@@ -97,6 +97,7 @@ App settings are stored server-side and include:
- `favorites` - List of favorited conversations (channels/contacts)
- `sidebar_sort_order` - 'recent' or 'alpha'
- `auto_decrypt_dm_on_advert` - Auto-decrypt historical DMs on new contact
+- `experimental_channel_double_send` - Experimental setting to send a byte-perfect channel resend after 3 seconds
- `last_message_times` - Map of conversation keys to last message timestamps
**Migration**: On first load, localStorage preferences are migrated to the server.
@@ -264,6 +265,7 @@ interface Favorite {
interface AppSettings {
max_radio_contacts: number;
+ experimental_channel_double_send: boolean;
favorites: Favorite[];
auto_decrypt_dm_on_advert: boolean;
sidebar_sort_order: 'recent' | 'alpha';
diff --git a/frontend/src/components/SettingsModal.tsx b/frontend/src/components/SettingsModal.tsx
index ee879b9..92ddfe2 100644
--- a/frontend/src/components/SettingsModal.tsx
+++ b/frontend/src/components/SettingsModal.tsx
@@ -92,6 +92,7 @@ export function SettingsModal({
const [cr, setCr] = useState('');
const [privateKey, setPrivateKey] = useState('');
const [maxRadioContacts, setMaxRadioContacts] = useState('');
+ const [experimentalChannelDoubleSend, setExperimentalChannelDoubleSend] = useState(false);
// Loading states
const [loading, setLoading] = useState(false);
@@ -161,6 +162,7 @@ export function SettingsModal({
useEffect(() => {
if (appSettings) {
setMaxRadioContacts(String(appSettings.max_radio_contacts));
+ setExperimentalChannelDoubleSend(appSettings.experimental_channel_double_send);
setAutoDecryptOnAdvert(appSettings.auto_decrypt_dm_on_advert);
setAdvertInterval(String(appSettings.advert_interval));
setBots(appSettings.bots || []);
@@ -290,9 +292,16 @@ export function SettingsModal({
setLoading(true);
try {
+ const update: AppSettingsUpdate = {};
const newMaxRadioContacts = parseInt(maxRadioContacts, 10);
if (!isNaN(newMaxRadioContacts) && newMaxRadioContacts !== appSettings?.max_radio_contacts) {
- await onSaveAppSettings({ max_radio_contacts: newMaxRadioContacts });
+ update.max_radio_contacts = newMaxRadioContacts;
+ }
+ if (experimentalChannelDoubleSend !== appSettings?.experimental_channel_double_send) {
+ update.experimental_channel_double_send = experimentalChannelDoubleSend;
+ }
+ if (Object.keys(update).length > 0) {
+ await onSaveAppSettings(update);
}
toast.success('Connectivity settings saved');
} catch (err) {
@@ -756,6 +765,27 @@ export function SettingsModal({
+
+
+ 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.
+