Make advert interval manual

This commit is contained in:
Jack Kingsman
2026-01-25 09:29:56 -08:00
parent b3c26507f4
commit 375ee74eb3
14 changed files with 163 additions and 51 deletions

View File

@@ -100,6 +100,13 @@ async def run_migrations(conn: aiosqlite.Connection) -> int:
await set_version(conn, 9)
applied += 1
# Migration 10: Add advert_interval column to app_settings
if version < 10:
logger.info("Applying migration 10: add advert_interval column")
await _migrate_010_add_advert_interval(conn)
await set_version(conn, 10)
applied += 1
if applied > 0:
logger.info(
"Applied %d migration(s), schema now at version %d", applied, await get_version(conn)
@@ -629,3 +636,21 @@ async def _migrate_009_create_app_settings_table(conn: aiosqlite.Connection) ->
await conn.commit()
logger.debug("Created app_settings table with default values")
async def _migrate_010_add_advert_interval(conn: aiosqlite.Connection) -> None:
"""
Add advert_interval column to app_settings table.
This enables configurable periodic advertisement interval (default 0 = disabled).
"""
try:
await conn.execute("ALTER TABLE app_settings ADD COLUMN advert_interval INTEGER DEFAULT 0")
logger.debug("Added advert_interval column to app_settings")
except aiosqlite.OperationalError as e:
if "duplicate column" in str(e).lower():
logger.debug("advert_interval column already exists, skipping")
else:
raise
await conn.commit()

View File

@@ -255,3 +255,7 @@ class AppSettings(BaseModel):
default=False,
description="Whether preferences have been migrated from localStorage",
)
advert_interval: int = Field(
default=0,
description="Periodic advertisement interval in seconds (0 = disabled)",
)

View File

@@ -32,8 +32,9 @@ MESSAGE_POLL_INTERVAL = 5
# Periodic advertisement task handle
_advert_task: asyncio.Task | None = None
# Advertisement interval in seconds (1 hour)
ADVERT_INTERVAL = 3600
# Default check interval when periodic advertising is disabled (seconds)
# We still need to periodically check if it's been enabled
ADVERT_CHECK_INTERVAL = 60
# Counter to pause polling during repeater operations (supports nested pauses)
_polling_pause_count: int = 0
@@ -358,31 +359,63 @@ async def send_advertisement() -> bool:
async def _periodic_advert_loop():
"""Background task that periodically sends advertisements."""
"""Background task that periodically sends advertisements.
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.
"""
from app.repository import AppSettingsRepository
last_advert_time = 0.0
while True:
try:
await asyncio.sleep(ADVERT_INTERVAL)
# Get current interval setting
settings = await AppSettingsRepository.get()
interval = settings.advert_interval
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)
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)
def start_periodic_advert():
"""Start the periodic advertisement background task."""
"""Start the periodic advertisement background task.
The task reads interval from app_settings dynamically, so it will
adapt to configuration changes without restart.
"""
global _advert_task
if _advert_task is None or _advert_task.done():
_advert_task = asyncio.create_task(_periodic_advert_loop())
logger.info(
"Started periodic advertisement (interval: %ds / %d min)",
ADVERT_INTERVAL,
ADVERT_INTERVAL // 60,
)
logger.info("Started periodic advertisement task (interval configured in settings)")
async def stop_periodic_advert():

View File

@@ -717,7 +717,8 @@ class AppSettingsRepository:
cursor = await db.conn.execute(
"""
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
FROM app_settings WHERE id = 1
"""
)
@@ -765,6 +766,7 @@ class AppSettingsRepository:
sidebar_sort_order=sort_order,
last_message_times=last_message_times,
preferences_migrated=bool(row["preferences_migrated"]),
advert_interval=row["advert_interval"] or 0,
)
@staticmethod
@@ -775,6 +777,7 @@ class AppSettingsRepository:
sidebar_sort_order: str | None = None,
last_message_times: dict[str, int] | None = None,
preferences_migrated: bool | None = None,
advert_interval: int | None = None,
) -> AppSettings:
"""Update app settings. Only provided fields are updated."""
updates = []
@@ -805,6 +808,10 @@ class AppSettingsRepository:
updates.append("preferences_migrated = ?")
params.append(1 if preferences_migrated else 0)
if advert_interval is not None:
updates.append("advert_interval = ?")
params.append(advert_interval)
if updates:
query = f"UPDATE app_settings SET {', '.join(updates)} WHERE id = 1"
await db.conn.execute(query, params)

View File

