mirror of
https://github.com/jkingsman/Remote-Terminal-for-MeshCore.git
synced 2026-03-28 17:43:05 +01:00
Add experimental double send
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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"
|
||||
)
|
||||
|
||||
@@ -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])
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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({
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="p-3 bg-yellow-500/10 border border-yellow-500/30 rounded-md space-y-3">
|
||||
<p className="text-sm text-yellow-500">
|
||||
<strong>Experimental:</strong> Adds a duplicate channel send after a 3-second
|
||||
delay, using the exact same timestamp bytes.
|
||||
</p>
|
||||
<label className="flex items-center gap-3 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={experimentalChannelDoubleSend}
|
||||
onChange={(e) => setExperimentalChannelDoubleSend(e.target.checked)}
|
||||
className="w-4 h-4 rounded border-input accent-primary"
|
||||
/>
|
||||
<span className="text-sm">Always send channel messages twice</span>
|
||||
</label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
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.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Button onClick={handleSaveConnectivity} disabled={loading} className="w-full">
|
||||
{loading ? 'Saving...' : 'Save Settings'}
|
||||
</Button>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<void>;
|
||||
onSaveAppSettings?: (update: AppSettingsUpdate) => Promise<void>;
|
||||
onRefreshAppSettings?: () => Promise<void>;
|
||||
}) {
|
||||
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,
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user