package live import ( "context" "io" "log" "sort" "strings" "sync" "time" "github.com/hashicorp/mdns" ) // DiscoveredClient is a snapshot of one mDNS advertisement for a // Sendspin client (`_sendspin._tcp`). It does not include client_id — // that field is only known after WebSocket handshake. The HTTP layer // merges this snapshot with sendspin.Server.Clients() to produce the // full /api/clients view. type DiscoveredClient struct { Instance string `json:"instance"` Host string `json:"host"` Port int `json:"port"` LastSeen time.Time `json:"last_seen"` } // Discovery continuously browses for _sendspin._tcp services. The list // of currently-known clients is exposed via List(). Entries persist for // `ttl` after their most recent sighting, then are pruned. type Discovery struct { ttl time.Duration mu sync.RWMutex clients map[string]DiscoveredClient ctx context.Context cancel context.CancelFunc } func NewDiscovery() *Discovery { ctx, cancel := context.WithCancel(context.Background()) return &Discovery{ ttl: 2 * time.Minute, clients: make(map[string]DiscoveredClient), ctx: ctx, cancel: cancel, } } func (d *Discovery) Start() { go d.browseLoop() go d.pruneLoop() } func (d *Discovery) Stop() { d.cancel() } func (d *Discovery) List() []DiscoveredClient { d.mu.RLock() defer d.mu.RUnlock() out := make([]DiscoveredClient, 0, len(d.clients)) for _, c := range d.clients { out = append(out, c) } sort.Slice(out, func(i, j int) bool { return out[i].Instance < out[j].Instance }) return out } // sendspinService matches entries advertised under `_sendspin._tcp` — // other devices on the LAN (WLED, Meshtastic, AirPlay, …) hit the same // multicast channel and hashicorp/mdns happily writes them to our // entries chan regardless of what we queried for. Filter by service // type before recording. const sendspinService = "_sendspin._tcp" func (d *Discovery) record(entry *mdns.ServiceEntry) { if !strings.Contains(entry.Name, "."+sendspinService) { return } host := "" if entry.AddrV4 != nil { host = entry.AddrV4.String() } else if entry.AddrV6 != nil { host = entry.AddrV6.String() } if host == "" { return } instance := entry.Name if i := strings.Index(instance, "."); i > 0 { instance = instance[:i] } d.mu.Lock() d.clients[instance] = DiscoveredClient{ Instance: instance, Host: host, Port: entry.Port, LastSeen: time.Now(), } d.mu.Unlock() } var silentLogger = log.New(io.Discard, "", 0) func (d *Discovery) browseLoop() { for { select { case <-d.ctx.Done(): return default: } entries := make(chan *mdns.ServiceEntry, 32) done := make(chan struct{}) go func() { defer close(done) for entry := range entries { d.record(entry) } }() params := &mdns.QueryParam{ Service: "_sendspin._tcp", Domain: "local", Timeout: 5 * time.Second, Entries: entries, Logger: silentLogger, } if err := mdns.Query(params); err != nil { log.Printf("mdns query: %v", err) } close(entries) <-done select { case <-d.ctx.Done(): return case <-time.After(10 * time.Second): } } } func (d *Discovery) pruneLoop() { t := time.NewTicker(30 * time.Second) defer t.Stop() for { select { case <-d.ctx.Done(): return case <-t.C: cutoff := time.Now().Add(-d.ttl) d.mu.Lock() for k, v := range d.clients { if v.LastSeen.Before(cutoff) { delete(d.clients, k) } } d.mu.Unlock() } } }