mirror of
https://github.com/jkingsman/Remote-Terminal-for-MeshCore.git
synced 2026-05-09 23:05:10 +02:00
Add RSSI/SNR to received messages. Closes #148.
This commit is contained in:
@@ -387,6 +387,8 @@ class MessagePath(BaseModel):
|
||||
default=None,
|
||||
description="Hop count. None = legacy (infer as len(path)//2, i.e. 1-byte hops)",
|
||||
)
|
||||
rssi: int | None = Field(default=None, description="Last-hop RSSI in dBm")
|
||||
snr: float | None = Field(default=None, description="Last-hop SNR in dB")
|
||||
|
||||
|
||||
class Message(BaseModel):
|
||||
|
||||
+22
-2
@@ -68,6 +68,8 @@ async def create_message_from_decrypted(
|
||||
received_at: int | None = None,
|
||||
path: str | None = None,
|
||||
path_len: int | None = None,
|
||||
rssi: int | None = None,
|
||||
snr: float | None = None,
|
||||
channel_name: str | None = None,
|
||||
realtime: bool = True,
|
||||
) -> int | None:
|
||||
@@ -81,6 +83,8 @@ async def create_message_from_decrypted(
|
||||
received_at=received_at,
|
||||
path=path,
|
||||
path_len=path_len,
|
||||
rssi=rssi,
|
||||
snr=snr,
|
||||
channel_name=channel_name,
|
||||
realtime=realtime,
|
||||
broadcast_fn=broadcast_event,
|
||||
@@ -95,6 +99,8 @@ async def create_dm_message_from_decrypted(
|
||||
received_at: int | None = None,
|
||||
path: str | None = None,
|
||||
path_len: int | None = None,
|
||||
rssi: int | None = None,
|
||||
snr: float | None = None,
|
||||
outgoing: bool = False,
|
||||
realtime: bool = True,
|
||||
) -> int | None:
|
||||
@@ -107,6 +113,8 @@ async def create_dm_message_from_decrypted(
|
||||
received_at=received_at,
|
||||
path=path,
|
||||
path_len=path_len,
|
||||
rssi=rssi,
|
||||
snr=snr,
|
||||
outgoing=outgoing,
|
||||
realtime=realtime,
|
||||
broadcast_fn=broadcast_event,
|
||||
@@ -319,7 +327,9 @@ async def process_raw_packet(
|
||||
# deduplication in create_message_from_decrypted handles adding paths to existing messages.
|
||||
# This is more reliable than trying to look up the message via raw packet linking.
|
||||
if payload_type == PayloadType.GROUP_TEXT:
|
||||
decrypt_result = await _process_group_text(raw_bytes, packet_id, ts, packet_info)
|
||||
decrypt_result = await _process_group_text(
|
||||
raw_bytes, packet_id, ts, packet_info, rssi=rssi, snr=snr
|
||||
)
|
||||
if decrypt_result:
|
||||
result.update(decrypt_result)
|
||||
|
||||
@@ -330,7 +340,9 @@ async def process_raw_packet(
|
||||
|
||||
elif payload_type == PayloadType.TEXT_MESSAGE:
|
||||
# Try to decrypt direct messages using stored private key and known contacts
|
||||
decrypt_result = await _process_direct_message(raw_bytes, packet_id, ts, packet_info)
|
||||
decrypt_result = await _process_direct_message(
|
||||
raw_bytes, packet_id, ts, packet_info, rssi=rssi, snr=snr
|
||||
)
|
||||
if decrypt_result:
|
||||
result.update(decrypt_result)
|
||||
|
||||
@@ -367,6 +379,8 @@ async def _process_group_text(
|
||||
packet_id: int,
|
||||
timestamp: int,
|
||||
packet_info: PacketInfo | None,
|
||||
rssi: int | None = None,
|
||||
snr: float | None = None,
|
||||
) -> dict | None:
|
||||
"""
|
||||
Process a GroupText (channel message) packet.
|
||||
@@ -403,6 +417,8 @@ async def _process_group_text(
|
||||
received_at=timestamp,
|
||||
path=packet_info.path.hex() if packet_info else None,
|
||||
path_len=packet_info.path_length if packet_info else None,
|
||||
rssi=rssi,
|
||||
snr=snr,
|
||||
)
|
||||
|
||||
return {
|
||||
@@ -544,6 +560,8 @@ async def _process_direct_message(
|
||||
packet_id: int,
|
||||
timestamp: int,
|
||||
packet_info: PacketInfo | None,
|
||||
rssi: int | None = None,
|
||||
snr: float | None = None,
|
||||
) -> dict | None:
|
||||
"""
|
||||
Process a TEXT_MESSAGE (direct message) packet.
|
||||
@@ -644,6 +662,8 @@ async def _process_direct_message(
|
||||
received_at=timestamp,
|
||||
path=packet_info.path.hex() if packet_info else None,
|
||||
path_len=packet_info.path_length if packet_info else None,
|
||||
rssi=rssi,
|
||||
snr=snr,
|
||||
outgoing=is_outgoing,
|
||||
)
|
||||
|
||||
|
||||
@@ -57,6 +57,8 @@ class MessageRepository:
|
||||
sender_timestamp: int | None = None,
|
||||
path: str | None = None,
|
||||
path_len: int | None = None,
|
||||
rssi: int | None = None,
|
||||
snr: float | None = None,
|
||||
txt_type: int = 0,
|
||||
signature: str | None = None,
|
||||
outgoing: bool = False,
|
||||
@@ -78,6 +80,10 @@ class MessageRepository:
|
||||
entry: dict = {"path": path, "received_at": received_at}
|
||||
if path_len is not None:
|
||||
entry["path_len"] = path_len
|
||||
if rssi is not None:
|
||||
entry["rssi"] = rssi
|
||||
if snr is not None:
|
||||
entry["snr"] = snr
|
||||
paths_json = json.dumps([entry])
|
||||
|
||||
# Normalize sender_key to lowercase so queries can match without LOWER().
|
||||
@@ -116,6 +122,8 @@ class MessageRepository:
|
||||
path: str,
|
||||
received_at: int | None = None,
|
||||
path_len: int | None = None,
|
||||
rssi: int | None = None,
|
||||
snr: float | None = None,
|
||||
) -> list[MessagePath]:
|
||||
"""Add a new path to an existing message.
|
||||
|
||||
@@ -129,6 +137,10 @@ class MessageRepository:
|
||||
entry: dict = {"path": path, "received_at": ts}
|
||||
if path_len is not None:
|
||||
entry["path_len"] = path_len
|
||||
if rssi is not None:
|
||||
entry["rssi"] = rssi
|
||||
if snr is not None:
|
||||
entry["snr"] = snr
|
||||
new_entry = json.dumps(entry)
|
||||
await db.conn.execute(
|
||||
"""UPDATE messages SET paths = json_insert(
|
||||
|
||||
@@ -144,6 +144,8 @@ async def _store_direct_message(
|
||||
received_at: int,
|
||||
path: str | None,
|
||||
path_len: int | None,
|
||||
rssi: int | None = None,
|
||||
snr: float | None = None,
|
||||
outgoing: bool,
|
||||
txt_type: int,
|
||||
signature: str | None,
|
||||
@@ -170,6 +172,8 @@ async def _store_direct_message(
|
||||
path=path,
|
||||
received_at=received_at,
|
||||
path_len=path_len,
|
||||
rssi=rssi,
|
||||
snr=snr,
|
||||
broadcast_fn=broadcast_fn,
|
||||
)
|
||||
return None
|
||||
@@ -189,6 +193,8 @@ async def _store_direct_message(
|
||||
path=path,
|
||||
received_at=received_at,
|
||||
path_len=path_len,
|
||||
rssi=rssi,
|
||||
snr=snr,
|
||||
broadcast_fn=broadcast_fn,
|
||||
)
|
||||
return None
|
||||
@@ -201,6 +207,8 @@ async def _store_direct_message(
|
||||
received_at=received_at,
|
||||
path=path,
|
||||
path_len=path_len,
|
||||
rssi=rssi,
|
||||
snr=snr,
|
||||
txt_type=txt_type,
|
||||
signature=signature,
|
||||
outgoing=outgoing,
|
||||
@@ -218,6 +226,8 @@ async def _store_direct_message(
|
||||
path=path,
|
||||
received_at=received_at,
|
||||
path_len=path_len,
|
||||
rssi=rssi,
|
||||
snr=snr,
|
||||
broadcast_fn=broadcast_fn,
|
||||
)
|
||||
return None
|
||||
@@ -232,7 +242,7 @@ async def _store_direct_message(
|
||||
text=text,
|
||||
sender_timestamp=sender_timestamp,
|
||||
received_at=received_at,
|
||||
paths=build_message_paths(path, received_at, path_len),
|
||||
paths=build_message_paths(path, received_at, path_len, rssi=rssi, snr=snr),
|
||||
txt_type=txt_type,
|
||||
signature=signature,
|
||||
sender_key=sender_key,
|
||||
@@ -261,6 +271,8 @@ async def ingest_decrypted_direct_message(
|
||||
received_at: int | None = None,
|
||||
path: str | None = None,
|
||||
path_len: int | None = None,
|
||||
rssi: int | None = None,
|
||||
snr: float | None = None,
|
||||
outgoing: bool = False,
|
||||
realtime: bool = True,
|
||||
broadcast_fn: BroadcastFn,
|
||||
@@ -311,6 +323,8 @@ async def ingest_decrypted_direct_message(
|
||||
received_at=received,
|
||||
path=path,
|
||||
path_len=path_len,
|
||||
rssi=rssi,
|
||||
snr=snr,
|
||||
outgoing=outgoing,
|
||||
txt_type=decrypted.txt_type,
|
||||
signature=signature,
|
||||
|
||||
@@ -37,10 +37,16 @@ def build_message_paths(
|
||||
path: str | None,
|
||||
received_at: int,
|
||||
path_len: int | None = None,
|
||||
rssi: int | None = None,
|
||||
snr: float | None = None,
|
||||
) -> list[MessagePath] | None:
|
||||
"""Build the single-path list used by message payloads."""
|
||||
return (
|
||||
[MessagePath(path=path or "", received_at=received_at, path_len=path_len)]
|
||||
[
|
||||
MessagePath(
|
||||
path=path or "", received_at=received_at, path_len=path_len, rssi=rssi, snr=snr
|
||||
)
|
||||
]
|
||||
if path is not None
|
||||
else None
|
||||
)
|
||||
@@ -166,6 +172,8 @@ async def reconcile_duplicate_message(
|
||||
path: str | None,
|
||||
received_at: int,
|
||||
path_len: int | None,
|
||||
rssi: int | None = None,
|
||||
snr: float | None = None,
|
||||
broadcast_fn: BroadcastFn,
|
||||
) -> None:
|
||||
logger.debug(
|
||||
@@ -177,7 +185,9 @@ async def reconcile_duplicate_message(
|
||||
)
|
||||
|
||||
if path is not None:
|
||||
paths = await MessageRepository.add_path(existing_msg.id, path, received_at, path_len)
|
||||
paths = await MessageRepository.add_path(
|
||||
existing_msg.id, path, received_at, path_len, rssi=rssi, snr=snr
|
||||
)
|
||||
else:
|
||||
paths = existing_msg.paths or []
|
||||
|
||||
@@ -214,6 +224,8 @@ async def handle_duplicate_message(
|
||||
path: str | None,
|
||||
received_at: int,
|
||||
path_len: int | None = None,
|
||||
rssi: int | None = None,
|
||||
snr: float | None = None,
|
||||
broadcast_fn: BroadcastFn,
|
||||
) -> None:
|
||||
"""Handle a duplicate message by updating paths/acks on the existing record."""
|
||||
@@ -239,6 +251,8 @@ async def handle_duplicate_message(
|
||||
path=path,
|
||||
received_at=received_at,
|
||||
path_len=path_len,
|
||||
rssi=rssi,
|
||||
snr=snr,
|
||||
broadcast_fn=broadcast_fn,
|
||||
)
|
||||
|
||||
@@ -253,6 +267,8 @@ async def create_message_from_decrypted(
|
||||
received_at: int | None = None,
|
||||
path: str | None = None,
|
||||
path_len: int | None = None,
|
||||
rssi: int | None = None,
|
||||
snr: float | None = None,
|
||||
channel_name: str | None = None,
|
||||
realtime: bool = True,
|
||||
broadcast_fn: BroadcastFn,
|
||||
@@ -276,6 +292,8 @@ async def create_message_from_decrypted(
|
||||
received_at=received,
|
||||
path=path,
|
||||
path_len=path_len,
|
||||
rssi=rssi,
|
||||
snr=snr,
|
||||
sender_name=sender,
|
||||
sender_key=resolved_sender_key,
|
||||
)
|
||||
@@ -291,6 +309,8 @@ async def create_message_from_decrypted(
|
||||
path=path,
|
||||
received_at=received,
|
||||
path_len=path_len,
|
||||
rssi=rssi,
|
||||
snr=snr,
|
||||
broadcast_fn=broadcast_fn,
|
||||
)
|
||||
return None
|
||||
@@ -312,7 +332,7 @@ async def create_message_from_decrypted(
|
||||
text=text,
|
||||
sender_timestamp=timestamp,
|
||||
received_at=received,
|
||||
paths=build_message_paths(path, received, path_len),
|
||||
paths=build_message_paths(path, received, path_len, rssi=rssi, snr=snr),
|
||||
sender_name=sender,
|
||||
sender_key=resolved_sender_key,
|
||||
channel_name=channel_name,
|
||||
@@ -334,6 +354,8 @@ async def create_dm_message_from_decrypted(
|
||||
received_at: int | None = None,
|
||||
path: str | None = None,
|
||||
path_len: int | None = None,
|
||||
rssi: int | None = None,
|
||||
snr: float | None = None,
|
||||
outgoing: bool = False,
|
||||
realtime: bool = True,
|
||||
broadcast_fn: BroadcastFn,
|
||||
@@ -348,6 +370,8 @@ async def create_dm_message_from_decrypted(
|
||||
received_at=received_at,
|
||||
path=path,
|
||||
path_len=path_len,
|
||||
rssi=rssi,
|
||||
snr=snr,
|
||||
outgoing=outgoing,
|
||||
realtime=realtime,
|
||||
broadcast_fn=broadcast_fn,
|
||||
|
||||
@@ -300,6 +300,9 @@ export function MessageList({
|
||||
const [resendableIds, setResendableIds] = useState<Set<number>>(new Set());
|
||||
const resendTimersRef = useRef<Map<number, ReturnType<typeof setTimeout>>>(new Map());
|
||||
const packetCacheRef = useRef<Map<number, RawPacket>>(new Map());
|
||||
const packetSignalOverrideRef = useRef<{ rssi: number | null; snr: number | null } | undefined>(
|
||||
undefined
|
||||
);
|
||||
const [packetInspectorSource, setPacketInspectorSource] = useState<
|
||||
| { kind: 'packet'; packet: RawPacket }
|
||||
| { kind: 'loading'; message: string }
|
||||
@@ -325,6 +328,13 @@ export function MessageList({
|
||||
const prevConvKeyRef = useRef<string | null>(null);
|
||||
|
||||
const handleAnalyzePacket = useCallback(async (message: Message) => {
|
||||
// Extract signal from the first path if available
|
||||
const firstPath = message.paths?.[0];
|
||||
packetSignalOverrideRef.current =
|
||||
firstPath && (firstPath.rssi != null || firstPath.snr != null)
|
||||
? { rssi: firstPath.rssi ?? null, snr: firstPath.snr ?? null }
|
||||
: undefined;
|
||||
|
||||
if (message.packet_id == null) {
|
||||
setPacketInspectorSource({
|
||||
kind: 'unavailable',
|
||||
@@ -1180,12 +1190,18 @@ export function MessageList({
|
||||
{packetInspectorSource && (
|
||||
<RawPacketInspectorDialog
|
||||
open={packetInspectorSource !== null}
|
||||
onOpenChange={(isOpen) => !isOpen && setPacketInspectorSource(null)}
|
||||
onOpenChange={(isOpen) => {
|
||||
if (!isOpen) {
|
||||
setPacketInspectorSource(null);
|
||||
packetSignalOverrideRef.current = undefined;
|
||||
}
|
||||
}}
|
||||
channels={channels}
|
||||
source={packetInspectorSource}
|
||||
title="Analyze Packet"
|
||||
description="On-demand raw packet analysis for a message-backed archival packet."
|
||||
notice={ANALYZE_PACKET_NOTICE}
|
||||
signalOverride={packetSignalOverrideRef.current}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -103,14 +103,25 @@ export function PathModal({
|
||||
) : null}
|
||||
|
||||
{/* Raw path summary */}
|
||||
<div className="text-sm">
|
||||
<div className="text-sm space-y-1">
|
||||
{paths.map((p, index) => {
|
||||
const hops = parsePathHops(p.path, p.path_len);
|
||||
const rawPath = hops.length > 0 ? hops.join('->') : 'direct';
|
||||
const hasSignal = p.rssi != null || p.snr != null;
|
||||
return (
|
||||
<div key={index}>
|
||||
<span className="text-foreground/70 font-semibold">Path {index + 1}:</span>{' '}
|
||||
<span className="font-mono text-muted-foreground">{rawPath}</span>
|
||||
<div>
|
||||
<span className="text-foreground/70 font-semibold">Path {index + 1}:</span>{' '}
|
||||
<span className="font-mono text-muted-foreground">{rawPath}</span>
|
||||
</div>
|
||||
{hasSignal && (
|
||||
<div className="text-[0.6875rem] text-muted-foreground ml-4">
|
||||
Last hop (as heard by you):{' '}
|
||||
{p.rssi != null && <span>{p.rssi} dBm RSSI</span>}
|
||||
{p.rssi != null && p.snr != null && <span> · </span>}
|
||||
{p.snr != null && <span>{p.snr.toFixed(1)} dB SNR</span>}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -35,6 +35,11 @@ type RawPacketInspectorDialogSource =
|
||||
message: string;
|
||||
};
|
||||
|
||||
interface SignalOverride {
|
||||
rssi: number | null;
|
||||
snr: number | null;
|
||||
}
|
||||
|
||||
interface RawPacketInspectorDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
@@ -43,10 +48,12 @@ interface RawPacketInspectorDialogProps {
|
||||
title: string;
|
||||
description: string;
|
||||
notice?: ReactNode;
|
||||
signalOverride?: SignalOverride;
|
||||
}
|
||||
|
||||
interface RawPacketInspectionPanelProps {
|
||||
packet: RawPacket;
|
||||
signalOverride?: SignalOverride;
|
||||
channels: Channel[];
|
||||
}
|
||||
|
||||
@@ -125,15 +132,21 @@ function formatTimestamp(timestamp: number): string {
|
||||
});
|
||||
}
|
||||
|
||||
function formatSignal(packet: RawPacket): string {
|
||||
const parts: string[] = [];
|
||||
if (packet.rssi !== null) {
|
||||
parts.push(`${packet.rssi} dBm RSSI`);
|
||||
}
|
||||
if (packet.snr !== null) {
|
||||
parts.push(`${packet.snr.toFixed(1)} dB SNR`);
|
||||
}
|
||||
return parts.length > 0 ? parts.join(' · ') : 'No signal sample';
|
||||
function formatSignal(
|
||||
packet: RawPacket,
|
||||
signalOverride?: SignalOverride
|
||||
): { lines: string[]; label: string } {
|
||||
const rssi = signalOverride?.rssi ?? packet.rssi;
|
||||
const snr = signalOverride?.snr ?? packet.snr;
|
||||
const lines: string[] = [];
|
||||
if (rssi !== null) lines.push(`${rssi} dBm RSSI`);
|
||||
if (snr !== null) lines.push(`${snr.toFixed(1)} dB SNR`);
|
||||
const isOverride =
|
||||
signalOverride != null && (signalOverride.rssi != null || signalOverride.snr != null);
|
||||
return {
|
||||
lines: lines.length > 0 ? lines : ['No signal sample'],
|
||||
label: isOverride ? 'Last Hop Signal' : 'Signal',
|
||||
};
|
||||
}
|
||||
|
||||
function formatByteRange(field: PacketByteField): string {
|
||||
@@ -569,7 +582,11 @@ function FieldSection({
|
||||
);
|
||||
}
|
||||
|
||||
export function RawPacketInspectionPanel({ packet, channels }: RawPacketInspectionPanelProps) {
|
||||
export function RawPacketInspectionPanel({
|
||||
packet,
|
||||
channels,
|
||||
signalOverride,
|
||||
}: RawPacketInspectionPanelProps) {
|
||||
const decoderOptions = useMemo(() => createDecoderOptions(channels), [channels]);
|
||||
const groupTextCandidates = useMemo(
|
||||
() => buildGroupTextResolutionCandidates(channels),
|
||||
@@ -641,11 +658,24 @@ export function RawPacketInspectionPanel({ packet, channels }: RawPacketInspecti
|
||||
primary={`${inspection.routeTypeName} · ${inspection.payloadTypeName}`}
|
||||
secondary={`${inspection.payloadVersionName} · ${formatPathMode(inspection.decoded?.pathHashSize, inspection.pathTokens.length)}`}
|
||||
/>
|
||||
<CompactMetaCard
|
||||
label="Signal"
|
||||
primary={formatSignal(packet)}
|
||||
secondary={packetContext ? null : undefined}
|
||||
/>
|
||||
{(() => {
|
||||
const sig = formatSignal(packet, signalOverride);
|
||||
return (
|
||||
<div className="rounded-lg border border-border/70 bg-card/70 p-2.5">
|
||||
<div className="text-[0.625rem] uppercase tracking-wider text-muted-foreground">
|
||||
{sig.label}
|
||||
</div>
|
||||
{sig.lines.map((line, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className={`${i === 0 ? 'mt-1' : 'mt-0.5'} text-sm font-medium leading-tight text-foreground`}
|
||||
>
|
||||
{line}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
</section>
|
||||
</div>
|
||||
|
||||
@@ -715,6 +745,7 @@ export function RawPacketInspectorDialog({
|
||||
title,
|
||||
description,
|
||||
notice,
|
||||
signalOverride,
|
||||
}: RawPacketInspectorDialogProps) {
|
||||
const [packetInput, setPacketInput] = useState('');
|
||||
|
||||
@@ -739,7 +770,13 @@ export function RawPacketInspectorDialog({
|
||||
|
||||
let body: ReactNode;
|
||||
if (source.kind === 'packet') {
|
||||
body = <RawPacketInspectionPanel packet={source.packet} channels={channels} />;
|
||||
body = (
|
||||
<RawPacketInspectionPanel
|
||||
packet={source.packet}
|
||||
channels={channels}
|
||||
signalOverride={signalOverride}
|
||||
/>
|
||||
);
|
||||
} else if (source.kind === 'paste') {
|
||||
body = (
|
||||
<>
|
||||
|
||||
@@ -255,6 +255,10 @@ export interface MessagePath {
|
||||
received_at: number;
|
||||
/** Hop count (number of intermediate nodes). Null for legacy data (infer as len(path)/2). */
|
||||
path_len?: number | null;
|
||||
/** Last-hop RSSI in dBm (null if not available, e.g. older data) */
|
||||
rssi?: number | null;
|
||||
/** Last-hop SNR in dB (null if not available, e.g. older data) */
|
||||
snr?: number | null;
|
||||
}
|
||||
|
||||
export interface Message {
|
||||
|
||||
@@ -883,7 +883,7 @@ class TestDirectMessageDirectionDetection:
|
||||
message_broadcasts = [b for b in broadcasts if b["type"] == "message"]
|
||||
assert len(message_broadcasts) == 1
|
||||
assert message_broadcasts[0]["data"]["paths"] == [
|
||||
{"path": "", "received_at": SENDER_TIMESTAMP, "path_len": 0}
|
||||
{"path": "", "received_at": SENDER_TIMESTAMP, "path_len": 0, "rssi": None, "snr": None}
|
||||
]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
|
||||
Reference in New Issue
Block a user