From 1e3a8388dad138becb0809d955f9e92cbf43cabc Mon Sep 17 00:00:00 2001 From: Patrick Skillen Date: Mon, 1 Jun 2026 11:39:17 +0100 Subject: [PATCH 1/5] feat(meshcore): extend heard-path adapters for per-feeder MC legs --- .../messages/heard-path-constants.ts | 3 + .../messages/heard-path-map-adapters.ts | 97 +++++++++++++++---- 2 files changed, 81 insertions(+), 19 deletions(-) create mode 100644 src/components/messages/heard-path-constants.ts diff --git a/src/components/messages/heard-path-constants.ts b/src/components/messages/heard-path-constants.ts new file mode 100644 index 0000000..01b12bf --- /dev/null +++ b/src/components/messages/heard-path-constants.ts @@ -0,0 +1,3 @@ +export const HEARD_PATH_SENDER_COLOR = '#16a34a'; + +export const HEARD_PATH_LEG_COLORS = ['#2563eb', '#0891b2', '#7c3aed', '#db2777', '#ea580c']; diff --git a/src/components/messages/heard-path-map-adapters.ts b/src/components/messages/heard-path-map-adapters.ts index 336ce76..ecef944 100644 --- a/src/components/messages/heard-path-map-adapters.ts +++ b/src/components/messages/heard-path-map-adapters.ts @@ -7,6 +7,7 @@ import type { TextMessage, } from '@/lib/models'; import type { TracerouteRouteNode } from '@/lib/models'; +import { HEARD_PATH_LEG_COLORS } from './heard-path-constants'; import type { HeardPathLeg } from './HeardPathMap'; const UNKNOWN_NODE_ID = 0xffffffff; @@ -17,6 +18,29 @@ export function isMeshCoreHeardObservation( return 'path_hashes' in obs; } +export type MeshCoreHeardLeg = { + observation: MeshCoreHeardObservation; + receiverLabel: string; + receiverPosition: MapPosition | null; + hops: ResolvedHop[]; + pathKnown: boolean; + lineColor: string; +}; + +export function resolvedHopsFromObservation(obs: MeshCoreHeardObservation): ResolvedHop[] { + if (obs.resolved_path?.length) { + return obs.resolved_path; + } + return (obs.path_hashes ?? []).map((hash) => ({ + hash, + status: 'unknown' as const, + node_id_str: null, + internal_id: null, + long_name: null, + ambiguous: false, + })); +} + function senderFromMcCandidates(candidates: McSenderCandidate[] | undefined): { label: string; position: MapPosition; @@ -33,7 +57,7 @@ function senderFromMcCandidates(candidates: McSenderCandidate[] | undefined): { }; } -function mapSender(message: TextMessage): { label: string; position: MapPosition } | null { +export function mapHeardPathSender(message: TextMessage): { label: string; position: MapPosition } | null { if (message.sender && message.sender_position) { return { label: message.sender.short_name || message.sender.node_id_str, @@ -49,6 +73,14 @@ function mapSender(message: TextMessage): { label: string; position: MapPosition return senderFromMcCandidates(message.mc_sender_candidates); } +export function heardPathSenderDisplayLabel( + message: TextMessage, + sender: { label: string; position: MapPosition } | null +): string { + if (sender?.label) return sender.label; + return message.mc_sender_label?.trim() || message.sender?.short_name || message.sender?.node_id_str || 'Sender'; +} + function hopToWaypoint(hop: ResolvedHop): TracerouteRouteNode { return { meshtastic_node_id: UNKNOWN_NODE_ID, @@ -58,14 +90,40 @@ function hopToWaypoint(hop: ResolvedHop): TracerouteRouteNode { }; } +export function meshCoreHeardLegs(message: TextMessage): { + sender: { label: string; position: MapPosition } | null; + senderDisplayLabel: string; + legs: MeshCoreHeardLeg[]; +} { + const sender = mapHeardPathSender(message); + const senderDisplayLabel = heardPathSenderDisplayLabel(message, sender); + const legs: MeshCoreHeardLeg[] = []; + let colorIndex = 0; + + for (const obs of message.heard ?? []) { + if (!isMeshCoreHeardObservation(obs)) continue; + legs.push({ + observation: obs, + receiverLabel: obs.observer.short_name || obs.observer.node_id_str, + receiverPosition: obs.observer.position, + hops: resolvedHopsFromObservation(obs), + pathKnown: obs.path_known ?? false, + lineColor: HEARD_PATH_LEG_COLORS[colorIndex % HEARD_PATH_LEG_COLORS.length], + }); + colorIndex += 1; + } + + return { sender, senderDisplayLabel, legs }; +} + export function meshtasticHeardToLegs(message: TextMessage): { sender: { label: string; position: MapPosition } | null; legs: HeardPathLeg[]; } { - const sender = mapSender(message); + const sender = mapHeardPathSender(message); const legs: HeardPathLeg[] = []; - for (const obs of message.heard) { + for (const obs of message.heard ?? []) { if (isMeshCoreHeardObservation(obs)) continue; const position = obs.observer_position; if (!position) continue; @@ -81,28 +139,23 @@ export function meshtasticHeardToLegs(message: TextMessage): { return { sender, legs }; } +/** Geo map legs for MC: positioned feeders only (hop polylines not drawn on map). */ export function meshCoreHeardToLegs(message: TextMessage): { sender: { label: string; position: MapPosition } | null; legs: HeardPathLeg[]; } { - const sender = mapSender(message); - - const legs: HeardPathLeg[] = []; - for (const obs of message.heard) { - if (!isMeshCoreHeardObservation(obs)) continue; - const position = obs.observer.position; - if (!position) continue; - const waypoints = (obs.resolved_path ?? []).map(hopToWaypoint); - legs.push({ - receiver: { - label: obs.observer.short_name || obs.observer.node_id_str, - position, - }, - waypoints, - pathKnown: obs.path_known ?? false, + const { sender, legs } = meshCoreHeardLegs(message); + const mapLegs: HeardPathLeg[] = []; + for (const leg of legs) { + if (!leg.receiverPosition) continue; + mapLegs.push({ + receiver: { label: leg.receiverLabel, position: leg.receiverPosition }, + waypoints: leg.hops.map(hopToWaypoint), + pathKnown: leg.pathKnown, + lineColor: leg.lineColor, }); } - return { sender, legs }; + return { sender, legs: mapLegs }; } export function messageToHeardPathLegs(message: TextMessage): { @@ -115,3 +168,9 @@ export function messageToHeardPathLegs(message: TextMessage): { } return meshtasticHeardToLegs(message); } + +export function isMeshCoreHeardMessage(message: TextMessage): boolean { + const proto = message.protocol?.toString().toLowerCase(); + if (proto === 'meshcore' || proto === '2') return true; + return (message.heard ?? []).some(isMeshCoreHeardObservation); +} From a8058d9d7af87356f4fd3e6b4e9c7028c9a6f41e Mon Sep 17 00:00:00 2001 From: Patrick Skillen Date: Mon, 1 Jun 2026 11:40:08 +0100 Subject: [PATCH 2/5] feat(meshcore): add logical heard-path flow and geo-anchor map components --- src/components/messages/HeardPathGeoMap.tsx | 142 ++++++++++++++++++ src/components/messages/HeardPathMap.tsx | 8 +- .../messages/MeshCoreHeardPathFlow.tsx | 49 ++++++ .../messages/MeshCoreHeardPathsPanel.tsx | 37 +++++ src/components/messages/PathHopBadge.tsx | 68 +++++++++ src/components/messages/PathHopChain.tsx | 24 +++ 6 files changed, 324 insertions(+), 4 deletions(-) create mode 100644 src/components/messages/HeardPathGeoMap.tsx create mode 100644 src/components/messages/MeshCoreHeardPathFlow.tsx create mode 100644 src/components/messages/MeshCoreHeardPathsPanel.tsx create mode 100644 src/components/messages/PathHopBadge.tsx create mode 100644 src/components/messages/PathHopChain.tsx diff --git a/src/components/messages/HeardPathGeoMap.tsx b/src/components/messages/HeardPathGeoMap.tsx new file mode 100644 index 0000000..096275d --- /dev/null +++ b/src/components/messages/HeardPathGeoMap.tsx @@ -0,0 +1,142 @@ +import type { MapPosition } from '@/lib/models'; +import { MAP_NODE_MARKER_CSS } from '@/lib/map-marker-styles'; +import { useMapTileUrl } from '@/hooks/useMapTileUrl'; +import { createNodeIcon } from '@/components/nodes/map-utils'; +import type { LatLng } from '@/lib/map-path-segments'; +import { HEARD_PATH_LEG_COLORS, HEARD_PATH_SENDER_COLOR } from './heard-path-constants'; +import L from 'leaflet'; +import { useEffect, useRef } from 'react'; +import 'leaflet/dist/leaflet.css'; + +const DEFAULT_CENTER: LatLng = [55.8642, -4.2518]; + +export type HeardPathGeoAnchor = { + label: string; + position: MapPosition; + color?: string; +}; + +export type HeardPathGeoMapProps = { + sender: HeardPathGeoAnchor | null; + feeders: HeardPathGeoAnchor[]; + senderName?: string | null; +}; + +function toLatLng(pos: MapPosition): LatLng { + return [pos.latitude, pos.longitude]; +} + +export function HeardPathGeoMap({ sender, feeders, senderName }: HeardPathGeoMapProps) { + const mapRef = useRef(null); + const mapInstanceRef = useRef(null); + const tileLayerRef = useRef(null); + const layersRef = useRef([]); + const { url: tileUrl, attribution } = useMapTileUrl(); + + useEffect(() => { + if (mapRef.current && !mapInstanceRef.current) { + const map = L.map(mapRef.current).setView(DEFAULT_CENTER, 13); + const tileLayer = L.tileLayer(tileUrl, { attribution }).addTo(map); + tileLayerRef.current = tileLayer; + mapInstanceRef.current = map; + + const style = document.createElement('style'); + style.id = 'heard-path-geo-map-styles'; + style.textContent = MAP_NODE_MARKER_CSS; + document.head.appendChild(style); + + return () => { + map.remove(); + mapInstanceRef.current = null; + tileLayerRef.current = null; + style.remove(); + }; + } + }, []); + + useEffect(() => { + const map = mapInstanceRef.current; + const oldLayer = tileLayerRef.current; + if (map && oldLayer) { + map.removeLayer(oldLayer); + const newLayer = L.tileLayer(tileUrl, { attribution }).addTo(map); + tileLayerRef.current = newLayer; + } + }, [tileUrl, attribution]); + + useEffect(() => { + const map = mapInstanceRef.current; + if (!map) return; + + layersRef.current.forEach((layer) => layer.remove()); + layersRef.current = []; + + const bounds = L.latLngBounds([]); + let hasMarker = false; + + if (sender) { + const pos = toLatLng(sender.position); + const marker = L.marker(pos, { + icon: createNodeIcon(sender.label, HEARD_PATH_SENDER_COLOR, false), + }).addTo(map); + layersRef.current.push(marker); + bounds.extend(pos); + hasMarker = true; + } + + feeders.forEach((feeder, index) => { + const pos = toLatLng(feeder.position); + const color = feeder.color ?? HEARD_PATH_LEG_COLORS[index % HEARD_PATH_LEG_COLORS.length]; + const marker = L.marker(pos, { + icon: createNodeIcon(feeder.label, color, false), + }).addTo(map); + layersRef.current.push(marker); + bounds.extend(pos); + hasMarker = true; + }); + + if (!hasMarker) return; + + map.invalidateSize(); + const t = setTimeout(() => { + if (mapInstanceRef.current !== map) return; + map.invalidateSize(); + const singlePoint = bounds.getNorthEast().equals(bounds.getSouthWest()); + if (singlePoint) { + map.setView(bounds.getCenter(), 13); + } else { + map.fitBounds(bounds, { padding: [40, 40], maxZoom: 15 }); + } + }, 150); + return () => clearTimeout(t); + }, [sender, feeders]); + + if (!sender && feeders.length === 0) { + return ( +
+ No map — sender and feeder positions unknown +
+ ); + } + + const showSenderWarning = !sender; + const warningLabel = senderName?.trim() || sender?.label; + + return ( +
+
+ {showSenderWarning && feeders.length > 0 && ( +
+ Sender position unknown + {warningLabel ? ` (${warningLabel})` : ''} — feeders shown; hop paths are schematic below. +
+ )} +
+ ); +} diff --git a/src/components/messages/HeardPathMap.tsx b/src/components/messages/HeardPathMap.tsx index dbef575..448417e 100644 --- a/src/components/messages/HeardPathMap.tsx +++ b/src/components/messages/HeardPathMap.tsx @@ -7,9 +7,9 @@ import L from 'leaflet'; import { useEffect, useRef } from 'react'; import 'leaflet/dist/leaflet.css'; +import { HEARD_PATH_LEG_COLORS, HEARD_PATH_SENDER_COLOR } from './heard-path-constants'; + const DEFAULT_CENTER: LatLng = [55.8642, -4.2518]; -const SENDER_COLOR = '#16a34a'; -const LEG_COLORS = ['#2563eb', '#0891b2', '#7c3aed', '#db2777', '#ea580c']; export type MapPosition = { latitude: number; longitude: number }; @@ -86,7 +86,7 @@ export function HeardPathMap({ sender, legs, senderName }: HeardPathMapProps) { if (senderPos) { const senderMarker = L.marker(senderPos, { - icon: createNodeIcon(sender?.label ?? 'S', SENDER_COLOR, false), + icon: createNodeIcon(sender?.label ?? 'S', HEARD_PATH_SENDER_COLOR, false), }).addTo(map); layersRef.current.push(senderMarker); bounds.extend(senderPos); @@ -94,7 +94,7 @@ export function HeardPathMap({ sender, legs, senderName }: HeardPathMapProps) { legs.forEach((leg, index) => { const receiverPos = toLatLng(leg.receiver.position); - const color = leg.lineColor ?? LEG_COLORS[index % LEG_COLORS.length]; + const color = leg.lineColor ?? HEARD_PATH_LEG_COLORS[index % HEARD_PATH_LEG_COLORS.length]; const receiverMarker = L.marker(receiverPos, { icon: createNodeIcon(leg.receiver.label, color, false), }).addTo(map); diff --git a/src/components/messages/MeshCoreHeardPathFlow.tsx b/src/components/messages/MeshCoreHeardPathFlow.tsx new file mode 100644 index 0000000..2fe6e49 --- /dev/null +++ b/src/components/messages/MeshCoreHeardPathFlow.tsx @@ -0,0 +1,49 @@ +import { Badge } from '@/components/ui/badge'; +import { PathHopChain } from './PathHopChain'; +import type { MeshCoreHeardLeg } from './heard-path-map-adapters'; +import { ArrowRight } from 'lucide-react'; +import { cn } from '@/lib/utils'; + +export type MeshCoreHeardPathFlowProps = { + leg: MeshCoreHeardLeg; + senderDisplayLabel: string; + senderKnown: boolean; +}; + +export function MeshCoreHeardPathFlow({ leg, senderDisplayLabel, senderKnown }: MeshCoreHeardPathFlowProps) { + const startBadge = ( + + {senderKnown ? senderDisplayLabel : `Sender unknown${senderDisplayLabel ? ` (${senderDisplayLabel})` : ''}`} + + ); + + const endBadge = ( + + {leg.receiverLabel} + + ); + + if (leg.hops.length === 0) { + return ( +
+ {startBadge} + + {endBadge} + No path recorded for this observation +
+ ); + } + + return ( +
+ {startBadge} + + + + {endBadge} +
+ ); +} diff --git a/src/components/messages/MeshCoreHeardPathsPanel.tsx b/src/components/messages/MeshCoreHeardPathsPanel.tsx new file mode 100644 index 0000000..b9f6fb0 --- /dev/null +++ b/src/components/messages/MeshCoreHeardPathsPanel.tsx @@ -0,0 +1,37 @@ +import { MeshCoreHeardPathFlow } from './MeshCoreHeardPathFlow'; +import type { MeshCoreHeardLeg } from './heard-path-map-adapters'; + +export type MeshCoreHeardPathsPanelProps = { + legs: MeshCoreHeardLeg[]; + senderDisplayLabel: string; + senderKnown: boolean; +}; + +export function MeshCoreHeardPathsPanel({ legs, senderDisplayLabel, senderKnown }: MeshCoreHeardPathsPanelProps) { + if (legs.length === 0) { + return ( +

+ No feeder observations for this message. +

+ ); + } + + return ( +
+

+ Paths are per feeder and may differ for the same message. Hop hashes are list-order evidence, not map + coordinates. +

+ {legs.map((leg) => ( +
+
Heard by {leg.receiverLabel}
+ +
+ ))} +
+ ); +} diff --git a/src/components/messages/PathHopBadge.tsx b/src/components/messages/PathHopBadge.tsx new file mode 100644 index 0000000..6028301 --- /dev/null +++ b/src/components/messages/PathHopBadge.tsx @@ -0,0 +1,68 @@ +import { Link } from 'react-router-dom'; +import { Badge } from '@/components/ui/badge'; +import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'; +import type { ResolvedHop } from '@/lib/models'; +import { nodeDetailPath } from '@/lib/node-detail-routes'; +import { cn } from '@/lib/utils'; + +function hopIsLinkable(hop: ResolvedHop): boolean { + return hop.status === 'resolved' && Boolean(hop.node_id_str?.trim()); +} + +function hopLabel(hop: ResolvedHop): string { + return hop.long_name?.trim() || hop.hash; +} + +export function PathHopBadge({ hop }: { hop: ResolvedHop }) { + const unknown = hop.status === 'unknown' || !hopIsLinkable(hop); + const badge = ( + + {hopLabel(hop)} + + ); + + const wrapped = + hop.ambiguous && unknown ? ( + + + + {badge} + + Multiple matches possible + + + ) : ( + badge + ); + + if (!hopIsLinkable(hop)) { + return wrapped; + } + + const path = nodeDetailPath({ + node_id_str: hop.node_id_str!, + internal_id: hop.internal_id ?? undefined, + protocol: 2, + }); + + if (!path) { + return wrapped; + } + + return ( + e.stopPropagation()} + > + {wrapped} + + ); +} diff --git a/src/components/messages/PathHopChain.tsx b/src/components/messages/PathHopChain.tsx new file mode 100644 index 0000000..56e1e8f --- /dev/null +++ b/src/components/messages/PathHopChain.tsx @@ -0,0 +1,24 @@ +import type { ResolvedHop } from '@/lib/models'; +import { PathHopBadge } from './PathHopBadge'; +import { ArrowRight } from 'lucide-react'; + +export function PathHopChain({ hops, emptyLabel }: { hops: ResolvedHop[]; emptyLabel?: string }) { + if (hops.length === 0) { + return ( + + {emptyLabel ?? 'No path recorded for this observation'} + + ); + } + + return ( +
+ {hops.map((hop, index) => ( + + {index > 0 && } + + + ))} +
+ ); +} From 563fa83320076c13a58016edffc6c061040137a4 Mon Sep 17 00:00:00 2001 From: Patrick Skillen Date: Mon, 1 Jun 2026 11:40:41 +0100 Subject: [PATCH 3/5] feat(meshcore): show logical per-feeder paths in message heard dialog --- src/components/messages/MessageItem.tsx | 222 ++++++++++++++---------- 1 file changed, 135 insertions(+), 87 deletions(-) diff --git a/src/components/messages/MessageItem.tsx b/src/components/messages/MessageItem.tsx index 24b7778..5adbfca 100644 --- a/src/components/messages/MessageItem.tsx +++ b/src/components/messages/MessageItem.tsx @@ -1,7 +1,16 @@ -import { TextMessage, type PacketObservation, type MeshCoreHeardObservation } from '@/lib/models'; +import { TextMessage, type PacketObservation } from '@/lib/models'; import { messageProtocol } from '@/lib/message-protocol'; +import { HeardPathGeoMap } from '@/components/messages/HeardPathGeoMap'; import { HeardPathMap } from '@/components/messages/HeardPathMap'; -import { isMeshCoreHeardObservation, messageToHeardPathLegs } from '@/components/messages/heard-path-map-adapters'; +import { MeshCoreHeardPathsPanel } from '@/components/messages/MeshCoreHeardPathsPanel'; +import { PathHopChain } from '@/components/messages/PathHopChain'; +import { + isMeshCoreHeardMessage, + isMeshCoreHeardObservation, + meshCoreHeardLegs, + messageToHeardPathLegs, + resolvedHopsFromObservation, +} from '@/components/messages/heard-path-map-adapters'; import { Avatar } from '@/components/ui/avatar'; import { Badge } from '@/components/ui/badge'; import { Button } from '@/components/ui/button'; @@ -14,6 +23,83 @@ import { ExternalLink } from 'lucide-react'; import { messageSenderDisplay } from '@/lib/message-display-sender'; import { nodeDetailPath } from '@/lib/node-detail-routes'; +function MeshCoreHeardDialogBody({ message }: { message: TextMessage }) { + const { sender, senderDisplayLabel, legs } = useMemo(() => meshCoreHeardLegs(message), [message]); + const senderKnown = sender != null; + + const geoFeeders = useMemo( + () => + legs + .filter((leg) => leg.receiverPosition != null) + .map((leg) => ({ + label: leg.receiverLabel, + position: leg.receiverPosition!, + color: leg.lineColor, + })), + [legs] + ); + + const geoSender = sender ? { label: sender.label, position: sender.position } : null; + + return ( + <> + + {message.mc_sender_candidates && message.mc_sender_candidates.length > 1 && ( +

+ Multiple nodes match sender "{message.mc_sender_label}" — map uses feeder positions only. +

+ )} + +
+ {legs.map((leg, index) => { + const mc = leg.observation; + const observerLink = nodeDetailPath({ + internal_id: mc.observer.internal_id ?? undefined, + node_id_str: mc.observer.node_id_str, + protocol: 2, + }); + const hops = resolvedHopsFromObservation(mc); + return ( +
+
+
+ {observerLink ? ( + + {leg.receiverLabel} + + ) : ( + leg.receiverLabel + )} +
+ {mc.observer.long_name &&
{mc.observer.long_name}
} +
+ {format(new Date(mc.rx_time), 'MMM d, yyyy h:mm a')} +
+
+ {mc.rx_rssi != null &&
RSSI: {mc.rx_rssi.toFixed(1)}
} + {mc.rx_snr != null &&
SNR: {mc.rx_snr.toFixed(1)}
} +
+
+
+
Path (this feeder)
+ +
+
+ ); + })} +
+ + ); +} + function HeardDialog({ message, size = 'sm', @@ -25,6 +111,7 @@ function HeardDialog({ }) { const observations = message.heard; const count = observations?.length || 0; + const meshCore = isMeshCoreHeardMessage(message); const { sender, legs } = useMemo(() => messageToHeardPathLegs(message), [message]); const protocol = messageProtocol(message); @@ -48,95 +135,56 @@ function HeardDialog({ Message Heard By - - {message.mc_sender_candidates && message.mc_sender_candidates.length > 1 && ( -

- Multiple nodes match sender "{message.mc_sender_label}" — map uses feeder positions only. -

- )} -
- {observations?.length ? ( - observations.map((observation, index) => { - if (protocol === 'meshcore' || isMeshCoreHeardObservation(observation)) { - const mc = observation as MeshCoreHeardObservation; - const observerLabel = mc.observer.short_name || mc.observer.node_id_str; - const observerLink = nodeDetailPath({ - internal_id: mc.observer.internal_id ?? undefined, - node_id_str: mc.observer.node_id_str, - protocol: 2, - }); - return ( -
-
-
- {observerLink ? ( - - {observerLabel} - - ) : ( - observerLabel + {meshCore ? ( + + ) : ( + <> + +
+ {observations?.length ? ( + observations.map((observation) => { + if (protocol === 'meshcore' || isMeshCoreHeardObservation(observation)) { + return null; + } + const mt = observation as PacketObservation; + return ( +
+
+
{mt.observer.short_name || mt.observer.node_id_str}
+ {mt.observer.long_name && ( +
{mt.observer.long_name}
)} +
+ {format(new Date(mt.rx_time), 'MMM d, yyyy h:mm a')} +
- {mc.observer.long_name && ( -
{mc.observer.long_name}
- )} -
- {format(new Date(mc.rx_time), 'MMM d, yyyy h:mm a')} +
+ {mt.direct_from_sender ? ( +
+ Direct + {mt.rx_rssi != null &&
RSSI: {mt.rx_rssi.toFixed(1)}
} + {mt.rx_snr != null &&
SNR: {mt.rx_snr.toFixed(1)}
} +
+ ) : ( + Hop: {mt.hop_count} + )}
- {mc.resolved_path && mc.resolved_path.length > 0 && ( -
- {mc.resolved_path.map((hop) => ( - - {hop.hash} - - ))} -
- )} -
-
- {mc.rx_rssi != null &&
RSSI: {mc.rx_rssi.toFixed(1)}
} - {mc.rx_snr != null &&
SNR: {mc.rx_snr.toFixed(1)}
} -
-
- ); - } - const mt = observation as PacketObservation; - return ( -
-
-
{mt.observer.short_name || mt.observer.node_id_str}
- {mt.observer.long_name && ( -
{mt.observer.long_name}
- )} -
- {format(new Date(mt.rx_time), 'MMM d, yyyy h:mm a')}
-
-
- {mt.direct_from_sender ? ( -
- Direct - {mt.rx_rssi != null &&
RSSI: {mt.rx_rssi.toFixed(1)}
} - {mt.rx_snr != null &&
SNR: {mt.rx_snr.toFixed(1)}
} -
- ) : ( - Hop: {mt.hop_count} - )} -
-
- ); - }) - ) : ( -
No observation data available
- )} -
+ ); + }) + ) : ( +
No observation data available
+ )} +
+ + )} ); From d22fe7e37da81b256d7a1393979cb321741106db Mon Sep 17 00:00:00 2001 From: Patrick Skillen Date: Mon, 1 Jun 2026 11:41:13 +0100 Subject: [PATCH 4/5] test(meshcore): cover heard-path logical layout and adapters --- .../messages/MeshCoreHeardPathFlow.test.tsx | 90 +++++++++++ .../messages/heard-path-map-adapters.test.ts | 143 +++++++++++++++++- 2 files changed, 232 insertions(+), 1 deletion(-) create mode 100644 src/components/messages/MeshCoreHeardPathFlow.test.tsx diff --git a/src/components/messages/MeshCoreHeardPathFlow.test.tsx b/src/components/messages/MeshCoreHeardPathFlow.test.tsx new file mode 100644 index 0000000..fab0cfa --- /dev/null +++ b/src/components/messages/MeshCoreHeardPathFlow.test.tsx @@ -0,0 +1,90 @@ +import { describe, expect, it } from 'vitest'; +import { render, screen } from '@testing-library/react'; +import { MemoryRouter } from 'react-router-dom'; +import { MeshCoreHeardPathFlow } from './MeshCoreHeardPathFlow'; +import type { MeshCoreHeardLeg } from './heard-path-map-adapters'; + +const baseLeg: MeshCoreHeardLeg = { + observation: { + observer: { + node_id_str: 'mc:feed', + internal_id: null, + long_name: 'Feeder', + short_name: 'F', + position: { latitude: 55.2, longitude: -4.2 }, + }, + rx_time: new Date().toISOString(), + rx_rssi: -90, + rx_snr: 2, + path_hashes: ['aa', 'bb'], + resolved_path: [ + { + hash: 'aa', + status: 'unknown', + node_id_str: null, + internal_id: null, + long_name: null, + ambiguous: false, + }, + { + hash: 'bb', + status: 'unknown', + node_id_str: null, + internal_id: null, + long_name: null, + ambiguous: false, + }, + ], + path_known: false, + }, + receiverLabel: 'F', + receiverPosition: { latitude: 55.2, longitude: -4.2 }, + hops: [ + { + hash: 'aa', + status: 'unknown', + node_id_str: null, + internal_id: null, + long_name: null, + ambiguous: false, + }, + { + hash: 'bb', + status: 'unknown', + node_id_str: null, + internal_id: null, + long_name: null, + ambiguous: false, + }, + ], + pathKnown: false, + lineColor: '#2563eb', +}; + +function renderFlow(leg: MeshCoreHeardLeg, senderKnown: boolean) { + return render( + + + + ); +} + +describe('MeshCoreHeardPathFlow', () => { + it('renders one hop badge per segment', () => { + renderFlow(baseLeg, true); + expect(screen.getByText('aa')).toBeInTheDocument(); + expect(screen.getByText('bb')).toBeInTheDocument(); + expect(screen.queryAllByRole('link')).toHaveLength(0); + }); + + it('shows empty path message when no hops', () => { + const leg = { ...baseLeg, hops: [] }; + renderFlow(leg, true); + expect(screen.getByText(/No path recorded for this observation/i)).toBeInTheDocument(); + }); + + it('shows sender unknown label when sender not known', () => { + renderFlow(baseLeg, false); + expect(screen.getByText(/Sender unknown/i)).toBeInTheDocument(); + }); +}); diff --git a/src/components/messages/heard-path-map-adapters.test.ts b/src/components/messages/heard-path-map-adapters.test.ts index 398ff80..a4fc556 100644 --- a/src/components/messages/heard-path-map-adapters.test.ts +++ b/src/components/messages/heard-path-map-adapters.test.ts @@ -1,5 +1,10 @@ import { describe, expect, it } from 'vitest'; -import { meshtasticHeardToLegs, meshCoreHeardToLegs } from './heard-path-map-adapters'; +import { + meshCoreHeardLegs, + meshCoreHeardToLegs, + meshtasticHeardToLegs, + resolvedHopsFromObservation, +} from './heard-path-map-adapters'; import type { TextMessage } from '@/lib/models'; describe('heard path adapters', () => { @@ -168,4 +173,140 @@ describe('heard path adapters', () => { expect(legs[0].waypoints[0].position).toBeNull(); expect(legs[0].pathKnown).toBe(false); }); + + it('meshCoreHeardLegs includes feeder without observer position', () => { + const message: TextMessage = { + id: '5', + packet_id: 5, + protocol: 'meshcore', + sender: { node_id_str: 'mc:abc', long_name: 'X', short_name: 'X' }, + sender_position: { latitude: 55.0, longitude: -4.0 }, + recipient_meshtastic_node_id: null, + channel: 1, + sent_at: new Date().toISOString(), + message_text: 'mc', + is_emoji: false, + reply_to_meshtastic_packet_id: null, + heard: [ + { + observer: { + node_id_str: 'mc:feed', + internal_id: null, + long_name: 'Feeder', + short_name: 'F', + position: null, + }, + rx_time: new Date().toISOString(), + rx_rssi: -90, + rx_snr: 2, + path_hashes: ['aa'], + path_known: false, + }, + ], + }; + const { legs } = meshCoreHeardLegs(message); + expect(legs).toHaveLength(1); + expect(legs[0].receiverPosition).toBeNull(); + expect(legs[0].hops).toHaveLength(1); + expect(legs[0].hops[0].hash).toBe('aa'); + }); + + it('meshCoreHeardLegs returns distinct paths per feeder', () => { + const message: TextMessage = { + id: '6', + packet_id: 6, + protocol: 'meshcore', + sender: { node_id_str: 'mc:abc', long_name: 'X', short_name: 'X' }, + sender_position: { latitude: 55.0, longitude: -4.0 }, + recipient_meshtastic_node_id: null, + channel: 1, + sent_at: new Date().toISOString(), + message_text: 'mc', + is_emoji: false, + reply_to_meshtastic_packet_id: null, + heard: [ + { + observer: { + node_id_str: 'mc:f1', + internal_id: null, + long_name: 'F1', + short_name: 'F1', + position: { latitude: 55.1, longitude: -4.1 }, + }, + rx_time: new Date().toISOString(), + rx_rssi: -90, + rx_snr: 2, + path_hashes: ['aa'], + resolved_path: [ + { + hash: 'aa', + status: 'unknown', + node_id_str: null, + internal_id: null, + long_name: null, + ambiguous: false, + }, + ], + path_known: false, + }, + { + observer: { + node_id_str: 'mc:f2', + internal_id: null, + long_name: 'F2', + short_name: 'F2', + position: { latitude: 55.2, longitude: -4.2 }, + }, + rx_time: new Date().toISOString(), + rx_rssi: -88, + rx_snr: 3, + path_hashes: ['aa', 'cc'], + resolved_path: [ + { + hash: 'aa', + status: 'unknown', + node_id_str: null, + internal_id: null, + long_name: null, + ambiguous: false, + }, + { + hash: 'cc', + status: 'unknown', + node_id_str: null, + internal_id: null, + long_name: null, + ambiguous: false, + }, + ], + path_known: false, + }, + ], + }; + const { legs } = meshCoreHeardLegs(message); + expect(legs).toHaveLength(2); + expect(legs[0].hops).toHaveLength(1); + expect(legs[1].hops).toHaveLength(2); + expect(legs[0].lineColor).not.toBe(legs[1].lineColor); + }); + + it('resolvedHopsFromObservation falls back to path_hashes', () => { + const hops = resolvedHopsFromObservation({ + observer: { + node_id_str: 'mc:f', + internal_id: null, + long_name: null, + short_name: 'F', + position: null, + }, + rx_time: new Date().toISOString(), + rx_rssi: null, + rx_snr: null, + path_hashes: ['de', 'ad'], + path_known: false, + }); + expect(hops).toHaveLength(2); + expect(hops[0].status).toBe('unknown'); + expect(hops[1].hash).toBe('ad'); + }); }); From dc93844516c9c6493683d38b3bfcce76cefffc93 Mon Sep 17 00:00:00 2001 From: Patrick Skillen Date: Mon, 1 Jun 2026 11:41:46 +0100 Subject: [PATCH 5/5] docs(meshcore): document heard dialog logical path layout --- docs/meshcore/heard-path-dialog.md | 17 +++++++++++++++++ docs/meshcore/passive-path-preview.md | 2 ++ 2 files changed, 19 insertions(+) create mode 100644 docs/meshcore/heard-path-dialog.md diff --git a/docs/meshcore/heard-path-dialog.md b/docs/meshcore/heard-path-dialog.md new file mode 100644 index 0000000..a07b7ed --- /dev/null +++ b/docs/meshcore/heard-path-dialog.md @@ -0,0 +1,17 @@ +## Message heard dialog (MeshCore paths) + +Opened from **N heard** on a MeshCore text message in message history. + +### Layout + +1. **Geo map** — Leaflet markers for sender (when position known) and feeders with coordinates. No hop polylines on the map. +2. **Paths by feeder** — One schematic row per observation: sender → hash hops → feeder. Hops use dashed monospace badges (`unknown` per API v1); they are **not** placed at geographic coordinates. +3. **Feeder list** — Each observer with RSSI/SNR and a **Path (this feeder)** column showing that observation’s hop chain. + +Each feeder can report a **different** `path_hashes` / `resolved_path` for the same message. + +### Related + +- Tracking: [meshflow-ui#311](https://github.com/pskillen/meshflow-ui/issues/311) +- Passive path diagnostic tables: [passive-path-preview.md](./passive-path-preview.md) (`/meshcore/path-tracing`) +- Geographic hop placement on maps: deferred — see [meshflow-api packet-path-tracing outstanding](https://github.com/pskillen/meshflow-api/blob/main/docs/features/meshcore/packet-path-tracing/packet-path-tracing-outstanding.md) diff --git a/docs/meshcore/passive-path-preview.md b/docs/meshcore/passive-path-preview.md index c22f599..6de0b7f 100644 --- a/docs/meshcore/passive-path-preview.md +++ b/docs/meshcore/passive-path-preview.md @@ -10,3 +10,5 @@ This page is a **diagnostic MVP** for MeshCore passive packet path tracing (API Staff users can manually annotate a segment (link hash to an observed node) via `PATCH /api/meshcore/path-tracing/segments/{id}/`. This preview supports **M2 decision-making** (mode/size distribution, chain sanity). The full map, realtime buffer, and centrality UI are tracked separately as [meshflow-ui#309](https://github.com/pskillen/meshflow-ui/issues/309) (M7), building on API work in [meshflow-api#372](https://github.com/pskillen/meshflow-api/issues/372). + +For **per-message** passive paths in the heard dialog (logical hop chains per feeder), see [heard-path-dialog.md](./heard-path-dialog.md) ([#311](https://github.com/pskillen/meshflow-ui/issues/311)).