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. +

+
+ diff --git a/frontend/src/test/appFavorites.test.tsx b/frontend/src/test/appFavorites.test.tsx index 64cfc35..4956632 100644 --- a/frontend/src/test/appFavorites.test.tsx +++ b/frontend/src/test/appFavorites.test.tsx @@ -162,6 +162,7 @@ const baseConfig = { const baseSettings = { max_radio_contacts: 200, + experimental_channel_double_send: false, favorites: [] as Array<{ type: 'channel' | 'contact'; id: string }>, auto_decrypt_dm_on_advert: false, sidebar_sort_order: 'recent' as const, diff --git a/frontend/src/test/settingsModal.test.tsx b/frontend/src/test/settingsModal.test.tsx index 6ac024f..24a1ff7 100644 --- a/frontend/src/test/settingsModal.test.tsx +++ b/frontend/src/test/settingsModal.test.tsx @@ -2,7 +2,7 @@ import { fireEvent, render, screen, waitFor } from '@testing-library/react'; import { describe, expect, it, vi } from 'vitest'; import { SettingsModal } from '../components/SettingsModal'; -import type { AppSettings, HealthStatus, RadioConfig } from '../types'; +import type { AppSettings, AppSettingsUpdate, HealthStatus, RadioConfig } from '../types'; const baseConfig: RadioConfig = { public_key: 'aa'.repeat(32), @@ -29,6 +29,7 @@ const baseHealth: HealthStatus = { const baseSettings: AppSettings = { max_radio_contacts: 200, + experimental_channel_double_send: false, favorites: [], auto_decrypt_dm_on_advert: false, sidebar_sort_order: 'recent', @@ -40,7 +41,7 @@ const baseSettings: AppSettings = { function renderModal(overrides?: { appSettings?: AppSettings; - onSaveAppSettings?: (update: { max_radio_contacts?: number }) => Promise; + onSaveAppSettings?: (update: AppSettingsUpdate) => Promise; onRefreshAppSettings?: () => Promise; }) { const onSaveAppSettings = overrides?.onSaveAppSettings ?? vi.fn(async () => {}); @@ -120,4 +121,22 @@ describe('SettingsModal', () => { expect(onSaveAppSettings).not.toHaveBeenCalled(); }); }); + + it('saves experimental channel double-send toggle through onSaveAppSettings', async () => { + const { onSaveAppSettings } = renderModal({ + appSettings: { ...baseSettings, experimental_channel_double_send: false }, + }); + + openConnectivityTab(); + + const toggle = screen.getByLabelText('Always send channel messages twice'); + fireEvent.click(toggle); + fireEvent.click(screen.getByRole('button', { name: 'Save Settings' })); + + await waitFor(() => { + expect(onSaveAppSettings).toHaveBeenCalledWith({ + experimental_channel_double_send: true, + }); + }); + }); }); diff --git a/frontend/src/types.ts b/frontend/src/types.ts index 59bf811..3525365 100644 --- a/frontend/src/types.ts +++ b/frontend/src/types.ts @@ -124,6 +124,7 @@ export interface BotConfig { export interface AppSettings { max_radio_contacts: number; + experimental_channel_double_send: boolean; favorites: Favorite[]; auto_decrypt_dm_on_advert: boolean; sidebar_sort_order: 'recent' | 'alpha'; @@ -135,6 +136,7 @@ export interface AppSettings { export interface AppSettingsUpdate { max_radio_contacts?: number; + experimental_channel_double_send?: boolean; auto_decrypt_dm_on_advert?: boolean; sidebar_sort_order?: 'recent' | 'alpha'; advert_interval?: number; diff --git a/tests/e2e/helpers/api.ts b/tests/e2e/helpers/api.ts index a03c267..ec7f031 100644 --- a/tests/e2e/helpers/api.ts +++ b/tests/e2e/helpers/api.ts @@ -133,6 +133,7 @@ export interface BotConfig { export interface AppSettings { max_radio_contacts: number; + experimental_channel_double_send: boolean; favorites: { type: string; id: string }[]; auto_decrypt_dm_on_advert: boolean; sidebar_sort_order: string; diff --git a/tests/test_api.py b/tests/test_api.py index 99f0cba..4e07c45 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -169,7 +169,7 @@ class TestMessagesEndpoint: @pytest.mark.asyncio async def test_send_channel_message_duplicate_returns_500(self): """If MessageRepository.create returns None (duplicate), returns 500.""" - from app.models import SendChannelMessageRequest + from app.models import AppSettings, SendChannelMessageRequest from app.routers.messages import send_channel_message mock_mc = MagicMock() @@ -187,6 +187,7 @@ class TestMessagesEndpoint: with ( patch("app.dependencies.radio_manager") as mock_rm, patch("app.repository.ChannelRepository") as mock_chan_repo, + patch("app.repository.AppSettingsRepository.get", new=AsyncMock(return_value=AppSettings())), patch("app.routers.messages.MessageRepository") as mock_msg_repo, ): mock_rm.is_connected = True diff --git a/tests/test_migrations.py b/tests/test_migrations.py index dff770f..de61dba 100644 --- a/tests/test_migrations.py +++ b/tests/test_migrations.py @@ -100,8 +100,8 @@ class TestMigration001: # Run migrations applied = await run_migrations(conn) - assert applied == 15 # All 15 migrations run - assert await get_version(conn) == 15 + assert applied == 16 # All 16 migrations run + assert await get_version(conn) == 16 # Verify columns exist by inserting and selecting await conn.execute( @@ -183,9 +183,9 @@ class TestMigration001: applied1 = await run_migrations(conn) applied2 = await run_migrations(conn) - assert applied1 == 15 # All 15 migrations run + assert applied1 == 16 # All 16 migrations run assert applied2 == 0 # No migrations on second run - assert await get_version(conn) == 15 + assert await get_version(conn) == 16 finally: await conn.close() @@ -245,9 +245,9 @@ class TestMigration001: # Run migrations - should not fail applied = await run_migrations(conn) - # All 15 migrations applied (version incremented) but no error - assert applied == 15 - assert await get_version(conn) == 15 + # All 16 migrations applied (version incremented) but no error + assert applied == 16 + assert await get_version(conn) == 16 finally: await conn.close() @@ -374,10 +374,10 @@ class TestMigration013: ) await conn.commit() - # Run migration 13 (plus 14+15 which also run) + # Run migration 13 (plus 14+15+16 which also run) applied = await run_migrations(conn) - assert applied == 3 - assert await get_version(conn) == 15 + assert applied == 4 + assert await get_version(conn) == 16 # Verify bots array was created with migrated data cursor = await conn.execute("SELECT bots FROM app_settings WHERE id = 1") diff --git a/tests/test_repository.py b/tests/test_repository.py index 3f41d52..97851a4 100644 --- a/tests/test_repository.py +++ b/tests/test_repository.py @@ -428,6 +428,7 @@ class TestAppSettingsRepository: mock_cursor.fetchone = AsyncMock( return_value={ "max_radio_contacts": 250, + "experimental_channel_double_send": 1, "favorites": "{not-json", "auto_decrypt_dm_on_advert": 1, "sidebar_sort_order": "invalid", @@ -448,6 +449,7 @@ class TestAppSettingsRepository: settings = await AppSettingsRepository.get() assert settings.max_radio_contacts == 250 + assert settings.experimental_channel_double_send is True assert settings.favorites == [] assert settings.last_message_times == {} assert settings.sidebar_sort_order == "recent" diff --git a/tests/test_send_messages.py b/tests/test_send_messages.py index b1681e0..4e96b49 100644 --- a/tests/test_send_messages.py +++ b/tests/test_send_messages.py @@ -6,7 +6,13 @@ from unittest.mock import AsyncMock, MagicMock, patch import pytest from meshcore import EventType -from app.models import Channel, Contact, SendChannelMessageRequest, SendDirectMessageRequest +from app.models import ( + AppSettings, + Channel, + Contact, + SendChannelMessageRequest, + SendDirectMessageRequest, +) from app.routers.messages import send_channel_message, send_direct_message @@ -133,7 +139,12 @@ class TestOutgoingChannelBotTrigger: "app.repository.ChannelRepository.get_by_key", new=AsyncMock(return_value=db_channel), ), + patch( + "app.repository.AppSettingsRepository.get", + new=AsyncMock(return_value=AppSettings()), + ), patch("app.repository.MessageRepository.create", new=AsyncMock(return_value=1)), + patch("app.repository.MessageRepository.get_ack_count", new=AsyncMock(return_value=0)), patch("app.decoder.calculate_channel_hash", return_value="abcd"), patch("app.bot.run_bot_for_message", new=AsyncMock()) as mock_bot, ): @@ -165,7 +176,12 @@ class TestOutgoingChannelBotTrigger: "app.repository.ChannelRepository.get_by_key", new=AsyncMock(return_value=db_channel), ), + patch( + "app.repository.AppSettingsRepository.get", + new=AsyncMock(return_value=AppSettings()), + ), patch("app.repository.MessageRepository.create", new=AsyncMock(return_value=1)), + patch("app.repository.MessageRepository.get_ack_count", new=AsyncMock(return_value=0)), patch("app.decoder.calculate_channel_hash", return_value="abcd"), patch("app.bot.run_bot_for_message", new=AsyncMock()) as mock_bot, ): @@ -193,10 +209,99 @@ class TestOutgoingChannelBotTrigger: "app.repository.ChannelRepository.get_by_key", new=AsyncMock(return_value=db_channel), ), + patch( + "app.repository.AppSettingsRepository.get", + new=AsyncMock(return_value=AppSettings()), + ), patch("app.repository.MessageRepository.create", new=AsyncMock(return_value=1)), + patch("app.repository.MessageRepository.get_ack_count", new=AsyncMock(return_value=0)), patch("app.decoder.calculate_channel_hash", return_value="abcd"), patch("app.bot.run_bot_for_message", new=slow_bot), ): request = SendChannelMessageRequest(channel_key=db_channel.key, text="test") message = await send_channel_message(request) assert message.outgoing is True + + @pytest.mark.asyncio + async def test_send_channel_msg_double_send_when_experimental_enabled(self): + """Experimental setting triggers an immediate byte-perfect duplicate send.""" + mc = _make_mc(name="MyNode") + db_channel = Channel(key="dd" * 16, name="#double") + settings = AppSettings(experimental_channel_double_send=True) + + with ( + patch("app.routers.messages.require_connected", return_value=mc), + patch( + "app.repository.ChannelRepository.get_by_key", + new=AsyncMock(return_value=db_channel), + ), + patch("app.repository.AppSettingsRepository.get", new=AsyncMock(return_value=settings)), + patch("app.repository.MessageRepository.create", new=AsyncMock(return_value=1)), + patch("app.repository.MessageRepository.get_ack_count", new=AsyncMock(return_value=0)), + patch("app.decoder.calculate_channel_hash", return_value="abcd"), + patch("app.bot.run_bot_for_message", new=AsyncMock()), + patch("app.routers.messages.asyncio.sleep", new=AsyncMock()) as mock_sleep, + ): + request = SendChannelMessageRequest(channel_key=db_channel.key, text="same bytes") + await send_channel_message(request) + + assert mc.commands.send_chan_msg.await_count == 2 + mock_sleep.assert_awaited_once_with(3) + first_call = mc.commands.send_chan_msg.await_args_list[0].kwargs + second_call = mc.commands.send_chan_msg.await_args_list[1].kwargs + assert first_call["chan"] == second_call["chan"] + assert first_call["msg"] == second_call["msg"] + assert first_call["timestamp"] == second_call["timestamp"] + + @pytest.mark.asyncio + async def test_send_channel_msg_single_send_when_experimental_disabled(self): + """Default setting keeps channel sends to a single radio command.""" + mc = _make_mc(name="MyNode") + db_channel = Channel(key="ee" * 16, name="#single") + + with ( + patch("app.routers.messages.require_connected", return_value=mc), + patch( + "app.repository.ChannelRepository.get_by_key", + new=AsyncMock(return_value=db_channel), + ), + patch( + "app.repository.AppSettingsRepository.get", + new=AsyncMock(return_value=AppSettings()), + ), + patch("app.repository.MessageRepository.create", new=AsyncMock(return_value=1)), + patch("app.repository.MessageRepository.get_ack_count", new=AsyncMock(return_value=0)), + patch("app.decoder.calculate_channel_hash", return_value="abcd"), + patch("app.bot.run_bot_for_message", new=AsyncMock()), + ): + request = SendChannelMessageRequest(channel_key=db_channel.key, text="single send") + await send_channel_message(request) + + assert mc.commands.send_chan_msg.await_count == 1 + + @pytest.mark.asyncio + async def test_send_channel_msg_response_includes_current_ack_count(self): + """Send response reflects latest DB ack count at response time.""" + mc = _make_mc(name="MyNode") + db_channel = Channel(key="ff" * 16, name="#acked") + + with ( + patch("app.routers.messages.require_connected", return_value=mc), + patch( + "app.repository.ChannelRepository.get_by_key", + new=AsyncMock(return_value=db_channel), + ), + patch( + "app.repository.AppSettingsRepository.get", + new=AsyncMock(return_value=AppSettings()), + ), + patch("app.repository.MessageRepository.create", new=AsyncMock(return_value=123)), + patch("app.repository.MessageRepository.get_ack_count", new=AsyncMock(return_value=2)), + patch("app.decoder.calculate_channel_hash", return_value="abcd"), + patch("app.bot.run_bot_for_message", new=AsyncMock()), + ): + request = SendChannelMessageRequest(channel_key=db_channel.key, text="acked now") + message = await send_channel_message(request) + + assert message.id == 123 + assert message.acked == 2 diff --git a/tests/test_settings_router.py b/tests/test_settings_router.py index 8bf9ea7..b93f9ed 100644 --- a/tests/test_settings_router.py +++ b/tests/test_settings_router.py @@ -21,9 +21,11 @@ def _settings( favorites: list[Favorite] | None = None, migrated: bool = False, max_radio_contacts: int = 200, + experimental_channel_double_send: bool = False, ) -> AppSettings: return AppSettings( max_radio_contacts=max_radio_contacts, + experimental_channel_double_send=experimental_channel_double_send, favorites=favorites or [], auto_decrypt_dm_on_advert=False, sidebar_sort_order="recent", @@ -45,7 +47,11 @@ class TestUpdateSettings: return_value=updated, ) as mock_update: result = await update_settings( - AppSettingsUpdate(max_radio_contacts=321, advert_interval=3600) + AppSettingsUpdate( + max_radio_contacts=321, + advert_interval=3600, + experimental_channel_double_send=True, + ) ) assert result.max_radio_contacts == 321 @@ -53,6 +59,7 @@ class TestUpdateSettings: assert mock_update.call_args.kwargs == { "max_radio_contacts": 321, "advert_interval": 3600, + "experimental_channel_double_send": True, } @pytest.mark.asyncio