Docs, dead code, and schema updates

This commit is contained in:
Jack Kingsman
2026-04-02 19:03:02 -07:00
parent c7d5d3887d
commit 975bf7f03f
11 changed files with 56 additions and 440 deletions

View File

@@ -190,6 +190,7 @@ app/
- `GET /contacts/analytics` — unified keyed-or-name analytics payload
- `GET /contacts/repeaters/advert-paths` — recent advert paths for all contacts
- `POST /contacts`
- `POST /contacts/bulk-delete`
- `DELETE /contacts/{public_key}`
- `POST /contacts/{public_key}/mark-read`
- `POST /contacts/{public_key}/command`
@@ -214,6 +215,7 @@ app/
- `GET /channels`
- `GET /channels/{key}/detail`
- `POST /channels`
- `POST /channels/bulk-hashtag`
- `DELETE /channels/{key}`
- `POST /channels/{key}/flood-scope-override`
- `POST /channels/{key}/mark-read`
@@ -306,7 +308,7 @@ Repository writes should prefer typed models such as `ContactUpsert` over ad hoc
- `advert_interval`
- `last_advert_time`
- `flood_scope`
- `blocked_keys`, `blocked_names`
- `blocked_keys`, `blocked_names`, `discovery_blocked_types`
Note: `sidebar_sort_order` remains in the backend model for compatibility and migration, but the current frontend sidebar uses per-section localStorage sort preferences instead of a single shared server-backed sort mode.

View File

