mirror of
https://github.com/Kpa-clawbot/meshcore-analyzer.git
synced 2026-08-28 14:34:14 +00:00
feat: expose hopsCompleted for TRACE packets, show real path on live map (#656)
## Summary TRACE packets on the live map previously animated the **full intended route** regardless of how far the trace actually reached. This made it impossible to distinguish a completed route from a failed one — undermining the primary diagnostic purpose of trace packets. ## Changes ### Backend — `cmd/server/decoder.go` - Added `HopsCompleted *int` field to the `Path` struct - For TRACE packets, the header path contains SNR bytes (one per hop that actually forwarded). Before overwriting `path.Hops` with the full intended route from the payload, we now capture the header path's `HashCount` as `hopsCompleted` - This field is included in API responses and WebSocket broadcasts via the existing JSON serialization ### Frontend — `public/live.js` - For TRACE packets with `hopsCompleted < totalHops`: - Animate only the **completed** portion (solid line + pulse) - Draw the **unreached** remainder as a dashed/ghosted line (25% opacity, `6,8` dash pattern) with ghost markers - Dashed lines and ghost markers auto-remove after 10 seconds - When `hopsCompleted` is absent or equals total hops, behavior is unchanged ### Tests — `cmd/server/decoder_test.go` - `TestDecodePacket_TraceHopsCompleted` — partial completion (2 of 4 hops) - `TestDecodePacket_TraceNoSNR` — zero completion (trace not forwarded yet) - `TestDecodePacket_TraceFullyCompleted` — all hops completed ## How it works The MeshCore firmware appends an SNR byte to `pkt->path[]` at each hop that forwards a TRACE packet. The count of these SNR bytes (`path_len`) indicates how far the trace actually got. CoreScope's decoder already parsed the header path, but the TRACE-specific code overwrote it with the payload hops (full intended route) without preserving the progress information. Now we save that count first. Fixes #651 --------- Co-authored-by: you <you@example.com>
This commit is contained in:
+10
-3
@@ -60,9 +60,10 @@ type TransportCodes struct {
|
||||
|
||||
// Path holds decoded path/hop information.
|
||||
type Path struct {
|
||||
HashSize int `json:"hashSize"`
|
||||
HashCount int `json:"hashCount"`
|
||||
Hops []string `json:"hops"`
|
||||
HashSize int `json:"hashSize"`
|
||||
HashCount int `json:"hashCount"`
|
||||
Hops []string `json:"hops"`
|
||||
HopsCompleted *int `json:"hopsCompleted,omitempty"`
|
||||
}
|
||||
|
||||
// AdvertFlags holds decoded advert flag bits.
|
||||
@@ -376,7 +377,12 @@ func DecodePacket(hexString string) (*DecodedPacket, error) {
|
||||
// TRACE packets store hop IDs in the payload (buf[9:]) rather than the header
|
||||
// path field. The header path byte still encodes hashSize in bits 6-7, which
|
||||
// we use to split the payload path data into individual hop prefixes.
|
||||
// The header path contains SNR bytes — one per hop that actually forwarded.
|
||||
// We expose hopsCompleted (count of SNR bytes) so consumers can distinguish
|
||||
// how far the trace got vs the full intended route.
|
||||
if header.PayloadType == PayloadTRACE && payload.PathData != "" {
|
||||
// The header path hops count represents SNR entries = completed hops
|
||||
hopsCompleted := path.HashCount
|
||||
pathBytes, err := hex.DecodeString(payload.PathData)
|
||||
if err == nil && path.HashSize > 0 {
|
||||
hops := make([]string, 0, len(pathBytes)/path.HashSize)
|
||||
@@ -385,6 +391,7 @@ func DecodePacket(hexString string) (*DecodedPacket, error) {
|
||||
}
|
||||
path.Hops = hops
|
||||
path.HashCount = len(hops)
|
||||
path.HopsCompleted = &hopsCompleted
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -323,3 +323,83 @@ func repeatHex(byteHex string, n int) string {
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
func TestDecodePacket_TraceHopsCompleted(t *testing.T) {
|
||||
// Build a TRACE packet:
|
||||
// header: route=FLOOD(1), payload=TRACE(9), version=0 → (0<<6)|(9<<2)|1 = 0x25
|
||||
// path_length: hash_size bits=0b00 (1-byte), hash_count=2 (2 SNR bytes) → 0x02
|
||||
// path: 2 SNR bytes: 0xAA, 0xBB
|
||||
// payload: tag(4 LE) + authCode(4 LE) + flags(1) + 4 hop hashes (1 byte each)
|
||||
hex := "2502AABB" + // header + path_length + 2 SNR bytes
|
||||
"01000000" + // tag = 1
|
||||
"02000000" + // authCode = 2
|
||||
"00" + // flags = 0
|
||||
"DEADBEEF" // 4 hops (1-byte hash each)
|
||||
|
||||
pkt, err := DecodePacket(hex)
|
||||
if err != nil {
|
||||
t.Fatalf("DecodePacket error: %v", err)
|
||||
}
|
||||
if pkt.Payload.Type != "TRACE" {
|
||||
t.Fatalf("expected TRACE, got %s", pkt.Payload.Type)
|
||||
}
|
||||
// Full intended route = 4 hops from payload
|
||||
if len(pkt.Path.Hops) != 4 {
|
||||
t.Errorf("expected 4 hops, got %d: %v", len(pkt.Path.Hops), pkt.Path.Hops)
|
||||
}
|
||||
// HopsCompleted = 2 (from header path SNR count)
|
||||
if pkt.Path.HopsCompleted == nil {
|
||||
t.Fatal("expected HopsCompleted to be set")
|
||||
}
|
||||
if *pkt.Path.HopsCompleted != 2 {
|
||||
t.Errorf("expected HopsCompleted=2, got %d", *pkt.Path.HopsCompleted)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDecodePacket_TraceNoSNR(t *testing.T) {
|
||||
// TRACE with 0 SNR bytes (trace hasn't been forwarded yet)
|
||||
// path_length: hash_size=0b00 (1-byte), hash_count=0 → 0x00
|
||||
hex := "2500" + // header + path_length (0 hops in header)
|
||||
"01000000" + // tag
|
||||
"02000000" + // authCode
|
||||
"00" + // flags
|
||||
"AABBCC" // 3 hops intended
|
||||
|
||||
pkt, err := DecodePacket(hex)
|
||||
if err != nil {
|
||||
t.Fatalf("DecodePacket error: %v", err)
|
||||
}
|
||||
if pkt.Path.HopsCompleted == nil {
|
||||
t.Fatal("expected HopsCompleted to be set")
|
||||
}
|
||||
if *pkt.Path.HopsCompleted != 0 {
|
||||
t.Errorf("expected HopsCompleted=0, got %d", *pkt.Path.HopsCompleted)
|
||||
}
|
||||
if len(pkt.Path.Hops) != 3 {
|
||||
t.Errorf("expected 3 hops, got %d", len(pkt.Path.Hops))
|
||||
}
|
||||
}
|
||||
|
||||
func TestDecodePacket_TraceFullyCompleted(t *testing.T) {
|
||||
// TRACE where all hops completed (SNR count = hop count)
|
||||
// path_length: hash_size=0b00 (1-byte), hash_count=3 → 0x03
|
||||
hex := "2503AABBCC" + // header + path_length + 3 SNR bytes
|
||||
"01000000" + // tag
|
||||
"02000000" + // authCode
|
||||
"00" + // flags
|
||||
"DDEEFF" // 3 hops intended
|
||||
|
||||
pkt, err := DecodePacket(hex)
|
||||
if err != nil {
|
||||
t.Fatalf("DecodePacket error: %v", err)
|
||||
}
|
||||
if pkt.Path.HopsCompleted == nil {
|
||||
t.Fatal("expected HopsCompleted to be set")
|
||||
}
|
||||
if *pkt.Path.HopsCompleted != 3 {
|
||||
t.Errorf("expected HopsCompleted=3, got %d", *pkt.Path.HopsCompleted)
|
||||
}
|
||||
if len(pkt.Path.Hops) != 3 {
|
||||
t.Errorf("expected 3 hops, got %d", len(pkt.Path.Hops))
|
||||
}
|
||||
}
|
||||
|
||||
+50
-1
@@ -1899,7 +1899,56 @@
|
||||
}
|
||||
}
|
||||
firstPathDone = true;
|
||||
animatePath(allPaths[ai].hopPositions, typeName, color, allPaths[ai].raw, onHop);
|
||||
// For TRACE packets, split at hopsCompleted: solid for completed, dashed for remaining
|
||||
var hopsCompleted = decoded.path && decoded.path.hopsCompleted;
|
||||
if (typeName === 'TRACE' && hopsCompleted != null && hopsCompleted < allPaths[ai].hopPositions.length) {
|
||||
var completedPositions = allPaths[ai].hopPositions.slice(0, hopsCompleted + 1);
|
||||
var remainingPositions = allPaths[ai].hopPositions.slice(hopsCompleted);
|
||||
if (completedPositions.length >= 2) {
|
||||
animatePath(completedPositions, typeName, color, allPaths[ai].raw, onHop);
|
||||
} else if (completedPositions.length === 1) {
|
||||
pulseNode(completedPositions[0].key, completedPositions[0].pos, typeName);
|
||||
}
|
||||
if (remainingPositions.length >= 2) {
|
||||
drawDashedPath(remainingPositions, color);
|
||||
}
|
||||
} else {
|
||||
animatePath(allPaths[ai].hopPositions, typeName, color, allPaths[ai].raw, onHop);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Draw a static dashed/ghosted line for unreached TRACE hops
|
||||
function drawDashedPath(hopPositions, color) {
|
||||
var GHOST_TIMEOUT_MS = 10000;
|
||||
var ghostColor = getComputedStyle(document.documentElement).getPropertyValue('--trace-ghost-color').trim() || '#94a3b8';
|
||||
if (!pathsLayer) return;
|
||||
for (var i = 0; i < hopPositions.length - 1; i++) {
|
||||
var from = hopPositions[i].pos;
|
||||
var to = hopPositions[i + 1].pos;
|
||||
var line = L.polyline([from, to], {
|
||||
color: color, weight: 2, opacity: 0.25, dashArray: '6, 8'
|
||||
}).addTo(pathsLayer);
|
||||
// Pulse the unreached hop nodes as ghost markers
|
||||
if (i > 0) {
|
||||
var hp = hopPositions[i];
|
||||
if (!nodeMarkers[hp.key]) {
|
||||
var ghost = L.circleMarker(hp.pos, {
|
||||
radius: 3, fillColor: ghostColor, fillOpacity: 0.2, color: color, weight: 1, opacity: 0.3
|
||||
}).addTo(pathsLayer);
|
||||
setTimeout((function(g) { return function() { if (pathsLayer.hasLayer(g)) pathsLayer.removeLayer(g); }; })(ghost), GHOST_TIMEOUT_MS);
|
||||
}
|
||||
}
|
||||
// Remove dashed line after timeout
|
||||
setTimeout((function(l) { return function() { if (pathsLayer.hasLayer(l)) pathsLayer.removeLayer(l); }; })(line), GHOST_TIMEOUT_MS);
|
||||
}
|
||||
// Ghost marker for the final unreached hop
|
||||
var last = hopPositions[hopPositions.length - 1];
|
||||
if (!nodeMarkers[last.key]) {
|
||||
var ghostEnd = L.circleMarker(last.pos, {
|
||||
radius: 4, fillColor: ghostColor, fillOpacity: 0.25, color: color, weight: 1, opacity: 0.35
|
||||
}).addTo(pathsLayer);
|
||||
setTimeout(function() { if (pathsLayer.hasLayer(ghostEnd)) pathsLayer.removeLayer(ghostEnd); }, GHOST_TIMEOUT_MS);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -30,6 +30,7 @@
|
||||
--content-bg: var(--surface-0);
|
||||
--card-bg: var(--surface-1);
|
||||
--hover-bg: rgba(0,0,0, 0.04);
|
||||
--trace-ghost-color: #94a3b8;
|
||||
}
|
||||
|
||||
/* ⚠️ DARK THEME VARIABLES — KEEP BOTH BLOCKS IN SYNC
|
||||
@@ -55,6 +56,7 @@
|
||||
--input-bg: #1e1e34;
|
||||
--selected-bg: #1e3a5f;
|
||||
--hover-bg: rgba(255,255,255, 0.06);
|
||||
--trace-ghost-color: #94a3b8;
|
||||
--section-bg: #1e1e34;
|
||||
}
|
||||
}
|
||||
@@ -78,6 +80,7 @@
|
||||
--input-bg: #1e1e34;
|
||||
--selected-bg: #1e3a5f;
|
||||
--hover-bg: rgba(255,255,255, 0.06);
|
||||
--trace-ghost-color: #94a3b8;
|
||||
--section-bg: #1e1e34;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user