// 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) } } } } }