// Copyright 2026 Beacon Contributors // SPDX-License-Identifier: AGPL-3.0-or-later package db import ( "context" "encoding/hex" "encoding/json" "errors" "fmt" "log" "time" sqlc "github.com/MeshCore-Beacon/beacon-server/db/sqlc" "github.com/MeshCore-Beacon/beacon-server/internal/api" "github.com/MeshCore-Beacon/beacon-server/internal/ingest" "github.com/google/uuid" "github.com/jackc/pgx/v5/pgtype" ) func (s *Store) UpsertNode(ctx context.Context, n ingest.UpsertNodeParams, radio ingest.RadioSettings) (uuid.UUID, error) { // advert.Timestamp is only meaningful when the advert actually decoded (AdvertTimestamp // is left at its zero value otherwise); a zero epoch timestamp would misreport as ~55 // years of drift, so only compute/store a delta when it's non-zero. var driftSeconds *int32 if n.AdvertTimestamp != 0 { d := int32(int64(n.AdvertTimestamp) - time.Now().Unix()) driftSeconds = &d } params := sqlc.UpsertNodeParams{ PublicKey: n.PublicKey, NodeType: int16(n.NodeType), Name: &n.Name, Latitude: n.Latitude, Longitude: n.Longitude, DeviceClockDriftSeconds: driftSeconds, } if radio.FreqMHz != 0 { params.RadioFreqMhz = &radio.FreqMHz params.RadioSf = &radio.SF params.RadioBwKhz = &radio.BWKHz } row, err := s.q.UpsertNode(ctx, params) if err != nil { return uuid.Nil, err } return row.ID, nil } func (s *Store) UpsertNodeIATA(ctx context.Context, nodeID uuid.UUID, iata string) error { params := sqlc.UpsertNodeIATAParams{NodeID: nodeID, Iata: iata} return s.q.UpsertNodeIATA(ctx, params) } func (s *Store) UpsertNodeShortID(ctx context.Context, nodeID uuid.UUID, iata string, prefix4 []byte) error { return s.q.UpsertNodeShortID(ctx, sqlc.UpsertNodeShortIDParams{ NodeID: nodeID, Iata: iata, Prefix4: prefix4, }) } func (s *Store) UpsertNodeNeighbor(ctx context.Context, nodeID, neighborID uuid.UUID, iata string, snr *float32, regionScope *string) error { return s.q.UpsertNodeNeighbor(ctx, sqlc.UpsertNodeNeighborParams{ NodeID: nodeID, NeighborID: neighborID, Iata: iata, Snr: snr, RegionScope: regionScope, }) } func (s *Store) SetNodeCapability(ctx context.Context, nodeID uuid.UUID, paths, traces bool) error { var errs []error if paths { errs = append(errs, s.q.SetNodeMultibytePaths(ctx, nodeID)) } if traces { errs = append(errs, s.q.SetNodeMultibyteTraces(ctx, nodeID)) } return errors.Join(errs...) } func (s *Store) SetNodeDefaultScope(ctx context.Context, nodeID uuid.UUID, scopeID int32) error { return s.q.SetNodeDefaultScope(ctx, sqlc.SetNodeDefaultScopeParams{ ID: nodeID, DefaultScopeID: &scopeID, }) } func (s *Store) ListNodes(ctx context.Context, nodeType int16, iatas []string, supportsMultibytePaths, supportsMultibyteTraces *bool, pubkey []byte, pubkeyPrefix, name, scope string, cursor int64, limit int32, includeNeighbors bool) (api.Page[api.NodeSummary], error) { var cursorTS pgtype.Timestamptz if cursor > 0 { cursorTS = pgtype.Timestamptz{Time: time.UnixMilli(cursor), Valid: true} } rows, err := s.q.ListNodes(ctx, sqlc.ListNodesParams{ Column1: nodeType, Column2: iatas, Column3: tristate(supportsMultibytePaths), Column4: tristate(supportsMultibyteTraces), Column5: pubkey, Column6: name, Column7: cursorTS, Limit: limit + 1, Column9: scope, Column10: includeNeighbors, Column11: pubkeyPrefix, }) if err != nil { return api.Page[api.NodeSummary]{}, err } hasMore := len(rows) > int(limit) if hasMore { rows = rows[:limit] } items := make([]api.NodeSummary, 0, len(rows)) for _, v := range rows { node := api.NodeSummary{ ID: v.ID, PublicKey: hex.EncodeToString(v.PublicKey), NodeType: v.NodeType, NodeTypeName: api.NodeTypeName(v.NodeType), Name: v.Name, Latitude: v.Latitude, Longitude: v.Longitude, IsObserver: v.IsObserver, ObserverID: nullableUUID(v.ObserverID), KnownNeighborCount: v.KnownNeighborCount, NeighborIDs: v.NeighborIds, Stale: v.LastSeen.Valid && v.LastSeen.Time.Before(time.Now().Add(-s.staleThreshold)), } if len(v.Iatas) > 0 { if err := json.Unmarshal(v.Iatas, &node.IATAs); err != nil { log.Printf("store: failed to unmarshal node iatas: %v", err) node.IATAs = []api.NodeIATA{} } } if v.RadioFreqMhz != nil && v.RadioSf != nil && v.RadioBwKhz != nil { s := fmt.Sprintf("%g,%g,%d", *v.RadioFreqMhz, *v.RadioBwKhz, *v.RadioSf) node.Radio = &s } items = append(items, node) } var nextCursor *int64 if hasMore && len(items) > 0 { ms := rows[len(rows)-1].LastSeen.Time.UnixMilli() nextCursor = &ms } return api.Page[api.NodeSummary]{ Items: items, NextCursor: nextCursor, HasMore: hasMore, }, nil } func (s *Store) GetNode(ctx context.Context, nodeID uuid.UUID) (*api.Node, error) { row, err := s.q.GetNodeByID(ctx, nodeID) if err != nil { return nil, err } node := &api.Node{ NodeSummary: api.NodeSummary{ ID: row.ID, PublicKey: hex.EncodeToString(row.PublicKey), NodeType: row.NodeType, NodeTypeName: api.NodeTypeName(row.NodeType), Name: row.Name, Latitude: row.Latitude, Longitude: row.Longitude, IsObserver: row.IsObserver, ObserverID: nullableUUID(row.ObserverID), DefaultScope: row.DefaultScopeName, KnownNeighborCount: row.KnownNeighborCount, Stale: row.LastSeen.Valid && row.LastSeen.Time.Before(time.Now().Add(-s.staleThreshold)), }, LocationSource: row.LocationSource, SupportsMultibytePaths: row.SupportsMultibytePaths, SupportsMultibyteTraces: row.SupportsMultibyteTraces, MinFirmwareVersion: row.MinFirmwareVersion, FirstSeen: row.FirstSeen.Time.UnixMilli(), LastSeen: row.LastSeen.Time.UnixMilli(), Metadata: row.Metadata, } neighbors, err := s.GetNodeNeighbors(ctx, nodeID) if err != nil { log.Printf("store: GetNodeNeighbors failed for %s: %v", nodeID, err) neighbors = []api.NodeNeighbor{} } node.Neighbors = neighbors if len(row.Iatas) > 0 { if err := json.Unmarshal(row.Iatas, &node.IATAs); err != nil { log.Printf("store: failed to unmarshal node iatas: %v", err) node.IATAs = []api.NodeIATA{} } } if row.RadioFreqMhz != nil && row.RadioSf != nil && row.RadioBwKhz != nil { s := fmt.Sprintf("%g,%g,%d", *row.RadioFreqMhz, *row.RadioBwKhz, *row.RadioSf) node.Radio = &s } if row.LastAdvertAt.Valid { ms := row.LastAdvertAt.Time.UnixMilli() node.LastAdvertAt = &ms } // Only repeaters (2) and room servers (3) sign adverts with a device clock worth // checking; omit entirely (not just zero) for other node types or an unmeasured node // per the API contract, so the frontend can distinguish "unknown" from "in sync". if (row.NodeType == 2 || row.NodeType == 3) && row.DeviceClockDriftSeconds != nil && row.LastAdvertAt.Valid { drift := int(*row.DeviceClockDriftSeconds) node.ClockDriftSeconds = &drift checkedAt := row.LastAdvertAt.Time.UnixMilli() node.ClockCheckedAt = &checkedAt outOfSync := time.Duration(abs(*row.DeviceClockDriftSeconds))*time.Second > s.clockDriftThreshold node.ClockOutOfSync = &outOfSync } return node, nil } // abs returns the absolute value of an int32 without overflowing on math.MinInt32. func abs(n int32) int32 { if n < 0 { if n == -2147483648 { // math.MinInt32; -n would overflow return 2147483647 } return -n } return n } func (s *Store) GetNodesByIDs(ctx context.Context, ids []uuid.UUID) (map[uuid.UUID]*api.ResolvedNode, error) { rows, err := s.q.GetNodesByIDs(ctx, ids) if err != nil { return nil, err } result := make(map[uuid.UUID]*api.ResolvedNode, len(rows)) for _, r := range rows { result[r.ID] = &api.ResolvedNode{ ID: r.ID, Name: r.Name, PublicKey: hex.EncodeToString(r.PublicKey), Latitude: r.Latitude, Longitude: r.Longitude, } } return result, nil } func (s *Store) GetNodeByPubkey(ctx context.Context, pubkey []byte) (uuid.UUID, error) { return s.q.GetNodeByPubkey(ctx, pubkey) } func (s *Store) GetNodeNeighbors(ctx context.Context, nodeID uuid.UUID) ([]api.NodeNeighbor, error) { rows, err := s.q.GetNodeNeighbors(ctx, nodeID) if err != nil { return nil, err } seen := make(map[uuid.UUID]int) items := make([]api.NodeNeighbor, 0, len(rows)) for _, r := range rows { if idx, ok := seen[r.ID]; ok { items[idx].ObservationCount += r.ObservationCount if r.LastSeen.Time.After(time.UnixMilli(items[idx].LastSeen)) { items[idx].LastSeen = r.LastSeen.Time.UnixMilli() items[idx].IATA = r.Iata items[idx].SNR = r.Snr } if r.FirstSeen.Time.Before(time.UnixMilli(items[idx].FirstSeen)) { items[idx].FirstSeen = r.FirstSeen.Time.UnixMilli() } continue } seen[r.ID] = len(items) items = append(items, api.NodeNeighbor{ ID: r.ID, Name: r.Name, PublicKey: hex.EncodeToString(r.PublicKey), NodeType: r.NodeType, NodeTypeName: api.NodeTypeName(r.NodeType), Latitude: r.Latitude, Longitude: r.Longitude, IATA: r.Iata, ObservationCount: r.ObservationCount, FirstSeen: r.FirstSeen.Time.UnixMilli(), LastSeen: r.LastSeen.Time.UnixMilli(), SNR: r.Snr, }) } return items, nil } func (s *Store) ReconfirmNeighbors(ctx context.Context) error { return s.q.ReconfirmNeighbors(ctx) } // DeleteOldNodes deletes nodes not seen since the given cutoff. See the DeleteOldNodes SQL // query for the observer_owners exclusion and known_routes caveat. func (s *Store) DeleteOldNodes(ctx context.Context, cutoff time.Time) error { return s.q.DeleteOldNodes(ctx, pgtype.Timestamptz{Time: cutoff, Valid: true}) }