Force auto-advert to respect set intervals

This commit is contained in:
Jack Kingsman
2026-01-26 20:34:35 -08:00
parent 4382f4ab74
commit 769f34ebfc
6 changed files with 110 additions and 51 deletions
+26
View File
@@ -107,6 +107,13 @@ async def run_migrations(conn: aiosqlite.Connection) -> int:
await set_version(conn, 10) await set_version(conn, 10)
applied += 1 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: if applied > 0:
logger.info( logger.info(
"Applied %d migration(s), schema now at version %d", applied, await get_version(conn) "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 raise
await conn.commit() 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()
+4
View File
@@ -259,3 +259,7 @@ class AppSettings(BaseModel):
default=0, default=0,
description="Periodic advertisement interval in seconds (0 = disabled)", description="Periodic advertisement interval in seconds (0 = disabled)",
) )
last_advert_time: int = Field(
default=0,
description="Unix timestamp of last advertisement sent (0 = never)",
)
+48 -36
View File
@@ -336,19 +336,54 @@ async def stop_message_polling():
logger.info("Stopped periodic 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. """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: if not radio_manager.is_connected or radio_manager.meshcore is None:
logger.debug("Cannot send advertisement: radio not connected") logger.debug("Cannot send advertisement: radio not connected")
return False 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: try:
result = await radio_manager.meshcore.commands.send_advert(flood=True) result = await radio_manager.meshcore.commands.send_advert(flood=True)
if result.type == EventType.OK: 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 return True
else: else:
logger.warning("Failed to send advertisement: %s", result.payload) logger.warning("Failed to send advertisement: %s", result.payload)
@@ -359,50 +394,27 @@ async def send_advertisement() -> bool:
async def _periodic_advert_loop(): 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 The actual throttling logic is in send_advertisement(), which checks
dynamic configuration changes without restarting the task. last_advert_time from the database. This loop just triggers the check
If interval is 0, advertising is disabled but the task continues periodically and sleeps between attempts.
running to detect when it's re-enabled.
""" """
from app.repository import AppSettingsRepository
last_advert_time = 0.0
while True: while True:
try: try:
# Get current interval setting # Try to send - send_advertisement() handles all checks
settings = await AppSettingsRepository.get() # (disabled, throttled, not connected)
interval = settings.advert_interval if radio_manager.is_connected:
await send_advertisement()
if interval <= 0: # Sleep before next check
# Advertising disabled - check again later await asyncio.sleep(ADVERT_CHECK_INTERVAL)
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)
except asyncio.CancelledError: except asyncio.CancelledError:
logger.info("Periodic advertisement task cancelled") logger.info("Periodic advertisement task cancelled")
break break
except Exception as e: except Exception as e:
logger.error("Error in periodic advertisement loop: %s", e) logger.error("Error in periodic advertisement loop: %s", e)
# Sleep a bit before retrying on error
await asyncio.sleep(ADVERT_CHECK_INTERVAL) await asyncio.sleep(ADVERT_CHECK_INTERVAL)
+7 -1
View File
@@ -682,7 +682,7 @@ class AppSettingsRepository:
""" """
SELECT max_radio_contacts, favorites, auto_decrypt_dm_on_advert, SELECT max_radio_contacts, favorites, auto_decrypt_dm_on_advert,
sidebar_sort_order, last_message_times, preferences_migrated, sidebar_sort_order, last_message_times, preferences_migrated,
advert_interval advert_interval, last_advert_time
FROM app_settings WHERE id = 1 FROM app_settings WHERE id = 1
""" """
) )
@@ -731,6 +731,7 @@ class AppSettingsRepository:
last_message_times=last_message_times, last_message_times=last_message_times,
preferences_migrated=bool(row["preferences_migrated"]), preferences_migrated=bool(row["preferences_migrated"]),
advert_interval=row["advert_interval"] or 0, advert_interval=row["advert_interval"] or 0,
last_advert_time=row["last_advert_time"] or 0,
) )
@staticmethod @staticmethod
@@ -742,6 +743,7 @@ class AppSettingsRepository:
last_message_times: dict[str, int] | None = None, last_message_times: dict[str, int] | None = None,
preferences_migrated: bool | None = None, preferences_migrated: bool | None = None,
advert_interval: int | None = None, advert_interval: int | None = None,
last_advert_time: int | None = None,
) -> AppSettings: ) -> AppSettings:
"""Update app settings. Only provided fields are updated.""" """Update app settings. Only provided fields are updated."""
updates = [] updates = []
@@ -776,6 +778,10 @@ class AppSettingsRepository:
updates.append("advert_interval = ?") updates.append("advert_interval = ?")
params.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: if updates:
query = f"UPDATE app_settings SET {', '.join(updates)} WHERE id = 1" query = f"UPDATE app_settings SET {', '.join(updates)} WHERE id = 1"
await db.conn.execute(query, params) await db.conn.execute(query, params)
+18 -7
View File
@@ -5,6 +5,7 @@ from meshcore import EventType
from pydantic import BaseModel, Field from pydantic import BaseModel, Field
from app.dependencies import require_connected 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 from app.radio_sync import sync_radio_time
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -129,16 +130,26 @@ async def set_private_key(update: PrivateKeyUpdate) -> dict:
@router.post("/advertise") @router.post("/advertise")
async def send_advertisement(flood: bool = True) -> dict: async def send_advertisement(flood: bool = True) -> dict:
"""Send a radio advertisement to announce presence on the mesh.""" """Send a radio advertisement to announce presence on the mesh.
mc = require_connected()
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) 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: if not success:
raise HTTPException( raise HTTPException(status_code=500, detail="Failed to send advertisement")
status_code=500, detail=f"Failed to send advertisement: {result.payload}"
)
return {"status": "ok", "flood": flood} return {"status": "ok", "flood": flood}
+7 -7
View File
@@ -100,8 +100,8 @@ class TestMigration001:
# Run migrations # Run migrations
applied = await run_migrations(conn) applied = await run_migrations(conn)
assert applied == 10 # All 10 migrations run assert applied == 11 # All 11 migrations run
assert await get_version(conn) == 10 assert await get_version(conn) == 11
# Verify columns exist by inserting and selecting # Verify columns exist by inserting and selecting
await conn.execute( await conn.execute(
@@ -183,9 +183,9 @@ class TestMigration001:
applied1 = await run_migrations(conn) applied1 = await run_migrations(conn)
applied2 = 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 applied2 == 0 # No migrations on second run
assert await get_version(conn) == 10 assert await get_version(conn) == 11
finally: finally:
await conn.close() await conn.close()
@@ -245,9 +245,9 @@ class TestMigration001:
# Run migrations - should not fail # Run migrations - should not fail
applied = await run_migrations(conn) applied = await run_migrations(conn)
# All 10 migrations applied (version incremented) but no error # All 11 migrations applied (version incremented) but no error
assert applied == 10 assert applied == 11
assert await get_version(conn) == 10 assert await get_version(conn) == 11
finally: finally:
await conn.close() await conn.close()