Skip to content
This repository was archived by the owner on Dec 5, 2025. It is now read-only.
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions src/meshcore_api/api/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -210,6 +210,10 @@ def create_app(
"name": "trace-paths",
"description": "Network trace path results",
},
{
"name": "signal-strength",
"description": "Signal strength measurements between nodes",
},
{
"name": "commands",
"description": "Send commands to the mesh network",
Expand Down Expand Up @@ -309,6 +313,7 @@ async def shutdown_event():
health,
messages,
nodes,
signal_strength,
tags,
telemetry,
trace_paths,
Expand All @@ -321,6 +326,7 @@ async def shutdown_event():
app.include_router(advertisements.router, prefix="/api/v1", tags=["advertisements"])
app.include_router(telemetry.router, prefix="/api/v1", tags=["telemetry"])
app.include_router(trace_paths.router, prefix="/api/v1", tags=["trace-paths"])
app.include_router(signal_strength.router, prefix="/api/v1", tags=["signal-strength"])
app.include_router(commands.router, prefix="/api/v1", tags=["commands"])

# =========================================================================
Expand Down
114 changes: 114 additions & 0 deletions src/meshcore_api/api/routes/signal_strength.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
"""Signal strength querying endpoints."""

from datetime import datetime
from typing import Optional

from fastapi import APIRouter, Depends, HTTPException, Query, status
from sqlalchemy import desc
from sqlalchemy.orm import Session

from ...database.models import SignalStrength
from ...utils.address import normalize_public_key, validate_public_key
from ..dependencies import get_db
from ..schemas import SignalStrengthListResponse

router = APIRouter()


@router.get(
"/signal-strength",
response_model=SignalStrengthListResponse,
summary="Query signal strength measurements",
description=(
"Get signal strength measurements between nodes with optional filters. "
"All public keys must be full 64 hex characters."
),
)
async def query_signal_strength(
source_public_key: Optional[str] = Query(
None,
min_length=64,
max_length=64,
description="Filter by source node public key (full 64 hex characters)",
),
destination_public_key: Optional[str] = Query(
None,
min_length=64,
max_length=64,
description="Filter by destination node public key (full 64 hex characters)",
),
start_date: Optional[datetime] = Query(
None, description="Filter signal strength records after this date (ISO 8601)"
),
end_date: Optional[datetime] = Query(
None, description="Filter signal strength records before this date (ISO 8601)"
),
limit: int = Query(100, ge=1, le=1000, description="Maximum number of records to return"),
offset: int = Query(0, ge=0, description="Number of records to skip"),
db: Session = Depends(get_db),
) -> SignalStrengthListResponse:
"""
Query signal strength measurements with filters.

Args:
source_public_key: Filter by source node public key (must be exactly 64 hex characters)
destination_public_key: Filter by destination node public key (exactly 64 hex characters)
start_date: Only include records after this timestamp
end_date: Only include records before this timestamp
limit: Maximum number of records to return (1-1000)
offset: Number of records to skip for pagination
db: Database session

Returns:
Paginated list of signal strength records matching the filters
"""
# Start with base query
query = db.query(SignalStrength)

# Apply source_public_key filter
if source_public_key:
try:
normalized_key = normalize_public_key(source_public_key)
if not validate_public_key(normalized_key, allow_prefix=False):
raise ValueError("Invalid public key length")
except (ValueError, TypeError):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="source_public_key must be exactly 64 hexadecimal characters",
)
query = query.filter(SignalStrength.source_public_key == normalized_key)

# Apply destination_public_key filter
if destination_public_key:
try:
normalized_key = normalize_public_key(destination_public_key)
if not validate_public_key(normalized_key, allow_prefix=False):
raise ValueError("Invalid public key length")
except (ValueError, TypeError):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="destination_public_key must be exactly 64 hexadecimal characters",
)
query = query.filter(SignalStrength.destination_public_key == normalized_key)

# Apply date filters
if start_date:
query = query.filter(SignalStrength.recorded_at >= start_date)
if end_date:
query = query.filter(SignalStrength.recorded_at <= end_date)

