Files
reflector-dashboard/svxdr/astmeta/astmeta.go
T

635 lines
16 KiB
Go

// svxdr — live-data daemon for Reflector Dashboard (nodelist + proxy + astmeta + xlink)
// Copyright (C) 2026 SA7AUX (Björn)
// SPDX-License-Identifier: AGPL-3.0-or-later
//
// This program is free software: you can redistribute it and/or modify it under
// the terms of the GNU Affero General Public License as published by the Free
// Software Foundation, either version 3 of the License, or (at your option) any
// later version. See the LICENSE file for details.
// Package astmeta is the svxdr astmeta subsystem — AllStar talker metadata,
// folded in from the standalone svx-web/astmeta. It polls AMI (rpt xnode) for
// keyed remote nodes, resolves callsign/system, feeds svxlink UsrpLogic (USRP),
// optionally MQTT, and POSTs talker-meta to the nodelist/proxy (kept for the
// REMOTE case, e.g. astmeta on the allstar box; when co-located a later phase
// shares the store directly). Logic preserved exactly, including the fan-out
// allstar remap. Config now comes from svxdr.json (astmeta + nodes + redis).
package astmeta
import (
"bufio"
"bytes"
"context"
"encoding/binary"
"encoding/json"
"fmt"
"io"
"log"
"net"
"net/http"
"os"
"regexp"
"strconv"
"strings"
"sync"
"time"
mqtt "github.com/eclipse/paho.mqtt.golang"
"github.com/redis/go-redis/v9"
"svxportal/svxdr/config"
)
// settings holds the astmeta-relevant config, derived from svxdr.json.
type settings struct {
AmiHost, AmiPort, AmiUser, AmiSecret string
IgnoreNodes map[string]bool
PollInterval, HangTime time.Duration
Network string
Target, Format, SpaceReplace string
RefreshInterval time.Duration
LookupEnabled bool
LookupURL, LookupFile string
CacheTTL time.Duration
RedisCache bool
RedisPrefix string
Redis config.Redis
MqttEnabled bool
MqttBroker, MqttUser, MqttPass, MqttPrefix string
NodelistURL, NodelistToken string
NodelistPeers []metaTarget
Via string
// per-node, from cfg.Nodes
WatchNodes []string
NodeTg map[string]string
NodeVia map[string]string
NodeAllstar map[string]string
Names map[string]string
ownNodes map[string]bool
}
func (s *settings) tgFor(wn string) string { return s.NodeTg[wn] }
func (s *settings) viaFor(wn string) string {
if v := s.NodeVia[wn]; v != "" {
return v
}
return s.Via
}
func newSettings(c *config.Config) *settings {
a := c.Astmeta
s := &settings{
AmiHost: orDef(a.AmiHost, "127.0.0.1"), AmiPort: orDef(a.AmiPort, "5038"),
AmiUser: a.AmiUser, AmiSecret: a.AmiSecret,
IgnoreNodes: map[string]bool{},
PollInterval: orDur(a.PollInterval.D(), time.Second),
HangTime: a.HangTime.D(),
Network: orDef(a.Network, "AllStar"),
Target: a.UsrpTarget, // empty = USRP sink disabled (newSender returns nil)
Format: orDef(a.Format, "info"),
SpaceReplace: orDef(a.SpaceReplace, "-"),
RefreshInterval: a.RefreshInterval.D(),
LookupEnabled: a.LookupEnabled,
LookupURL: a.LookupURL, LookupFile: a.LookupFile,
CacheTTL: orDur(a.CacheTTL.D(), 24*time.Hour),
RedisCache: a.RedisCache,
RedisPrefix: orDef(a.RedisCachePrefix, "astmeta:node:"),
Redis: c.Redis,
MqttEnabled: a.MqttEnabled, MqttBroker: a.MqttBroker, MqttUser: a.MqttUser,
MqttPass: a.MqttPass, MqttPrefix: a.MqttPrefix,
NodelistURL: a.NodelistURL, NodelistToken: a.NodelistToken,
NodeTg: map[string]string{},
NodeVia: map[string]string{},
NodeAllstar: map[string]string{},
Names: map[string]string{},
ownNodes: map[string]bool{},
}
s.Via = "" // global via fallback is unused now that nodes carry their own via
for _, n := range a.IgnoreNodes {
if n = strings.TrimSpace(n); n != "" {
s.IgnoreNodes[n] = true
}
}
for _, p := range a.NodelistPeers {
if p.URL != "" {
s.NodelistPeers = append(s.NodelistPeers, metaTarget{URL: p.URL, Token: p.Token})
}
}
// per-node bindings from nodes[] (the source of truth)
for _, e := range c.Nodes {
if !e.IsEnabled() || e.SvxNode == 0 {
continue
}
wn := strconv.Itoa(e.SvxNode)
s.WatchNodes = append(s.WatchNodes, wn)
s.ownNodes[wn] = true
if e.TG != 0 {
s.NodeTg[wn] = strconv.Itoa(e.TG)
}
via := e.Via
if via == "" {
via = e.Callsign
}
if via != "" {
s.NodeVia[wn] = via
}
if e.Allstar != 0 {
s.NodeAllstar[wn] = strconv.Itoa(e.Allstar)
if e.System != "" {
s.Names[strconv.Itoa(e.Allstar)] = e.System
}
}
}
return s
}
func orDef(v, d string) string {
if v == "" {
return d
}
return v
}
func orDur(v, d time.Duration) time.Duration {
if v <= 0 {
return d
}
return v
}
func sleepCtx(ctx context.Context, d time.Duration) bool {
t := time.NewTimer(d)
defer t.Stop()
select {
case <-ctx.Done():
return false
case <-t.C:
return true
}
}
// ---------------------------------------------------------------- AMI --------
type ami struct {
s *settings
conn net.Conn
rd *bufio.Reader
}
func (a *ami) connect() error {
conn, err := net.DialTimeout("tcp", net.JoinHostPort(a.s.AmiHost, a.s.AmiPort), 5*time.Second)
if err != nil {
return err
}
a.conn = conn
a.rd = bufio.NewReader(conn)
a.conn.SetReadDeadline(time.Now().Add(5 * time.Second))
if _, err := a.rd.ReadString('\n'); err != nil {
conn.Close()
return fmt.Errorf("ami banner: %w", err)
}
resp, err := a.request(fmt.Sprintf(
"Action: Login\r\nUsername: %s\r\nSecret: %s\r\nEvents: off\r\n\r\n",
a.s.AmiUser, a.s.AmiSecret))
if err != nil {
conn.Close()
return err
}
if !strings.Contains(resp, "Success") {
conn.Close()
return fmt.Errorf("ami login failed: %s", strings.TrimSpace(resp))
}
return nil
}
func (a *ami) request(action string) (string, error) {
a.conn.SetWriteDeadline(time.Now().Add(5 * time.Second))
if _, err := io.WriteString(a.conn, action); err != nil {
return "", err
}
var b strings.Builder
follows := false
for {
a.conn.SetReadDeadline(time.Now().Add(5 * time.Second))
line, err := a.rd.ReadString('\n')
if err != nil {
return "", err
}
b.WriteString(line)
t := strings.TrimRight(line, "\r\n")
if strings.HasPrefix(t, "Response: Follows") {
follows = true
}
if follows {
if strings.Contains(t, "--END COMMAND--") {
follows = false
}
continue
}
if t == "" {
return b.String(), nil
}
}
}
func (a *ami) alinks(node string) ([]string, error) {
resp, err := a.request(fmt.Sprintf("Action: Command\r\nCommand: rpt xnode %s\r\n\r\n", node))
if err != nil {
return nil, err
}
for _, line := range strings.Split(resp, "\n") {
line = strings.TrimPrefix(strings.TrimSpace(line), "Output: ")
if v, ok := strings.CutPrefix(line, "RPT_ALINKS="); ok {
parts := strings.Split(strings.TrimSpace(v), ",")
if len(parts) > 1 {
return parts[1:], nil
}
return nil, nil
}
}
return nil, fmt.Errorf("no RPT_ALINKS in xnode output for %s", node)
}
// ------------------------------------------------------------- resolver ------
var astdbLine = regexp.MustCompile(`^([0-9]+)\|([^|]*)\|([^|]*)\|(.*)$`)
type resolver struct {
s *settings
ctx context.Context
rdb *redis.Client
mu sync.RWMutex
astdb map[string][2]string
last time.Time
}
func newResolver(ctx context.Context, s *settings) *resolver {
r := &resolver{s: s, ctx: ctx, astdb: map[string][2]string{}}
if s.RedisCache {
opt := &redis.Options{Password: s.Redis.Password, DB: s.Redis.DB}
if s.Redis.Socket != "" {
opt.Network, opt.Addr = "unix", s.Redis.Socket
} else {
opt.Addr = orDef(s.Redis.Addr, "127.0.0.1:6379")
}
r.rdb = redis.NewClient(opt)
}
return r
}
func (r *resolver) refresh() {
if !r.s.LookupEnabled || time.Since(r.last) < r.s.CacheTTL {
return
}
var rd io.ReadCloser
var err error
switch {
case r.s.LookupFile != "":
rd, err = os.Open(r.s.LookupFile)
case r.s.LookupURL != "":
var resp *http.Response
cl := &http.Client{Timeout: 60 * time.Second}
resp, err = cl.Get(r.s.LookupURL)
if err == nil {
rd = resp.Body
}
default:
r.last = time.Now()
return
}
if err != nil {
log.Printf("astmeta lookup: refresh failed: %v", err)
r.last = time.Now().Add(-r.s.CacheTTL + 5*time.Minute)
return
}
defer rd.Close()
m := map[string][2]string{}
sc := bufio.NewScanner(rd)
sc.Buffer(make([]byte, 1024*1024), 1024*1024)
for sc.Scan() {
if f := astdbLine.FindStringSubmatch(sc.Text()); f != nil {
m[f[1]] = [2]string{f[2], f[3]}
}
}
if len(m) > 0 {
r.mu.Lock()
r.astdb = m
r.mu.Unlock()
log.Printf("astmeta lookup: astdb loaded, %d nodes", len(m))
}
r.last = time.Now()
}
func (r *resolver) Resolve(node string) (call, system string) {
system = r.s.Names[node]
call = node
if r.rdb != nil {
if v, err := r.rdb.Get(r.ctx, r.s.RedisPrefix+node).Result(); err == nil && v != "" {
return v, system
}
}
r.refresh()
r.mu.RLock()
e, ok := r.astdb[node]
r.mu.RUnlock()
if ok && e[0] != "" {
if r.rdb != nil {
r.rdb.Set(r.ctx, r.s.RedisPrefix+node, e[0], r.s.CacheTTL)
}
call = e[0]
}
return call, system
}
// ---------------------------------------------------------------- sender -----
type sender struct {
s *settings
conn net.Conn
seq uint32
}
func newSender(s *settings) (*sender, error) {
if s.Target == "" {
return nil, nil // USRP sink optional
}
conn, err := net.Dial("udp", s.Target)
if err != nil {
return nil, err
}
return &sender{s: s, conn: conn}, nil
}
func (s *sender) header(utype uint32) []byte {
h := make([]byte, 32)
copy(h[0:4], "USRP")
binary.LittleEndian.PutUint32(h[4:8], s.seq)
binary.LittleEndian.PutUint32(h[20:24], utype)
s.seq++
return h
}
func (s *sender) SendInfo(call, node, tg string) error {
payload := map[string]any{
"ab": map[string]string{"version": "astmeta"},
"digital": map[string]string{
"gw": node, "rpt": node, "tg": tg, "ts": "1", "cc": "1",
"call": call, "ambe_mode": "USRP",
},
"last_tune": tg,
}
j, err := json.Marshal(payload)
if err != nil {
return err
}
frame := append(s.header(2), []byte("INFO:")...)
frame = append(frame, j...)
_, err = s.conn.Write(frame)
return err
}
func (s *sender) SendTlv(call, node, tg string) error {
if len(call) > 8 {
call = call[:8]
}
tgn, _ := strconv.Atoi(tg)
nodeNum, _ := strconv.Atoi(node)
body := make([]byte, 2+3+4+3+1+1+306)
body[0] = 0x08
body[1] = byte(len(call) + 13)
binary.LittleEndian.PutUint32(body[5:9], uint32(nodeNum))
body[9] = byte(tgn >> 16)
body[10] = byte(tgn >> 8)
body[11] = byte(tgn)
body[12] = 1
body[13] = 1
copy(body[14:], call)
frame := append(s.header(2), body...)
_, err := s.conn.Write(frame)
return err
}
func (s *sender) Send(call, node, tg string) error {
call = strings.Join(strings.Fields(call), s.s.SpaceReplace)
if s.s.Format == "tlv" {
return s.SendTlv(call, node, tg)
}
return s.SendInfo(call, node, tg)
}
// ------------------------------------------------------------- publisher -----
type publisher struct {
s *settings
cl mqtt.Client
}
func newPublisher(s *settings) *publisher {
if !s.MqttEnabled {
return nil
}
opts := mqtt.NewClientOptions().
AddBroker(s.MqttBroker).SetClientID("svxdr-astmeta").
SetUsername(s.MqttUser).SetPassword(s.MqttPass).
SetAutoReconnect(true).SetConnectRetry(true).
SetConnectRetryInterval(5 * time.Second)
cl := mqtt.NewClient(opts)
if t := cl.Connect(); t.Wait() && t.Error() != nil {
log.Printf("astmeta mqtt: connect failed: %v (will keep retrying)", t.Error())
}
return &publisher{s: s, cl: cl}
}
func (p *publisher) PublishMeta(call, node, system, tg, via, network string) {
topic := strings.TrimRight(p.s.MqttPrefix, "/") + "/talker/" + tg + "/meta"
j, _ := json.Marshal(map[string]any{
"callsign": call, "node": node, "system": system, "via": via,
"network": network, "ts": time.Now().UnixMilli(),
})
p.cl.Publish(topic, 0, false, j)
}
// --------------------------------------------------------------- poster ------
type metaTarget struct{ URL, Token string }
type poster struct {
targets []metaTarget
cl *http.Client
}
func newPoster(s *settings) *poster {
var targets []metaTarget
if s.NodelistURL != "" && s.NodelistToken != "" {
targets = append(targets, metaTarget{URL: s.NodelistURL, Token: s.NodelistToken})
}
targets = append(targets, s.NodelistPeers...)
if len(targets) == 0 {
return nil
}
return &poster{targets: targets, cl: &http.Client{Timeout: 5 * time.Second}}
}
func (p *poster) PostMeta(call, node, system, event string, tg int, via, network string) {
if tg <= 0 {
return
}
body, _ := json.Marshal(map[string]any{
"tg": tg, "event": event, "callsign": call, "node": node,
"system": system, "via": via, "network": network,
})
for _, t := range p.targets {
req, err := http.NewRequest(http.MethodPost, t.URL, bytes.NewReader(body))
if err != nil {
continue
}
req.Header.Set("Content-Type", "application/json")
if t.Token != "" {
req.Header.Set("X-Ingest-Token", t.Token)
}
resp, err := p.cl.Do(req)
if err != nil {
log.Printf("astmeta nodelist: post to %s failed: %v", t.URL, err)
continue
}
resp.Body.Close()
if resp.StatusCode >= 300 {
log.Printf("astmeta nodelist: post %s to %s returned HTTP %d", event, t.URL, resp.StatusCode)
}
}
}
// ------------------------------------------------------------------ Run ------
var alinkEntry = regexp.MustCompile(`^([0-9]+)([A-Z]+)$`)
// Run polls AMI for every watched node and emits talker metadata until ctx ends.
func Run(ctx context.Context, c *config.Config) error {
s := newSettings(c)
if len(s.WatchNodes) == 0 {
return fmt.Errorf("astmeta: no nodes to watch (nodes[] empty)")
}
if s.AmiUser == "" || s.AmiSecret == "" {
return fmt.Errorf("astmeta: ami_user/ami_secret required")
}
resolver := newResolver(ctx, s)
snd, err := newSender(s)
if err != nil {
return fmt.Errorf("astmeta usrp: %w", err)
}
pub := newPublisher(s)
post := newPoster(s)
log.Printf("astmeta: watching %v via AMI %s:%s (usrp=%v mqtt=%v post=%v)",
s.WatchNodes, s.AmiHost, s.AmiPort, snd != nil, pub != nil, post != nil)
a := &ami{s: s}
keyed := map[string]bool{}
unkeyedAt := map[string]time.Time{}
lastSent := map[string]time.Time{}
for {
if ctx.Err() != nil {
return nil
}
if a.conn == nil {
if err := a.connect(); err != nil {
log.Printf("astmeta ami: %v — retrying in 10s", err)
if !sleepCtx(ctx, 10*time.Second) {
return nil
}
continue
}
log.Printf("astmeta ami: connected")
}
if !sleepCtx(ctx, s.PollInterval) {
return nil
}
seen := map[string]bool{}
fail := false
for _, wn := range s.WatchNodes {
tg := s.tgFor(wn)
via := s.viaFor(wn)
tgNum, _ := strconv.Atoi(tg)
entries, err := a.alinks(wn)
if err != nil {
log.Printf("astmeta ami: %v — reconnecting", err)
a.conn.Close()
a.conn = nil
fail = true
break
}
for _, e := range entries {
f := alinkEntry.FindStringSubmatch(e)
if f == nil {
continue
}
node, flags := f[1], f[2]
if s.IgnoreNodes[node] {
continue
}
reportNode := node
if static := s.NodeAllstar[wn]; static != "" && node != static && s.ownNodes[node] {
reportNode = static
}
k := wn + "|" + node
isKeyed := strings.HasSuffix(flags, "K")
seen[k] = true
refresh := s.RefreshInterval > 0 && time.Since(lastSent[k]) >= s.RefreshInterval
if isKeyed {
delete(unkeyedAt, k)
if !keyed[k] || refresh {
call, system := resolver.Resolve(reportNode)
display := system
if display == "" {
display = call
}
if snd != nil {
if err := snd.Send(display, reportNode, tg); err != nil {
log.Printf("astmeta usrp: send failed: %v", err)
}
}
if !keyed[k] {
log.Printf("astmeta talker start: node %s call %s system %q tg %s via %s",
reportNode, call, system, tg, via)
}
if pub != nil {
pub.PublishMeta(call, reportNode, system, tg, via, s.Network)
}
if post != nil {
post.PostMeta(call, reportNode, system, "start", tgNum, via, s.Network)
}
lastSent[k] = time.Now()
}
keyed[k] = true
} else if keyed[k] {
if _, pending := unkeyedAt[k]; !pending {
unkeyedAt[k] = time.Now()
}
if s.HangTime <= 0 || time.Since(unkeyedAt[k]) >= s.HangTime {
log.Printf("astmeta talker stop: node %s tg %s", reportNode, tg)
if post != nil {
post.PostMeta("", reportNode, "", "stop", tgNum, via, s.Network)
}
keyed[k] = false
delete(unkeyedAt, k)
}
}
}
}
if !fail {
for k := range keyed {
if !seen[k] {
delete(keyed, k)
delete(lastSent, k)
delete(unkeyedAt, k)
}
}
}
}
}