🎉 live server seems to be working now

This commit is contained in:
2026-05-14 14:29:57 +02:00
commit d72e439fd9
181 changed files with 47406 additions and 0 deletions

164
internal/live/mdns.go Normal file
View File

@@ -0,0 +1,164 @@
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()
}
}
}