@@ -46,7 +46,7 @@ CREATE TABLE IF NOT EXISTS messages (
text TEXT NOT NULL,
sender_timestamp INTEGER,
received_at INTEGER NOT NULL,
path TEXT,
paths TEXT,
txt_type INTEGER DEFAULT 0,
signature TEXT,
outgoing INTEGER DEFAULT 0,
@@ -91,23 +91,66 @@ CREATE TABLE IF NOT EXISTS contact_name_history (
FOREIGN KEY (public_key) REFERENCES contacts(public_key) ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS app_settings (
id INTEGER PRIMARY KEY CHECK (id = 1),
max_radio_contacts INTEGER DEFAULT 200,
favorites TEXT DEFAULT '[]',
auto_decrypt_dm_on_advert INTEGER DEFAULT 1,
sidebar_sort_order TEXT DEFAULT 'recent',
last_message_times TEXT DEFAULT '{}',
preferences_migrated INTEGER DEFAULT 0,
advert_interval INTEGER DEFAULT 0,
last_advert_time INTEGER DEFAULT 0,
flood_scope TEXT DEFAULT '',
blocked_keys TEXT DEFAULT '[]',
blocked_names TEXT DEFAULT '[]',
discovery_blocked_types TEXT DEFAULT '[]'
);
INSERT OR IGNORE INTO app_settings (id) VALUES (1);
CREATE TABLE IF NOT EXISTS fanout_configs (
id TEXT PRIMARY KEY,
type TEXT NOT NULL,
name TEXT NOT NULL,
enabled INTEGER DEFAULT 0,
config TEXT NOT NULL DEFAULT '{}',
scope TEXT NOT NULL DEFAULT '{}',
sort_order INTEGER DEFAULT 0,
created_at INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS repeater_telemetry_history (
id INTEGER PRIMARY KEY AUTOINCREMENT,
public_key TEXT NOT NULL,
timestamp INTEGER NOT NULL,
data TEXT NOT NULL,
FOREIGN KEY (public_key) REFERENCES contacts(public_key) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_messages_received ON messages(received_at);
CREATE UNIQUE INDEX IF NOT EXISTS idx_messages_dedup_null_safe
ON messages(type, conversation_key, text, COALESCE(sender_timestamp, 0))
WHERE type = 'CHAN';
CREATE UNIQUE INDEX IF NOT EXISTS idx_messages_incoming_priv_dedup
ON messages(type, conversation_key, text, COALESCE(sender_timestamp, 0))
WHERE type = 'PRIV' AND outgoing = 0;
CREATE INDEX IF NOT EXISTS idx_messages_sender_key ON messages(sender_key);
CREATE INDEX IF NOT EXISTS idx_messages_pagination
ON messages(type, conversation_key, received_at DESC, id DESC);
CREATE INDEX IF NOT EXISTS idx_messages_unread_covering
ON messages(type, conversation_key, outgoing, received_at);
CREATE INDEX IF NOT EXISTS idx_raw_packets_message_id ON raw_packets(message_id);
CREATE INDEX IF NOT EXISTS idx_raw_packets_timestamp ON raw_packets(timestamp);
CREATE UNIQUE INDEX IF NOT EXISTS idx_raw_packets_payload_hash ON raw_packets(payload_hash);
CREATE INDEX IF NOT EXISTS idx_contacts_on_radio ON contacts(on_radio);
CREATE INDEX IF NOT EXISTS idx_contacts_type_last_seen ON contacts(type, last_seen);
CREATE INDEX IF NOT EXISTS idx_messages_type_received_conversation
ON messages(type, received_at, conversation_key);
-- idx_messages_sender_key is created by migration 25 (after adding the sender_key column)
-- idx_messages_incoming_priv_dedup is created by migration 44 after legacy rows are reconciled
CREATE INDEX IF NOT EXISTS idx_contact_advert_paths_recent
ON contact_advert_paths(public_key, last_seen DESC);
CREATE INDEX IF NOT EXISTS idx_contact_name_history_key
ON contact_name_history(public_key, last_seen DESC);
CREATE INDEX IF NOT EXISTS idx_repeater_telemetry_pk_ts
ON repeater_telemetry_history(public_key, timestamp);
"""

View File

@@ -202,7 +202,6 @@ async def on_path_update(event: "Event") -> None:
# Legacy firmware/library payloads only support 1-byte hop hashes.
normalized_path_hash_mode = -1 if normalized_path_len == -1 else 0
else:
normalized_path_hash_mode = None
try:
normalized_path_hash_mode = int(path_hash_mode)
except (TypeError, ValueError):

View File

@@ -80,14 +80,6 @@ _PAYLOAD_ADAPTERS: dict[WsEventType, TypeAdapter[Any]] = {
}
def validate_ws_event_payload(event_type: str, data: Any) -> WsEventPayload | Any:
"""Validate known WebSocket payloads; pass unknown events through unchanged."""
adapter = _PAYLOAD_ADAPTERS.get(event_type) # type: ignore[arg-type]
if adapter is None:
return data
return adapter.validate_python(data)
def dump_ws_event(event_type: str, data: Any) -> str:
"""Serialize a WebSocket event envelope with validation for known event types."""
adapter = _PAYLOAD_ADAPTERS.get(event_type) # type: ignore[arg-type]
@@ -104,13 +96,3 @@ def dump_ws_event(event_type: str, data: Any) -> str:
event_type,
)
return json.dumps({"type": event_type, "data": data})
def dump_ws_event_payload(event_type: str, data: Any) -> Any:
"""Return the JSON-serializable payload for a WebSocket event."""
adapter = _PAYLOAD_ADAPTERS.get(event_type) # type: ignore[arg-type]
if adapter is None:
return data
validated = adapter.validate_python(data)
return adapter.dump_python(validated, mode="json")

View File

@@ -144,11 +144,8 @@ class MapUploadModule(FanoutModule):
if advert is None:
return
# TODO: advert Ed25519 signature verification is skipped here.
# The radio has already validated the packet before passing it to RT,
# so re-verification is redundant in practice. If added, verify that
# nacl.bindings.crypto_sign_open(sig + (pubkey_bytes || timestamp_bytes),
# advert.public_key_bytes) succeeds before proceeding.
# Advert Ed25519 signature verification is intentionally skipped.
# The radio validates packets before passing them to RT.
# Only process repeaters (2) and rooms (3) — any other role is rejected
if advert.device_role not in _ALLOWED_DEVICE_ROLES:

View File

@@ -283,30 +283,6 @@ class NearestRepeater(BaseModel):
heard_count: int
class ContactDetail(BaseModel):
"""Comprehensive contact profile data."""
contact: Contact
name_history: list[ContactNameHistory] = Field(default_factory=list)
dm_message_count: int = 0
channel_message_count: int = 0
most_active_rooms: list[ContactActiveRoom] = Field(default_factory=list)
advert_paths: list[ContactAdvertPath] = Field(default_factory=list)
advert_frequency: float | None = Field(
default=None,
description="Advert observations per hour (includes multi-path arrivals of same advert)",
)
nearest_repeaters: list[NearestRepeater] = Field(default_factory=list)
class NameOnlyContactDetail(BaseModel):
"""Channel activity summary for a sender name that is not tied to a known key."""
name: str
channel_message_count: int = 0
most_active_rooms: list[ContactActiveRoom] = Field(default_factory=list)
class ContactAnalyticsHourlyBucket(BaseModel):
"""A single hourly activity bucket for contact analytics."""

View File

@@ -253,70 +253,6 @@ async def should_run_full_periodic_sync(mc: MeshCore) -> bool:
return False
async def sync_and_offload_contacts(mc: MeshCore) -> dict:
"""
Sync contacts from radio to database, then remove them from radio.
Returns counts of synced and removed contacts.
"""
synced = 0
removed = 0
try:
# Get all contacts from radio
result = await mc.commands.get_contacts()
if result is None or result.type == EventType.ERROR:
logger.error(
"Failed to get contacts from radio: %s. "
"If you see this repeatedly, the radio may be visible on the "
"serial/TCP/BLE port but not responding to commands. Check for "
"another process with the serial port open (other RemoteTerm "
"instances, serial monitors, etc.), verify the firmware is "
"up-to-date and in client mode (not repeater), or try a "
"power cycle.",
result,
)
return {"synced": 0, "removed": 0, "error": str(result)}
contacts = result.payload or {}
logger.info("Found %d contacts on radio", len(contacts))
# Sync each contact to database, then remove from radio
for public_key, contact_data in contacts.items():
# Save to database
await ContactRepository.upsert(
ContactUpsert.from_radio_dict(public_key, contact_data, on_radio=False)
)
asyncio.create_task(
_reconcile_contact_messages_background(
public_key,
contact_data.get("adv_name"),
)
)
synced += 1
# Remove from radio
try:
remove_result = await mc.commands.remove_contact(contact_data)
if remove_result.type == EventType.OK:
removed += 1
_evict_removed_contact_from_library_cache(mc, public_key)
else:
logger.warning(
"Failed to remove contact %s: %s", public_key[:12], remove_result.payload
)
except Exception as e:
logger.warning("Error removing contact %s: %s", public_key[:12], e)
logger.info("Synced %d contacts, removed %d from radio", synced, removed)
except Exception as e:
logger.error("Error during contact sync: %s", e)
return {"synced": synced, "removed": removed, "error": str(e)}
return {"synced": synced, "removed": removed}
async def sync_and_offload_channels(mc: MeshCore, max_channels: int | None = None) -> dict:
"""
Sync channels from radio to database, then clear them from radio.