diff --git a/AGENTS.md b/AGENTS.md
index ae8aa39..4cdc22a 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -427,7 +427,7 @@ mc.subscribe(EventType.ACK, handler)
| `MESHCORE_DATABASE_PATH` | `data/meshcore.db` | SQLite database location |
| `MESHCORE_DISABLE_BOTS` | `false` | Disable bot system entirely (blocks execution and config) |
-**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`, `bots`, all MQTT configuration (`mqtt_broker_host`, `mqtt_broker_port`, `mqtt_username`, `mqtt_password`, `mqtt_use_tls`, `mqtt_tls_insecure`, `mqtt_topic_prefix`, `mqtt_publish_messages`, `mqtt_publish_raw_packets`), and community MQTT configuration (`community_mqtt_enabled`, `community_mqtt_iata`, `community_mqtt_broker_host`, `community_mqtt_broker_port`, `community_mqtt_email`). 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`, `bots`, all MQTT configuration (`mqtt_broker_host`, `mqtt_broker_port`, `mqtt_username`, `mqtt_password`, `mqtt_use_tls`, `mqtt_tls_insecure`, `mqtt_topic_prefix`, `mqtt_publish_messages`, `mqtt_publish_raw_packets`), community MQTT configuration (`community_mqtt_enabled`, `community_mqtt_iata`, `community_mqtt_broker_host`, `community_mqtt_broker_port`, `community_mqtt_email`), and `flood_scope`. They are configured via `GET/PATCH /api/settings` (and related settings endpoints).
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.
diff --git a/app/AGENTS.md b/app/AGENTS.md
index 8b7efe1..eb21f24 100644
--- a/app/AGENTS.md
+++ b/app/AGENTS.md
@@ -244,6 +244,7 @@ Main tables:
- `mqtt_broker_host`, `mqtt_broker_port`, `mqtt_username`, `mqtt_password`
- `mqtt_use_tls`, `mqtt_tls_insecure`, `mqtt_topic_prefix`, `mqtt_publish_messages`, `mqtt_publish_raw_packets`
- `community_mqtt_enabled`, `community_mqtt_iata`, `community_mqtt_broker_host`, `community_mqtt_broker_port`, `community_mqtt_email`
+- `flood_scope`
## Security Posture (intentional)
diff --git a/app/migrations.py b/app/migrations.py
index e075caa..5166b9f 100644
--- a/app/migrations.py
+++ b/app/migrations.py
@@ -268,6 +268,13 @@ async def run_migrations(conn: aiosqlite.Connection) -> int:
await set_version(conn, 33)
applied += 1
+ # Migration 34: Add flood_scope column to app_settings
+ if version < 34:
+ logger.info("Applying migration 34: add flood_scope column to app_settings")
+ await _migrate_034_add_flood_scope(conn)
+ await set_version(conn, 34)
+ applied += 1
+
if applied > 0:
logger.info(
"Applied %d migration(s), schema now at version %d", applied, await get_version(conn)
@@ -1951,3 +1958,21 @@ async def _migrate_033_seed_remoteterm_channel(conn: aiosqlite.Connection) -> No
await conn.commit()
except Exception:
logger.debug("Skipping #remoteterm seed (channels table not ready)")
+
+
+async def _migrate_034_add_flood_scope(conn: aiosqlite.Connection) -> None:
+ """Add flood_scope column to app_settings for outbound region tagging.
+
+ Empty string means disabled (no scope set, messages sent unscoped).
+ """
+ try:
+ await conn.execute("ALTER TABLE app_settings ADD COLUMN flood_scope TEXT DEFAULT ''")
+ await conn.commit()
+ except Exception as e:
+ error_msg = str(e).lower()
+ if "duplicate column" in error_msg:
+ logger.debug("flood_scope column already exists, skipping")
+ elif "no such table" in error_msg:
+ logger.debug("app_settings table not ready, skipping flood_scope migration")
+ else:
+ raise
diff --git a/app/models.py b/app/models.py
index 6ea44f3..af4da03 100644
--- a/app/models.py
+++ b/app/models.py
@@ -518,6 +518,10 @@ class AppSettings(BaseModel):
default="",
description="Email address for node claiming on the community aggregator (optional)",
)
+ flood_scope: str = Field(
+ default="",
+ description="Outbound flood scope / region name (empty = disabled, no tagging)",
+ )
class BusyChannel(BaseModel):
diff --git a/app/radio.py b/app/radio.py
index bb5fbe3..4f0100a 100644
--- a/app/radio.py
+++ b/app/radio.py
@@ -258,6 +258,14 @@ class RadioManager:
# Sync radio clock with system time
await sync_radio_time(mc)
+ # Apply flood scope from settings
+ from app.repository import AppSettingsRepository
+
+ app_settings = await AppSettingsRepository.get()
+ scope = app_settings.flood_scope
+ await mc.commands.set_flood_scope(scope if scope else "")
+ logger.info("Applied flood_scope=%r", scope or "(disabled)")
+
# Sync contacts/channels from radio to DB and clear radio
logger.info("Syncing and offloading radio data...")
result = await sync_and_offload_all(mc)
diff --git a/app/repository/settings.py b/app/repository/settings.py
index ef489fd..599cbad 100644
--- a/app/repository/settings.py
+++ b/app/repository/settings.py
@@ -32,7 +32,7 @@ class AppSettingsRepository:
mqtt_publish_messages, mqtt_publish_raw_packets,
community_mqtt_enabled, community_mqtt_iata,
community_mqtt_broker_host, community_mqtt_broker_port,
- community_mqtt_email
+ community_mqtt_email, flood_scope
FROM app_settings WHERE id = 1
"""
)
@@ -112,6 +112,7 @@ class AppSettingsRepository:
or "mqtt-us-v1.letsmesh.net",
community_mqtt_broker_port=row["community_mqtt_broker_port"] or 443,
community_mqtt_email=row["community_mqtt_email"] or "",
+ flood_scope=row["flood_scope"] or "",
)
@staticmethod
@@ -139,6 +140,7 @@ class AppSettingsRepository:
community_mqtt_broker_host: str | None = None,
community_mqtt_broker_port: int | None = None,
community_mqtt_email: str | None = None,
+ flood_scope: str | None = None,
) -> AppSettings:
"""Update app settings. Only provided fields are updated."""
updates = []
@@ -238,6 +240,10 @@ class AppSettingsRepository:
updates.append("community_mqtt_email = ?")
params.append(community_mqtt_email)
+ if flood_scope is not None:
+ updates.append("flood_scope = ?")
+ params.append(flood_scope)
+
if updates:
query = f"UPDATE app_settings SET {', '.join(updates)} WHERE id = 1"
await db.conn.execute(query, params)
diff --git a/app/routers/settings.py b/app/routers/settings.py
index cdb7a3b..08c7fb7 100644
--- a/app/routers/settings.py
+++ b/app/routers/settings.py
@@ -121,6 +121,10 @@ class AppSettingsUpdate(BaseModel):
default=None,
description="Email address for node claiming on the community aggregator",
)
+ flood_scope: str | None = Field(
+ default=None,
+ description="Outbound flood scope / region name (empty = disabled)",
+ )
class FavoriteRequest(BaseModel):
@@ -237,6 +241,13 @@ async def update_settings(update: AppSettingsUpdate) -> AppSettings:
kwargs["community_mqtt_email"] = update.community_mqtt_email
community_mqtt_changed = True
+ # Flood scope
+ flood_scope_changed = False
+ if update.flood_scope is not None:
+ stripped = update.flood_scope.strip()
+ kwargs["flood_scope"] = stripped
+ flood_scope_changed = True
+
# Require IATA when enabling community MQTT
if kwargs.get("community_mqtt_enabled", False):
# Check the IATA value being set, or fall back to current settings
@@ -265,6 +276,19 @@ async def update_settings(update: AppSettingsUpdate) -> AppSettings:
await community_publisher.restart(result)
+ # Apply flood scope to radio immediately if changed
+ if flood_scope_changed:
+ from app.radio import radio_manager
+
+ if radio_manager.is_connected:
+ try:
+ scope = result.flood_scope
+ async with radio_manager.radio_operation("set_flood_scope") as mc:
+ await mc.commands.set_flood_scope(scope if scope else "")
+ logger.info("Applied flood_scope=%r to radio", scope or "(disabled)")
+ except Exception as e:
+ logger.warning("Failed to apply flood_scope to radio: %s", e)
+
return result
return await AppSettingsRepository.get()
diff --git a/frontend/AGENTS.md b/frontend/AGENTS.md
index 73998c2..0cc2e37 100644
--- a/frontend/AGENTS.md
+++ b/frontend/AGENTS.md
@@ -246,6 +246,7 @@ LocalStorage migration helpers for favorites; canonical favorites are server-sid
- `mqtt_broker_host`, `mqtt_broker_port`, `mqtt_username`, `mqtt_password`
- `mqtt_use_tls`, `mqtt_tls_insecure`, `mqtt_topic_prefix`, `mqtt_publish_messages`, `mqtt_publish_raw_packets`
- `community_mqtt_enabled`, `community_mqtt_iata`, `community_mqtt_broker_host`, `community_mqtt_broker_port`, `community_mqtt_email`
+- `flood_scope`
`HealthStatus` includes `mqtt_status` (`"connected"`, `"disconnected"`, `"disabled"`, or `null`).
`HealthStatus` also includes `community_mqtt_status` with the same status values.
diff --git a/frontend/src/components/settings/SettingsIdentitySection.tsx b/frontend/src/components/settings/SettingsIdentitySection.tsx
index 7474769..1ac2b8f 100644
--- a/frontend/src/components/settings/SettingsIdentitySection.tsx
+++ b/frontend/src/components/settings/SettingsIdentitySection.tsx
@@ -40,6 +40,7 @@ export function SettingsIdentitySection({
const [name, setName] = useState('');
const [privateKey, setPrivateKey] = useState('');
const [advertIntervalHours, setAdvertIntervalHours] = useState('0');
+ const [floodScope, setFloodScope] = useState('');
const [busy, setBusy] = useState(false);
const [rebooting, setRebooting] = useState(false);
const [advertising, setAdvertising] = useState(false);
@@ -51,6 +52,7 @@ export function SettingsIdentitySection({
useEffect(() => {
setAdvertIntervalHours(String(Math.round(appSettings.advert_interval / 3600)));
+ setFloodScope(appSettings.flood_scope);
}, [appSettings]);
const handleSaveIdentity = async () => {
@@ -61,10 +63,17 @@ export function SettingsIdentitySection({
const update: RadioConfigUpdate = { name };
await onSave(update);
+ const appUpdate: AppSettingsUpdate = {};
const hours = parseInt(advertIntervalHours, 10);
const newAdvertInterval = isNaN(hours) ? 0 : hours * 3600;
if (newAdvertInterval !== appSettings.advert_interval) {
- await onSaveAppSettings({ advert_interval: newAdvertInterval });
+ appUpdate.advert_interval = newAdvertInterval;
+ }
+ if (floodScope !== appSettings.flood_scope) {
+ appUpdate.flood_scope = floodScope;
+ }
+ if (Object.keys(appUpdate).length > 0) {
+ await onSaveAppSettings(appUpdate);
}
toast.success('Identity settings saved');
@@ -140,6 +149,20 @@ export function SettingsIdentitySection({
+
+
Flood Scope / Region
+
setFloodScope(e.target.value)}
+ placeholder="#MyRegion"
+ />
+
+ Tag outgoing flood messages with a region name (e.g. #MyRegion). Repeaters with this
+ region configured will prioritize your traffic. Leave empty to disable.
+
+
+
{busy ? 'Saving...' : 'Save Identity Settings'}
diff --git a/frontend/src/test/settingsModal.test.tsx b/frontend/src/test/settingsModal.test.tsx
index d67d2d3..66b69a6 100644
--- a/frontend/src/test/settingsModal.test.tsx
+++ b/frontend/src/test/settingsModal.test.tsx
@@ -67,6 +67,7 @@ const baseSettings: AppSettings = {
community_mqtt_broker_host: 'mqtt-us-v1.letsmesh.net',
community_mqtt_broker_port: 443,
community_mqtt_email: '',
+ flood_scope: '',
};
function renderModal(overrides?: {
diff --git a/frontend/src/types.ts b/frontend/src/types.ts
index 24c84d7..2c79de0 100644
--- a/frontend/src/types.ts
+++ b/frontend/src/types.ts
@@ -228,6 +228,7 @@ export interface AppSettings {
community_mqtt_broker_host: string;
community_mqtt_broker_port: number;
community_mqtt_email: string;
+ flood_scope: string;
}
export interface AppSettingsUpdate {
@@ -250,6 +251,7 @@ export interface AppSettingsUpdate {
community_mqtt_broker_host?: string;
community_mqtt_broker_port?: number;
community_mqtt_email?: string;
+ flood_scope?: string;
}
export interface MigratePreferencesRequest {
diff --git a/tests/test_migrations.py b/tests/test_migrations.py
index 8b059bc..b5bba1c 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 == 33 # All migrations run
- assert await get_version(conn) == 33
+ assert applied == 34 # All migrations run
+ assert await get_version(conn) == 34
# 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 == 33 # All migrations run
+ assert applied1 == 34 # All migrations run
assert applied2 == 0 # No migrations on second run
- assert await get_version(conn) == 33
+ assert await get_version(conn) == 34
finally:
await conn.close()
@@ -246,8 +246,8 @@ class TestMigration001:
applied = await run_migrations(conn)
# All migrations applied (version incremented) but no error
- assert applied == 33
- assert await get_version(conn) == 33
+ assert applied == 34
+ assert await get_version(conn) == 34
finally:
await conn.close()
@@ -374,10 +374,10 @@ class TestMigration013:
)
await conn.commit()
- # Run migration 13 (plus 14-33 which also run)
+ # Run migration 13 (plus 14-34 which also run)
applied = await run_migrations(conn)
- assert applied == 21
- assert await get_version(conn) == 33
+ assert applied == 22
+ assert await get_version(conn) == 34
# Verify bots array was created with migrated data
cursor = await conn.execute("SELECT bots FROM app_settings WHERE id = 1")
@@ -497,7 +497,7 @@ class TestMigration018:
assert await cursor.fetchone() is not None
await run_migrations(conn)
- assert await get_version(conn) == 33
+ assert await get_version(conn) == 34
# Verify autoindex is gone
cursor = await conn.execute(
@@ -575,8 +575,8 @@ class TestMigration018:
await conn.commit()
applied = await run_migrations(conn)
- assert applied == 16 # Migrations 18-33 run (18+19 skip internally)
- assert await get_version(conn) == 33
+ assert applied == 17 # Migrations 18-34 run (18+19 skip internally)
+ assert await get_version(conn) == 34
finally:
await conn.close()
@@ -648,7 +648,7 @@ class TestMigration019:
assert await cursor.fetchone() is not None
await run_migrations(conn)
- assert await get_version(conn) == 33
+ assert await get_version(conn) == 34
# Verify autoindex is gone
cursor = await conn.execute(
@@ -714,8 +714,8 @@ class TestMigration020:
assert (await cursor.fetchone())[0] == "delete"
applied = await run_migrations(conn)
- assert applied == 14 # Migrations 20-33
- assert await get_version(conn) == 33
+ assert applied == 15 # Migrations 20-34
+ assert await get_version(conn) == 34
# Verify WAL mode
cursor = await conn.execute("PRAGMA journal_mode")
@@ -745,7 +745,7 @@ class TestMigration020:
await set_version(conn, 20)
applied = await run_migrations(conn)
- assert applied == 13 # Migrations 21-33 still run
+ assert applied == 14 # Migrations 21-34 still run
# Still WAL + INCREMENTAL
cursor = await conn.execute("PRAGMA journal_mode")
@@ -803,8 +803,8 @@ class TestMigration028:
await conn.commit()
applied = await run_migrations(conn)
- assert applied == 6
- assert await get_version(conn) == 33
+ assert applied == 7
+ assert await get_version(conn) == 34
# Verify payload_hash column is now BLOB
cursor = await conn.execute("PRAGMA table_info(raw_packets)")
@@ -873,8 +873,8 @@ class TestMigration028:
await conn.commit()
applied = await run_migrations(conn)
- assert applied == 6 # Version still bumped
- assert await get_version(conn) == 33
+ assert applied == 7 # Version still bumped
+ assert await get_version(conn) == 34
# Verify data unchanged
cursor = await conn.execute("SELECT payload_hash FROM raw_packets")
@@ -923,8 +923,8 @@ class TestMigration032:
await conn.commit()
applied = await run_migrations(conn)
- assert applied == 2
- assert await get_version(conn) == 33
+ assert applied == 3
+ assert await get_version(conn) == 34
# Verify all columns exist with correct defaults
cursor = await conn.execute(
@@ -943,6 +943,70 @@ class TestMigration032:
await conn.close()
+class TestMigration034:
+ """Test migration 034: add flood_scope column to app_settings."""
+
+ @pytest.mark.asyncio
+ async def test_migration_adds_flood_scope_column(self):
+ """Migration adds flood_scope column with empty string default."""
+ conn = await aiosqlite.connect(":memory:")
+ conn.row_factory = aiosqlite.Row
+ try:
+ await set_version(conn, 33)
+
+ # Create app_settings without flood_scope (pre-migration schema)
+ await conn.execute("""
+ CREATE TABLE app_settings (
+ id INTEGER PRIMARY KEY,
+ max_radio_contacts INTEGER DEFAULT 200,
+ favorites TEXT DEFAULT '[]',
+ auto_decrypt_dm_on_advert INTEGER DEFAULT 0,
+ sidebar_sort_order TEXT DEFAULT 'recent',
+ last_message_times TEXT DEFAULT '{}',
+ preferences_migrated INTEGER DEFAULT 0,
+ advert_interval INTEGER DEFAULT 0,
+ last_advert_time INTEGER DEFAULT 0,
+ bots TEXT DEFAULT '[]',
+ mqtt_broker_host TEXT DEFAULT '',
+ mqtt_broker_port INTEGER DEFAULT 1883,
+ mqtt_username TEXT DEFAULT '',
+ mqtt_password TEXT DEFAULT '',
+ mqtt_use_tls INTEGER DEFAULT 0,
+ mqtt_tls_insecure INTEGER DEFAULT 0,
+ mqtt_topic_prefix TEXT DEFAULT 'meshcore',
+ mqtt_publish_messages INTEGER DEFAULT 0,
+ mqtt_publish_raw_packets INTEGER DEFAULT 0,
+ community_mqtt_enabled INTEGER DEFAULT 0,
+ community_mqtt_iata TEXT DEFAULT '',
+ community_mqtt_broker_host TEXT DEFAULT 'mqtt-us-v1.letsmesh.net',
+ community_mqtt_broker_port INTEGER DEFAULT 443,
+ community_mqtt_email TEXT DEFAULT ''
+ )
+ """)
+ await conn.execute("INSERT INTO app_settings (id) VALUES (1)")
+ # Channels table needed for migration 33
+ await conn.execute("""
+ CREATE TABLE channels (
+ key TEXT PRIMARY KEY,
+ name TEXT NOT NULL,
+ is_hashtag INTEGER DEFAULT 0,
+ on_radio INTEGER DEFAULT 0
+ )
+ """)
+ await conn.commit()
+
+ applied = await run_migrations(conn)
+ assert applied == 1
+ assert await get_version(conn) == 34
+
+ # Verify column exists with correct default
+ cursor = await conn.execute("SELECT flood_scope FROM app_settings WHERE id = 1")
+ row = await cursor.fetchone()
+ assert row["flood_scope"] == ""
+ finally:
+ await conn.close()
+
+
class TestMigration033:
"""Test migration 033: seed #remoteterm channel."""
@@ -975,8 +1039,8 @@ class TestMigration033:
await conn.commit()
applied = await run_migrations(conn)
- assert applied == 1
- assert await get_version(conn) == 33
+ assert applied == 2
+ assert await get_version(conn) == 34
cursor = await conn.execute(
"SELECT key, name, is_hashtag, on_radio FROM channels WHERE key = ?",
diff --git a/tests/test_radio.py b/tests/test_radio.py
index b363508..2ff8cce 100644
--- a/tests/test_radio.py
+++ b/tests/test_radio.py
@@ -495,11 +495,13 @@ class TestPostConnectSetupOrdering:
@pytest.mark.asyncio
async def test_drain_runs_before_auto_fetch(self):
"""drain_pending_messages must be called BEFORE start_auto_message_fetching."""
+ from app.models import AppSettings
from app.radio import RadioManager
rm = RadioManager()
mock_mc = MagicMock()
mock_mc.start_auto_message_fetching = AsyncMock()
+ mock_mc.commands.set_flood_scope = AsyncMock()
rm._meshcore = mock_mc
call_order = []
@@ -517,6 +519,11 @@ class TestPostConnectSetupOrdering:
patch("app.event_handlers.register_event_handlers"),
patch("app.keystore.export_and_store_private_key", new_callable=AsyncMock),
patch("app.radio_sync.sync_radio_time", new_callable=AsyncMock),
+ patch(
+ "app.repository.AppSettingsRepository.get",
+ new_callable=AsyncMock,
+ return_value=AppSettings(),
+ ),
patch("app.radio_sync.sync_and_offload_all", new_callable=AsyncMock, return_value={}),
patch("app.radio_sync.start_periodic_sync"),
patch("app.radio_sync.send_advertisement", new_callable=AsyncMock, return_value=False),
@@ -537,11 +544,13 @@ class TestPostConnectSetupOrdering:
@pytest.mark.asyncio
async def test_setup_sets_and_clears_in_progress_flag(self):
"""is_setup_in_progress is True during setup and False after."""
+ from app.models import AppSettings
from app.radio import RadioManager
rm = RadioManager()
mock_mc = MagicMock()
mock_mc.start_auto_message_fetching = AsyncMock()
+ mock_mc.commands.set_flood_scope = AsyncMock()
rm._meshcore = mock_mc
observed_during = None
@@ -555,6 +564,11 @@ class TestPostConnectSetupOrdering:
patch("app.event_handlers.register_event_handlers"),
patch("app.keystore.export_and_store_private_key", new_callable=AsyncMock),
patch("app.radio_sync.sync_radio_time", new_callable=AsyncMock),
+ patch(
+ "app.repository.AppSettingsRepository.get",
+ new_callable=AsyncMock,
+ return_value=AppSettings(),
+ ),
patch("app.radio_sync.sync_and_offload_all", new_callable=AsyncMock, return_value={}),
patch("app.radio_sync.start_periodic_sync"),
patch("app.radio_sync.send_advertisement", new_callable=AsyncMock, return_value=False),
@@ -606,3 +620,71 @@ class TestPostConnectSetupOrdering:
# Should not raise or call any functions
await rm.post_connect_setup()
assert rm.is_setup_in_progress is False
+
+ @pytest.mark.asyncio
+ async def test_flood_scope_applied_during_setup(self):
+ """Non-empty flood_scope from settings is applied during post_connect_setup."""
+ from app.models import AppSettings
+ from app.radio import RadioManager
+
+ rm = RadioManager()
+ mock_mc = MagicMock()
+ mock_mc.start_auto_message_fetching = AsyncMock()
+ mock_mc.commands.set_flood_scope = AsyncMock()
+ rm._meshcore = mock_mc
+
+ mock_settings = AppSettings(flood_scope="#TestRegion")
+
+ with (
+ patch("app.event_handlers.register_event_handlers"),
+ patch("app.keystore.export_and_store_private_key", new_callable=AsyncMock),
+ patch("app.radio_sync.sync_radio_time", new_callable=AsyncMock),
+ patch(
+ "app.repository.AppSettingsRepository.get",
+ new_callable=AsyncMock,
+ return_value=mock_settings,
+ ),
+ patch("app.radio_sync.sync_and_offload_all", new_callable=AsyncMock, return_value={}),
+ patch("app.radio_sync.start_periodic_sync"),
+ patch("app.radio_sync.send_advertisement", new_callable=AsyncMock, return_value=False),
+ patch("app.radio_sync.start_periodic_advert"),
+ patch("app.radio_sync.drain_pending_messages", new_callable=AsyncMock, return_value=0),
+ patch("app.radio_sync.start_message_polling"),
+ ):
+ await rm.post_connect_setup()
+
+ mock_mc.commands.set_flood_scope.assert_awaited_once_with("#TestRegion")
+
+ @pytest.mark.asyncio
+ async def test_flood_scope_empty_resets_during_setup(self):
+ """Empty flood_scope calls set_flood_scope("") during post_connect_setup."""
+ from app.models import AppSettings
+ from app.radio import RadioManager
+
+ rm = RadioManager()
+ mock_mc = MagicMock()
+ mock_mc.start_auto_message_fetching = AsyncMock()
+ mock_mc.commands.set_flood_scope = AsyncMock()
+ rm._meshcore = mock_mc
+
+ mock_settings = AppSettings(flood_scope="")
+
+ with (
+ patch("app.event_handlers.register_event_handlers"),
+ patch("app.keystore.export_and_store_private_key", new_callable=AsyncMock),
+ patch("app.radio_sync.sync_radio_time", new_callable=AsyncMock),
+ patch(
+ "app.repository.AppSettingsRepository.get",
+ new_callable=AsyncMock,
+ return_value=mock_settings,
+ ),
+ patch("app.radio_sync.sync_and_offload_all", new_callable=AsyncMock, return_value={}),
+ patch("app.radio_sync.start_periodic_sync"),
+ patch("app.radio_sync.send_advertisement", new_callable=AsyncMock, return_value=False),
+ patch("app.radio_sync.start_periodic_advert"),
+ patch("app.radio_sync.drain_pending_messages", new_callable=AsyncMock, return_value=0),
+ patch("app.radio_sync.start_message_polling"),
+ ):
+ await rm.post_connect_setup()
+
+ mock_mc.commands.set_flood_scope.assert_awaited_once_with("")
diff --git a/tests/test_repository.py b/tests/test_repository.py
index f4aad30..5044959 100644
--- a/tests/test_repository.py
+++ b/tests/test_repository.py
@@ -507,6 +507,7 @@ class TestAppSettingsRepository:
"community_mqtt_broker_host": "mqtt-us-v1.letsmesh.net",
"community_mqtt_broker_port": 443,
"community_mqtt_email": "",
+ "flood_scope": "",
}
)
mock_conn.execute = AsyncMock(return_value=mock_cursor)
diff --git a/tests/test_settings_router.py b/tests/test_settings_router.py
index 6ee4a2d..7120742 100644
--- a/tests/test_settings_router.py
+++ b/tests/test_settings_router.py
@@ -192,6 +192,76 @@ class TestUpdateSettings:
assert settings.community_mqtt_iata == ""
assert settings.community_mqtt_email == ""
+ @pytest.mark.asyncio
+ async def test_flood_scope_round_trip(self, test_db):
+ """Flood scope should be saved and retrieved correctly."""
+ result = await update_settings(AppSettingsUpdate(flood_scope="#MyRegion"))
+ assert result.flood_scope == "#MyRegion"
+
+ fresh = await AppSettingsRepository.get()
+ assert fresh.flood_scope == "#MyRegion"
+
+ @pytest.mark.asyncio
+ async def test_flood_scope_default_empty(self, test_db):
+ """Fresh DB should have flood_scope as empty string."""
+ settings = await AppSettingsRepository.get()
+ assert settings.flood_scope == ""
+
+ @pytest.mark.asyncio
+ async def test_flood_scope_whitespace_stripped(self, test_db):
+ """Flood scope should be stripped of whitespace."""
+ result = await update_settings(AppSettingsUpdate(flood_scope=" #MyRegion "))
+ assert result.flood_scope == "#MyRegion"
+
+ @pytest.mark.asyncio
+ async def test_flood_scope_applies_to_radio(self, test_db):
+ """When radio is connected, setting flood_scope calls set_flood_scope on radio."""
+ mock_mc = AsyncMock()
+ mock_mc.commands.set_flood_scope = AsyncMock()
+
+ mock_rm = AsyncMock()
+ mock_rm.is_connected = True
+ mock_rm.meshcore = mock_mc
+
+ from contextlib import asynccontextmanager
+
+ @asynccontextmanager
+ async def mock_radio_op(name):
+ yield mock_mc
+
+ mock_rm.radio_operation = mock_radio_op
+
+ with patch("app.radio.radio_manager", mock_rm):
+ await update_settings(AppSettingsUpdate(flood_scope="#TestRegion"))
+
+ mock_mc.commands.set_flood_scope.assert_awaited_once_with("#TestRegion")
+
+ @pytest.mark.asyncio
+ async def test_flood_scope_empty_resets_radio(self, test_db):
+ """Setting flood_scope to empty calls set_flood_scope("") on radio."""
+ # First set a non-empty scope
+ await update_settings(AppSettingsUpdate(flood_scope="#TestRegion"))
+
+ mock_mc = AsyncMock()
+ mock_mc.commands.set_flood_scope = AsyncMock()
+
+ mock_rm = AsyncMock()
+ mock_rm.is_connected = True
+ mock_rm.meshcore = mock_mc
+
+ from contextlib import asynccontextmanager
+
+ @asynccontextmanager
+ async def mock_radio_op(name):
+ yield mock_mc
+
+ mock_rm.radio_operation = mock_radio_op
+
+ with patch("app.radio.radio_manager", mock_rm):
+ await update_settings(AppSettingsUpdate(flood_scope=""))
+
+ mock_mc.commands.set_flood_scope.assert_awaited_once_with("")
+
class TestToggleFavorite:
@pytest.mark.asyncio