This commit is contained in:
Martino Ferrari
2026-05-15 17:42:14 +02:00
commit e3389f932b
40 changed files with 7622 additions and 0 deletions
+7
View File
@@ -0,0 +1,7 @@
module udpstreamer-webui
go 1.21
require github.com/gorilla/websocket v1.5.1
require golang.org/x/net v0.17.0 // indirect
+4
View File
@@ -0,0 +1,4 @@
github.com/gorilla/websocket v1.5.1 h1:gmztn0JnHVt9JZquRuzLw3g4wouNVzKL15iLr/zn/QY=
github.com/gorilla/websocket v1.5.1/go.mod h1:x3kM2JMyaluk02fnUJpQuwD2dCS5NDG2ZHL0uE0tcaY=
golang.org/x/net v0.17.0 h1:pVaXccu2ozPjCXewfr1S7xza/zcXTity9cCdXQYSjIM=
golang.org/x/net v0.17.0/go.mod h1:NxSsAGuq816PNPmqtQdLE42eU2Fs7NoRIZrHJAlaCOE=
+383
View File
@@ -0,0 +1,383 @@
package main
import (
"encoding/json"
"log"
"net/http"
"sync"
"time"
"github.com/gorilla/websocket"
)
// ─── WebSocket client ─────────────────────────────────────────────────────────
type wsClient struct {
hub *Hub
conn *websocket.Conn
send chan []byte
}
func (c *wsClient) writePump() {
pingTicker := time.NewTicker(30 * time.Second)
defer func() {
pingTicker.Stop()
c.conn.Close()
}()
for {
select {
case msg, ok := <-c.send:
if !ok {
c.conn.WriteMessage(websocket.CloseMessage, []byte{})
return
}
if err := c.conn.WriteMessage(websocket.TextMessage, msg); err != nil {
return
}
case <-pingTicker.C:
if err := c.conn.WriteControl(websocket.PingMessage, []byte{},
time.Now().Add(10*time.Second)); err != nil {
return
}
}
}
}
func (c *wsClient) readPump() {
defer func() {
c.hub.unregister <- c
c.conn.Close()
}()
c.conn.SetReadLimit(64 * 1024)
c.conn.SetReadDeadline(time.Now().Add(60 * time.Second))
c.conn.SetPongHandler(func(string) error {
c.conn.SetReadDeadline(time.Now().Add(60 * time.Second))
return nil
})
for {
_, msg, err := c.conn.ReadMessage()
if err != nil {
break
}
// Handle client messages (ping, setWindow, etc.) currently just log.
var env map[string]interface{}
if json.Unmarshal(msg, &env) == nil {
if t, ok := env["type"].(string); ok {
switch t {
case "ping":
resp, _ := json.Marshal(map[string]string{"type": "pong"})
select {
case c.send <- resp:
default:
}
}
}
}
c.conn.SetReadDeadline(time.Now().Add(60 * time.Second))
}
}
// ─── Hub ─────────────────────────────────────────────────────────────────────
var upgrader = websocket.Upgrader{
ReadBufferSize: 4096,
WriteBufferSize: 64 * 1024,
CheckOrigin: func(r *http.Request) bool { return true },
}
// Hub is the central data broker between the UDP client and WebSocket clients.
type Hub struct {
mu sync.RWMutex
signals []SignalInfo
configJS []byte // cached JSON config message
clients map[*wsClient]bool
register chan *wsClient
unregister chan *wsClient
broadcastCh chan []byte // all sends go through Run() to avoid races on c.send
dataCh chan DataSample // incoming samples from UDP goroutine
}
// NewHub creates an initialised Hub.
func NewHub() *Hub {
return &Hub{
clients: make(map[*wsClient]bool),
register: make(chan *wsClient, 8),
unregister: make(chan *wsClient, 8),
broadcastCh: make(chan []byte, 64),
dataCh: make(chan DataSample, 256),
}
}
// UpdateConfig stores a new signal config and broadcasts it to all WS clients.
func (h *Hub) UpdateConfig(sigs []SignalInfo) {
msg, err := json.Marshal(map[string]interface{}{
"type": "config",
"signals": sigs,
})
if err != nil {
log.Printf("hub: marshal config: %v", err)
return
}
h.mu.Lock()
h.signals = sigs
h.configJS = msg
h.mu.Unlock()
h.broadcast(msg)
}
// PushData enqueues a data sample for broadcasting to WebSocket clients.
func (h *Hub) PushData(s DataSample) {
select {
case h.dataCh <- s:
default:
// Drop if buffer full to avoid blocking the UDP goroutine.
}
}
// broadcast enqueues a message for delivery to all WebSocket clients.
// All actual sends happen inside Run() to avoid concurrent access to c.send.
func (h *Hub) broadcast(msg []byte) {
select {
case h.broadcastCh <- msg:
default:
// Drop if the broadcast queue is full.
}
}
// HandleWebSocket upgrades an HTTP request to a WebSocket connection.
func (h *Hub) HandleWebSocket(w http.ResponseWriter, r *http.Request) {
conn, err := upgrader.Upgrade(w, r, nil)
if err != nil {
log.Printf("ws upgrade: %v", err)
return
}
c := &wsClient{
hub: h,
conn: conn,
send: make(chan []byte, 64),
}
h.register <- c
go c.writePump()
go c.readPump()
}
// Run is the hub's main goroutine. It must be started with go hub.Run().
func (h *Hub) Run() {
// Batch data at ≤30 Hz.
ticker := time.NewTicker(time.Second / 30)
defer ticker.Stop()
// Accumulate samples between ticks.
pending := make([]DataSample, 0, 64)
for {
select {
case c := <-h.register:
h.mu.Lock()
h.clients[c] = true
cfg := h.configJS
h.mu.Unlock()
// Send current config immediately if we have one.
if cfg != nil {
select {
case c.send <- cfg:
default:
}
}
case c := <-h.unregister:
h.mu.Lock()
if _, ok := h.clients[c]; ok {
delete(h.clients, c)
close(c.send)
}
h.mu.Unlock()
case msg := <-h.broadcastCh:
h.mu.RLock()
for c := range h.clients {
select {
case c.send <- msg:
default:
}
}
h.mu.RUnlock()
case s := <-h.dataCh:
pending = append(pending, s)
case <-ticker.C:
if len(pending) == 0 {
continue
}
h.mu.RLock()
sigs := h.signals
noClients := len(h.clients) == 0
h.mu.RUnlock()
if noClients || len(sigs) == 0 {
pending = pending[:0]
continue
}
msg := h.buildDataMessage(pending, sigs)
pending = pending[:0]
if msg != nil {
h.broadcast(msg)
}
}
}
}
// maxBatchPoints is the maximum number of points sent per signal per 30 Hz display tick.
// For temporal-array signals (e.g. 1 MSps packed as 1000 samples/packet), the expanded
// sample stream is decimated to this limit before transmission to WebSocket clients.
const maxBatchPoints = 2000
// sigData carries one signal's worth of time+value pairs in a single batch message.
type sigData struct {
T []float64 `json:"t"` // unix seconds
V []float64 `json:"v"` // physical values
}
// dataMsg is the JSON envelope for batched data sent to WebSocket clients.
// Each signal carries its own time axis so that temporal arrays (packed sample
// bursts with a known sampling rate) and scalar signals can coexist cleanly.
type dataMsg struct {
Type string `json:"type"`
Signals map[string]sigData `json:"signals"`
}
// buildDataMessage merges a batch of DataSamples into one JSON message.
//
// Three cases are handled:
//
// 1. Temporal array (NumElements > 1, TimeMode != PacketTime):
// The N samples in each packet represent a contiguous time burst at SamplingRate.
// Wall arrival time is treated as the timestamp of the last sample; earlier
// samples are reconstructed as t[k] = wallT - (N-1-k)/SamplingRate.
// The full expanded stream is decimated to maxBatchPoints if needed.
//
// 2. Scalar signal (NumElements == 1):
// One {t, v} pair per packet wall arrival time used as timestamp.
//
// 3. Spatial / PacketTime array (NumElements > 1, TimeMode == 0):
// Each element is tracked as a separate stream keyed "sig[i]", with wall
// arrival time as the shared timestamp.
func (h *Hub) buildDataMessage(batch []DataSample, sigs []SignalInfo) []byte {
if len(batch) == 0 {
return nil
}
out := make(map[string]sigData, len(sigs)*2)
for _, sig := range sigs {
n := sig.NumElements()
isTemporal := n > 1 && sig.TimeMode != 0
switch {
case isTemporal:
// Expand each packet's N samples into individual time-stamped points.
allT := make([]float64, 0, len(batch)*n)
allV := make([]float64, 0, len(batch)*n)
dt := 0.0
if sig.SamplingRate > 0 {
dt = 1.0 / sig.SamplingRate
}
for _, s := range batch {
vals, ok := s.Values[sig.Name]
if !ok || len(vals) < n {
continue
}
// wallT ≈ arrival time of the packet ≈ timestamp of the last sample.
wallT := float64(s.WallTime.UnixNano()) / 1e9
for k := 0; k < n; k++ {
allT = append(allT, wallT-float64(n-1-k)*dt)
allV = append(allV, vals[k])
}
}
// Decimate to maxBatchPoints if necessary.
step := 1
if len(allT) > maxBatchPoints {
step = len(allT) / maxBatchPoints
if step < 1 {
step = 1
}
}
decimT := make([]float64, 0, len(allT)/step+1)
decimV := make([]float64, 0, len(allV)/step+1)
for i := 0; i < len(allT); i += step {
decimT = append(decimT, allT[i])
decimV = append(decimV, allV[i])
}
out[sig.Name] = sigData{T: decimT, V: decimV}
case n == 1:
// Scalar signal: one sample per packet.
ts := make([]float64, 0, len(batch))
vs := make([]float64, 0, len(batch))
for _, s := range batch {
vals, ok := s.Values[sig.Name]
if !ok || len(vals) < 1 {
continue
}
ts = append(ts, float64(s.WallTime.UnixNano())/1e9)
vs = append(vs, vals[0])
}
out[sig.Name] = sigData{T: ts, V: vs}
default:
// Spatial / PacketTime array: one stream per element, keyed "sig[i]".
for i := 0; i < n; i++ {
key := arrayKey(sig.Name, i)
ts := make([]float64, 0, len(batch))
vs := make([]float64, 0, len(batch))
for _, s := range batch {
vals, ok := s.Values[sig.Name]
if !ok || len(vals) <= i {
continue
}
ts = append(ts, float64(s.WallTime.UnixNano())/1e9)
vs = append(vs, vals[i])
}
out[key] = sigData{T: ts, V: vs}
}
}
}
result, err := json.Marshal(dataMsg{
Type: "data",
Signals: out,
})
if err != nil {
log.Printf("hub: marshal data: %v", err)
return nil
}
return result
}
// arrayKey returns the buffer key for element i of an array signal.
func arrayKey(name string, i int) string {
return name + "[" + itoa(i) + "]"
}
func itoa(n int) string {
if n == 0 {
return "0"
}
buf := [20]byte{}
pos := len(buf)
for n > 0 {
pos--
buf[pos] = byte('0' + n%10)
n /= 10
}
return string(buf[pos:])
}
+47
View File
@@ -0,0 +1,47 @@
package main
import (
"embed"
"flag"
"log"
"net/http"
)
//go:embed static/index.html
var staticFiles embed.FS
func main() {
streamerAddr := flag.String("streamer", "127.0.0.1:44500", "MARTe2 UDP streamer address (host:port)")
listenAddr := flag.String("listen", ":8080", "HTTP listen address")
clientPort := flag.Int("clientport", 44900, "Local UDP port to bind for streamer data")
flag.Parse()
hub := NewHub()
go hub.Run()
udpClient := NewUDPClient(*streamerAddr, *clientPort, hub)
go udpClient.Run()
// Serve the embedded index.html at /.
http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/" {
http.NotFound(w, r)
return
}
data, err := staticFiles.ReadFile("static/index.html")
if err != nil {
http.Error(w, "index.html not found", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.Write(data)
})
http.HandleFunc("/ws", hub.HandleWebSocket)
log.Printf("WebUI listening on %s (streamer=%s, local UDP port=%d)",
*listenAddr, *streamerAddr, *clientPort)
if err := http.ListenAndServe(*listenAddr, nil); err != nil {
log.Fatalf("http: %v", err)
}
}
+298
View File
@@ -0,0 +1,298 @@
package main
import (
"bytes"
"encoding/binary"
"fmt"
"math"
"time"
)
// ─── Constants ───────────────────────────────────────────────────────────────
const (
MagicUDPS uint32 = 0x53504455 // 'UDPS' little-endian
PktData uint8 = 0
PktConfig uint8 = 1
PktACK uint8 = 2
PktConnect uint8 = 3
PktDisconnect uint8 = 4
HeaderSize = 17
SigDescSize = 136
NoTimeSignal = uint32(0xFFFFFFFF)
QuantNone uint8 = 0
QuantUint8 uint8 = 1
QuantInt8 uint8 = 2
QuantUint16 uint8 = 3
QuantInt16 uint8 = 4
)
// ─── Packet header (17 bytes, little-endian, packed) ─────────────────────────
type PacketHeader struct {
Magic uint32
Type uint8
Counter uint32
FragmentIdx uint16
TotalFragments uint16
PayloadBytes uint32
}
// ParseHeader decodes exactly HeaderSize bytes into a PacketHeader.
func ParseHeader(b []byte) (PacketHeader, error) {
if len(b) < HeaderSize {
return PacketHeader{}, fmt.Errorf("header too short: %d bytes", len(b))
}
var h PacketHeader
r := bytes.NewReader(b[:HeaderSize])
if err := binary.Read(r, binary.LittleEndian, &h); err != nil {
return PacketHeader{}, err
}
if h.Magic != MagicUDPS {
return PacketHeader{}, fmt.Errorf("bad magic: 0x%08X", h.Magic)
}
return h, nil
}
// buildHeader serialises a PacketHeader to a 17-byte slice.
func buildHeader(h PacketHeader) []byte {
buf := new(bytes.Buffer)
_ = binary.Write(buf, binary.LittleEndian, h)
return buf.Bytes()
}
// BuildConnectPacket returns a 17-byte CONNECT datagram.
func BuildConnectPacket() []byte {
return buildHeader(PacketHeader{
Magic: MagicUDPS,
Type: PktConnect,
Counter: 0,
FragmentIdx: 0,
TotalFragments: 1,
PayloadBytes: 0,
})
}
// BuildDisconnectPacket returns a 17-byte DISCONNECT datagram.
func BuildDisconnectPacket() []byte {
return buildHeader(PacketHeader{
Magic: MagicUDPS,
Type: PktDisconnect,
Counter: 0,
FragmentIdx: 0,
TotalFragments: 1,
PayloadBytes: 0,
})
}
// ─── Signal descriptor (136 bytes) ───────────────────────────────────────────
// SignalInfo holds the parsed metadata for one signal.
type SignalInfo struct {
Name string `json:"name"`
TypeCode uint8 `json:"typeCode"`
QuantType uint8 `json:"quantType"`
NumDimensions uint8 `json:"numDimensions"`
NumRows uint32 `json:"numRows"`
NumCols uint32 `json:"numCols"`
RangeMin float64 `json:"rangeMin"`
RangeMax float64 `json:"rangeMax"`
TimeMode uint8 `json:"timeMode"`
SamplingRate float64 `json:"samplingRate"`
TimeSignalIdx uint32 `json:"timeSignalIdx"`
Unit string `json:"unit"`
}
// NumElements returns the total number of scalar values in one sample of this signal.
func (s SignalInfo) NumElements() int {
r := int(s.NumRows)
c := int(s.NumCols)
if r == 0 {
r = 1
}
if c == 0 {
c = 1
}
return r * c
}
// rawTypeSize returns the byte size for one element of the raw (unquantised) type.
func rawTypeSize(typeCode uint8) int {
switch typeCode {
case 0, 1: // uint8, int8
return 1
case 2, 3: // uint16, int16
return 2
case 4, 5: // uint32, int32
return 4
case 6, 7: // uint64, int64
return 8
case 8: // float32
return 4
case 9: // float64
return 8
default:
return 1
}
}
// quantSize returns the byte size of one quantised element.
func quantSize(qt uint8) int {
switch qt {
case QuantUint8, QuantInt8:
return 1
case QuantUint16, QuantInt16:
return 2
default:
return 0
}
}
// readRawElement reads one element at offset and converts it to float64.
func readRawElement(b []byte, offset int, typeCode uint8) float64 {
switch typeCode {
case 0:
return float64(b[offset])
case 1:
return float64(int8(b[offset]))
case 2:
return float64(binary.LittleEndian.Uint16(b[offset:]))
case 3:
return float64(int16(binary.LittleEndian.Uint16(b[offset:])))
case 4:
return float64(binary.LittleEndian.Uint32(b[offset:]))
case 5:
return float64(int32(binary.LittleEndian.Uint32(b[offset:])))
case 6:
return float64(binary.LittleEndian.Uint64(b[offset:]))
case 7:
return float64(int64(binary.LittleEndian.Uint64(b[offset:])))
case 8:
bits := binary.LittleEndian.Uint32(b[offset:])
return float64(math.Float32frombits(bits))
case 9:
bits := binary.LittleEndian.Uint64(b[offset:])
return math.Float64frombits(bits)
default:
return 0
}
}
// dequantise converts a raw quantised integer to a physical float64.
func dequantise(qt uint8, raw uint16, rangeMin, rangeMax float64) float64 {
span := rangeMax - rangeMin
switch qt {
case QuantUint8:
return rangeMin + (float64(uint8(raw))/255.0)*span
case QuantInt8:
return rangeMin + (float64(int8(raw)+127)/254.0)*span
case QuantUint16:
return rangeMin + (float64(raw)/65535.0)*span
case QuantInt16:
return rangeMin + (float64(int16(raw)+32767)/65534.0)*span
default:
return 0
}
}
// nullTermString converts a zero-padded byte slice to a Go string.
func nullTermString(b []byte) string {
n := bytes.IndexByte(b, 0)
if n < 0 {
return string(b)
}
return string(b[:n])
}
// ─── CONFIG payload parser ────────────────────────────────────────────────────
// ParseConfig decodes a fully-reassembled CONFIG payload.
func ParseConfig(payload []byte) ([]SignalInfo, error) {
if len(payload) < 4 {
return nil, fmt.Errorf("config payload too short")
}
numSigs := binary.LittleEndian.Uint32(payload[0:4])
offset := 4
sigs := make([]SignalInfo, 0, numSigs)
for i := uint32(0); i < numSigs; i++ {
if offset+SigDescSize > len(payload) {
return nil, fmt.Errorf("config payload truncated at signal %d", i)
}
raw := payload[offset : offset+SigDescSize]
si := SignalInfo{
Name: nullTermString(raw[0:64]),
TypeCode: raw[64],
QuantType: raw[65],
NumDimensions: raw[66],
NumRows: binary.LittleEndian.Uint32(raw[67:71]),
NumCols: binary.LittleEndian.Uint32(raw[71:75]),
RangeMin: math.Float64frombits(binary.LittleEndian.Uint64(raw[75:83])),
RangeMax: math.Float64frombits(binary.LittleEndian.Uint64(raw[83:91])),
TimeMode: raw[91],
SamplingRate: math.Float64frombits(binary.LittleEndian.Uint64(raw[92:100])),
TimeSignalIdx: binary.LittleEndian.Uint32(raw[100:104]),
Unit: nullTermString(raw[104:136]),
}
sigs = append(sigs, si)
offset += SigDescSize
}
return sigs, nil
}
// ─── DATA payload parser ──────────────────────────────────────────────────────
// DataSample holds the decoded values from one DATA packet.
type DataSample struct {
HRTTimestamp uint64
WallTime time.Time // wall-clock time at UDP arrival; used as x-axis
Values map[string][]float64 // key = signal name, value = []float64 with NumElements entries
}
// ParseData decodes a fully-reassembled DATA payload using the provided signal config.
// arrivalTime is the wall-clock time at which the packet was received.
func ParseData(payload []byte, sigs []SignalInfo, arrivalTime time.Time) (DataSample, error) {
if len(payload) < 8 {
return DataSample{}, fmt.Errorf("data payload too short")
}
hrt := binary.LittleEndian.Uint64(payload[0:8])
offset := 8
vals := make(map[string][]float64, len(sigs))
for _, sig := range sigs {
n := sig.NumElements()
elems := make([]float64, n)
if sig.QuantType == QuantNone {
sz := rawTypeSize(sig.TypeCode)
needed := n * sz
if offset+needed > len(payload) {
return DataSample{}, fmt.Errorf("data payload truncated for signal %q", sig.Name)
}
for i := 0; i < n; i++ {
elems[i] = readRawElement(payload, offset+i*sz, sig.TypeCode)
}
offset += needed
} else {
sz := quantSize(sig.QuantType)
needed := n * sz
if offset+needed > len(payload) {
return DataSample{}, fmt.Errorf("data payload truncated (quant) for signal %q", sig.Name)
}
for i := 0; i < n; i++ {
var raw uint16
if sz == 1 {
raw = uint16(payload[offset+i])
} else {
raw = binary.LittleEndian.Uint16(payload[offset+i*2:])
}
elems[i] = dequantise(sig.QuantType, raw, sig.RangeMin, sig.RangeMax)
}
offset += needed
}
vals[sig.Name] = elems
}
return DataSample{HRTTimestamp: hrt, WallTime: arrivalTime, Values: vals}, nil
}
+105
View File
@@ -0,0 +1,105 @@
package main
import (
"sync"
"time"
)
// fragmentSet holds the received fragments for one (counter, type) pair.
type fragmentSet struct {
total int
received int
fragments [][]byte // indexed by fragmentIdx
lastSeen time.Time
}
// Reassembler reassembles fragmented UDP payloads.
// Key: uint64(counter)<<8 | uint64(pktType)
type Reassembler struct {
mu sync.Mutex
sets map[uint64]*fragmentSet
expiry time.Duration // how long to keep incomplete sets
}
// NewReassembler creates a Reassembler that expires stale fragment sets after ttl.
func NewReassembler(ttl time.Duration) *Reassembler {
r := &Reassembler{
sets: make(map[uint64]*fragmentSet),
expiry: ttl,
}
go r.gcLoop()
return r
}
func reassemblyKey(counter uint32, pktType uint8) uint64 {
return uint64(counter)<<8 | uint64(pktType)
}
// AddFragment registers one fragment. Returns the reassembled payload and true
// when all fragments for this (counter, type) have been received, otherwise
// returns nil, false.
func (r *Reassembler) AddFragment(h PacketHeader, payload []byte) ([]byte, bool) {
key := reassemblyKey(h.Counter, h.Type)
total := int(h.TotalFragments)
idx := int(h.FragmentIdx)
// Fast path: single-fragment packet.
if total == 1 && idx == 0 {
return payload, true
}
r.mu.Lock()
defer r.mu.Unlock()
fs, ok := r.sets[key]
if !ok {
fs = &fragmentSet{
total: total,
fragments: make([][]byte, total),
}
r.sets[key] = fs
}
if idx >= len(fs.fragments) {
// Stale or corrupt discard.
return nil, false
}
if fs.fragments[idx] == nil {
fs.fragments[idx] = payload
fs.received++
}
fs.lastSeen = time.Now()
if fs.received < fs.total {
return nil, false
}
// All fragments received concatenate in order.
delete(r.sets, key)
total_len := 0
for _, f := range fs.fragments {
total_len += len(f)
}
out := make([]byte, 0, total_len)
for _, f := range fs.fragments {
out = append(out, f...)
}
return out, true
}
// gcLoop periodically removes fragment sets that have been incomplete for too long.
func (r *Reassembler) gcLoop() {
ticker := time.NewTicker(r.expiry / 2)
defer ticker.Stop()
for range ticker.C {
r.mu.Lock()
now := time.Now()
for k, fs := range r.sets {
if now.Sub(fs.lastSeen) > r.expiry {
delete(r.sets, k)
}
}
r.mu.Unlock()
}
}
File diff suppressed because it is too large Load Diff
+161
View File
@@ -0,0 +1,161 @@
package main
import (
"log"
"net"
"time"
)
const (
silenceTimeout = 5 * time.Second
reconnectDelay = 2 * time.Second
readBufSize = 65536
)
// UDPClient manages the UDP connection to the MARTe2 streamer.
type UDPClient struct {
serverAddr string
localPort int
hub *Hub
stopCh chan struct{}
}
// NewUDPClient creates a UDPClient. Call Run() in a goroutine.
func NewUDPClient(serverAddr string, localPort int, hub *Hub) *UDPClient {
return &UDPClient{
serverAddr: serverAddr,
localPort: localPort,
hub: hub,
stopCh: make(chan struct{}),
}
}
// Stop asks the client to disconnect and exit.
func (u *UDPClient) Stop() {
close(u.stopCh)
}
// Run is the main loop; it reconnects automatically if the server goes silent.
func (u *UDPClient) Run() {
for {
select {
case <-u.stopCh:
return
default:
}
log.Printf("udp: connecting to %s (local port %d)", u.serverAddr, u.localPort)
err := u.runSession()
if err != nil {
log.Printf("udp: session ended: %v", err)
}
// Check stop before sleeping.
select {
case <-u.stopCh:
return
case <-time.After(reconnectDelay):
}
}
}
// runSession opens a UDP socket, sends CONNECT, reads data until the server
// goes silent or an error occurs.
func (u *UDPClient) runSession() error {
localAddr := &net.UDPAddr{Port: u.localPort}
conn, err := net.ListenUDP("udp4", localAddr)
if err != nil {
return err
}
defer conn.Close()
serverAddr, err := net.ResolveUDPAddr("udp4", u.serverAddr)
if err != nil {
return err
}
// Send CONNECT.
if _, err := conn.WriteToUDP(BuildConnectPacket(), serverAddr); err != nil {
return err
}
log.Printf("udp: sent CONNECT")
reassembler := NewReassembler(2 * time.Second)
buf := make([]byte, readBufSize)
var currentSigs []SignalInfo
for {
// Silence timeout.
conn.SetReadDeadline(time.Now().Add(silenceTimeout))
n, _, err := conn.ReadFromUDP(buf)
arrivalTime := time.Now()
if err != nil {
// Send DISCONNECT best-effort before returning.
conn.WriteToUDP(BuildDisconnectPacket(), serverAddr)
return err
}
if n < HeaderSize {
log.Printf("udp: short datagram (%d bytes), skipping", n)
continue
}
hdr, err := ParseHeader(buf[:n])
if err != nil {
log.Printf("udp: parse header: %v", err)
continue
}
payload := make([]byte, n-HeaderSize)
copy(payload, buf[HeaderSize:n])
complete, ok := reassembler.AddFragment(hdr, payload)
if !ok {
continue
}
switch hdr.Type {
case PktConfig:
sigs, err := ParseConfig(complete)
if err != nil {
log.Printf("udp: parse config: %v", err)
continue
}
currentSigs = sigs
log.Printf("udp: received CONFIG (%d signals)", len(sigs))
u.hub.UpdateConfig(sigs)
case PktData:
if len(currentSigs) == 0 {
// We haven't received a CONFIG yet ignore.
continue
}
sample, err := ParseData(complete, currentSigs, arrivalTime)
if err != nil {
log.Printf("udp: parse data: %v", err)
continue
}
u.hub.PushData(sample)
case PktACK:
log.Printf("udp: received ACK (counter=%d)", hdr.Counter)
case PktDisconnect:
log.Printf("udp: server sent DISCONNECT")
return nil
default:
log.Printf("udp: unknown packet type %d", hdr.Type)
}
// Check stop request.
select {
case <-u.stopCh:
conn.WriteToUDP(BuildDisconnectPacket(), serverAddr)
return nil
default:
}
}
}