@@ -26,6 +26,11 @@ class AppSettingsUpdate(BaseModel):
default=None,
description="Sidebar sort order: 'recent' or 'alpha'",
)
advert_interval: int | None = Field(
default=None,
ge=0,
description="Periodic advertisement interval in seconds (0 = disabled)",
)
class FavoriteRequest(BaseModel):
@@ -85,6 +90,10 @@ async def update_settings(update: AppSettingsUpdate) -> AppSettings:
logger.info("Updating sidebar_sort_order to %s", update.sidebar_sort_order)
kwargs["sidebar_sort_order"] = update.sidebar_sort_order
if update.advert_interval is not None:
logger.info("Updating advert_interval to %d", update.advert_interval)
kwargs["advert_interval"] = update.advert_interval
if kwargs:
return await AppSettingsRepository.update(**kwargs)

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -13,8 +13,8 @@
<link rel="shortcut icon" href="/favicon.ico" />
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png" />
<link rel="manifest" href="/site.webmanifest" />
<script type="module" crossorigin src="/assets/index-CJZtvuFn.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-BH0I1D-N.css">
<script type="module" crossorigin src="/assets/index-lvsK6fUB.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-BsZWFlnF.css">
</head>
<body>
<div id="root"></div>

View File

@@ -100,6 +100,9 @@ export function SettingsModal({
const [cleaning, setCleaning] = useState(false);
const [autoDecryptOnAdvert, setAutoDecryptOnAdvert] = useState(false);
// Advertisement interval state
const [advertInterval, setAdvertInterval] = useState('0');
useEffect(() => {
if (config) {
setName(config.name);
@@ -117,6 +120,7 @@ export function SettingsModal({
if (appSettings) {
setMaxRadioContacts(String(appSettings.max_radio_contacts));
setAutoDecryptOnAdvert(appSettings.auto_decrypt_dm_on_advert);
setAdvertInterval(String(appSettings.advert_interval));
}
}, [appSettings]);
@@ -220,9 +224,17 @@ export function SettingsModal({
setLoading(true);
try {
// Save radio name
const update: RadioConfigUpdate = { name };
await onSave(update);
toast.success('Identity saved');
// Save advert interval to app settings
const newAdvertInterval = parseInt(advertInterval, 10);
if (!isNaN(newAdvertInterval) && newAdvertInterval !== appSettings?.advert_interval) {
await onSaveAppSettings({ advert_interval: newAdvertInterval });
}
toast.success('Identity settings saved');
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to save');
} finally {
@@ -349,7 +361,8 @@ export function SettingsModal({
<DialogTitle>Radio & Settings</DialogTitle>
<DialogDescription className="sr-only">
{activeTab === 'radio' && 'Configure radio frequency, power, and location settings'}
{activeTab === 'identity' && 'Manage radio name, public key, and private key'}
{activeTab === 'identity' &&
'Manage radio name, public key, private key, and advertising settings'}
{activeTab === 'serial' && 'View serial port connection and configure contact sync'}
{activeTab === 'database' && 'View database statistics and clean up old packets'}
{activeTab === 'advertise' && 'Send a flood advertisement to announce your presence'}
@@ -526,8 +539,27 @@ export function SettingsModal({
<Input id="name" value={name} onChange={(e) => setName(e.target.value)} />
</div>
<div className="space-y-2">
<Label htmlFor="advert-interval">Periodic Advertising Interval</Label>
<div className="flex items-center gap-2">
<Input
id="advert-interval"
type="number"
min="0"
value={advertInterval}
onChange={(e) => setAdvertInterval(e.target.value)}
className="w-28"
/>
<span className="text-sm text-muted-foreground">seconds (0 = off)</span>
</div>
<p className="text-xs text-muted-foreground">
How often to automatically advertise presence. Set to 0 to disable. Recommended:
3600 (1 hour) or higher.
</p>
</div>
<Button onClick={handleSaveIdentity} disabled={loading} className="w-full">
{loading ? 'Saving...' : 'Set Name'}
{loading ? 'Saving...' : 'Save Identity Settings'}
</Button>
<Separator />

View File

@@ -136,12 +136,14 @@ export interface AppSettings {
sidebar_sort_order: 'recent' | 'alpha';
last_message_times: Record<string, number>;
preferences_migrated: boolean;
advert_interval: number;
}
export interface AppSettingsUpdate {
max_radio_contacts?: number;
auto_decrypt_dm_on_advert?: boolean;
sidebar_sort_order?: 'recent' | 'alpha';
advert_interval?: number;
}
export interface MigratePreferencesRequest {

View File

@@ -100,8 +100,8 @@ class TestMigration001:
# Run migrations
applied = await run_migrations(conn)
assert applied == 9 # All 9 migrations run
assert await get_version(conn) == 9
assert applied == 10 # All 10 migrations run
assert await get_version(conn) == 10
# 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 == 9 # All 9 migrations run
assert applied1 == 10 # All 10 migrations run
assert applied2 == 0 # No migrations on second run
assert await get_version(conn) == 9
assert await get_version(conn) == 10
finally:
await conn.close()
@@ -245,9 +245,9 @@ class TestMigration001:
# Run migrations - should not fail
applied = await run_migrations(conn)
# All 9 migrations applied (version incremented) but no error
assert applied == 9
assert await get_version(conn) == 9
# All 10 migrations applied (version incremented) but no error
assert applied == 10
assert await get_version(conn) == 10
finally:
await conn.close()