2026-01-23 23:31:57 -08:00
|
|
|
package tendrils
|
|
|
|
|
|
|
|
|
|
import (
|
2026-01-28 22:36:44 -08:00
|
|
|
"context"
|
2026-01-24 11:03:34 -08:00
|
|
|
"encoding/json"
|
2026-01-23 23:31:57 -08:00
|
|
|
"fmt"
|
2026-01-30 09:35:42 -08:00
|
|
|
"math"
|
2026-01-23 23:31:57 -08:00
|
|
|
"net"
|
|
|
|
|
"sort"
|
|
|
|
|
"strings"
|
2026-01-28 22:15:54 -08:00
|
|
|
"time"
|
2026-01-23 23:31:57 -08:00
|
|
|
|
|
|
|
|
"github.com/fvbommel/sortorder"
|
2026-01-24 11:03:34 -08:00
|
|
|
"go.jetify.com/typeid"
|
2026-01-23 23:31:57 -08:00
|
|
|
)
|
|
|
|
|
|
2026-01-28 22:36:44 -08:00
|
|
|
func newID(prefix string) string {
|
2026-01-24 11:03:34 -08:00
|
|
|
tid, _ := typeid.WithPrefix(prefix)
|
|
|
|
|
return tid.String()
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-28 22:36:44 -08:00
|
|
|
type ArtNetUniverse int
|
|
|
|
|
|
|
|
|
|
func (u ArtNetUniverse) Net() int {
|
|
|
|
|
return (int(u) >> 8) & 0x7f
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (u ArtNetUniverse) Subnet() int {
|
|
|
|
|
return (int(u) >> 4) & 0x0f
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (u ArtNetUniverse) Universe() int {
|
|
|
|
|
return int(u) & 0x0f
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (u ArtNetUniverse) String() string {
|
|
|
|
|
return fmt.Sprintf("%d/%d/%d", u.Net(), u.Subnet(), u.Universe())
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
type ArtNetUniverseSet map[ArtNetUniverse]time.Time
|
|
|
|
|
|
|
|
|
|
func (s ArtNetUniverseSet) Add(u ArtNetUniverse) {
|
|
|
|
|
s[u] = time.Now()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (s ArtNetUniverseSet) Universes() []ArtNetUniverse {
|
|
|
|
|
result := make([]ArtNetUniverse, 0, len(s))
|
|
|
|
|
for u := range s {
|
|
|
|
|
result = append(result, u)
|
|
|
|
|
}
|
|
|
|
|
sort.Slice(result, func(i, j int) bool { return result[i] < result[j] })
|
|
|
|
|
return result
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (s ArtNetUniverseSet) Expire(maxAge time.Duration) {
|
|
|
|
|
expireTime := time.Now().Add(-maxAge)
|
|
|
|
|
for u, lastSeen := range s {
|
|
|
|
|
if lastSeen.Before(expireTime) {
|
|
|
|
|
delete(s, u)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (s ArtNetUniverseSet) MarshalJSON() ([]byte, error) {
|
|
|
|
|
return json.Marshal(s.Universes())
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
type SACNUniverse int
|
|
|
|
|
|
|
|
|
|
func (u SACNUniverse) String() string {
|
|
|
|
|
return fmt.Sprintf("%d", u)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
type SACNUniverseSet map[SACNUniverse]time.Time
|
|
|
|
|
|
|
|
|
|
func (s SACNUniverseSet) Add(u SACNUniverse) {
|
|
|
|
|
s[u] = time.Now()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (s SACNUniverseSet) Universes() []SACNUniverse {
|
|
|
|
|
result := make([]SACNUniverse, 0, len(s))
|
|
|
|
|
for u := range s {
|
|
|
|
|
result = append(result, u)
|
|
|
|
|
}
|
|
|
|
|
sort.Slice(result, func(i, j int) bool { return result[i] < result[j] })
|
|
|
|
|
return result
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (s SACNUniverseSet) Expire(maxAge time.Duration) {
|
|
|
|
|
expireTime := time.Now().Add(-maxAge)
|
|
|
|
|
for u, lastSeen := range s {
|
|
|
|
|
if lastSeen.Before(expireTime) {
|
|
|
|
|
delete(s, u)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (s SACNUniverseSet) MarshalJSON() ([]byte, error) {
|
|
|
|
|
return json.Marshal(s.Universes())
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-30 22:59:58 -08:00
|
|
|
type ArtmapMapping struct {
|
|
|
|
|
From string `json:"from"`
|
|
|
|
|
To string `json:"to"`
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-28 22:36:44 -08:00
|
|
|
type MulticastGroupID int
|
|
|
|
|
|
|
|
|
|
const (
|
|
|
|
|
MulticastUnknown MulticastGroupID = iota
|
|
|
|
|
MulticastMDNS
|
|
|
|
|
MulticastPTP
|
|
|
|
|
MulticastPTPAnnounce
|
|
|
|
|
MulticastPTPSync
|
|
|
|
|
MulticastPTPDelay
|
|
|
|
|
MulticastSAP
|
|
|
|
|
MulticastShureSLP
|
|
|
|
|
MulticastSSDP
|
|
|
|
|
MulticastSLP
|
|
|
|
|
MulticastAdminScopedBroadcast
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
type MulticastGroup struct {
|
|
|
|
|
ID MulticastGroupID
|
|
|
|
|
SACNUniverse SACNUniverse
|
|
|
|
|
DanteFlow int
|
|
|
|
|
DanteAV int
|
|
|
|
|
RawIP string
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (g MulticastGroup) String() string {
|
|
|
|
|
switch g.ID {
|
|
|
|
|
case MulticastMDNS:
|
|
|
|
|
return "mdns"
|
|
|
|
|
case MulticastPTP:
|
|
|
|
|
return "ptp"
|
|
|
|
|
case MulticastPTPAnnounce:
|
|
|
|
|
return "ptp-announce"
|
|
|
|
|
case MulticastPTPSync:
|
|
|
|
|
return "ptp-sync"
|
|
|
|
|
case MulticastPTPDelay:
|
|
|
|
|
return "ptp-delay"
|
|
|
|
|
case MulticastSAP:
|
|
|
|
|
return "sap"
|
|
|
|
|
case MulticastShureSLP:
|
|
|
|
|
return "shure-slp"
|
|
|
|
|
case MulticastSSDP:
|
|
|
|
|
return "ssdp"
|
|
|
|
|
case MulticastSLP:
|
|
|
|
|
return "slp"
|
|
|
|
|
case MulticastAdminScopedBroadcast:
|
|
|
|
|
return "admin-scoped-broadcast"
|
|
|
|
|
}
|
|
|
|
|
if g.SACNUniverse > 0 {
|
|
|
|
|
return fmt.Sprintf("sacn:%d", g.SACNUniverse)
|
|
|
|
|
}
|
|
|
|
|
if g.DanteFlow > 0 {
|
|
|
|
|
return fmt.Sprintf("dante-mcast:%d", g.DanteFlow)
|
|
|
|
|
}
|
|
|
|
|
if g.DanteAV > 0 {
|
|
|
|
|
return fmt.Sprintf("dante-av:%d", g.DanteAV)
|
|
|
|
|
}
|
|
|
|
|
return g.RawIP
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (g MulticastGroup) MarshalJSON() ([]byte, error) {
|
|
|
|
|
return json.Marshal(g.String())
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (g MulticastGroup) IsDante() bool {
|
|
|
|
|
return g.DanteFlow > 0 || g.DanteAV > 0
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (g MulticastGroup) IsSACN() bool {
|
|
|
|
|
return g.SACNUniverse > 0
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func ParseMulticastGroup(ip net.IP) MulticastGroup {
|
|
|
|
|
ip4 := ip.To4()
|
|
|
|
|
if ip4 == nil {
|
|
|
|
|
return MulticastGroup{RawIP: ip.String()}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
switch ip.String() {
|
|
|
|
|
case "224.0.0.251":
|
|
|
|
|
return MulticastGroup{ID: MulticastMDNS}
|
|
|
|
|
case "224.0.1.129":
|
|
|
|
|
return MulticastGroup{ID: MulticastPTP}
|
|
|
|
|
case "224.0.1.130":
|
|
|
|
|
return MulticastGroup{ID: MulticastPTPAnnounce}
|
|
|
|
|
case "224.0.1.131":
|
|
|
|
|
return MulticastGroup{ID: MulticastPTPSync}
|
|
|
|
|
case "224.0.1.132":
|
|
|
|
|
return MulticastGroup{ID: MulticastPTPDelay}
|
|
|
|
|
case "224.2.127.254":
|
|
|
|
|
return MulticastGroup{ID: MulticastSAP}
|
|
|
|
|
case "239.255.254.253":
|
|
|
|
|
return MulticastGroup{ID: MulticastShureSLP}
|
|
|
|
|
case "239.255.255.250":
|
|
|
|
|
return MulticastGroup{ID: MulticastSSDP}
|
|
|
|
|
case "239.255.255.253":
|
|
|
|
|
return MulticastGroup{ID: MulticastSLP}
|
|
|
|
|
case "239.255.255.255":
|
|
|
|
|
return MulticastGroup{ID: MulticastAdminScopedBroadcast}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if ip4[0] == 239 && ip4[1] == 255 {
|
|
|
|
|
universe := int(ip4[2])*256 + int(ip4[3])
|
|
|
|
|
if universe >= 1 && universe <= 63999 {
|
|
|
|
|
return MulticastGroup{SACNUniverse: SACNUniverse(universe)}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if ip4[0] == 239 && ip4[1] >= 69 && ip4[1] <= 71 {
|
|
|
|
|
flowID := (int(ip4[1]-69) << 16) | (int(ip4[2]) << 8) | int(ip4[3])
|
|
|
|
|
return MulticastGroup{DanteFlow: flowID}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if ip4[0] == 239 && ip4[1] == 253 {
|
|
|
|
|
flowID := (int(ip4[2]) << 8) | int(ip4[3])
|
|
|
|
|
return MulticastGroup{DanteAV: flowID}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return MulticastGroup{RawIP: ip.String()}
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-28 22:48:55 -08:00
|
|
|
type MulticastMembershipSet map[MulticastGroup]time.Time
|
2026-01-28 22:36:44 -08:00
|
|
|
|
|
|
|
|
func (s MulticastMembershipSet) Add(group MulticastGroup) {
|
2026-01-28 22:48:55 -08:00
|
|
|
s[group] = time.Now()
|
2026-01-28 22:36:44 -08:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (s MulticastMembershipSet) Remove(group MulticastGroup) {
|
2026-01-28 22:48:55 -08:00
|
|
|
delete(s, group)
|
2026-01-28 22:36:44 -08:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (s MulticastMembershipSet) Groups() []MulticastGroup {
|
|
|
|
|
result := make([]MulticastGroup, 0, len(s))
|
2026-01-28 22:48:55 -08:00
|
|
|
for g := range s {
|
|
|
|
|
result = append(result, g)
|
2026-01-28 22:36:44 -08:00
|
|
|
}
|
|
|
|
|
sort.Slice(result, func(i, j int) bool {
|
|
|
|
|
return result[i].String() < result[j].String()
|
|
|
|
|
})
|
|
|
|
|
return result
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (s MulticastMembershipSet) SACNInputs() []SACNUniverse {
|
|
|
|
|
var result []SACNUniverse
|
2026-01-28 22:48:55 -08:00
|
|
|
for g := range s {
|
|
|
|
|
if g.IsSACN() {
|
|
|
|
|
result = append(result, g.SACNUniverse)
|
2026-01-28 22:36:44 -08:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
sort.Slice(result, func(i, j int) bool { return result[i] < result[j] })
|
|
|
|
|
return result
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (s MulticastMembershipSet) Expire(maxAge time.Duration) {
|
|
|
|
|
expireTime := time.Now().Add(-maxAge)
|
2026-01-28 22:48:55 -08:00
|
|
|
for g, lastSeen := range s {
|
|
|
|
|
if lastSeen.Before(expireTime) {
|
|
|
|
|
delete(s, g)
|
2026-01-28 22:36:44 -08:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (s MulticastMembershipSet) MarshalJSON() ([]byte, error) {
|
|
|
|
|
return json.Marshal(s.Groups())
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-24 11:03:34 -08:00
|
|
|
type MAC string
|
|
|
|
|
|
|
|
|
|
func (m MAC) Parse() net.HardwareAddr {
|
|
|
|
|
mac, _ := net.ParseMAC(string(m))
|
|
|
|
|
return mac
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func MACFrom(mac net.HardwareAddr) MAC {
|
|
|
|
|
if mac == nil {
|
|
|
|
|
return ""
|
|
|
|
|
}
|
|
|
|
|
return MAC(mac.String())
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
type IPSet map[string]bool
|
|
|
|
|
|
|
|
|
|
func (s IPSet) MarshalJSON() ([]byte, error) {
|
|
|
|
|
ips := make([]string, 0, len(s))
|
|
|
|
|
for ip := range s {
|
|
|
|
|
ips = append(ips, ip)
|
|
|
|
|
}
|
|
|
|
|
sort.Strings(ips)
|
|
|
|
|
return json.Marshal(ips)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (s IPSet) Add(ip net.IP) {
|
|
|
|
|
s[ip.String()] = true
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (s IPSet) Has(ip string) bool {
|
|
|
|
|
return s[ip]
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (s IPSet) Slice() []string {
|
|
|
|
|
ips := make([]string, 0, len(s))
|
|
|
|
|
for ip := range s {
|
|
|
|
|
ips = append(ips, ip)
|
|
|
|
|
}
|
|
|
|
|
sort.Strings(ips)
|
|
|
|
|
return ips
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
type NameSet map[string]bool
|
|
|
|
|
|
2026-01-25 18:22:22 -08:00
|
|
|
func sortNamesByLength(names []string) {
|
|
|
|
|
sort.Slice(names, func(i, j int) bool {
|
|
|
|
|
if len(names[i]) != len(names[j]) {
|
|
|
|
|
return len(names[i]) < len(names[j])
|
|
|
|
|
}
|
|
|
|
|
return names[i] < names[j]
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-24 11:03:34 -08:00
|
|
|
func (s NameSet) MarshalJSON() ([]byte, error) {
|
|
|
|
|
names := make([]string, 0, len(s))
|
|
|
|
|
for name := range s {
|
|
|
|
|
names = append(names, name)
|
|
|
|
|
}
|
2026-01-25 18:22:22 -08:00
|
|
|
sortNamesByLength(names)
|
2026-01-24 11:03:34 -08:00
|
|
|
return json.Marshal(names)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (s NameSet) Add(name string) {
|
|
|
|
|
s[name] = true
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (s NameSet) Has(name string) bool {
|
|
|
|
|
return s[name]
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
type InterfaceMap map[string]*Interface
|
|
|
|
|
|
|
|
|
|
func (m InterfaceMap) MarshalJSON() ([]byte, error) {
|
|
|
|
|
keys := make([]string, 0, len(m))
|
|
|
|
|
for k := range m {
|
|
|
|
|
keys = append(keys, k)
|
|
|
|
|
}
|
|
|
|
|
sort.Slice(keys, func(i, j int) bool {
|
|
|
|
|
return sortorder.NaturalLess(keys[i], keys[j])
|
|
|
|
|
})
|
|
|
|
|
ifaces := make([]*Interface, 0, len(m))
|
|
|
|
|
for _, k := range keys {
|
|
|
|
|
ifaces = append(ifaces, m[k])
|
|
|
|
|
}
|
|
|
|
|
return json.Marshal(ifaces)
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-23 23:31:57 -08:00
|
|
|
type Interface struct {
|
2026-01-24 11:03:34 -08:00
|
|
|
Name string `json:"name,omitempty"`
|
|
|
|
|
MAC MAC `json:"mac"`
|
2026-01-30 10:09:17 -08:00
|
|
|
IPs IPSet `json:"ips,omitempty"`
|
2026-01-31 13:01:07 -08:00
|
|
|
Up bool `json:"up,omitempty"`
|
2026-01-24 11:03:34 -08:00
|
|
|
Stats *InterfaceStats `json:"stats,omitempty"`
|
2026-01-23 23:31:57 -08:00
|
|
|
}
|
|
|
|
|
|
2026-01-30 10:09:17 -08:00
|
|
|
func (i *Interface) MarshalJSON() ([]byte, error) {
|
|
|
|
|
type ifaceJSON struct {
|
|
|
|
|
Name string `json:"name,omitempty"`
|
|
|
|
|
MAC MAC `json:"mac"`
|
|
|
|
|
IPs []string `json:"ips,omitempty"`
|
2026-01-31 13:01:07 -08:00
|
|
|
Up bool `json:"up,omitempty"`
|
2026-01-30 10:09:17 -08:00
|
|
|
Stats *InterfaceStats `json:"stats,omitempty"`
|
|
|
|
|
}
|
|
|
|
|
var ips []string
|
|
|
|
|
if len(i.IPs) > 0 {
|
|
|
|
|
ips = i.IPs.Slice()
|
|
|
|
|
}
|
|
|
|
|
return json.Marshal(ifaceJSON{
|
|
|
|
|
Name: i.Name,
|
|
|
|
|
MAC: i.MAC,
|
|
|
|
|
IPs: ips,
|
2026-01-31 13:01:07 -08:00
|
|
|
Up: i.Up,
|
2026-01-30 10:09:17 -08:00
|
|
|
Stats: i.Stats,
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-23 23:31:57 -08:00
|
|
|
type InterfaceStats struct {
|
2026-01-30 09:35:42 -08:00
|
|
|
Speed uint64 `json:"speed,omitempty"`
|
2026-01-31 12:06:59 -08:00
|
|
|
Uptime uint64 `json:"uptime,omitempty"`
|
2026-01-30 09:35:42 -08:00
|
|
|
InErrors uint64 `json:"in_errors,omitempty"`
|
|
|
|
|
OutErrors uint64 `json:"out_errors,omitempty"`
|
|
|
|
|
InPktsRate float64 `json:"in_pkts_rate,omitempty"`
|
|
|
|
|
OutPktsRate float64 `json:"out_pkts_rate,omitempty"`
|
|
|
|
|
InBytesRate float64 `json:"in_bytes_rate,omitempty"`
|
|
|
|
|
OutBytesRate float64 `json:"out_bytes_rate,omitempty"`
|
|
|
|
|
PoE *PoEStats `json:"poe,omitempty"`
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func round2(v float64) float64 {
|
|
|
|
|
return math.Round(v*100) / 100
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (s *InterfaceStats) MarshalJSON() ([]byte, error) {
|
|
|
|
|
type statsJSON struct {
|
|
|
|
|
Speed uint64 `json:"speed,omitempty"`
|
2026-01-31 12:06:59 -08:00
|
|
|
Uptime uint64 `json:"uptime,omitempty"`
|
2026-01-30 09:35:42 -08:00
|
|
|
InErrors uint64 `json:"in_errors,omitempty"`
|
|
|
|
|
OutErrors uint64 `json:"out_errors,omitempty"`
|
|
|
|
|
InPktsRate float64 `json:"in_pkts_rate,omitempty"`
|
|
|
|
|
OutPktsRate float64 `json:"out_pkts_rate,omitempty"`
|
|
|
|
|
InBytesRate float64 `json:"in_bytes_rate,omitempty"`
|
|
|
|
|
OutBytesRate float64 `json:"out_bytes_rate,omitempty"`
|
|
|
|
|
PoE *PoEStats `json:"poe,omitempty"`
|
|
|
|
|
}
|
|
|
|
|
return json.Marshal(statsJSON{
|
|
|
|
|
Speed: s.Speed,
|
2026-01-31 12:06:59 -08:00
|
|
|
Uptime: s.Uptime,
|
2026-01-30 09:35:42 -08:00
|
|
|
InErrors: s.InErrors,
|
|
|
|
|
OutErrors: s.OutErrors,
|
|
|
|
|
InPktsRate: round2(s.InPktsRate),
|
|
|
|
|
OutPktsRate: round2(s.OutPktsRate),
|
|
|
|
|
InBytesRate: round2(s.InBytesRate),
|
|
|
|
|
OutBytesRate: round2(s.OutBytesRate),
|
|
|
|
|
PoE: s.PoE,
|
|
|
|
|
})
|
2026-01-23 23:31:57 -08:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
type PoEStats struct {
|
2026-01-24 11:03:34 -08:00
|
|
|
Power float64 `json:"power"`
|
|
|
|
|
MaxPower float64 `json:"max_power"`
|
2026-01-23 23:31:57 -08:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
type PoEBudget struct {
|
2026-01-24 11:03:34 -08:00
|
|
|
Power float64 `json:"power"`
|
|
|
|
|
MaxPower float64 `json:"max_power"`
|
2026-01-23 23:31:57 -08:00
|
|
|
}
|
|
|
|
|
|
2026-01-28 23:15:24 -08:00
|
|
|
type DanteFlows struct {
|
|
|
|
|
Tx []*DantePeer `json:"tx,omitempty"`
|
|
|
|
|
Rx []*DantePeer `json:"rx,omitempty"`
|
|
|
|
|
lastSeen time.Time
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (f *DanteFlows) Expire(maxAge time.Duration) bool {
|
|
|
|
|
if f.lastSeen.IsZero() {
|
|
|
|
|
return false
|
|
|
|
|
}
|
|
|
|
|
return time.Since(f.lastSeen) > maxAge
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-23 23:31:57 -08:00
|
|
|
type Node struct {
|
2026-01-28 23:15:24 -08:00
|
|
|
ID string `json:"id"`
|
|
|
|
|
Names NameSet `json:"names"`
|
|
|
|
|
Interfaces InterfaceMap `json:"interfaces"`
|
2026-01-31 13:20:20 -08:00
|
|
|
MACTable map[string]string `json:"mac_table,omitempty"`
|
2026-01-28 23:15:24 -08:00
|
|
|
PoEBudget *PoEBudget `json:"poe_budget,omitempty"`
|
|
|
|
|
DanteTxChannels int `json:"dante_tx_channels,omitempty"`
|
|
|
|
|
DanteClockMasterSeen time.Time `json:"-"`
|
|
|
|
|
DanteFlows *DanteFlows `json:"dante_flows,omitempty"`
|
|
|
|
|
MulticastGroups MulticastMembershipSet `json:"multicast_groups,omitempty"`
|
|
|
|
|
ArtNetInputs ArtNetUniverseSet `json:"artnet_inputs,omitempty"`
|
|
|
|
|
ArtNetOutputs ArtNetUniverseSet `json:"artnet_outputs,omitempty"`
|
2026-01-30 13:03:35 -08:00
|
|
|
SACNUnicastInputs SACNUniverseSet `json:"sacn_unicast_inputs,omitempty"`
|
2026-01-28 23:15:24 -08:00
|
|
|
SACNOutputs SACNUniverseSet `json:"sacn_outputs,omitempty"`
|
2026-01-30 22:59:58 -08:00
|
|
|
ArtmapMappings []ArtmapMapping `json:"artmap_mappings,omitempty"`
|
2026-01-28 23:15:24 -08:00
|
|
|
Unreachable bool `json:"unreachable,omitempty"`
|
2026-01-31 09:52:51 -08:00
|
|
|
Avoid bool `json:"avoid,omitempty"`
|
2026-01-28 23:15:24 -08:00
|
|
|
errors *ErrorTracker
|
|
|
|
|
pollTrigger chan struct{}
|
|
|
|
|
cancelFunc context.CancelFunc
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (n *Node) IsDanteClockMaster() bool {
|
|
|
|
|
return !n.DanteClockMasterSeen.IsZero() && time.Since(n.DanteClockMasterSeen) < 5*time.Minute
|
2026-01-28 22:36:44 -08:00
|
|
|
}
|
|
|
|
|
|
2026-01-28 23:06:26 -08:00
|
|
|
func (n *Node) SetUnreachable(unreachable bool) bool {
|
|
|
|
|
if n.Unreachable == unreachable {
|
|
|
|
|
return false
|
|
|
|
|
}
|
|
|
|
|
n.Unreachable = unreachable
|
|
|
|
|
if n.errors != nil {
|
|
|
|
|
if unreachable {
|
|
|
|
|
n.errors.AddUnreachable(n)
|
|
|
|
|
} else {
|
|
|
|
|
n.errors.RemoveUnreachable(n)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return true
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (n *Node) SetInterfaceStats(portName string, stats *InterfaceStats) {
|
|
|
|
|
iface := n.Interfaces[portName]
|
|
|
|
|
if iface == nil {
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
oldStats := iface.Stats
|
|
|
|
|
iface.Stats = stats
|
|
|
|
|
|
|
|
|
|
if n.errors == nil {
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
var inDelta, outDelta uint64
|
|
|
|
|
if oldStats != nil {
|
|
|
|
|
if stats.InErrors > oldStats.InErrors {
|
|
|
|
|
inDelta = stats.InErrors - oldStats.InErrors
|
|
|
|
|
}
|
|
|
|
|
if stats.OutErrors > oldStats.OutErrors {
|
|
|
|
|
outDelta = stats.OutErrors - oldStats.OutErrors
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
if inDelta > 0 || outDelta > 0 {
|
|
|
|
|
n.errors.AddPortError(n, portName, stats, inDelta, outDelta)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if stats.Speed > 0 {
|
|
|
|
|
maxBytesRate := stats.InBytesRate
|
|
|
|
|
if stats.OutBytesRate > maxBytesRate {
|
|
|
|
|
maxBytesRate = stats.OutBytesRate
|
|
|
|
|
}
|
|
|
|
|
speedBytes := float64(stats.Speed) / 8.0
|
|
|
|
|
utilization := (maxBytesRate / speedBytes) * 100.0
|
|
|
|
|
|
|
|
|
|
var oldUtilization float64
|
|
|
|
|
if oldStats != nil && oldStats.Speed > 0 {
|
|
|
|
|
oldMax := oldStats.InBytesRate
|
|
|
|
|
if oldStats.OutBytesRate > oldMax {
|
|
|
|
|
oldMax = oldStats.OutBytesRate
|
|
|
|
|
}
|
|
|
|
|
oldUtilization = (oldMax / (float64(oldStats.Speed) / 8.0)) * 100.0
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if oldUtilization < 70.0 && utilization >= 70.0 {
|
|
|
|
|
n.errors.AddUtilizationError(n, portName, utilization)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-28 22:36:44 -08:00
|
|
|
func (n *Node) MACTableSize() int {
|
|
|
|
|
return len(n.MACTable)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (n *Node) SACNInputs() []SACNUniverse {
|
|
|
|
|
if n.MulticastGroups == nil {
|
|
|
|
|
return nil
|
|
|
|
|
}
|
|
|
|
|
return n.MulticastGroups.SACNInputs()
|
|
|
|
|
}
|
2026-01-28 22:15:54 -08:00
|
|
|
|
2026-01-28 23:21:33 -08:00
|
|
|
type DanteFlowStatus uint8
|
|
|
|
|
|
|
|
|
|
const (
|
|
|
|
|
DanteFlowUnsubscribed DanteFlowStatus = 0x00
|
|
|
|
|
DanteFlowNoSource DanteFlowStatus = 0x01
|
|
|
|
|
DanteFlowActive DanteFlowStatus = 0x09
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
func (s DanteFlowStatus) String() string {
|
|
|
|
|
switch s {
|
|
|
|
|
case DanteFlowActive:
|
|
|
|
|
return "active"
|
|
|
|
|
case DanteFlowNoSource:
|
|
|
|
|
return "no-source"
|
|
|
|
|
default:
|
|
|
|
|
return ""
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (s DanteFlowStatus) MarshalJSON() ([]byte, error) {
|
|
|
|
|
str := s.String()
|
|
|
|
|
if str == "" {
|
|
|
|
|
return []byte("null"), nil
|
|
|
|
|
}
|
|
|
|
|
return json.Marshal(str)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
type DanteChannelType uint16
|
|
|
|
|
|
|
|
|
|
const (
|
|
|
|
|
DanteChannelUnknown DanteChannelType = 0
|
|
|
|
|
DanteChannelAudio DanteChannelType = 0x000f
|
|
|
|
|
DanteChannelAudio2 DanteChannelType = 0x0006
|
|
|
|
|
DanteChannelVideo DanteChannelType = 0x000e
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
func (t DanteChannelType) String() string {
|
|
|
|
|
switch t {
|
|
|
|
|
case DanteChannelAudio, DanteChannelAudio2:
|
|
|
|
|
return "audio"
|
|
|
|
|
case DanteChannelVideo:
|
|
|
|
|
return "video"
|
|
|
|
|
default:
|
|
|
|
|
return ""
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (t DanteChannelType) MarshalJSON() ([]byte, error) {
|
|
|
|
|
str := t.String()
|
|
|
|
|
if str == "" {
|
|
|
|
|
return []byte("null"), nil
|
|
|
|
|
}
|
|
|
|
|
return json.Marshal(str)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
type DanteChannel struct {
|
|
|
|
|
TxChannel string `json:"tx_channel"`
|
|
|
|
|
RxChannel int `json:"rx_channel"`
|
|
|
|
|
Type DanteChannelType `json:"type,omitempty"`
|
|
|
|
|
Status DanteFlowStatus `json:"status,omitempty"`
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-28 21:50:48 -08:00
|
|
|
type DantePeer struct {
|
2026-01-28 23:21:33 -08:00
|
|
|
Node *Node `json:"node"`
|
|
|
|
|
Channels []*DanteChannel `json:"channels,omitempty"`
|
2026-01-28 21:50:48 -08:00
|
|
|
}
|
|
|
|
|
|
2026-01-28 22:15:54 -08:00
|
|
|
func (p *DantePeer) MarshalJSON() ([]byte, error) {
|
|
|
|
|
type peerJSON struct {
|
2026-01-30 09:35:42 -08:00
|
|
|
NodeID string `json:"node_id"`
|
2026-01-28 23:21:33 -08:00
|
|
|
Channels []*DanteChannel `json:"channels,omitempty"`
|
2026-01-28 22:15:54 -08:00
|
|
|
}
|
|
|
|
|
return json.Marshal(peerJSON{
|
2026-01-30 09:35:42 -08:00
|
|
|
NodeID: p.Node.ID,
|
2026-01-28 22:15:54 -08:00
|
|
|
Channels: p.Channels,
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-25 09:53:31 -08:00
|
|
|
func (n *Node) WithInterface(ifaceKey string) *Node {
|
|
|
|
|
if ifaceKey == "" {
|
|
|
|
|
return n
|
|
|
|
|
}
|
|
|
|
|
iface, exists := n.Interfaces[ifaceKey]
|
|
|
|
|
if !exists {
|
|
|
|
|
return n
|
|
|
|
|
}
|
|
|
|
|
return &Node{
|
2026-01-28 23:15:24 -08:00
|
|
|
ID: n.ID,
|
|
|
|
|
Names: n.Names,
|
|
|
|
|
Interfaces: InterfaceMap{ifaceKey: iface},
|
|
|
|
|
MACTable: n.MACTable,
|
|
|
|
|
PoEBudget: n.PoEBudget,
|
|
|
|
|
DanteClockMasterSeen: n.DanteClockMasterSeen,
|
|
|
|
|
DanteTxChannels: n.DanteTxChannels,
|
2026-01-25 09:53:31 -08:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-23 23:31:57 -08:00
|
|
|
func (i *Interface) String() string {
|
|
|
|
|
var parts []string
|
2026-01-24 11:03:34 -08:00
|
|
|
parts = append(parts, string(i.MAC))
|
2026-01-23 23:31:57 -08:00
|
|
|
if i.Name != "" {
|
|
|
|
|
parts = append(parts, fmt.Sprintf("(%s)", i.Name))
|
|
|
|
|
}
|
2026-01-24 11:03:34 -08:00
|
|
|
if len(i.IPs) > 0 {
|
|
|
|
|
parts = append(parts, fmt.Sprintf("%v", i.IPs.Slice()))
|
2026-01-23 23:31:57 -08:00
|
|
|
}
|
|
|
|
|
if i.Stats != nil {
|
|
|
|
|
parts = append(parts, i.Stats.String())
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
result := parts[0]
|
|
|
|
|
for _, p := range parts[1:] {
|
|
|
|
|
result += " " + p
|
|
|
|
|
}
|
|
|
|
|
return result
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (s *InterfaceStats) String() string {
|
|
|
|
|
var parts []string
|
|
|
|
|
|
|
|
|
|
if s.Speed > 0 {
|
|
|
|
|
if s.Speed >= 1000000000 {
|
|
|
|
|
parts = append(parts, fmt.Sprintf("%dG", s.Speed/1000000000))
|
|
|
|
|
} else if s.Speed >= 1000000 {
|
|
|
|
|
parts = append(parts, fmt.Sprintf("%dM", s.Speed/1000000))
|
|
|
|
|
} else {
|
|
|
|
|
parts = append(parts, fmt.Sprintf("%d", s.Speed))
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if s.InErrors > 0 || s.OutErrors > 0 {
|
|
|
|
|
parts = append(parts, fmt.Sprintf("err:%d/%d", s.InErrors, s.OutErrors))
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-26 11:16:32 -08:00
|
|
|
if s.InBytesRate > 0 || s.OutBytesRate > 0 {
|
|
|
|
|
parts = append(parts, fmt.Sprintf("%.0f/%.0fB/s", s.InBytesRate, s.OutBytesRate))
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-23 23:31:57 -08:00
|
|
|
if s.PoE != nil {
|
|
|
|
|
if s.PoE.MaxPower > 0 {
|
|
|
|
|
parts = append(parts, fmt.Sprintf("poe:%.1f/%.1fW", s.PoE.Power, s.PoE.MaxPower))
|
|
|
|
|
} else {
|
|
|
|
|
parts = append(parts, fmt.Sprintf("poe:%.1fW", s.PoE.Power))
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return "[" + strings.Join(parts, " ") + "]"
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (n *Node) String() string {
|
|
|
|
|
name := n.DisplayName()
|
|
|
|
|
if name == "" {
|
|
|
|
|
name = "??"
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
var parts []string
|
|
|
|
|
parts = append(parts, name)
|
|
|
|
|
|
|
|
|
|
if n.PoEBudget != nil {
|
|
|
|
|
parts = append(parts, fmt.Sprintf("[poe:%.0f/%.0fW]", n.PoEBudget.Power, n.PoEBudget.MaxPower))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
var ifaces []string
|
|
|
|
|
for _, iface := range n.Interfaces {
|
|
|
|
|
ifaces = append(ifaces, iface.String())
|
|
|
|
|
}
|
|
|
|
|
sort.Slice(ifaces, func(i, j int) bool { return sortorder.NaturalLess(ifaces[i], ifaces[j]) })
|
|
|
|
|
|
|
|
|
|
parts = append(parts, fmt.Sprintf("{%v}", ifaces))
|
|
|
|
|
|
|
|
|
|
return strings.Join(parts, " ")
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (n *Node) DisplayName() string {
|
2026-01-26 14:50:55 -08:00
|
|
|
if len(n.Names) > 0 {
|
|
|
|
|
var names []string
|
|
|
|
|
for name := range n.Names {
|
|
|
|
|
names = append(names, name)
|
|
|
|
|
}
|
|
|
|
|
sortNamesByLength(names)
|
|
|
|
|
return strings.Join(names, "/")
|
2026-01-23 23:31:57 -08:00
|
|
|
}
|
2026-01-26 14:50:55 -08:00
|
|
|
for _, iface := range n.Interfaces {
|
|
|
|
|
for ip := range iface.IPs {
|
|
|
|
|
return ip
|
|
|
|
|
}
|
2026-01-23 23:31:57 -08:00
|
|
|
}
|
2026-01-26 14:50:55 -08:00
|
|
|
for _, iface := range n.Interfaces {
|
|
|
|
|
if iface.MAC != "" {
|
|
|
|
|
return string(iface.MAC)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return ""
|
2026-01-23 23:31:57 -08:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (n *Node) FirstMAC() string {
|
|
|
|
|
for _, iface := range n.Interfaces {
|
2026-01-24 11:03:34 -08:00
|
|
|
if iface.MAC != "" {
|
|
|
|
|
return string(iface.MAC)
|
2026-01-23 23:31:57 -08:00
|
|
|
}
|
|
|
|
|
}
|
2026-01-28 21:16:35 -08:00
|
|
|
return ""
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (n *Node) FirstIP() string {
|
|
|
|
|
for _, iface := range n.Interfaces {
|
|
|
|
|
for ip := range iface.IPs {
|
|
|
|
|
return ip
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return ""
|
2026-01-23 23:31:57 -08:00
|
|
|
}
|