# Order by recorded_at (newest first)
query = query.order_by(desc(SignalStrength.recorded_at))

# Get total count before pagination
total = query.count()

# Apply pagination
signal_strengths = query.limit(limit).offset(offset).all()

return SignalStrengthListResponse(
signal_strengths=[s.__dict__ for s in signal_strengths],
total=total,
limit=limit,
offset=offset,
)
51 changes: 51 additions & 0 deletions src/meshcore_api/api/schemas.py
Original file line number Diff line number Diff line change
Expand Up @@ -226,6 +226,57 @@ class TelemetryFilters(BaseModel):
end_date: Optional[datetime] = Field(None, description="Filter telemetry before this date")


# ============================================================================
# Signal Strength Schemas
# ============================================================================


class SignalStrengthResponse(BaseModel):
"""Response model for a signal strength measurement."""

id: int
source_public_key: str
destination_public_key: str
snr: float
trace_path_id: Optional[int] = None
recorded_at: datetime

class Config:
from_attributes = True


class SignalStrengthListResponse(BaseModel):
"""Response model for signal strength list."""

signal_strengths: List[SignalStrengthResponse]
total: int
limit: int
offset: int


class SignalStrengthFilters(BaseModel):
"""Query filters for signal strength measurements."""

source_public_key: Optional[str] = Field(
None,
min_length=64,
max_length=64,
description="Filter by source node public key (full 64 hex characters)",
)
destination_public_key: Optional[str] = Field(
None,
min_length=64,
max_length=64,
description="Filter by destination node public key (full 64 hex characters)",
)
start_date: Optional[datetime] = Field(
None, description="Filter signal strength records after this date"
)
end_date: Optional[datetime] = Field(
None, description="Filter signal strength records before this date"
)

Comment on lines +234 to +278
Copy link

Copilot AI Dec 1, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The new SignalStrengthResponse, SignalStrengthListResponse, and SignalStrengthFilters schemas lack test coverage. Based on the existing test patterns in tests/unit/test_api_schemas.py, these schemas should have corresponding unit tests similar to TestTelemetryFilters, TestTracePathFilters, etc. to validate field constraints (e.g., min_length/max_length for public keys) and proper instantiation.

Copilot uses AI. Check for mistakes.

# ============================================================================
# Command Request Schemas
# ============================================================================
Expand Down
18 changes: 18 additions & 0 deletions src/meshcore_api/database/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -140,6 +140,24 @@ class Telemetry(Base):
received_at: Mapped[datetime] = mapped_column(DateTime, default=func.now(), index=True)


class SignalStrength(Base):
"""Represents signal strength measurement between two nodes."""

__tablename__ = "signal_strength"

id: Mapped[int] = mapped_column(Integer, primary_key=True)
source_public_key: Mapped[str] = mapped_column(String(64), nullable=False, index=True)
destination_public_key: Mapped[str] = mapped_column(String(64), nullable=False, index=True)
snr: Mapped[float] = mapped_column(Float, nullable=False)
trace_path_id: Mapped[Optional[int]] = mapped_column(Integer, index=True) # Reference to trace
recorded_at: Mapped[datetime] = mapped_column(DateTime, default=func.now(), index=True)

__table_args__ = (
Index("idx_signal_strength_source_dest", "source_public_key", "destination_public_key"),
Index("idx_signal_strength_recorded", "recorded_at"),
Comment on lines +153 to +157
Copy link

Copilot AI Dec 1, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The recorded_at field has index=True on line 153 and is also explicitly indexed in __table_args__ on line 157. This creates a duplicate index. Remove either the index=True parameter from the mapped_column or the explicit Index definition in __table_args__.

Copilot uses AI. Check for mistakes.
)


class EventLog(Base):
"""Raw event log for all MeshCore events."""

Expand Down
111 changes: 111 additions & 0 deletions src/meshcore_api/subscriber/event_handler.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
EventLog,
Message,
Node,
SignalStrength,
Telemetry,
TracePath,
)
Expand Down Expand Up @@ -388,6 +389,50 @@ async def _handle_channel_message(self, event: Event) -> None:
)
session.add(message)

