mirror of
https://github.com/jkingsman/Remote-Terminal-for-MeshCore.git
synced 2026-05-01 11:02:56 +02:00
Add intervalized repeater metrics collection. Closes #151.
This commit is contained in:
@@ -104,7 +104,8 @@ CREATE TABLE IF NOT EXISTS app_settings (
|
||||
flood_scope TEXT DEFAULT '',
|
||||
blocked_keys TEXT DEFAULT '[]',
|
||||
blocked_names TEXT DEFAULT '[]',
|
||||
discovery_blocked_types TEXT DEFAULT '[]'
|
||||
discovery_blocked_types TEXT DEFAULT '[]',
|
||||
tracked_telemetry_repeaters TEXT DEFAULT '[]'
|
||||
);
|
||||
INSERT OR IGNORE INTO app_settings (id) VALUES (1);
|
||||
|
||||
|
||||
@@ -21,6 +21,7 @@ from app.radio_sync import (
|
||||
stop_message_polling,
|
||||
stop_periodic_advert,
|
||||
stop_periodic_sync,
|
||||
stop_telemetry_collect,
|
||||
)
|
||||
from app.routers import (
|
||||
channels,
|
||||
@@ -103,6 +104,7 @@ async def lifespan(app: FastAPI):
|
||||
await stop_noise_floor_sampling()
|
||||
await stop_periodic_advert()
|
||||
await stop_periodic_sync()
|
||||
await stop_telemetry_collect()
|
||||
if radio_manager.meshcore:
|
||||
await radio_manager.meshcore.stop_auto_message_fetching()
|
||||
await radio_manager.disconnect()
|
||||
|
||||
@@ -401,6 +401,12 @@ async def run_migrations(conn: aiosqlite.Connection) -> int:
|
||||
await set_version(conn, 52)
|
||||
applied += 1
|
||||
|
||||
if version < 53:
|
||||
logger.info("Applying migration 53: add tracked_telemetry_repeaters to app_settings")
|
||||
await _migrate_053_tracked_telemetry_repeaters(conn)
|
||||
await set_version(conn, 53)
|
||||
applied += 1
|
||||
|
||||
if applied > 0:
|
||||
logger.info(
|
||||
"Applied %d migration(s), schema now at version %d", applied, await get_version(conn)
|
||||
@@ -3171,3 +3177,18 @@ async def _migrate_052_add_channel_path_hash_mode_override(conn: aiosqlite.Conne
|
||||
await conn.commit()
|
||||
else:
|
||||
raise
|
||||
|
||||
|
||||
async def _migrate_053_tracked_telemetry_repeaters(conn: aiosqlite.Connection) -> None:
|
||||
"""Add tracked_telemetry_repeaters JSON list column to app_settings."""
|
||||
tables_cursor = await conn.execute("SELECT name FROM sqlite_master WHERE type='table'")
|
||||
if "app_settings" not in {row[0] for row in await tables_cursor.fetchall()}:
|
||||
await conn.commit()
|
||||
return
|
||||
col_cursor = await conn.execute("PRAGMA table_info(app_settings)")
|
||||
columns = {row[1] for row in await col_cursor.fetchall()}
|
||||
if "tracked_telemetry_repeaters" not in columns:
|
||||
await conn.execute(
|
||||
"ALTER TABLE app_settings ADD COLUMN tracked_telemetry_repeaters TEXT DEFAULT '[]'"
|
||||
)
|
||||
await conn.commit()
|
||||
|
||||
@@ -826,6 +826,10 @@ class AppSettings(BaseModel):
|
||||
"advertisements should not create new contacts; existing contacts are still updated"
|
||||
),
|
||||
)
|
||||
tracked_telemetry_repeaters: list[str] = Field(
|
||||
default_factory=list,
|
||||
description="Public keys of repeaters opted into periodic telemetry collection (max 8)",
|
||||
)
|
||||
|
||||
|
||||
class FanoutConfig(BaseModel):
|
||||
|
||||
@@ -28,6 +28,7 @@ from app.repository import (
|
||||
AppSettingsRepository,
|
||||
ChannelRepository,
|
||||
ContactRepository,
|
||||
RepeaterTelemetryRepository,
|
||||
)
|
||||
from app.services.contact_reconciliation import (
|
||||
promote_prefix_contacts_for_contact,
|
||||
@@ -155,6 +156,15 @@ ADVERT_CHECK_INTERVAL = 60
|
||||
# more frequently than this.
|
||||
MIN_ADVERT_INTERVAL = 3600
|
||||
|
||||
# Periodic telemetry collection task handle
|
||||
_telemetry_collect_task: asyncio.Task | None = None
|
||||
|
||||
# Telemetry collection interval (8 hours)
|
||||
TELEMETRY_COLLECT_INTERVAL = 8 * 3600
|
||||
|
||||
# Initial delay before the first telemetry collection cycle (let radio settle)
|
||||
TELEMETRY_COLLECT_INITIAL_DELAY = 60
|
||||
|
||||
# Counter to pause polling during repeater operations (supports nested pauses)
|
||||
_polling_pause_count: int = 0
|
||||
|
||||
@@ -1524,3 +1534,158 @@ async def sync_recent_contacts_to_radio(force: bool = False, mc: MeshCore | None
|
||||
except Exception as e:
|
||||
logger.error("Error syncing contacts to radio: %s", e, exc_info=True)
|
||||
return {"loaded": 0, "error": str(e)}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Periodic repeater telemetry collection
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
async def _collect_repeater_telemetry(mc: MeshCore, contact: Contact) -> bool:
|
||||
"""Fetch status telemetry from a single repeater and record it.
|
||||
|
||||
Returns True on success, False on failure (logged, not raised).
|
||||
"""
|
||||
try:
|
||||
await mc.commands.add_contact(contact.to_radio_dict())
|
||||
status = await mc.commands.req_status_sync(contact.public_key, timeout=10, min_timeout=5)
|
||||
except Exception as e:
|
||||
logger.debug(
|
||||
"Telemetry collect: radio command failed for %s: %s",
|
||||
contact.public_key[:12],
|
||||
e,
|
||||
)
|
||||
return False
|
||||
|
||||
if status is None:
|
||||
logger.debug("Telemetry collect: no response from %s", contact.public_key[:12])
|
||||
return False
|
||||
|
||||
# Map to the same field names as the manual repeater status endpoint
|
||||
data = {
|
||||
"battery_volts": status.get("bat", 0) / 1000.0,
|
||||
"tx_queue_len": status.get("tx_queue_len", 0),
|
||||
"noise_floor_dbm": status.get("noise_floor", 0),
|
||||
"last_rssi_dbm": status.get("last_rssi", 0),
|
||||
"last_snr_db": status.get("last_snr", 0.0),
|
||||
"packets_received": status.get("nb_recv", 0),
|
||||
"packets_sent": status.get("nb_sent", 0),
|
||||
"airtime_seconds": status.get("airtime", 0),
|
||||
"rx_airtime_seconds": status.get("rx_airtime", 0),
|
||||
"uptime_seconds": status.get("uptime", 0),
|
||||
"sent_flood": status.get("sent_flood", 0),
|
||||
"sent_direct": status.get("sent_direct", 0),
|
||||
"recv_flood": status.get("recv_flood", 0),
|
||||
"recv_direct": status.get("recv_direct", 0),
|
||||
"flood_dups": status.get("flood_dups", 0),
|
||||
"direct_dups": status.get("direct_dups", 0),
|
||||
"full_events": status.get("full_evts", 0),
|
||||
}
|
||||
|
||||
try:
|
||||
await RepeaterTelemetryRepository.record(
|
||||
public_key=contact.public_key,
|
||||
timestamp=int(time.time()),
|
||||
data=data,
|
||||
)
|
||||
logger.info(
|
||||
"Telemetry collect: recorded snapshot for %s (%s)",
|
||||
contact.name or contact.public_key[:12],
|
||||
contact.public_key[:12],
|
||||
)
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
"Telemetry collect: failed to record for %s: %s",
|
||||
contact.public_key[:12],
|
||||
e,
|
||||
)
|
||||
return False
|
||||
|
||||
|
||||
async def _telemetry_collect_loop() -> None:
|
||||
"""Background task that collects telemetry from tracked repeaters every 8 hours.
|
||||
|
||||
Runs a first cycle after a short initial delay (so newly tracked repeaters
|
||||
get a sample promptly), then sleeps the full interval between subsequent cycles.
|
||||
|
||||
Acquires the radio lock per-repeater (non-blocking) so manual operations can
|
||||
interleave. Failures are logged and skipped.
|
||||
"""
|
||||
first_run = True
|
||||
while True:
|
||||
try:
|
||||
delay = TELEMETRY_COLLECT_INITIAL_DELAY if first_run else TELEMETRY_COLLECT_INTERVAL
|
||||
await asyncio.sleep(delay)
|
||||
first_run = False
|
||||
|
||||
if not radio_manager.is_connected:
|
||||
logger.debug("Telemetry collect: radio not connected, skipping cycle")
|
||||
continue
|
||||
|
||||
app_settings = await AppSettingsRepository.get()
|
||||
tracked = app_settings.tracked_telemetry_repeaters
|
||||
if not tracked:
|
||||
continue
|
||||
|
||||
logger.info("Telemetry collect: starting cycle for %d repeater(s)", len(tracked))
|
||||
collected = 0
|
||||
|
||||
for pub_key in tracked:
|
||||
contact = await ContactRepository.get_by_key(pub_key)
|
||||
if not contact or contact.type != 2:
|
||||
logger.debug(
|
||||
"Telemetry collect: skipping %s (not found or not repeater)",
|
||||
pub_key[:12],
|
||||
)
|
||||
continue
|
||||
|
||||
try:
|
||||
async with radio_manager.radio_operation(
|
||||
"telemetry_collect",
|
||||
blocking=False,
|
||||
suspend_auto_fetch=True,
|
||||
) as mc:
|
||||
if await _collect_repeater_telemetry(mc, contact):
|
||||
collected += 1
|
||||
except RadioOperationBusyError:
|
||||
logger.debug(
|
||||
"Telemetry collect: radio busy, skipping %s",
|
||||
pub_key[:12],
|
||||
)
|
||||
|
||||
logger.info(
|
||||
"Telemetry collect: cycle complete, %d/%d successful",
|
||||
collected,
|
||||
len(tracked),
|
||||
)
|
||||
|
||||
except asyncio.CancelledError:
|
||||
logger.info("Telemetry collect task cancelled")
|
||||
break
|
||||
except Exception as e:
|
||||
logger.error("Error in telemetry collect loop: %s", e, exc_info=True)
|
||||
|
||||
|
||||
def start_telemetry_collect() -> None:
|
||||
"""Start the periodic telemetry collection background task."""
|
||||
global _telemetry_collect_task
|
||||
if _telemetry_collect_task is None or _telemetry_collect_task.done():
|
||||
_telemetry_collect_task = asyncio.create_task(_telemetry_collect_loop())
|
||||
logger.info(
|
||||
"Started periodic telemetry collection (interval: %ds)",
|
||||
TELEMETRY_COLLECT_INTERVAL,
|
||||
)
|
||||
|
||||
|
||||
async def stop_telemetry_collect() -> None:
|
||||
"""Stop the periodic telemetry collection background task."""
|
||||
global _telemetry_collect_task
|
||||
if _telemetry_collect_task and not _telemetry_collect_task.done():
|
||||
_telemetry_collect_task.cancel()
|
||||
try:
|
||||
await _telemetry_collect_task
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
_telemetry_collect_task = None
|
||||
logger.info("Stopped periodic telemetry collection")
|
||||
|
||||
@@ -29,7 +29,8 @@ class AppSettingsRepository:
|
||||
SELECT max_radio_contacts, favorites, auto_decrypt_dm_on_advert,
|
||||
last_message_times, preferences_migrated,
|
||||
advert_interval, last_advert_time, flood_scope,
|
||||
blocked_keys, blocked_names, discovery_blocked_types
|
||||
blocked_keys, blocked_names, discovery_blocked_types,
|
||||
tracked_telemetry_repeaters
|
||||
FROM app_settings WHERE id = 1
|
||||
"""
|
||||
)
|
||||
@@ -89,6 +90,15 @@ class AppSettingsRepository:
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
discovery_blocked_types = []
|
||||
|
||||
# Parse tracked_telemetry_repeaters JSON
|
||||
tracked_telemetry_repeaters: list[str] = []
|
||||
try:
|
||||
raw_tracked = row["tracked_telemetry_repeaters"]
|
||||
if raw_tracked:
|
||||
tracked_telemetry_repeaters = json.loads(raw_tracked)
|
||||
except (json.JSONDecodeError, TypeError, KeyError):
|
||||
tracked_telemetry_repeaters = []
|
||||
|
||||
return AppSettings(
|
||||
max_radio_contacts=row["max_radio_contacts"],
|
||||
favorites=favorites,
|
||||
@@ -101,6 +111,7 @@ class AppSettingsRepository:
|
||||
blocked_keys=blocked_keys,
|
||||
blocked_names=blocked_names,
|
||||
discovery_blocked_types=discovery_blocked_types,
|
||||
tracked_telemetry_repeaters=tracked_telemetry_repeaters,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
@@ -116,6 +127,7 @@ class AppSettingsRepository:
|
||||
blocked_keys: list[str] | None = None,
|
||||
blocked_names: list[str] | None = None,
|
||||
discovery_blocked_types: list[int] | None = None,
|
||||
tracked_telemetry_repeaters: list[str] | None = None,
|
||||
) -> AppSettings:
|
||||
"""Update app settings. Only provided fields are updated."""
|
||||
updates = []
|
||||
@@ -166,6 +178,10 @@ class AppSettingsRepository:
|
||||
updates.append("discovery_blocked_types = ?")
|
||||
params.append(json.dumps(discovery_blocked_types))
|
||||
|
||||
if tracked_telemetry_repeaters is not None:
|
||||
updates.append("tracked_telemetry_repeaters = ?")
|
||||
params.append(json.dumps(tracked_telemetry_repeaters))
|
||||
|
||||
if updates:
|
||||
query = f"UPDATE app_settings SET {', '.join(updates)} WHERE id = 1"
|
||||
await db.conn.execute(query, params)
|
||||
|
||||
@@ -2,16 +2,18 @@ import asyncio
|
||||
import logging
|
||||
from typing import Literal
|
||||
|
||||
from fastapi import APIRouter
|
||||
from fastapi import APIRouter, HTTPException
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from app.models import AppSettings
|
||||
from app.models import CONTACT_TYPE_REPEATER, AppSettings
|
||||
from app.region_scope import normalize_region_scope
|
||||
from app.repository import AppSettingsRepository
|
||||
from app.repository import AppSettingsRepository, ContactRepository
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter(prefix="/settings", tags=["settings"])
|
||||
|
||||
MAX_TRACKED_TELEMETRY_REPEATERS = 8
|
||||
|
||||
|
||||
class AppSettingsUpdate(BaseModel):
|
||||
max_radio_contacts: int | None = Field(
|
||||
@@ -66,6 +68,19 @@ class FavoriteRequest(BaseModel):
|
||||
id: str = Field(description="Channel key or contact public key")
|
||||
|
||||
|
||||
class TrackedTelemetryRequest(BaseModel):
|
||||
public_key: str = Field(description="Public key of the repeater to toggle tracking")
|
||||
|
||||
|
||||
class TrackedTelemetryResponse(BaseModel):
|
||||
tracked_telemetry_repeaters: list[str] = Field(
|
||||
description="Current list of tracked repeater public keys"
|
||||
)
|
||||
names: dict[str, str] = Field(
|
||||
description="Map of public key to display name for tracked repeaters"
|
||||
)
|
||||
|
||||
|
||||
class MigratePreferencesRequest(BaseModel):
|
||||
favorites: list[FavoriteRequest] = Field(
|
||||
default_factory=list,
|
||||
@@ -191,6 +206,61 @@ async def toggle_blocked_name(request: BlockNameRequest) -> AppSettings:
|
||||
return await AppSettingsRepository.toggle_blocked_name(request.name)
|
||||
|
||||
|
||||
@router.post("/tracked-telemetry/toggle", response_model=TrackedTelemetryResponse)
|
||||
async def toggle_tracked_telemetry(request: TrackedTelemetryRequest) -> TrackedTelemetryResponse:
|
||||
"""Toggle periodic telemetry collection for a repeater.
|
||||
|
||||
Max 8 repeaters may be tracked. Returns 409 if the limit is reached and
|
||||
the requested repeater is not already tracked.
|
||||
"""
|
||||
key = request.public_key.lower()
|
||||
settings = await AppSettingsRepository.get()
|
||||
current = settings.tracked_telemetry_repeaters
|
||||
|
||||
async def _resolve_names(keys: list[str]) -> dict[str, str]:
|
||||
names: dict[str, str] = {}
|
||||
for k in keys:
|
||||
contact = await ContactRepository.get_by_key(k)
|
||||
names[k] = contact.name if contact and contact.name else k[:12]
|
||||
return names
|
||||
|
||||
if key in current:
|
||||
# Remove
|
||||
new_list = [k for k in current if k != key]
|
||||
logger.info("Removing repeater %s from tracked telemetry", key[:12])
|
||||
await AppSettingsRepository.update(tracked_telemetry_repeaters=new_list)
|
||||
return TrackedTelemetryResponse(
|
||||
tracked_telemetry_repeaters=new_list,
|
||||
names=await _resolve_names(new_list),
|
||||
)
|
||||
|
||||
# Validate it's a repeater
|
||||
contact = await ContactRepository.get_by_key(key)
|
||||
if not contact:
|
||||
raise HTTPException(status_code=404, detail="Contact not found")
|
||||
if contact.type != CONTACT_TYPE_REPEATER:
|
||||
raise HTTPException(status_code=400, detail="Contact is not a repeater")
|
||||
|
||||
if len(current) >= MAX_TRACKED_TELEMETRY_REPEATERS:
|
||||
names = await _resolve_names(current)
|
||||
raise HTTPException(
|
||||
status_code=409,
|
||||
detail={
|
||||
"message": f"Limit of {MAX_TRACKED_TELEMETRY_REPEATERS} tracked repeaters reached",
|
||||
"tracked_telemetry_repeaters": current,
|
||||
"names": names,
|
||||
},
|
||||
)
|
||||
|
||||
new_list = current + [key]
|
||||
logger.info("Adding repeater %s to tracked telemetry", key[:12])
|
||||
await AppSettingsRepository.update(tracked_telemetry_repeaters=new_list)
|
||||
return TrackedTelemetryResponse(
|
||||
tracked_telemetry_repeaters=new_list,
|
||||
names=await _resolve_names(new_list),
|
||||
)
|
||||
|
||||
|
||||
@router.post("/migrate", response_model=MigratePreferencesResponse)
|
||||
async def migrate_preferences(request: MigratePreferencesRequest) -> MigratePreferencesResponse:
|
||||
"""Migrate all preferences from frontend localStorage to database.
|
||||
|
||||
@@ -33,6 +33,7 @@ async def run_post_connect_setup(radio_manager) -> None:
|
||||
start_message_polling,
|
||||
start_periodic_advert,
|
||||
start_periodic_sync,
|
||||
start_telemetry_collect,
|
||||
sync_and_offload_all,
|
||||
sync_radio_time,
|
||||
)
|
||||
@@ -241,6 +242,7 @@ async def run_post_connect_setup(radio_manager) -> None:
|
||||
start_periodic_sync()
|
||||
start_periodic_advert()
|
||||
start_message_polling()
|
||||
start_telemetry_collect()
|
||||
|
||||
radio_manager._setup_complete = True
|
||||
finally:
|
||||
|
||||
@@ -156,6 +156,7 @@ export function App() {
|
||||
handleToggleFavorite,
|
||||
handleToggleBlockedKey,
|
||||
handleToggleBlockedName,
|
||||
handleToggleTrackedTelemetry,
|
||||
} = useAppSettings();
|
||||
|
||||
// Keep user's name in ref for mention detection in WebSocket callback
|
||||
@@ -555,6 +556,8 @@ export function App() {
|
||||
);
|
||||
}
|
||||
},
|
||||
trackedTelemetryRepeaters: appSettings?.tracked_telemetry_repeaters ?? [],
|
||||
onToggleTrackedTelemetry: handleToggleTrackedTelemetry,
|
||||
};
|
||||
const searchProps = {
|
||||
contacts,
|
||||
@@ -588,6 +591,8 @@ export function App() {
|
||||
const keySet = new Set(deletedKeys.map((k) => k.toLowerCase()));
|
||||
setContacts((prev) => prev.filter((c) => !keySet.has(c.public_key.toLowerCase())));
|
||||
},
|
||||
trackedTelemetryRepeaters: appSettings?.tracked_telemetry_repeaters ?? [],
|
||||
onToggleTrackedTelemetry: handleToggleTrackedTelemetry,
|
||||
};
|
||||
const crackerProps = {
|
||||
packets: rawPackets,
|
||||
|
||||
@@ -36,6 +36,7 @@ import type {
|
||||
RepeaterRadioSettingsResponse,
|
||||
RepeaterStatusResponse,
|
||||
TelemetryHistoryEntry,
|
||||
TrackedTelemetryResponse,
|
||||
StatisticsResponse,
|
||||
TraceResponse,
|
||||
UnreadCounts,
|
||||
@@ -327,6 +328,13 @@ export const api = {
|
||||
body: JSON.stringify({ name }),
|
||||
}),
|
||||
|
||||
// Tracked telemetry
|
||||
toggleTrackedTelemetry: (publicKey: string) =>
|
||||
fetchJson<TrackedTelemetryResponse>('/settings/tracked-telemetry/toggle', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ public_key: publicKey }),
|
||||
}),
|
||||
|
||||
// Favorites
|
||||
toggleFavorite: (type: Favorite['type'], id: string) =>
|
||||
fetchJson<AppSettings>('/settings/favorites/toggle', {
|
||||
|
||||
@@ -79,6 +79,8 @@ interface ConversationPaneProps {
|
||||
onDismissUnreadMarker: () => void;
|
||||
onSendMessage: (text: string) => Promise<void>;
|
||||
onToggleNotifications: () => void;
|
||||
trackedTelemetryRepeaters: string[];
|
||||
onToggleTrackedTelemetry: (publicKey: string) => Promise<void>;
|
||||
}
|
||||
|
||||
function LoadingPane({ label }: { label: string }) {
|
||||
@@ -148,6 +150,8 @@ export function ConversationPane({
|
||||
onDismissUnreadMarker,
|
||||
onSendMessage,
|
||||
onToggleNotifications,
|
||||
trackedTelemetryRepeaters,
|
||||
onToggleTrackedTelemetry,
|
||||
}: ConversationPaneProps) {
|
||||
const [roomAuthenticated, setRoomAuthenticated] = useState(false);
|
||||
const activeContactIsRepeater = useMemo(() => {
|
||||
@@ -241,6 +245,8 @@ export function ConversationPane({
|
||||
onToggleFavorite={onToggleFavorite}
|
||||
onDeleteContact={onDeleteContact}
|
||||
onOpenContactInfo={onOpenContactInfo}
|
||||
trackedTelemetryRepeaters={trackedTelemetryRepeaters}
|
||||
onToggleTrackedTelemetry={onToggleTrackedTelemetry}
|
||||
/>
|
||||
</Suspense>
|
||||
);
|
||||
|
||||
@@ -54,6 +54,8 @@ interface RepeaterDashboardProps {
|
||||
onToggleFavorite: (type: 'channel' | 'contact', id: string) => void;
|
||||
onDeleteContact: (publicKey: string) => void;
|
||||
onOpenContactInfo?: (publicKey: string) => void;
|
||||
trackedTelemetryRepeaters: string[];
|
||||
onToggleTrackedTelemetry: (publicKey: string) => Promise<void>;
|
||||
}
|
||||
|
||||
export function RepeaterDashboard({
|
||||
@@ -72,6 +74,8 @@ export function RepeaterDashboard({
|
||||
onToggleFavorite,
|
||||
onDeleteContact,
|
||||
onOpenContactInfo,
|
||||
trackedTelemetryRepeaters,
|
||||
onToggleTrackedTelemetry,
|
||||
}: RepeaterDashboardProps) {
|
||||
const [pathDiscoveryOpen, setPathDiscoveryOpen] = useState(false);
|
||||
const contact = contacts.find((c) => c.public_key === conversation.id) ?? null;
|
||||
@@ -396,7 +400,13 @@ export function RepeaterDashboard({
|
||||
/>
|
||||
|
||||
{/* Telemetry history chart — full width, below console */}
|
||||
<TelemetryHistoryPane entries={telemetryHistory} />
|
||||
<TelemetryHistoryPane
|
||||
entries={telemetryHistory}
|
||||
publicKey={conversation.id}
|
||||
contacts={contacts}
|
||||
trackedTelemetryRepeaters={trackedTelemetryRepeaters}
|
||||
onToggleTrackedTelemetry={onToggleTrackedTelemetry}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -50,6 +50,8 @@ interface SettingsModalBaseProps {
|
||||
onToggleBlockedName?: (name: string) => void;
|
||||
contacts?: Contact[];
|
||||
onBulkDeleteContacts?: (deletedKeys: string[]) => void;
|
||||
trackedTelemetryRepeaters?: string[];
|
||||
onToggleTrackedTelemetry?: (publicKey: string) => Promise<void>;
|
||||
}
|
||||
|
||||
export type SettingsModalProps = SettingsModalBaseProps &
|
||||
@@ -85,6 +87,8 @@ export function SettingsModal(props: SettingsModalProps) {
|
||||
onToggleBlockedName,
|
||||
contacts,
|
||||
onBulkDeleteContacts,
|
||||
trackedTelemetryRepeaters,
|
||||
onToggleTrackedTelemetry,
|
||||
} = props;
|
||||
const externalSidebarNav = props.externalSidebarNav === true;
|
||||
const desktopSection = props.externalSidebarNav ? props.desktopSection : undefined;
|
||||
@@ -246,6 +250,8 @@ export function SettingsModal(props: SettingsModalProps) {
|
||||
onToggleBlockedName={onToggleBlockedName}
|
||||
contacts={contacts}
|
||||
onBulkDeleteContacts={onBulkDeleteContacts}
|
||||
trackedTelemetryRepeaters={trackedTelemetryRepeaters}
|
||||
onToggleTrackedTelemetry={onToggleTrackedTelemetry}
|
||||
className={sectionContentClass}
|
||||
/>
|
||||
) : (
|
||||
|
||||
@@ -9,7 +9,11 @@ import {
|
||||
ResponsiveContainer,
|
||||
} from 'recharts';
|
||||
import { cn } from '@/lib/utils';
|
||||
import type { TelemetryHistoryEntry } from '../../types';
|
||||
import { Button } from '../ui/button';
|
||||
import { Separator } from '../ui/separator';
|
||||
import type { TelemetryHistoryEntry, Contact } from '../../types';
|
||||
|
||||
const MAX_TRACKED = 8;
|
||||
|
||||
type Metric = 'battery_volts' | 'noise_floor_dbm' | 'packets' | 'uptime_seconds';
|
||||
|
||||
@@ -47,8 +51,26 @@ function formatUptime(seconds: number): string {
|
||||
return `${(seconds / 86400).toFixed(1)}d`;
|
||||
}
|
||||
|
||||
export function TelemetryHistoryPane({ entries }: { entries: TelemetryHistoryEntry[] }) {
|
||||
interface TelemetryHistoryPaneProps {
|
||||
entries: TelemetryHistoryEntry[];
|
||||
publicKey: string;
|
||||
contacts: Contact[];
|
||||
trackedTelemetryRepeaters: string[];
|
||||
onToggleTrackedTelemetry: (publicKey: string) => Promise<void>;
|
||||
}
|
||||
|
||||
export function TelemetryHistoryPane({
|
||||
entries,
|
||||
publicKey,
|
||||
contacts,
|
||||
trackedTelemetryRepeaters,
|
||||
onToggleTrackedTelemetry,
|
||||
}: TelemetryHistoryPaneProps) {
|
||||
const [metric, setMetric] = useState<Metric>('battery_volts');
|
||||
const [toggling, setToggling] = useState(false);
|
||||
|
||||
const isTracked = trackedTelemetryRepeaters.includes(publicKey);
|
||||
const slotsFull = trackedTelemetryRepeaters.length >= MAX_TRACKED && !isTracked;
|
||||
|
||||
const config = METRIC_CONFIG[metric];
|
||||
|
||||
@@ -68,13 +90,87 @@ export function TelemetryHistoryPane({ entries }: { entries: TelemetryHistoryEnt
|
||||
|
||||
const dataKeys = metric === 'packets' ? ['packets_received', 'packets_sent'] : [metric];
|
||||
|
||||
const handleToggle = async () => {
|
||||
setToggling(true);
|
||||
try {
|
||||
await onToggleTrackedTelemetry(publicKey);
|
||||
} finally {
|
||||
setToggling(false);
|
||||
}
|
||||
};
|
||||
|
||||
const trackedNames = useMemo(() => {
|
||||
if (!slotsFull) return [];
|
||||
return trackedTelemetryRepeaters.map((key) => {
|
||||
const contact = contacts.find((c) => c.public_key === key);
|
||||
return { key, name: contact?.name ?? key.slice(0, 12) };
|
||||
});
|
||||
}, [slotsFull, trackedTelemetryRepeaters, contacts]);
|
||||
|
||||
return (
|
||||
<div className="border border-border rounded-lg overflow-hidden">
|
||||
<div className="flex items-center justify-between px-3 py-2 bg-muted/50 border-b border-border">
|
||||
<h3 className="text-sm font-medium">Telemetry History</h3>
|
||||
<span className="text-[10px] text-muted-foreground">{entries.length} samples</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<h3 className="text-sm font-medium">Telemetry History</h3>
|
||||
{entries.length > 0 && (
|
||||
<span className="text-[10px] text-muted-foreground">{entries.length} samples</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="p-3">
|
||||
{/* Explanation + tracking toggle */}
|
||||
<div className="mb-3 space-y-3">
|
||||
<p className="text-xs text-muted-foreground leading-relaxed">
|
||||
Any time repeater telemetry is fetched, the metrics are stored for 30 days (or 1,000
|
||||
samples, whichever comes first). This telemetry is stored on normal interactive fetches
|
||||
via the repeater pane, API calls to the endpoint (
|
||||
<code className="text-[11px]">POST /api/contacts/<key>/repeater/status</code>), or
|
||||
when the repeater is opted into interval telemetry polling, in which case the repeater
|
||||
will be polled for metrics every 8 hours. You can see which repeaters are opted into
|
||||
this flow in the{' '}
|
||||
<a
|
||||
href="#settings/database"
|
||||
className="underline text-primary hover:text-primary/80 transition-colors"
|
||||
>
|
||||
Database & Messaging
|
||||
</a>{' '}
|
||||
settings pane. A maximum of {MAX_TRACKED} repeaters may be opted into this for the sake
|
||||
of keeping mesh congestion reasonable.
|
||||
</p>
|
||||
|
||||
{isTracked ? (
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={handleToggle}
|
||||
disabled={toggling}
|
||||
className="border-destructive/50 text-destructive hover:bg-destructive/10"
|
||||
>
|
||||
{toggling ? 'Updating...' : 'Remove Repeater from Interval Metrics Tracking'}
|
||||
</Button>
|
||||
) : slotsFull ? (
|
||||
<div className="space-y-2">
|
||||
<Button variant="outline" disabled>
|
||||
Tracking Full ({trackedTelemetryRepeaters.length}/{MAX_TRACKED} slots used)
|
||||
</Button>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Disable tracking on another repeater to free a slot:{' '}
|
||||
{trackedNames.map((t) => t.name).join(', ')}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={handleToggle}
|
||||
disabled={toggling}
|
||||
className="border-green-600/50 text-green-600 hover:bg-green-600/10"
|
||||
>
|
||||
{toggling ? 'Updating...' : 'Opt Repeater into 8hr Interval Metrics Tracking'}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Separator className="mb-3" />
|
||||
|
||||
{/* Metric selector */}
|
||||
<div className="flex gap-1 mb-2">
|
||||
{(Object.keys(METRIC_CONFIG) as Metric[]).map((m) => (
|
||||
@@ -149,10 +245,15 @@ export function TelemetryHistoryPane({ entries }: { entries: TelemetryHistoryEnt
|
||||
fill={metric === 'packets' ? (i === 0 ? '#0ea5e9' : '#f43f5e') : config.color}
|
||||
fillOpacity={0.15}
|
||||
strokeWidth={1.5}
|
||||
dot={false}
|
||||
activeDot={{
|
||||
dot={{
|
||||
r: 4,
|
||||
fill: metric === 'packets' ? (i === 0 ? '#0ea5e9' : '#f43f5e') : config.color,
|
||||
strokeWidth: 1.5,
|
||||
stroke: 'hsl(var(--popover))',
|
||||
}}
|
||||
activeDot={{
|
||||
r: 6,
|
||||
fill: metric === 'packets' ? (i === 0 ? '#0ea5e9' : '#f43f5e') : config.color,
|
||||
strokeWidth: 2,
|
||||
stroke: 'hsl(var(--popover))',
|
||||
}}
|
||||
|
||||
@@ -20,6 +20,8 @@ export function SettingsDatabaseSection({
|
||||
onToggleBlockedName,
|
||||
contacts = [],
|
||||
onBulkDeleteContacts,
|
||||
trackedTelemetryRepeaters = [],
|
||||
onToggleTrackedTelemetry,
|
||||
className,
|
||||
}: {
|
||||
appSettings: AppSettings;
|
||||
@@ -32,6 +34,8 @@ export function SettingsDatabaseSection({
|
||||
onToggleBlockedName?: (name: string) => void;
|
||||
contacts?: Contact[];
|
||||
onBulkDeleteContacts?: (deletedKeys: string[]) => void;
|
||||
trackedTelemetryRepeaters?: string[];
|
||||
onToggleTrackedTelemetry?: (publicKey: string) => Promise<void>;
|
||||
className?: string;
|
||||
}) {
|
||||
const [retentionDays, setRetentionDays] = useState('14');
|
||||
@@ -223,6 +227,50 @@ export function SettingsDatabaseSection({
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
{/* ── Tracked Repeater Telemetry ── */}
|
||||
<div className="space-y-3">
|
||||
<Label className="text-base">Tracked Repeater Telemetry</Label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Repeaters opted into automatic telemetry collection are polled every 8 hours. Up to 8
|
||||
repeaters may be tracked at a time ({trackedTelemetryRepeaters.length} / 8 slots used).
|
||||
</p>
|
||||
|
||||
{trackedTelemetryRepeaters.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground italic">
|
||||
No repeaters are being tracked. Enable tracking from a repeater's dashboard.
|
||||
</p>
|
||||
) : (
|
||||
<div className="space-y-1">
|
||||
{trackedTelemetryRepeaters.map((key) => {
|
||||
const contact = contacts.find((c) => c.public_key === key);
|
||||
const displayName = contact?.name ?? key.slice(0, 12);
|
||||
return (
|
||||
<div key={key} className="flex items-center justify-between gap-2">
|
||||
<div className="flex-1 min-w-0">
|
||||
<span className="text-sm truncate block">{displayName}</span>
|
||||
<span className="text-[10px] text-muted-foreground font-mono">
|
||||
{key.slice(0, 12)}
|
||||
</span>
|
||||
</div>
|
||||
{onToggleTrackedTelemetry && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => onToggleTrackedTelemetry(key)}
|
||||
className="h-7 text-xs flex-shrink-0 text-destructive hover:text-destructive"
|
||||
>
|
||||
Remove
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="text-sm text-destructive" role="alert">
|
||||
{error}
|
||||
|
||||
@@ -120,6 +120,39 @@ export function useAppSettings() {
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleToggleTrackedTelemetry = useCallback(async (publicKey: string) => {
|
||||
const key = publicKey.toLowerCase();
|
||||
setAppSettings((prev) => {
|
||||
if (!prev) return prev;
|
||||
const current = prev.tracked_telemetry_repeaters ?? [];
|
||||
const wasTracked = current.includes(key);
|
||||
const optimistic = wasTracked ? current.filter((k) => k !== key) : [...current, key];
|
||||
return { ...prev, tracked_telemetry_repeaters: optimistic };
|
||||
});
|
||||
|
||||
try {
|
||||
const result = await api.toggleTrackedTelemetry(publicKey);
|
||||
setAppSettings((prev) =>
|
||||
prev ? { ...prev, tracked_telemetry_repeaters: result.tracked_telemetry_repeaters } : prev
|
||||
);
|
||||
} catch (err) {
|
||||
console.error('Failed to toggle tracked telemetry:', err);
|
||||
try {
|
||||
const settings = await api.getSettings();
|
||||
setAppSettings(settings);
|
||||
} catch {
|
||||
// If refetch also fails, leave optimistic state
|
||||
}
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const detail = (err as any)?.body?.detail;
|
||||
if (typeof detail === 'object' && detail?.message) {
|
||||
toast.error(detail.message);
|
||||
} else {
|
||||
toast.error('Failed to update tracked telemetry');
|
||||
}
|
||||
}
|
||||
}, []);
|
||||
|
||||
// One-time migration of localStorage preferences to server
|
||||
useEffect(() => {
|
||||
if (!appSettings || hasMigratedRef.current) return;
|
||||
@@ -182,5 +215,6 @@ export function useAppSettings() {
|
||||
handleToggleFavorite,
|
||||
handleToggleBlockedKey,
|
||||
handleToggleBlockedName,
|
||||
handleToggleTrackedTelemetry,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -164,6 +164,8 @@ function createProps(overrides: Partial<React.ComponentProps<typeof Conversation
|
||||
onDismissUnreadMarker: vi.fn(),
|
||||
onSendMessage: vi.fn(async () => {}),
|
||||
onToggleNotifications: vi.fn(),
|
||||
trackedTelemetryRepeaters: [],
|
||||
onToggleTrackedTelemetry: vi.fn(async () => {}),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -124,6 +124,8 @@ const defaultProps = {
|
||||
onToggleNotifications: vi.fn(),
|
||||
onToggleFavorite: vi.fn(),
|
||||
onDeleteContact: vi.fn(),
|
||||
trackedTelemetryRepeaters: [] as string[],
|
||||
onToggleTrackedTelemetry: vi.fn(async () => {}),
|
||||
};
|
||||
|
||||
function createDeferred<T>() {
|
||||
@@ -677,7 +679,7 @@ describe('RepeaterDashboard', () => {
|
||||
render(<RepeaterDashboard {...defaultProps} />);
|
||||
|
||||
expect(screen.getByText('Telemetry History')).toBeInTheDocument();
|
||||
expect(screen.getByText('0 samples')).toBeInTheDocument();
|
||||
expect(screen.getByText(/No history yet/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('updates history from live status fetch', async () => {
|
||||
|
||||
@@ -69,6 +69,7 @@ const baseSettings: AppSettings = {
|
||||
blocked_keys: [],
|
||||
blocked_names: [],
|
||||
discovery_blocked_types: [],
|
||||
tracked_telemetry_repeaters: [],
|
||||
};
|
||||
|
||||
function renderModal(overrides?: {
|
||||
|
||||
@@ -325,6 +325,7 @@ export interface AppSettings {
|
||||
blocked_keys: string[];
|
||||
blocked_names: string[];
|
||||
discovery_blocked_types: number[];
|
||||
tracked_telemetry_repeaters: string[];
|
||||
}
|
||||
|
||||
export interface AppSettingsUpdate {
|
||||
@@ -337,6 +338,11 @@ export interface AppSettingsUpdate {
|
||||
discovery_blocked_types?: number[];
|
||||
}
|
||||
|
||||
export interface TrackedTelemetryResponse {
|
||||
tracked_telemetry_repeaters: string[];
|
||||
names: Record<string, string>;
|
||||
}
|
||||
|
||||
export interface MigratePreferencesRequest {
|
||||
favorites: Favorite[];
|
||||
sort_order: string;
|
||||
|
||||
@@ -1249,8 +1249,8 @@ class TestMigration039:
|
||||
|
||||
applied = await run_migrations(conn)
|
||||
|
||||
assert applied == 14
|
||||
assert await get_version(conn) == 52
|
||||
assert applied == 15
|
||||
assert await get_version(conn) == 53
|
||||
|
||||
cursor = await conn.execute(
|
||||
"""
|
||||
@@ -1321,8 +1321,8 @@ class TestMigration039:
|
||||
|
||||
applied = await run_migrations(conn)
|
||||
|
||||
assert applied == 14
|
||||
assert await get_version(conn) == 52
|
||||
assert applied == 15
|
||||
assert await get_version(conn) == 53
|
||||
|
||||
cursor = await conn.execute(
|
||||
"""
|
||||
@@ -1388,8 +1388,8 @@ class TestMigration039:
|
||||
|
||||
applied = await run_migrations(conn)
|
||||
|
||||
assert applied == 8
|
||||
assert await get_version(conn) == 52
|
||||
assert applied == 9
|
||||
assert await get_version(conn) == 53
|
||||
|
||||
cursor = await conn.execute(
|
||||
"""
|
||||
@@ -1441,8 +1441,8 @@ class TestMigration040:
|
||||
|
||||
applied = await run_migrations(conn)
|
||||
|
||||
assert applied == 13
|
||||
assert await get_version(conn) == 52
|
||||
assert applied == 14
|
||||
assert await get_version(conn) == 53
|
||||
|
||||
await conn.execute(
|
||||
"""
|
||||
@@ -1503,8 +1503,8 @@ class TestMigration041:
|
||||
|
||||
applied = await run_migrations(conn)
|
||||
|
||||
assert applied == 12
|
||||
assert await get_version(conn) == 52
|
||||
assert applied == 13
|
||||
assert await get_version(conn) == 53
|
||||
|
||||
await conn.execute(
|
||||
"""
|
||||
@@ -1556,8 +1556,8 @@ class TestMigration042:
|
||||
|
||||
applied = await run_migrations(conn)
|
||||
|
||||
assert applied == 11
|
||||
assert await get_version(conn) == 52
|
||||
assert applied == 12
|
||||
assert await get_version(conn) == 53
|
||||
|
||||
await conn.execute(
|
||||
"""
|
||||
@@ -1696,8 +1696,8 @@ class TestMigration046:
|
||||
|
||||
applied = await run_migrations(conn)
|
||||
|
||||
assert applied == 7
|
||||
assert await get_version(conn) == 52
|
||||
assert applied == 8
|
||||
assert await get_version(conn) == 53
|
||||
|
||||
cursor = await conn.execute(
|
||||
"""
|
||||
@@ -1790,8 +1790,8 @@ class TestMigration047:
|
||||
|
||||
applied = await run_migrations(conn)
|
||||
|
||||
assert applied == 6
|
||||
assert await get_version(conn) == 52
|
||||
assert applied == 7
|
||||
assert await get_version(conn) == 53
|
||||
|
||||
cursor = await conn.execute(
|
||||
"""
|
||||
|
||||
@@ -3,15 +3,18 @@
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
import pytest
|
||||
from fastapi import HTTPException
|
||||
|
||||
from app.models import AppSettings
|
||||
from app.repository import AppSettingsRepository
|
||||
from app.models import CONTACT_TYPE_REPEATER, AppSettings, ContactUpsert
|
||||
from app.repository import AppSettingsRepository, ContactRepository
|
||||
from app.routers.settings import (
|
||||
AppSettingsUpdate,
|
||||
FavoriteRequest,
|
||||
MigratePreferencesRequest,
|
||||
TrackedTelemetryRequest,
|
||||
migrate_preferences,
|
||||
toggle_favorite,
|
||||
toggle_tracked_telemetry,
|
||||
update_settings,
|
||||
)
|
||||
|
||||
@@ -202,3 +205,83 @@ class TestMigratePreferences:
|
||||
|
||||
assert response.migrated is False
|
||||
assert response.settings.preferences_migrated is True
|
||||
|
||||
|
||||
class TestToggleTrackedTelemetry:
|
||||
"""Tests for POST /settings/tracked-telemetry/toggle."""
|
||||
|
||||
async def _create_repeater(self, key: str, name: str = "TestRepeater") -> None:
|
||||
await ContactRepository.upsert(
|
||||
ContactUpsert(public_key=key, name=name, type=CONTACT_TYPE_REPEATER)
|
||||
)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_add_repeater_to_tracking(self, test_db):
|
||||
key = "aa" * 32
|
||||
await self._create_repeater(key)
|
||||
|
||||
result = await toggle_tracked_telemetry(TrackedTelemetryRequest(public_key=key))
|
||||
|
||||
assert key in result.tracked_telemetry_repeaters
|
||||
assert result.names[key] == "TestRepeater"
|
||||
|
||||
# Verify persisted
|
||||
settings = await AppSettingsRepository.get()
|
||||
assert key in settings.tracked_telemetry_repeaters
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_remove_repeater_from_tracking(self, test_db):
|
||||
key = "bb" * 32
|
||||
await self._create_repeater(key)
|
||||
await AppSettingsRepository.update(tracked_telemetry_repeaters=[key])
|
||||
|
||||
result = await toggle_tracked_telemetry(TrackedTelemetryRequest(public_key=key))
|
||||
|
||||
assert key not in result.tracked_telemetry_repeaters
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_rejects_non_repeater_contact(self, test_db):
|
||||
key = "cc" * 32
|
||||
await ContactRepository.upsert(ContactUpsert(public_key=key, name="Client", type=1))
|
||||
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
await toggle_tracked_telemetry(TrackedTelemetryRequest(public_key=key))
|
||||
assert exc_info.value.status_code == 400
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_rejects_unknown_contact(self, test_db):
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
await toggle_tracked_telemetry(TrackedTelemetryRequest(public_key="dd" * 32))
|
||||
assert exc_info.value.status_code == 404
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_rejects_when_limit_reached(self, test_db):
|
||||
existing_keys = []
|
||||
for i in range(8):
|
||||
key = f"{i:02x}" * 32
|
||||
await self._create_repeater(key, name=f"Repeater{i}")
|
||||
existing_keys.append(key)
|
||||
await AppSettingsRepository.update(tracked_telemetry_repeaters=existing_keys)
|
||||
|
||||
new_key = "ff" * 32
|
||||
await self._create_repeater(new_key, name="NewRepeater")
|
||||
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
await toggle_tracked_telemetry(TrackedTelemetryRequest(public_key=new_key))
|
||||
assert exc_info.value.status_code == 409
|
||||
detail = exc_info.value.detail
|
||||
assert len(detail["tracked_telemetry_repeaters"]) == 8
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_remove_still_works_when_limit_reached(self, test_db):
|
||||
"""Toggling OFF an already-tracked repeater should work even at max capacity."""
|
||||
keys = []
|
||||
for i in range(8):
|
||||
key = f"{i:02x}" * 32
|
||||
await self._create_repeater(key)
|
||||
keys.append(key)
|
||||
await AppSettingsRepository.update(tracked_telemetry_repeaters=keys)
|
||||
|
||||
result = await toggle_tracked_telemetry(TrackedTelemetryRequest(public_key=keys[0]))
|
||||
assert keys[0] not in result.tracked_telemetry_repeaters
|
||||
assert len(result.tracked_telemetry_repeaters) == 7
|
||||
|
||||
Reference in New Issue
Block a user