mirror of
https://github.com/jkingsman/Remote-Terminal-for-MeshCore.git
synced 2026-08-07 09:13:04 +02:00
Initial tracke telemetry for contacts
This commit is contained in:
@@ -14,11 +14,14 @@ from app.models import (
|
||||
ContactAdvertPathSummary,
|
||||
ContactAnalytics,
|
||||
ContactRoutingOverrideRequest,
|
||||
ContactTelemetryResponse,
|
||||
ContactUpsert,
|
||||
CreateContactRequest,
|
||||
LppSensor,
|
||||
NearestRepeater,
|
||||
PathDiscoveryResponse,
|
||||
PathDiscoveryRoute,
|
||||
TelemetryHistoryEntry,
|
||||
TraceResponse,
|
||||
)
|
||||
from app.packet_processor import start_historical_dm_decryption
|
||||
@@ -613,3 +616,71 @@ async def set_contact_routing_override(
|
||||
await _broadcast_contact_update(updated_contact)
|
||||
|
||||
return {"status": "ok", "public_key": contact.public_key}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# On-demand contact telemetry (CayenneLPP)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@router.post("/{public_key}/telemetry", response_model=ContactTelemetryResponse)
|
||||
async def request_contact_telemetry(public_key: str) -> ContactTelemetryResponse:
|
||||
"""Fetch CayenneLPP telemetry from any contact (single attempt, 10s timeout).
|
||||
|
||||
Persists the result in contact_telemetry_history and returns the latest
|
||||
sensor readings along with recent telemetry history.
|
||||
"""
|
||||
from app.repository.contact_telemetry import ContactTelemetryRepository
|
||||
|
||||
radio_manager.require_connected()
|
||||
contact = await _resolve_contact_or_404(public_key)
|
||||
|
||||
async with radio_manager.radio_operation(
|
||||
"contact_telemetry", pause_polling=True, suspend_auto_fetch=True
|
||||
) as mc:
|
||||
await _ensure_on_radio(mc, contact)
|
||||
telemetry = await mc.commands.req_telemetry_sync(
|
||||
contact.public_key, timeout=10, min_timeout=5
|
||||
)
|
||||
|
||||
if telemetry is None:
|
||||
raise HTTPException(status_code=504, detail="No telemetry response from contact")
|
||||
|
||||
sensors: list[LppSensor] = []
|
||||
for entry in telemetry:
|
||||
channel = entry.get("channel", 0)
|
||||
type_name = str(entry.get("type", "unknown"))
|
||||
value = entry.get("value", 0)
|
||||
sensors.append(LppSensor(channel=channel, type_name=type_name, value=value))
|
||||
|
||||
fetched_at = int(time.time())
|
||||
|
||||
# Persist snapshot
|
||||
data = {"lpp_sensors": [s.model_dump() for s in sensors]}
|
||||
await ContactTelemetryRepository.record(
|
||||
public_key=contact.public_key,
|
||||
timestamp=fetched_at,
|
||||
data=data,
|
||||
)
|
||||
|
||||
# Fetch recent history (30 days)
|
||||
since = fetched_at - 30 * 86400
|
||||
rows = await ContactTelemetryRepository.get_history(contact.public_key, since)
|
||||
history = [TelemetryHistoryEntry(**row) for row in rows]
|
||||
|
||||
return ContactTelemetryResponse(
|
||||
sensors=sensors,
|
||||
fetched_at=fetched_at,
|
||||
telemetry_history=history,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/{public_key}/telemetry-history", response_model=list[TelemetryHistoryEntry])
|
||||
async def get_contact_telemetry_history(public_key: str) -> list[TelemetryHistoryEntry]:
|
||||
"""Get stored telemetry history for a contact (read-only, no radio access)."""
|
||||
from app.repository.contact_telemetry import ContactTelemetryRepository
|
||||
|
||||
contact = await _resolve_contact_or_404(public_key)
|
||||
since = int(time.time()) - 30 * 86400
|
||||
rows = await ContactTelemetryRepository.get_history(contact.public_key, since)
|
||||
return [TelemetryHistoryEntry(**row) for row in rows]
|
||||
|
||||
+113
-3
@@ -20,6 +20,7 @@ logger = logging.getLogger(__name__)
|
||||
router = APIRouter(prefix="/settings", tags=["settings"])
|
||||
|
||||
MAX_TRACKED_TELEMETRY_REPEATERS = 8
|
||||
MAX_TRACKED_TELEMETRY_CONTACTS = 8
|
||||
|
||||
|
||||
class AppSettingsUpdate(BaseModel):
|
||||
@@ -350,6 +351,8 @@ async def toggle_tracked_telemetry(request: TrackedTelemetryRequest) -> TrackedT
|
||||
names[k] = contact.name if contact and contact.name else k[:12]
|
||||
return names
|
||||
|
||||
n_contacts = len(settings.tracked_telemetry_contacts)
|
||||
|
||||
if key in current:
|
||||
# Remove
|
||||
new_list = [k for k in current if k != key]
|
||||
@@ -359,7 +362,7 @@ async def toggle_tracked_telemetry(request: TrackedTelemetryRequest) -> TrackedT
|
||||
tracked_telemetry_repeaters=new_list,
|
||||
names=await _resolve_names(new_list),
|
||||
schedule=_build_schedule(
|
||||
len(new_list),
|
||||
len(new_list) + n_contacts,
|
||||
settings.telemetry_interval_hours,
|
||||
settings.telemetry_routed_hourly,
|
||||
),
|
||||
@@ -390,7 +393,7 @@ async def toggle_tracked_telemetry(request: TrackedTelemetryRequest) -> TrackedT
|
||||
tracked_telemetry_repeaters=new_list,
|
||||
names=await _resolve_names(new_list),
|
||||
schedule=_build_schedule(
|
||||
len(new_list),
|
||||
len(new_list) + n_contacts,
|
||||
settings.telemetry_interval_hours,
|
||||
settings.telemetry_routed_hourly,
|
||||
),
|
||||
@@ -404,10 +407,117 @@ async def get_telemetry_schedule() -> TelemetrySchedule:
|
||||
The UI uses this to render the interval dropdown (legal options),
|
||||
surface saved-vs-effective when they differ, and show the next-run-at
|
||||
timestamp so users know when the next cycle will fire.
|
||||
|
||||
The tracked count includes both repeaters and contacts for ceiling
|
||||
enforcement.
|
||||
"""
|
||||
app_settings = await AppSettingsRepository.get()
|
||||
combined_count = len(app_settings.tracked_telemetry_repeaters) + len(
|
||||
app_settings.tracked_telemetry_contacts
|
||||
)
|
||||
return _build_schedule(
|
||||
len(app_settings.tracked_telemetry_repeaters),
|
||||
combined_count,
|
||||
app_settings.telemetry_interval_hours,
|
||||
app_settings.telemetry_routed_hourly,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tracked contact telemetry (non-repeater LPP telemetry collection)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TrackedTelemetryContactsResponse(BaseModel):
|
||||
tracked_telemetry_contacts: list[str] = Field(
|
||||
description="Current list of tracked contact public keys"
|
||||
)
|
||||
names: dict[str, str] = Field(
|
||||
description="Map of public key to display name for tracked contacts"
|
||||
)
|
||||
schedule: TelemetrySchedule = Field(description="Current scheduling state")
|
||||
|
||||
|
||||
@router.post("/tracked-telemetry-contacts/toggle", response_model=TrackedTelemetryContactsResponse)
|
||||
async def toggle_tracked_telemetry_contact(
|
||||
request: TrackedTelemetryRequest,
|
||||
) -> TrackedTelemetryContactsResponse:
|
||||
"""Toggle periodic LPP telemetry collection for any contact.
|
||||
|
||||
Max 8 contacts may be tracked. The daily check ceiling is shared with
|
||||
tracked repeaters.
|
||||
"""
|
||||
key = request.public_key.lower()
|
||||
settings = await AppSettingsRepository.get()
|
||||
current = settings.tracked_telemetry_contacts
|
||||
|
||||
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
|
||||
|
||||
def combined_count(lst: list[str]) -> int:
|
||||
return len(settings.tracked_telemetry_repeaters) + len(lst)
|
||||
|
||||
if key in current:
|
||||
# Remove
|
||||
new_list = [k for k in current if k != key]
|
||||
logger.info("Removing contact %s from tracked telemetry", key[:12])
|
||||
await AppSettingsRepository.update(tracked_telemetry_contacts=new_list)
|
||||
return TrackedTelemetryContactsResponse(
|
||||
tracked_telemetry_contacts=new_list,
|
||||
names=await _resolve_names(new_list),
|
||||
schedule=_build_schedule(
|
||||
combined_count(new_list),
|
||||
settings.telemetry_interval_hours,
|
||||
settings.telemetry_routed_hourly,
|
||||
),
|
||||
)
|
||||
|
||||
# Validate contact exists
|
||||
contact = await ContactRepository.get_by_key(key)
|
||||
if not contact:
|
||||
raise HTTPException(status_code=404, detail="Contact not found")
|
||||
|
||||
if len(current) >= MAX_TRACKED_TELEMETRY_CONTACTS:
|
||||
names = await _resolve_names(current)
|
||||
raise HTTPException(
|
||||
status_code=409,
|
||||
detail={
|
||||
"message": f"Limit of {MAX_TRACKED_TELEMETRY_CONTACTS} tracked contacts reached",
|
||||
"tracked_telemetry_contacts": current,
|
||||
"names": names,
|
||||
},
|
||||
)
|
||||
|
||||
new_list = current + [key]
|
||||
logger.info("Adding contact %s to tracked telemetry", key[:12])
|
||||
await AppSettingsRepository.update(tracked_telemetry_contacts=new_list)
|
||||
return TrackedTelemetryContactsResponse(
|
||||
tracked_telemetry_contacts=new_list,
|
||||
names=await _resolve_names(new_list),
|
||||
schedule=_build_schedule(
|
||||
combined_count(new_list),
|
||||
settings.telemetry_interval_hours,
|
||||
settings.telemetry_routed_hourly,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@router.get("/tracked-telemetry-contacts/schedule", response_model=TelemetrySchedule)
|
||||
async def get_contact_telemetry_schedule() -> TelemetrySchedule:
|
||||
"""Return the current telemetry scheduling derivation for contacts.
|
||||
|
||||
Uses the combined tracked count (repeaters + contacts) for ceiling
|
||||
enforcement since they share one collection loop.
|
||||
"""
|
||||
app_settings = await AppSettingsRepository.get()
|
||||
combined_count = len(app_settings.tracked_telemetry_repeaters) + len(
|
||||
app_settings.tracked_telemetry_contacts
|
||||
)
|
||||
return _build_schedule(
|
||||
combined_count,
|
||||
app_settings.telemetry_interval_hours,
|
||||
app_settings.telemetry_routed_hourly,
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user