mirror of
https://github.com/MeshCore-Beacon/beacon-server.git
synced 2026-10-15 22:39:56 +00:00
MarkSent ran after the side effects, so a broker copy on another worker could win it and be streamed as a repeat of the first hearing. It now runs before any further DB work, unconditionally so the set is warm before anyone opts in, and never for TRACE, whose path bytes are per-hop SNR.
310 lines
13 KiB
Go
310 lines
13 KiB
Go
// Copyright 2026 Beacon Contributors
|
|
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
|
|
package api
|
|
|
|
import (
|
|
"encoding/hex"
|
|
"encoding/json"
|
|
"strings"
|
|
|
|
"github.com/google/uuid"
|
|
"github.com/meshcore-go/meshcore-go"
|
|
)
|
|
|
|
// PacketLatestObserver is the most recent observer summary rolled into a packet list item.
|
|
type PacketLatestObserver struct {
|
|
ID uuid.UUID `json:"id"`
|
|
DisplayName *string `json:"displayName,omitempty"`
|
|
IATA string `json:"iata"`
|
|
// PathLength/PathBytes and captured endpoint resolutions are stored on the observation,
|
|
// so list/backfill reads need no per-hash resolution queries. Legacy observations have
|
|
// no endpoint snapshot. ResolvedPath remains a detail/opted-in WS feature.
|
|
PathLength *PacketPathLength `json:"pathLength,omitempty"`
|
|
PathBytes *string `json:"pathBytes,omitempty"` // hex-encoded accumulated path hashes
|
|
ResolvedPath []ResolvedHop `json:"resolvedPath,omitempty"`
|
|
ResolvedSource *ResolvedHop `json:"resolvedSource,omitempty"`
|
|
ResolvedDestination *ResolvedHop `json:"resolvedDestination,omitempty"`
|
|
}
|
|
|
|
// PacketSummary is the minimal packet representation used in list responses.
|
|
// Includes the latest observation rolled in for display purposes.
|
|
type PacketSummary struct {
|
|
PacketHash string `json:"packetHash"` // hex-encoded
|
|
PayloadType int16 `json:"payloadType"`
|
|
PayloadTypeName string `json:"payloadTypeName"`
|
|
RouteType int16 `json:"routeType"`
|
|
RouteTypeName string `json:"routeTypeName"`
|
|
Scope *string `json:"scope,omitempty"` // matched transport scope name e.g. "#bc"
|
|
FirstHeardAt int64 `json:"firstHeardAt"` // epoch ms
|
|
LastHeardAt int64 `json:"lastHeardAt"` // epoch ms
|
|
ObservationCount int32 `json:"observationCount"`
|
|
LatestObserver *PacketLatestObserver `json:"latestObserver,omitempty"`
|
|
Summary *string `json:"summary,omitempty"` // advert name, or the ACK/TRACE/PING summary; omitted when unavailable
|
|
}
|
|
|
|
// PacketPathLength is the decoded path_length byte from a packet observation.
|
|
// The raw byte encodes both hash size and hop count in a bit-packed format (§2.5).
|
|
type PacketPathLength struct {
|
|
Raw string `json:"raw"` // hex-encoded single byte
|
|
HashSize int16 `json:"hashSize"` // per-hop hash size in bytes (1, 2, or 3)
|
|
HopCount int16 `json:"hopCount"` // number of path hashes present
|
|
}
|
|
|
|
// PacketObservationDetail is a full observation including radio settings and resolved path.
|
|
type PacketObservationDetail struct {
|
|
ID int64 `json:"id"`
|
|
ObserverID uuid.UUID `json:"observerId"`
|
|
ObserverName *string `json:"observerName,omitempty"`
|
|
IATA string `json:"iata"`
|
|
HeardAt int64 `json:"heardAt"` // epoch ms
|
|
PathLength PacketPathLength `json:"pathLength"`
|
|
PathBytes *string `json:"pathBytes,omitempty"` // hex-encoded accumulated path hashes
|
|
RSSI *int16 `json:"rssi,omitempty"`
|
|
SNR *float32 `json:"snr,omitempty"`
|
|
PropagationTimeMs *int32 `json:"propagationTimeMs"` // ms since first observation; 0 for first
|
|
Radio *PacketRadio `json:"radio,omitempty"`
|
|
SourceBroker string `json:"sourceBroker"`
|
|
ResolvedPath []ResolvedHop `json:"resolvedPath"` // per-observation resolved path hashes
|
|
// ResolvedSource/ResolvedDestination are the packet's endpoints, when the payload type
|
|
// carries one. Prefer the snapshot captured at ingest; legacy observations without a
|
|
// snapshot use the current node registry. Endpoint matching itself is unchanged:
|
|
// an exact match for ADVERT's full pubkey, an ambiguous
|
|
// hash-prefix match (like intermediate hops) for TEXT_MESSAGE/PATH/ANON_REQ's 1-byte
|
|
// source/destination hashes. Nil when the payload type doesn't carry one at all (e.g.
|
|
// GRP_TXT/GRP_DATA/TRACE aren't node-to-node addressed) -- see BuildResolvedPath and
|
|
// ResolveExactNode for how each is built.
|
|
ResolvedSource *ResolvedHop `json:"resolvedSource,omitempty"`
|
|
ResolvedDestination *ResolvedHop `json:"resolvedDestination,omitempty"`
|
|
}
|
|
|
|
// PacketRadio holds the radio settings copied from the observer at observation time.
|
|
type PacketRadio struct {
|
|
FreqMHz *float32 `json:"freqMhz,omitempty"`
|
|
SpreadFactor *int16 `json:"spreadFactor,omitempty"`
|
|
BandwidthKHz *float32 `json:"bandwidthKhz,omitempty"`
|
|
CodingRate *int16 `json:"codingRate,omitempty"`
|
|
}
|
|
|
|
// ResolvedHop is a single hop in a packet's resolved path.
|
|
// Confidence is "high" (exactly one match), "ambiguous" (multiple matches), or "none" (no match).
|
|
type ResolvedHop struct {
|
|
Confidence string `json:"confidence"` // "high", "ambiguous", or "none"
|
|
SNR *float32 `json:"snr,omitempty"`
|
|
Nodes []ResolvedNode `json:"nodes"` // empty for "none", one for "high", multiple for "ambiguous"
|
|
}
|
|
|
|
// ResolvedNode is a node reference within a resolved path hop.
|
|
type ResolvedNode struct {
|
|
ID uuid.UUID `json:"id"`
|
|
Name *string `json:"name,omitempty"`
|
|
PublicKey string `json:"publicKey"` // hex-encoded prefix used for resolution
|
|
Latitude *float64 `json:"latitude,omitempty"`
|
|
Longitude *float64 `json:"longitude,omitempty"`
|
|
}
|
|
|
|
// ResolvedPathEntry is an internal type used by the store layer to carry node
|
|
// details returned from ResolvePathHashes before mapping to ResolvedNode.
|
|
type ResolvedPathEntry struct {
|
|
NodeID uuid.UUID
|
|
Name *string
|
|
Latitude *float64
|
|
Longitude *float64
|
|
PublicKey []byte
|
|
}
|
|
|
|
// PacketHeader holds the decoded header byte and its bit-packed fields.
|
|
// The raw header byte encodes payload version, payload type, and route type (§2.3).
|
|
type PacketHeader struct {
|
|
Raw string `json:"raw"` // hex-encoded single byte
|
|
RouteType int16 `json:"routeType"` // bits 0-1
|
|
RouteTypeName string `json:"routeTypeName"` // FLOOD, DIRECT, TRANSPORT_FLOOD, TRANSPORT_DIRECT
|
|
PayloadType int16 `json:"payloadType"` // bits 2-5
|
|
PayloadTypeName string `json:"payloadTypeName"` // advert, request, group_text, etc.
|
|
PayloadVersion int16 `json:"payloadVersion"` // bits 6-7
|
|
}
|
|
|
|
// PacketTransportCodes holds the decoded transport codes present in TRANSPORT_FLOOD
|
|
// and TRANSPORT_DIRECT packets. RegionCode is transport_code_1; SubRegionCode is
|
|
// transport_code_2 (reserved in v1, always 0 on the wire).
|
|
type PacketTransportCodes struct {
|
|
RegionCode int32 `json:"regionCode"`
|
|
SubRegionCode int32 `json:"subRegionCode"`
|
|
}
|
|
|
|
// Packet is the full packet representation including all observations and resolved paths.
|
|
type Packet struct {
|
|
PacketHash string `json:"packetHash"`
|
|
Header PacketHeader `json:"header"`
|
|
TransportCodes *PacketTransportCodes `json:"transportCodes,omitempty"`
|
|
OriginPubkey *string `json:"originPubkey,omitempty"` // hex-encoded; nil when not extractable from payload
|
|
ParsedPayload json.RawMessage `json:"parsedPayload,omitempty"`
|
|
RawPayload string `json:"rawPayload"` // hex-encoded payload bytes (excludes header and path)
|
|
Decrypted bool `json:"decrypted"` // true if group text was successfully decrypted
|
|
ChannelHash *string `json:"channelHash,omitempty"` // hex-encoded single byte; non-nil for group_text/group_data
|
|
Scope *string `json:"scope,omitempty"` // matched transport scope name e.g. "#bc"
|
|
FirstHeardAt int64 `json:"firstHeardAt"` // epoch ms
|
|
LastHeardAt int64 `json:"lastHeardAt"` // epoch ms
|
|
FirstToLastMs int64 `json:"firstToLastMs"` // ms between first and last observation
|
|
ObservationCount int32 `json:"observationCount"`
|
|
ResolvedRoute []ResolvedHop `json:"resolvedRoute,omitempty"` // trace packets only: resolved intended route
|
|
Observations []PacketObservationDetail `json:"observations"`
|
|
}
|
|
|
|
// AdvertObservation extends PacketObservationSummary with node identity fields
|
|
// specific to advert packets (payload_type=4).
|
|
type AdvertObservation struct {
|
|
PacketObservationSummary
|
|
NodeName *string `json:"nodeName,omitempty"`
|
|
NodePublicKey *string `json:"nodePublicKey,omitempty"` // hex-encoded
|
|
}
|
|
|
|
// PacketObservationSummary is a lightweight packet+observation pair used in
|
|
// list contexts such as observer adverts and node observations.
|
|
type PacketObservationSummary struct {
|
|
ID int64 `json:"id"` // observation ID, use as cursor for pagination
|
|
PacketHash string `json:"packetHash"` // hex-encoded
|
|
PayloadType int16 `json:"payloadType"`
|
|
PayloadTypeName string `json:"payloadTypeName"`
|
|
IATA string `json:"iata"`
|
|
HeardAt int64 `json:"heardAt"` // epoch ms
|
|
RSSI *int16 `json:"rssi,omitempty"`
|
|
SNR *float32 `json:"snr,omitempty"`
|
|
HopCount *int16 `json:"hopCount,omitempty"`
|
|
}
|
|
|
|
// PayloadTypeName returns a human-readable name for a payload type integer.
|
|
func PayloadTypeName(t int16) string {
|
|
switch t {
|
|
case 0x00:
|
|
return "request"
|
|
case 0x01:
|
|
return "response"
|
|
case 0x02:
|
|
return "text_message"
|
|
case 0x03:
|
|
return "acknowledgement"
|
|
case 0x04:
|
|
return "advert"
|
|
case 0x05:
|
|
return "group_text"
|
|
case 0x06:
|
|
return "group_data"
|
|
case 0x07:
|
|
return "anonymous_request"
|
|
case 0x08:
|
|
return "path"
|
|
case 0x09:
|
|
return "trace"
|
|
case 0x0A:
|
|
return "multipart"
|
|
case 0x0B:
|
|
return "control"
|
|
case 0x0C:
|
|
fallthrough
|
|
case 0x0D:
|
|
fallthrough
|
|
case 0x0E:
|
|
return "reserved"
|
|
case 0x0F:
|
|
return "raw_custom"
|
|
default:
|
|
return "unknown"
|
|
}
|
|
}
|
|
|
|
// PayloadTypeFromString returns the integer payload type for a given name.
|
|
// Returns -1 (no filter) if the string is empty or unrecognized.
|
|
func PayloadTypeFromString(s string) int16 {
|
|
switch strings.ToLower(s) {
|
|
case "request", "req":
|
|
return int16(meshcore.PayloadTypeReq)
|
|
case "response":
|
|
return int16(meshcore.PayloadTypeResponse)
|
|
case "txt_msg", "txtmsg", "text", "direct":
|
|
return int16(meshcore.PayloadTypeTxtMsg)
|
|
case "acknowledgement", "ack":
|
|
return int16(meshcore.PayloadTypeAck)
|
|
case "advertisement", "advert":
|
|
return int16(meshcore.PayloadTypeAdvert)
|
|
case "grp_txt", "grptxt", "group_text", "group":
|
|
return int16(meshcore.PayloadTypeGrpTxt)
|
|
case "grp_data", "grpdata", "group_data", "data":
|
|
return int16(meshcore.PayloadTypeGrpData)
|
|
case "anonymous_request", "anon_req", "anonreq":
|
|
return int16(meshcore.PayloadTypeAnonReq)
|
|
case "path":
|
|
return int16(meshcore.PayloadTypePath)
|
|
case "trace":
|
|
return int16(meshcore.PayloadTypeTrace)
|
|
case "multipart", "multi-part":
|
|
return int16(meshcore.PayloadTypeMultiPart)
|
|
case "control":
|
|
return int16(meshcore.PayloadTypeControl)
|
|
case "raw_custom", "raw", "custom":
|
|
return int16(meshcore.PayloadTypeRawCustom)
|
|
default:
|
|
return -1
|
|
}
|
|
}
|
|
|
|
// RouteTypeName returns a human-readable name for a route type integer.
|
|
func RouteTypeName(t int16) string {
|
|
switch byte(t) {
|
|
case meshcore.RouteTypeFlood:
|
|
return "FLOOD"
|
|
case meshcore.RouteTypeDirect:
|
|
return "DIRECT"
|
|
case meshcore.RouteTypeTransportFlood:
|
|
return "TRANSPORT_FLOOD"
|
|
case meshcore.RouteTypeTransportDirect:
|
|
return "TRANSPORT_DIRECT"
|
|
default:
|
|
return "unknown"
|
|
}
|
|
}
|
|
|
|
// BuildResolvedPath maps an ordered list of path hashes and their resolved
|
|
// candidates (as returned by ResolvePathHashes) into per-hop confidence
|
|
// records: "high" for exactly one match, "ambiguous" for multiple matches,
|
|
// "none" for zero. Shared by the REST packet/observation endpoints and the
|
|
// ingest packetObservation WS event so both report path resolution the same way.
|
|
func BuildResolvedPath(hashes [][]byte, resolved map[string][]ResolvedPathEntry) []ResolvedHop {
|
|
path := make([]ResolvedHop, 0, len(hashes))
|
|
for _, hash := range hashes {
|
|
key := hex.EncodeToString(hash)
|
|
entries := resolved[key]
|
|
hop := ResolvedHop{Nodes: make([]ResolvedNode, 0, len(entries))}
|
|
switch len(entries) {
|
|
case 0:
|
|
hop.Confidence = "none"
|
|
case 1:
|
|
hop.Confidence = "high"
|
|
default:
|
|
hop.Confidence = "ambiguous"
|
|
}
|
|
for _, e := range entries {
|
|
hop.Nodes = append(hop.Nodes, ResolvedNode{
|
|
ID: e.NodeID,
|
|
Name: e.Name,
|
|
Latitude: e.Latitude,
|
|
Longitude: e.Longitude,
|
|
PublicKey: hex.EncodeToString(e.PublicKey),
|
|
})
|
|
}
|
|
path = append(path, hop)
|
|
}
|
|
return path
|
|
}
|
|
|
|
// ResolveExactNode builds a ResolvedHop for an endpoint resolved by an exact, unambiguous
|
|
// key -- e.g. an ADVERT's full public key already resolved to a single node -- as opposed
|
|
// to BuildResolvedPath's hash-prefix matching, which can be ambiguous. Confidence is always
|
|
// "high" when a node was found, "none" when it wasn't (unknown node, or lookup failed).
|
|
func ResolveExactNode(node *ResolvedNode) ResolvedHop {
|
|
if node == nil {
|
|
return ResolvedHop{Confidence: "none", Nodes: []ResolvedNode{}}
|
|
}
|
|
return ResolvedHop{Confidence: "high", Nodes: []ResolvedNode{*node}}
|
|
}
|