mirror of
https://github.com/jkingsman/Remote-Terminal-for-MeshCore.git
synced 2026-04-30 18:42:51 +02:00
First wave of work
This commit is contained in:
@@ -79,9 +79,11 @@ class PacketInfo:
|
||||
route_type: RouteType
|
||||
payload_type: PayloadType
|
||||
payload_version: int
|
||||
path_length: int
|
||||
path: bytes # The routing path (empty if path_length is 0)
|
||||
path_length: int # Hop count encoded in the lower 6 bits of the path byte
|
||||
path: bytes # The routing path bytes (empty if path_length is 0)
|
||||
payload: bytes
|
||||
path_hash_size: int = 1 # Bytes per hop encoded in the upper 2 bits of the path byte
|
||||
path_byte_length: int = 0
|
||||
|
||||
|
||||
def calculate_channel_hash(channel_key: bytes) -> str:
|
||||
@@ -93,6 +95,14 @@ def calculate_channel_hash(channel_key: bytes) -> str:
|
||||
return format(hash_bytes[0], "02x")
|
||||
|
||||
|
||||
def _decode_path_metadata(path_byte: int) -> tuple[int, int, int]:
|
||||
"""Decode the packed path byte into hop count and byte length."""
|
||||
path_hash_size = (path_byte >> 6) + 1
|
||||
path_length = path_byte & 0x3F
|
||||
path_byte_length = path_length * path_hash_size
|
||||
return path_length, path_hash_size, path_byte_length
|
||||
|
||||
|
||||
def extract_payload(raw_packet: bytes) -> bytes | None:
|
||||
"""
|
||||
Extract just the payload from a raw packet, skipping header and path.
|
||||
@@ -100,8 +110,10 @@ def extract_payload(raw_packet: bytes) -> bytes | None:
|
||||
Packet structure:
|
||||
- Byte 0: header (route_type, payload_type, version)
|
||||
- For TRANSPORT routes: bytes 1-4 are transport codes
|
||||
- Next byte: path_length
|
||||
- Next path_length bytes: path data
|
||||
- Next byte: packed path metadata
|
||||
- upper 2 bits: bytes per hop minus 1
|
||||
- lower 6 bits: hop count
|
||||
- Next hop_count * path_hash_size bytes: path data
|
||||
- Remaining: payload
|
||||
|
||||
Returns the payload bytes, or None if packet is malformed.
|
||||
@@ -120,16 +132,16 @@ def extract_payload(raw_packet: bytes) -> bytes | None:
|
||||
return None
|
||||
offset += 4
|
||||
|
||||
# Get path length
|
||||
# Decode packed path metadata
|
||||
if len(raw_packet) < offset + 1:
|
||||
return None
|
||||
path_length = raw_packet[offset]
|
||||
path_length, _path_hash_size, path_byte_length = _decode_path_metadata(raw_packet[offset])
|
||||
offset += 1
|
||||
|
||||
# Skip path data
|
||||
if len(raw_packet) < offset + path_length:
|
||||
if len(raw_packet) < offset + path_byte_length:
|
||||
return None
|
||||
offset += path_length
|
||||
offset += path_byte_length
|
||||
|
||||
# Rest is payload
|
||||
return raw_packet[offset:]
|
||||
@@ -156,17 +168,17 @@ def parse_packet(raw_packet: bytes) -> PacketInfo | None:
|
||||
return None
|
||||
offset += 4
|
||||
|
||||
# Get path length
|
||||
# Decode packed path metadata
|
||||
if len(raw_packet) < offset + 1:
|
||||
return None
|
||||
path_length = raw_packet[offset]
|
||||
path_length, path_hash_size, path_byte_length = _decode_path_metadata(raw_packet[offset])
|
||||
offset += 1
|
||||
|
||||
# Extract path data
|
||||
if len(raw_packet) < offset + path_length:
|
||||
if len(raw_packet) < offset + path_byte_length:
|
||||
return None
|
||||
path = raw_packet[offset : offset + path_length]
|
||||
offset += path_length
|
||||
path = raw_packet[offset : offset + path_byte_length]
|
||||
offset += path_byte_length
|
||||
|
||||
# Rest is payload
|
||||
payload = raw_packet[offset:]
|
||||
@@ -178,6 +190,8 @@ def parse_packet(raw_packet: bytes) -> PacketInfo | None:
|
||||
path_length=path_length,
|
||||
path=path,
|
||||
payload=payload,
|
||||
path_hash_size=path_hash_size,
|
||||
path_byte_length=path_byte_length,
|
||||
)
|
||||
except (ValueError, IndexError):
|
||||
return None
|
||||
|
||||
@@ -106,13 +106,21 @@ async def on_contact_message(event: "Event") -> None:
|
||||
ts = payload.get("sender_timestamp")
|
||||
sender_timestamp = ts if ts is not None else received_at
|
||||
sender_name = contact.name if contact else None
|
||||
path = payload.get("path")
|
||||
payload_path_len = payload.get("path_len")
|
||||
normalized_path_len = (
|
||||
payload_path_len
|
||||
if isinstance(payload_path_len, int)
|
||||
else (len(path) // 2 if path is not None else None)
|
||||
)
|
||||
msg_id = await MessageRepository.create(
|
||||
msg_type="PRIV",
|
||||
text=payload.get("text", ""),
|
||||
conversation_key=sender_pubkey,
|
||||
sender_timestamp=sender_timestamp,
|
||||
received_at=received_at,
|
||||
path=payload.get("path"),
|
||||
path=path,
|
||||
path_len=normalized_path_len,
|
||||
txt_type=txt_type,
|
||||
signature=payload.get("signature"),
|
||||
sender_key=sender_pubkey,
|
||||
@@ -129,8 +137,11 @@ async def on_contact_message(event: "Event") -> None:
|
||||
logger.debug("DM from %s handled by event handler (fallback path)", sender_pubkey[:12])
|
||||
|
||||
# Build paths array for broadcast
|
||||
path = payload.get("path")
|
||||
paths = [MessagePath(path=path or "", received_at=received_at)] if path is not None else None
|
||||
paths = (
|
||||
[MessagePath(path=path or "", received_at=received_at, path_len=normalized_path_len)]
|
||||
if path is not None
|
||||
else None
|
||||
)
|
||||
|
||||
# Broadcast the new message
|
||||
broadcast_event(
|
||||
|
||||
@@ -56,7 +56,13 @@ def _format_body(data: dict, *, include_path: bool) -> str:
|
||||
if path_str == "":
|
||||
via = " **via:** [`direct`]"
|
||||
else:
|
||||
hops = [path_str[i : i + 2] for i in range(0, len(path_str), 2)]
|
||||
path_len = paths[0].get("path_len") if isinstance(paths[0], dict) else None
|
||||
hop_chars = (
|
||||
len(path_str) // path_len
|
||||
if isinstance(path_len, int) and path_len > 0 and len(path_str) % path_len == 0
|
||||
else 2
|
||||
)
|
||||
hops = [path_str[i : i + hop_chars] for i in range(0, len(path_str), hop_chars)]
|
||||
if hops:
|
||||
hop_list = ", ".join(f"`{h}`" for h in hops)
|
||||
via = f" **via:** [{hop_list}]"
|
||||
|
||||
@@ -19,6 +19,26 @@ class Contact(BaseModel):
|
||||
last_read_at: int | None = None # Server-side read state tracking
|
||||
first_seen: int | None = None
|
||||
|
||||
@staticmethod
|
||||
def _derive_out_path_hash_mode(path_hex: str | None, path_len: int) -> int:
|
||||
"""Infer the contact path hash mode from stored path bytes and hop count."""
|
||||
if path_len < 0:
|
||||
return -1
|
||||
if path_len == 0 or not path_hex:
|
||||
return 0
|
||||
|
||||
if len(path_hex) % 2 != 0:
|
||||
return 0
|
||||
|
||||
path_bytes = len(path_hex) // 2
|
||||
if path_bytes == 0 or path_bytes % path_len != 0:
|
||||
return 0
|
||||
|
||||
bytes_per_hop = path_bytes // path_len
|
||||
if bytes_per_hop < 1:
|
||||
return 0
|
||||
return bytes_per_hop - 1
|
||||
|
||||
def to_radio_dict(self) -> dict:
|
||||
"""Convert to the dict format expected by meshcore radio commands.
|
||||
|
||||
@@ -32,6 +52,9 @@ class Contact(BaseModel):
|
||||
"flags": self.flags,
|
||||
"out_path": self.last_path or "",
|
||||
"out_path_len": self.last_path_len,
|
||||
"out_path_hash_mode": self._derive_out_path_hash_mode(
|
||||
self.last_path, self.last_path_len
|
||||
),
|
||||
"adv_lat": self.lat if self.lat is not None else 0.0,
|
||||
"adv_lon": self.lon if self.lon is not None else 0.0,
|
||||
"last_advert": self.last_advert if self.last_advert is not None else 0,
|
||||
@@ -176,8 +199,11 @@ class ChannelDetail(BaseModel):
|
||||
class MessagePath(BaseModel):
|
||||
"""A single path that a message took to reach us."""
|
||||
|
||||
path: str = Field(description="Hex-encoded routing path (2 chars per hop)")
|
||||
path: str = Field(description="Hex-encoded routing path")
|
||||
received_at: int = Field(description="Unix timestamp when this path was received")
|
||||
path_len: int | None = Field(
|
||||
default=None, description="Number of hops in the path, when known"
|
||||
)
|
||||
|
||||
|
||||
class Message(BaseModel):
|
||||
|
||||
@@ -57,6 +57,7 @@ async def _handle_duplicate_message(
|
||||
text: str,
|
||||
sender_timestamp: int,
|
||||
path: str | None,
|
||||
path_len: int | None,
|
||||
received: int,
|
||||
) -> None:
|
||||
"""Handle a duplicate message by updating paths/acks on the existing record.
|
||||
@@ -90,7 +91,9 @@ async def _handle_duplicate_message(
|
||||
|
||||
# Add path if provided
|
||||
if path is not None:
|
||||
paths = await MessageRepository.add_path(existing_msg.id, path, received)
|
||||
paths = await MessageRepository.add_path(
|
||||
existing_msg.id, path, received, path_len=path_len
|
||||
)
|
||||
else:
|
||||
# Get current paths for broadcast
|
||||
paths = existing_msg.paths or []
|
||||
@@ -128,6 +131,7 @@ async def create_message_from_decrypted(
|
||||
timestamp: int,
|
||||
received_at: int | None = None,
|
||||
path: str | None = None,
|
||||
path_len: int | None = None,
|
||||
channel_name: str | None = None,
|
||||
realtime: bool = True,
|
||||
) -> int | None:
|
||||
@@ -150,6 +154,9 @@ async def create_message_from_decrypted(
|
||||
Returns the message ID if created, None if duplicate.
|
||||
"""
|
||||
received = received_at or int(time.time())
|
||||
normalized_path_len = (
|
||||
path_len if isinstance(path_len, int) else (len(path) // 2 if path is not None else None)
|
||||
)
|
||||
|
||||
# Format the message text with sender prefix if present
|
||||
text = f"{sender}: {message_text}" if sender else message_text
|
||||
@@ -172,6 +179,7 @@ async def create_message_from_decrypted(
|
||||
sender_timestamp=timestamp,
|
||||
received_at=received,
|
||||
path=path,
|
||||
path_len=normalized_path_len,
|
||||
sender_name=sender,
|
||||
sender_key=resolved_sender_key,
|
||||
)
|
||||
@@ -182,7 +190,14 @@ async def create_message_from_decrypted(
|
||||
# 2. Same message arrives via multiple paths before first is committed
|
||||
# In either case, add the path to the existing message.
|
||||
await _handle_duplicate_message(
|
||||
packet_id, "CHAN", channel_key_normalized, text, timestamp, path, received
|
||||
packet_id,
|
||||
"CHAN",
|
||||
channel_key_normalized,
|
||||
text,
|
||||
timestamp,
|
||||
path,
|
||||
normalized_path_len,
|
||||
received,
|
||||
)
|
||||
return None
|
||||
|
||||
@@ -193,7 +208,11 @@ async def create_message_from_decrypted(
|
||||
|
||||
# Build paths array for broadcast
|
||||
# Use "is not None" to include empty string (direct/0-hop messages)
|
||||
paths = [MessagePath(path=path or "", received_at=received)] if path is not None else None
|
||||
paths = (
|
||||
[MessagePath(path=path or "", received_at=received, path_len=normalized_path_len)]
|
||||
if path is not None
|
||||
else None
|
||||
)
|
||||
|
||||
# Broadcast new message to connected clients (and fanout modules when realtime)
|
||||
broadcast_event(
|
||||
@@ -223,6 +242,7 @@ async def create_dm_message_from_decrypted(
|
||||
our_public_key: str | None,
|
||||
received_at: int | None = None,
|
||||
path: str | None = None,
|
||||
path_len: int | None = None,
|
||||
outgoing: bool = False,
|
||||
realtime: bool = True,
|
||||
) -> int | None:
|
||||
@@ -255,6 +275,9 @@ async def create_dm_message_from_decrypted(
|
||||
return None
|
||||
|
||||
received = received_at or int(time.time())
|
||||
normalized_path_len = (
|
||||
path_len if isinstance(path_len, int) else (len(path) // 2 if path is not None else None)
|
||||
)
|
||||
|
||||
# conversation_key is always the other party's public key
|
||||
conversation_key = their_public_key.lower()
|
||||
@@ -270,6 +293,7 @@ async def create_dm_message_from_decrypted(
|
||||
sender_timestamp=decrypted.timestamp,
|
||||
received_at=received,
|
||||
path=path,
|
||||
path_len=normalized_path_len,
|
||||
outgoing=outgoing,
|
||||
sender_key=conversation_key if not outgoing else None,
|
||||
sender_name=sender_name,
|
||||
@@ -284,6 +308,7 @@ async def create_dm_message_from_decrypted(
|
||||
decrypted.message,
|
||||
decrypted.timestamp,
|
||||
path,
|
||||
normalized_path_len,
|
||||
received,
|
||||
)
|
||||
return None
|
||||
@@ -299,7 +324,11 @@ async def create_dm_message_from_decrypted(
|
||||
await RawPacketRepository.mark_decrypted(packet_id, msg_id)
|
||||
|
||||
# Build paths array for broadcast
|
||||
paths = [MessagePath(path=path or "", received_at=received)] if path is not None else None
|
||||
paths = (
|
||||
[MessagePath(path=path or "", received_at=received, path_len=normalized_path_len)]
|
||||
if path is not None
|
||||
else None
|
||||
)
|
||||
|
||||
# Broadcast new message to connected clients (and fanout modules when realtime)
|
||||
sender_name = contact.name if contact and not outgoing else None
|
||||
@@ -391,6 +420,7 @@ async def run_historical_dm_decryption(
|
||||
our_public_key=our_public_key_bytes.hex(),
|
||||
received_at=packet_timestamp,
|
||||
path=path_hex,
|
||||
path_len=packet_info.path_length if packet_info else None,
|
||||
outgoing=outgoing,
|
||||
realtime=False, # Historical decryption should not trigger fanout
|
||||
)
|
||||
@@ -606,6 +636,7 @@ async def _process_group_text(
|
||||
timestamp=decrypted.timestamp,
|
||||
received_at=timestamp,
|
||||
path=packet_info.path.hex() if packet_info else None,
|
||||
path_len=packet_info.path_length if packet_info else None,
|
||||
)
|
||||
|
||||
return {
|
||||
@@ -872,6 +903,7 @@ async def _process_direct_message(
|
||||
our_public_key=our_public_key.hex(),
|
||||
received_at=timestamp,
|
||||
path=packet_info.path.hex() if packet_info else None,
|
||||
path_len=packet_info.path_length if packet_info else None,
|
||||
outgoing=is_outgoing,
|
||||
)
|
||||
|
||||
|
||||
@@ -26,6 +26,7 @@ class MessageRepository:
|
||||
conversation_key: str,
|
||||
sender_timestamp: int | None = None,
|
||||
path: str | None = None,
|
||||
path_len: int | None = None,
|
||||
txt_type: int = 0,
|
||||
signature: str | None = None,
|
||||
outgoing: bool = False,
|
||||
@@ -43,7 +44,10 @@ class MessageRepository:
|
||||
# Convert single path to paths array format
|
||||
paths_json = None
|
||||
if path is not None:
|
||||
paths_json = json.dumps([{"path": path, "received_at": received_at}])
|
||||
normalized_path_len = path_len if isinstance(path_len, int) else len(path) // 2
|
||||
path_entry: dict[str, Any] = {"path": path, "received_at": received_at}
|
||||
path_entry["path_len"] = normalized_path_len
|
||||
paths_json = json.dumps([path_entry])
|
||||
|
||||
cursor = await db.conn.execute(
|
||||
"""
|
||||
@@ -74,7 +78,7 @@ class MessageRepository:
|
||||
|
||||
@staticmethod
|
||||
async def add_path(
|
||||
message_id: int, path: str, received_at: int | None = None
|
||||
message_id: int, path: str, received_at: int | None = None, path_len: int | None = None
|
||||
) -> list[MessagePath]:
|
||||
"""Add a new path to an existing message.
|
||||
|
||||
@@ -85,7 +89,10 @@ class MessageRepository:
|
||||
|
||||
# Atomic append: use json_insert to avoid read-modify-write race when
|
||||
# multiple duplicate packets arrive concurrently for the same message.
|
||||
new_entry = json.dumps({"path": path, "received_at": ts})
|
||||
normalized_path_len = path_len if isinstance(path_len, int) else len(path) // 2
|
||||
new_entry_dict: dict[str, Any] = {"path": path, "received_at": ts}
|
||||
new_entry_dict["path_len"] = normalized_path_len
|
||||
new_entry = json.dumps(new_entry_dict)
|
||||
await db.conn.execute(
|
||||
"""UPDATE messages SET paths = json_insert(
|
||||
COALESCE(paths, '[]'), '$[#]', json(?)
|
||||
|
||||
@@ -71,6 +71,7 @@ async def _run_historical_channel_decryption(
|
||||
timestamp=result.timestamp,
|
||||
received_at=packet_timestamp,
|
||||
path=path_hex,
|
||||
path_len=packet_info.path_length if packet_info else None,
|
||||
realtime=False, # Historical decryption should not trigger fanout
|
||||
)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user