diff --git a/app/migrations.py b/app/migrations.py index 9d81569..caf4ceb 100644 --- a/app/migrations.py +++ b/app/migrations.py @@ -107,6 +107,13 @@ async def run_migrations(conn: aiosqlite.Connection) -> int: await set_version(conn, 10) applied += 1 + # Migration 11: Add last_advert_time column to app_settings + if version < 11: + logger.info("Applying migration 11: add last_advert_time column") + await _migrate_011_add_last_advert_time(conn) + await set_version(conn, 11) + applied += 1 + if applied > 0: logger.info( "Applied %d migration(s), schema now at version %d", applied, await get_version(conn) @@ -654,3 +661,22 @@ async def _migrate_010_add_advert_interval(conn: aiosqlite.Connection) -> None: raise await conn.commit() + + +async def _migrate_011_add_last_advert_time(conn: aiosqlite.Connection) -> None: + """ + Add last_advert_time column to app_settings table. + + This tracks when the last advertisement was sent, ensuring we never + advertise faster than the configured advert_interval. + """ + try: + await conn.execute("ALTER TABLE app_settings ADD COLUMN last_advert_time INTEGER DEFAULT 0") + logger.debug("Added last_advert_time column to app_settings") + except aiosqlite.OperationalError as e: + if "duplicate column" in str(e).lower(): + logger.debug("last_advert_time column already exists, skipping") + else: + raise + + await conn.commit() diff --git a/app/models.py b/app/models.py index 632b1e2..b037fa2 100644 --- a/app/models.py +++ b/app/models.py @@ -259,3 +259,7 @@ class AppSettings(BaseModel): default=0, description="Periodic advertisement interval in seconds (0 = disabled)", ) + last_advert_time: int = Field( + default=0, + description="Unix timestamp of last advertisement sent (0 = never)", + ) diff --git a/app/radio_sync.py b/app/radio_sync.py index a073f45..0558cc9 100644 --- a/app/radio_sync.py +++ b/app/radio_sync.py @@ -336,19 +336,54 @@ async def stop_message_polling(): logger.info("Stopped periodic message polling") -async def send_advertisement() -> bool: +async def send_advertisement(force: bool = False) -> bool: """Send an advertisement to announce presence on the mesh. - Returns True if successful, False otherwise. + Respects the configured advert_interval - won't send if not enough time + has elapsed since the last advertisement, unless force=True. + + Args: + force: If True, send immediately regardless of interval. + + Returns True if successful, False otherwise (including if throttled). """ + from app.repository import AppSettingsRepository + if not radio_manager.is_connected or radio_manager.meshcore is None: logger.debug("Cannot send advertisement: radio not connected") return False + # Check if enough time has elapsed (unless forced) + if not force: + settings = await AppSettingsRepository.get() + interval = settings.advert_interval + last_time = settings.last_advert_time + now = int(time.time()) + + # If interval is 0, advertising is disabled + if interval <= 0: + logger.debug("Advertisement skipped: periodic advertising is disabled") + return False + + # Check if enough time has passed + elapsed = now - last_time + if elapsed < interval: + remaining = interval - elapsed + logger.debug( + "Advertisement throttled: %d seconds remaining (interval=%d, elapsed=%d)", + remaining, + interval, + elapsed, + ) + return False + try: result = await radio_manager.meshcore.commands.send_advert(flood=True) if result.type == EventType.OK: - logger.info("Periodic advertisement sent successfully") + # Update last_advert_time in database + now = int(time.time()) + await AppSettingsRepository.update(last_advert_time=now) + logger.info("Advertisement sent successfully") return True else: logger.warning("Failed to send advertisement: %s", result.payload) @@ -359,50 +394,27 @@ async def send_advertisement() -> bool: async def _periodic_advert_loop(): - """Background task that periodically sends advertisements. + """Background task that periodically checks if an advertisement should be sent. - Reads the interval from app_settings on each iteration, allowing - dynamic configuration changes without restarting the task. - If interval is 0, advertising is disabled but the task continues - running to detect when it's re-enabled. + The actual throttling logic is in send_advertisement(), which checks + last_advert_time from the database. This loop just triggers the check + periodically and sleeps between attempts. """ - from app.repository import AppSettingsRepository - - last_advert_time = 0.0 - while True: try: - # Get current interval setting - settings = await AppSettingsRepository.get() - interval = settings.advert_interval + # Try to send - send_advertisement() handles all checks + # (disabled, throttled, not connected) + if radio_manager.is_connected: + await send_advertisement() - if interval <= 0: - # Advertising disabled - check again later - await asyncio.sleep(ADVERT_CHECK_INTERVAL) - continue - - # Check if enough time has passed since last advertisement - now = asyncio.get_running_loop().time() - time_since_last = now - last_advert_time - - if time_since_last >= interval and radio_manager.is_connected: - if await send_advertisement(): - last_advert_time = now - elif time_since_last < interval: - # Sleep until next advertisement is due - sleep_time = min(interval - time_since_last, ADVERT_CHECK_INTERVAL) - await asyncio.sleep(sleep_time) - continue - - # Sleep for the configured interval - await asyncio.sleep(interval) + # Sleep before next check + await asyncio.sleep(ADVERT_CHECK_INTERVAL) except asyncio.CancelledError: logger.info("Periodic advertisement task cancelled") break except Exception as e: logger.error("Error in periodic advertisement loop: %s", e) - # Sleep a bit before retrying on error await asyncio.sleep(ADVERT_CHECK_INTERVAL) diff --git a/app/repository.py b/app/repository.py index e478c4e..ef9a627 100644 --- a/app/repository.py +++ b/app/repository.py @@ -682,7 +682,7 @@ class AppSettingsRepository: """ SELECT max_radio_contacts, favorites, auto_decrypt_dm_on_advert, sidebar_sort_order, last_message_times, preferences_migrated, - advert_interval + advert_interval, last_advert_time FROM app_settings WHERE id = 1 """ ) @@ -731,6 +731,7 @@ class AppSettingsRepository: last_message_times=last_message_times, preferences_migrated=bool(row["preferences_migrated"]), advert_interval=row["advert_interval"] or 0, + last_advert_time=row["last_advert_time"] or 0, ) @staticmethod @@ -742,6 +743,7 @@ class AppSettingsRepository: last_message_times: dict[str, int] | None = None, preferences_migrated: bool | None = None, advert_interval: int | None = None, + last_advert_time: int | None = None, ) -> AppSettings: """Update app settings. Only provided fields are updated.""" updates = [] @@ -776,6 +778,10 @@ class AppSettingsRepository: updates.append("advert_interval = ?") params.append(advert_interval) + if last_advert_time is not None: + updates.append("last_advert_time = ?") + params.append(last_advert_time) + if updates: query = f"UPDATE app_settings SET {', '.join(updates)} WHERE id = 1" await db.conn.execute(query, params) diff --git a/app/routers/radio.py b/app/routers/radio.py index 797a97f..cacc2aa 100644 --- a/app/routers/radio.py +++ b/app/routers/radio.py @@ -5,6 +5,7 @@ from meshcore import EventType from pydantic import BaseModel, Field from app.dependencies import require_connected +from app.radio_sync import send_advertisement as do_send_advertisement from app.radio_sync import sync_radio_time logger = logging.getLogger(__name__) @@ -129,16 +130,26 @@ async def set_private_key(update: PrivateKeyUpdate) -> dict: @router.post("/advertise") async def send_advertisement(flood: bool = True) -> dict: - """Send a radio advertisement to announce presence on the mesh.""" - mc = require_connected() + """Send a radio advertisement to announce presence on the mesh. + Manual advertisement requests always send immediately, updating the + last_advert_time which affects when the next periodic/startup advert + can occur. + + Args: + flood: Whether to flood the advertisement (default True). + + Returns: + status: "ok" if sent successfully + """ + require_connected() + + # Manual requests always send (force=True), but still update last_advert_time logger.info("Sending advertisement (flood=%s)", flood) - result = await mc.commands.send_advert(flood=flood) + success = await do_send_advertisement(force=True) - if result.type == EventType.ERROR: - raise HTTPException( - status_code=500, detail=f"Failed to send advertisement: {result.payload}" - ) + if not success: + raise HTTPException(status_code=500, detail="Failed to send advertisement") return {"status": "ok", "flood": flood} diff --git a/tests/test_migrations.py b/tests/test_migrations.py index 1feb716..043aabe 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 == 10 # All 10 migrations run - assert await get_version(conn) == 10 + assert applied == 11 # All 11 migrations run + assert await get_version(conn) == 11 # 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 == 10 # All 10 migrations run + assert applied1 == 11 # All 11 migrations run assert applied2 == 0 # No migrations on second run - assert await get_version(conn) == 10 + assert await get_version(conn) == 11 finally: await conn.close() @@ -245,9 +245,9 @@ class TestMigration001: # Run migrations - should not fail applied = await run_migrations(conn) - # All 10 migrations applied (version incremented) but no error - assert applied == 10 - assert await get_version(conn) == 10 + # All 11 migrations applied (version incremented) but no error + assert applied == 11 + assert await get_version(conn) == 11 finally: await conn.close()