Event handler dedupe, CLAUDE.md patchups, more (jeez

) acked field int vs bool fixes, and throw exceptions not assertions (+Pydantic v2)
This commit is contained in:
Jack Kingsman
2026-01-17 19:01:34 -08:00
parent 94bcf42cff
commit 546d2f2f7d
7 changed files with 271 additions and 68 deletions
+7 -43
View File
@@ -291,45 +291,12 @@ if result:
### Direct Message Decryption
Direct messages use ECDH key exchange (Ed25519 → X25519) with the sender's public key
and recipient's private key:
Direct messages use ECDH key exchange (Ed25519 → X25519). Server-side decryption
of direct messages is **not yet implemented**. Currently, direct messages are
decrypted by the MeshCore library on the radio itself.
```python
from app.decoder import try_decrypt_packet_with_contact_key
result = try_decrypt_packet_with_contact_key(
raw_bytes, sender_pub_key, recipient_prv_key
)
if result:
print(f"Message: {result.message}")
```
**Requirements:**
- Sender's Ed25519 public key (32 bytes)
- Recipient's Ed25519 private key (64 bytes) - from ephemeral KeyStore
### Ephemeral Key Store (`keystore.py`)
Private keys are stored **only in memory** for security:
```python
from app.keystore import KeyStore
# Set private key (exported from radio)
KeyStore.set_private_key(private_key_bytes)
# Check if available
if KeyStore.has_private_key():
key = KeyStore.get_private_key()
# Clear from memory
KeyStore.clear_private_key()
```
**Security guarantees:**
- Never written to disk
- Never logged
- Lost on server restart (must re-export from radio)
The decoder module contains a `try_decrypt_packet_with_contact_key()` function
that could support this feature in the future.
## Advertisement Parsing (`decoder.py`)
@@ -432,13 +399,10 @@ All endpoints are prefixed with `/api`.
### Radio
- `GET /api/radio/config` - Read config (public key, name, radio params)
- `PATCH /api/radio/config` - Update name, lat/lon, tx_power, radio params
- `PUT /api/radio/private-key` - Import private key (write-only)
- `PUT /api/radio/private-key` - Import private key to radio (write-only)
- `POST /api/radio/advertise?flood=true` - Send advertisement
- `POST /api/radio/reboot` - Reboot radio
- `POST /api/radio/reboot` - Reboot radio or reconnect if disconnected
- `POST /api/radio/reconnect` - Manual reconnection attempt
- `POST /api/radio/enable-server-decryption` - Export private key from radio, enable server-side decryption
- `GET /api/radio/decryption-status` - Check if server-side decryption is enabled
- `POST /api/radio/disable-server-decryption` - Clear private key from memory
### Contacts
- `GET /api/contacts` - List from database
+3 -3
View File
@@ -1,19 +1,19 @@
import logging
from typing import Literal
from pydantic import ConfigDict
from pydantic_settings import BaseSettings
class Settings(BaseSettings):
model_config = ConfigDict(env_prefix="MESHCORE_")
serial_port: str = "" # Empty string triggers auto-detection
serial_baudrate: int = 115200
log_level: Literal["DEBUG", "INFO", "WARNING", "ERROR"] = "INFO"
database_path: str = "data/meshcore.db"
max_radio_contacts: int = 200 # Max non-repeater contacts to keep on radio for DM ACKs
class Config:
env_prefix = "MESHCORE_"
settings = Settings()
+37 -7
View File
@@ -10,10 +10,14 @@ from app.repository import ContactRepository, MessageRepository
from app.websocket import broadcast_event
if TYPE_CHECKING:
from meshcore.events import Event
from meshcore.events import Event, Subscription
logger = logging.getLogger(__name__)
# Track active subscriptions so we can unsubscribe before re-registering
# This prevents handler duplication after reconnects
_active_subscriptions: list["Subscription"] = []
# Track pending ACKs: expected_ack_code -> (message_id, timestamp, timeout_ms)
_pending_acks: dict[str, tuple[int, float, int]] = {}
@@ -100,7 +104,7 @@ async def on_contact_message(event: "Event") -> None:
"txt_type": payload.get("txt_type", 0),
"signature": payload.get("signature"),
"outgoing": False,
"acked": False,
"acked": 0,
},
)
@@ -200,10 +204,36 @@ def register_event_handlers(meshcore) -> None:
Note: CHANNEL_MSG_RECV and ADVERTISEMENT events are NOT subscribed.
These are handled by the packet processor via RX_LOG_DATA to avoid
duplicate processing and ensure consistent handling.
This function is safe to call multiple times (e.g., after reconnect).
Existing handlers are unsubscribed before new ones are registered.
"""
meshcore.subscribe(EventType.CONTACT_MSG_RECV, on_contact_message)
meshcore.subscribe(EventType.RX_LOG_DATA, on_rx_log_data)
meshcore.subscribe(EventType.PATH_UPDATE, on_path_update)
meshcore.subscribe(EventType.NEW_CONTACT, on_new_contact)
meshcore.subscribe(EventType.ACK, on_ack)
global _active_subscriptions
# Unsubscribe existing handlers to prevent duplication after reconnects.
# Try/except handles the case where the old dispatcher is in a bad state
# (e.g., after reconnect with a new MeshCore instance).
for sub in _active_subscriptions:
try:
sub.unsubscribe()
except Exception:
pass # Old dispatcher may be gone, that's fine
_active_subscriptions.clear()
# Register handlers and track subscriptions
_active_subscriptions.append(
meshcore.subscribe(EventType.CONTACT_MSG_RECV, on_contact_message)
)
_active_subscriptions.append(
meshcore.subscribe(EventType.RX_LOG_DATA, on_rx_log_data)
)
_active_subscriptions.append(
meshcore.subscribe(EventType.PATH_UPDATE, on_path_update)
)
_active_subscriptions.append(
meshcore.subscribe(EventType.NEW_CONTACT, on_new_contact)
)
_active_subscriptions.append(
meshcore.subscribe(EventType.ACK, on_ack)
)
logger.info("Event handlers registered")
+10 -2
View File
@@ -95,7 +95,11 @@ async def send_direct_message(request: SendDirectMessageRequest) -> Message:
received_at=now,
outgoing=True,
)
assert message_id is not None # Outgoing messages are always new (unique timestamp)
if message_id is None:
raise HTTPException(
status_code=500,
detail="Failed to store outgoing message - unexpected duplicate",
)
# Update last_contacted for the contact
await ContactRepository.update_last_contacted(db_contact.public_key, now)
@@ -191,7 +195,11 @@ async def send_channel_message(request: SendChannelMessageRequest) -> Message:
received_at=now,
outgoing=True,
)
assert message_id is not None # Outgoing messages are always new (unique timestamp)
if message_id is None:
raise HTTPException(
status_code=500,
detail="Failed to store outgoing message - unexpected duplicate",
)
# Track for repeat detection (flood messages get confirmed by hearing repeats)
track_pending_repeat(channel_key_upper, request.text, now, message_id)