mirror of
https://github.com/Kpa-clawbot/meshcore-analyzer.git
synced 2026-11-11 21:18:04 +00:00
Red commit: 3bb81254 (fails on assertions both ways: `zero-hop counts =
map[pk_relayed:map[SAT:1]], want map[pk_transport:map[AUS:1]]`)
Fixes #2225
## What this does
`nodeZeroHopRegionCounts` read the path byte at offset 1 for
`ROUTE_TYPE_TRANSPORT_DIRECT` adverts. Transport routes carry 4
transport-code bytes between the header and `path_length` (firmware
`docs/packet_format.md`), so offset 1 is the first byte of
`transport_code_1`. Two effects, both fixed:
- A transport-direct zero-hop advert counted as zero-hop only when that
code byte's low 6 bits happened to be 0, so the node usually got
`fewest_hops` instead of `zero_hop`, and #804 analytics missed it.
- A relayed transport-direct advert whose first code byte was `0x00` (or
`0x40`, `0x80`, …) counted as zero-hop.
## How
- New `packetpath.PathByte(rawHex) (routeType int, pathByte byte, ok
bool)` applies the transport-code offset in one place, next to the
existing `IsTransportRoute`, `HashSize` and `DecodePathFromRawHex`.
- Every reader of the path byte from `raw_hex` in `store.go` now uses
it:
- `nodeZeroHopRegionCounts` (the bug);
- `computeAnalyticsHashSizes`;
- `computeNodeHashSizeInfo`;
- `computeMultiByteCapability`.
The last three already had the right offset, each with its own copy of
the rule, so their behavior doesn't change. `packetpath.HashSize` uses
`PathByte` too.
## Performance
- `PathByte` runs once per advert on every recompute, so it must not
allocate. `hexByteAt` now decodes the two hex nibbles itself instead of
calling `hex.DecodeString`; `TestPathByteDoesNotAllocate` pins it at 0
allocs.
- Without that, `BenchmarkRecomputeNodeHomeRegionSets` went from 17,232
to 457,232 allocs/op. With it, the count is unchanged from master.
- Time on the same machine (Apple M5, Docker linux/arm64, `-count 3`):
~61 ms/op on master, ~54 ms/op here. Your hardware measured ~3x higher
on #2190, so expect proportionally more there; the change itself only
removes work.
## Tests
- `TestZeroHopCountsReadTransportDirectPathByte`: a transport-direct
zero-hop advert whose first code byte is `0xaa` counts, and a relayed
one (path byte `0x41`) whose first code byte is `0x00` doesn't. Checked
through both `nodeZeroHopRegionCounts` and `computeNodeHomeRegions`
(#804).
- `TestPathByte` covers all four route types, upper-case hex, and
too-short or malformed input. `TestPathByteDoesNotAllocate` pins the
allocation count.
- `go test` passes for `cmd/server`, `cmd/ingestor` and
`internal/packetpath` (`CGO_ENABLED=1`). `gofmt` and `go vet` are clean.
No API or UI change, no new `map[string]interface{}`, and no config.
152 lines
4.8 KiB
Go
152 lines
4.8 KiB
Go
// Package packetpath provides shared helpers for extracting path hops from
|
|
// raw MeshCore packet hex bytes.
|
|
package packetpath
|
|
|
|
import (
|
|
"encoding/hex"
|
|
"fmt"
|
|
"strings"
|
|
)
|
|
|
|
// DecodePathFromRawHex extracts the header path hops directly from raw hex bytes.
|
|
// This is the authoritative path that matches what's in raw_hex, as opposed to
|
|
// decoded.Path.Hops which may be overwritten for TRACE packets (issue #886).
|
|
//
|
|
// WARNING: This function returns the literal header path bytes regardless of
|
|
// payload type. For TRACE packets these bytes are SNR values, NOT hop hashes.
|
|
// Callers that may receive TRACE packets MUST check PathBytesAreHops(payloadType)
|
|
// first, or use the safer DecodeHopsForPayload wrapper.
|
|
func DecodePathFromRawHex(rawHex string) ([]string, error) {
|
|
buf, err := hex.DecodeString(rawHex)
|
|
if err != nil || len(buf) < 2 {
|
|
return nil, fmt.Errorf("invalid or too-short hex")
|
|
}
|
|
|
|
headerByte := buf[0]
|
|
offset := 1
|
|
if IsTransportRoute(int(headerByte & 0x03)) {
|
|
if len(buf) < offset+4 {
|
|
return nil, fmt.Errorf("too short for transport codes")
|
|
}
|
|
offset += 4
|
|
}
|
|
if offset >= len(buf) {
|
|
return nil, fmt.Errorf("too short for path byte")
|
|
}
|
|
|
|
pathByte := buf[offset]
|
|
offset++
|
|
|
|
hashSize := int(pathByte>>6) + 1
|
|
hashCount := int(pathByte & 0x3F)
|
|
|
|
hops := make([]string, 0, hashCount)
|
|
for i := 0; i < hashCount; i++ {
|
|
start := offset + i*hashSize
|
|
end := start + hashSize
|
|
if end > len(buf) {
|
|
break
|
|
}
|
|
hops = append(hops, strings.ToUpper(hex.EncodeToString(buf[start:end])))
|
|
}
|
|
return hops, nil
|
|
}
|
|
|
|
// HashSize returns the path hash size (1-3 bytes) the originator chose, read
|
|
// from the path byte of raw_hex, or 0 when the packet does not carry one.
|
|
//
|
|
// A flood packet always carries it: Mesh::sendFlood sets the path byte to
|
|
// (size-1)<<6 before the first hop, so it holds even at 0 hops. A direct
|
|
// packet with no hops carries none: Mesh::sendZeroHop writes 0x00, and a
|
|
// direct path that has run out of hops has no hash left to size. That is the
|
|
// same rule cmd/server/decoder.go applies to path.hashSize. TRACE path bytes
|
|
// are SNR readings, not hashes. Only the first few bytes are parsed, so this
|
|
// is cheap on hot paths.
|
|
func HashSize(rawHex string) int {
|
|
header, ok := hexByteAt(rawHex, 0)
|
|
if !ok || !PathBytesAreHops(header>>2&0x0F) {
|
|
return 0
|
|
}
|
|
routeType, pathByte, ok := PathByte(rawHex)
|
|
if !ok {
|
|
return 0
|
|
}
|
|
if pathByte&0x3F == 0 && (routeType == RouteDirect || routeType == RouteTransportDirect) {
|
|
return 0
|
|
}
|
|
size := int(pathByte>>6) + 1
|
|
if size > 3 {
|
|
return 0 // 0b11 is reserved; firmware rejects sizes above 3
|
|
}
|
|
return size
|
|
}
|
|
|
|
// PathByte returns a packet's route type and path byte (hash size in the top
|
|
// two bits, hop count in the low six), read from raw_hex: the header is byte
|
|
// 0, transport routes carry four transport-code bytes after it, then comes
|
|
// the path byte (firmware docs/packet_format.md). ok is false when raw_hex is
|
|
// too short or not hex there. Only those bytes are parsed, so this is cheap on
|
|
// hot paths.
|
|
func PathByte(rawHex string) (routeType int, pathByte byte, ok bool) {
|
|
header, ok := hexByteAt(rawHex, 0)
|
|
if !ok {
|
|
return 0, 0, false
|
|
}
|
|
routeType = int(header & 0x03)
|
|
offset := 1
|
|
if IsTransportRoute(routeType) {
|
|
offset += 4
|
|
}
|
|
pathByte, ok = hexByteAt(rawHex, offset)
|
|
return routeType, pathByte, ok
|
|
}
|
|
|
|
// hexByteAt decodes the byte at byte index i of a hex string, without
|
|
// allocating: PathByte runs per advert on the server's recompute paths.
|
|
func hexByteAt(s string, i int) (byte, bool) {
|
|
if i < 0 || len(s) < 2*i+2 {
|
|
return 0, false
|
|
}
|
|
hi, ok1 := hexNibble(s[2*i])
|
|
lo, ok2 := hexNibble(s[2*i+1])
|
|
if !ok1 || !ok2 {
|
|
return 0, false
|
|
}
|
|
return hi<<4 | lo, true
|
|
}
|
|
|
|
func hexNibble(c byte) (byte, bool) {
|
|
switch {
|
|
case '0' <= c && c <= '9':
|
|
return c - '0', true
|
|
case 'a' <= c && c <= 'f':
|
|
return c - 'a' + 10, true
|
|
case 'A' <= c && c <= 'F':
|
|
return c - 'A' + 10, true
|
|
}
|
|
return 0, false
|
|
}
|
|
|
|
// DecodeHopsForPayload returns the header path hops only when the payload type's
|
|
// header bytes are actually route hops (i.e. PathBytesAreHops(payloadType) is true).
|
|
// For TRACE packets it returns (nil, ErrPayloadHasNoHeaderHops) so the caller is
|
|
// forced to source hops from the decoded payload instead.
|
|
//
|
|
// Prefer this over DecodePathFromRawHex when the payload type is known.
|
|
func DecodeHopsForPayload(rawHex string, payloadType byte) ([]string, error) {
|
|
if !PathBytesAreHops(payloadType) {
|
|
return nil, ErrPayloadHasNoHeaderHops
|
|
}
|
|
return DecodePathFromRawHex(rawHex)
|
|
}
|
|
|
|
// ErrPayloadHasNoHeaderHops is returned by DecodeHopsForPayload when the
|
|
// payload type repurposes the raw_hex header path bytes (e.g. TRACE → SNR values).
|
|
var ErrPayloadHasNoHeaderHops = errPayloadHasNoHeaderHops{}
|
|
|
|
type errPayloadHasNoHeaderHops struct{}
|
|
|
|
func (errPayloadHasNoHeaderHops) Error() string {
|
|
return "payload type repurposes header path bytes; source hops from decoded payload"
|
|
}
|