def _resolve_prefix_to_full_key(self, session, prefix: str) -> Optional[str]:
"""
Resolve a 2-character public key prefix to a full 64-character public key.

Uses existing database APIs to find matching nodes. If multiple nodes match,
returns the one with the most recent last_seen timestamp.

Args:
session: Database session
prefix: 2-character public key prefix (lowercase)

Returns:
Full 64-character public key if found, None otherwise
"""
if not prefix or len(prefix) < 2:
return None

# Find all nodes matching this prefix
nodes = Node.find_by_prefix(session, prefix)

if not nodes:
logger.debug(f"No nodes found for prefix '{prefix}'")
return None

if len(nodes) == 1:
return nodes[0].public_key

# Multiple matches - use the one with the most recent last_seen
nodes_with_last_seen = [n for n in nodes if n.last_seen is not None]
if nodes_with_last_seen:
most_recent = max(nodes_with_last_seen, key=lambda n: n.last_seen)
logger.debug(
f"Multiple nodes ({len(nodes)}) match prefix '{prefix}', "
f"using most recent: {most_recent.public_key[:8]}..."
)
return most_recent.public_key

# No nodes have last_seen set, use the first one (by creation order)
logger.debug(
f"Multiple nodes ({len(nodes)}) match prefix '{prefix}' with no last_seen, "
f"using first: {nodes[0].public_key[:8]}..."
)
return nodes[0].public_key

async def _handle_trace_data(self, event: Event) -> None:
"""Handle TRACE_DATA event."""
payload = event.payload
Expand Down Expand Up @@ -434,6 +479,72 @@ async def _handle_trace_data(self, event: Event) -> None:
hop_count=hop_count,
)
session.add(trace)
session.flush() # Get the trace ID

# Create SignalStrength records for consecutive node pairs
if path_hashes and snr_values and len(path_hashes) >= 2:
self._create_signal_strength_records(session, trace.id, path_hashes, snr_values)

def _create_signal_strength_records(
self,
session,
trace_path_id: int,
path_hashes: List[str],
snr_values: List[float],
) -> None:
"""
Create SignalStrength records for consecutive node pairs in a trace path.

The SNR at index i represents the signal received by node path_hashes[i]
from the previous node. For i > 0, we can create records where:
- source = path_hashes[i-1]
- destination = path_hashes[i]
- snr = snr_values[i]

Args:
session: Database session
trace_path_id: ID of the trace path for reference
path_hashes: List of 2-char node prefixes in path order
snr_values: List of SNR values corresponding to each hop
"""
created_count = 0

# Create records for consecutive pairs (starting from index 1)
# snr_values[i] is the signal from path_hashes[i-1] to path_hashes[i]
for i in range(1, min(len(path_hashes), len(snr_values))):
source_prefix = path_hashes[i - 1]
dest_prefix = path_hashes[i]
snr = snr_values[i]

if source_prefix is None or dest_prefix is None or snr is None:
logger.debug(f"Skipping hop {i}: missing data")
continue

# Resolve prefixes to full public keys
source_key = self._resolve_prefix_to_full_key(session, source_prefix)
dest_key = self._resolve_prefix_to_full_key(session, dest_prefix)

if source_key is None:
logger.debug(f"Could not resolve source prefix '{source_prefix}' for hop {i}")
continue
if dest_key is None:
logger.debug(f"Could not resolve dest prefix '{dest_prefix}' for hop {i}")
continue

# Create SignalStrength record
signal_strength = SignalStrength(
source_public_key=source_key,
destination_public_key=dest_key,
snr=float(snr),
trace_path_id=trace_path_id,
)
session.add(signal_strength)
created_count += 1

if created_count > 0:
logger.debug(
f"Created {created_count} SignalStrength records for trace path {trace_path_id}"
)

async def _handle_telemetry(self, event: Event) -> None:
"""Handle TELEMETRY_RESPONSE event."""
Expand Down
Loading