341 lines
8.6 KiB
Go
341 lines
8.6 KiB
Go
package pva
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/binary"
|
|
"fmt"
|
|
"log/slog"
|
|
"net"
|
|
"sync"
|
|
"sync/atomic"
|
|
"time"
|
|
|
|
"github.com/uopi/gopva/pvdata"
|
|
)
|
|
|
|
// DefaultTCPPort is the standard PVA TCP port for channel connections.
|
|
const DefaultTCPPort = 5075
|
|
|
|
// DefaultUDPPort is the PVA broadcast/search port (distinct from TCP).
|
|
const DefaultUDPPort = 5076
|
|
|
|
// Client is a PVA client that manages connections to one or more servers.
|
|
// It performs UDP search to locate PVs, then opens TCP connections on demand.
|
|
type Client struct {
|
|
mu sync.Mutex
|
|
conns map[string]*conn // server addr → connection
|
|
pending map[string][]waiter // pvName → waiters for address
|
|
|
|
searchSeq atomic.Uint32
|
|
udp *net.UDPConn
|
|
|
|
ctx context.Context
|
|
cancel context.CancelFunc
|
|
}
|
|
|
|
type waiter struct {
|
|
pvName string
|
|
cb func(addr string, err error)
|
|
}
|
|
|
|
// NewClient creates a PVA client. Call Close when done.
|
|
func NewClient() (*Client, error) {
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
cl := &Client{
|
|
conns: make(map[string]*conn),
|
|
pending: make(map[string][]waiter),
|
|
ctx: ctx,
|
|
cancel: cancel,
|
|
}
|
|
if err := cl.startUDP(); err != nil {
|
|
cancel()
|
|
return nil, err
|
|
}
|
|
return cl, nil
|
|
}
|
|
|
|
// Close shuts down the client and all connections.
|
|
func (cl *Client) Close() {
|
|
cl.cancel()
|
|
if cl.udp != nil {
|
|
cl.udp.Close()
|
|
}
|
|
cl.mu.Lock()
|
|
defer cl.mu.Unlock()
|
|
for _, c := range cl.conns {
|
|
c.close()
|
|
}
|
|
}
|
|
|
|
// Get issues a one-shot GET for pvName and returns the decoded structure.
|
|
func (cl *Client) Get(ctx context.Context, pvName string) (pvdata.StructValue, error) {
|
|
ch := make(chan struct {
|
|
v pvdata.StructValue
|
|
err error
|
|
}, 1)
|
|
cl.withConn(ctx, pvName, func(c *conn, err error) {
|
|
if err != nil {
|
|
ch <- struct {
|
|
v pvdata.StructValue
|
|
err error
|
|
}{err: err}
|
|
return
|
|
}
|
|
c.get(pvName, func(v pvdata.StructValue, e error) {
|
|
ch <- struct {
|
|
v pvdata.StructValue
|
|
err error
|
|
}{v, e}
|
|
})
|
|
})
|
|
select {
|
|
case res := <-ch:
|
|
return res.v, res.err
|
|
case <-ctx.Done():
|
|
return pvdata.StructValue{}, ctx.Err()
|
|
}
|
|
}
|
|
|
|
// Monitor subscribes to pvName and returns a channel that receives updates.
|
|
// The channel is closed when ctx is cancelled or a fatal error occurs.
|
|
func (cl *Client) Monitor(ctx context.Context, pvName string) <-chan MonitorEvent {
|
|
ch := make(chan MonitorEvent, 16)
|
|
cl.withConn(ctx, pvName, func(c *conn, err error) {
|
|
if err != nil {
|
|
ch <- MonitorEvent{Err: err}
|
|
close(ch)
|
|
return
|
|
}
|
|
c.subscribe(ctx, pvName, ch)
|
|
})
|
|
return ch
|
|
}
|
|
|
|
// ---- Internal --------------------------------------------------------
|
|
|
|
// withConn locates (or creates) a connection to the server hosting pvName
|
|
// and calls cb on it. cb may be called from a goroutine.
|
|
func (cl *Client) withConn(ctx context.Context, pvName string, cb func(*conn, error)) {
|
|
cl.search(ctx, pvName, func(addr string, err error) {
|
|
if err != nil {
|
|
cb(nil, err)
|
|
return
|
|
}
|
|
cl.mu.Lock()
|
|
c, ok := cl.conns[addr]
|
|
cl.mu.Unlock()
|
|
if ok {
|
|
cb(c, nil)
|
|
return
|
|
}
|
|
// open new TCP connection
|
|
tc, err := dial(ctx, addr)
|
|
if err != nil {
|
|
cb(nil, fmt.Errorf("pva: connect %s: %w", addr, err))
|
|
return
|
|
}
|
|
cl.mu.Lock()
|
|
cl.conns[addr] = tc
|
|
cl.mu.Unlock()
|
|
cb(tc, nil)
|
|
})
|
|
}
|
|
|
|
// ---- UDP search -------------------------------------------------------
|
|
|
|
func (cl *Client) startUDP() error {
|
|
conn, err := net.ListenUDP("udp4", &net.UDPAddr{Port: 0})
|
|
if err != nil {
|
|
return fmt.Errorf("pva: UDP listen: %w", err)
|
|
}
|
|
cl.udp = conn
|
|
go cl.udpReadLoop()
|
|
return nil
|
|
}
|
|
|
|
// search sends a PVA search request and calls cb when a response arrives.
|
|
// Currently does broadcast + localhost; production code would also check
|
|
// EPICS_PVA_ADDR_LIST environment variable.
|
|
func (cl *Client) search(ctx context.Context, pvName string, cb func(string, error)) {
|
|
seq := cl.searchSeq.Add(1)
|
|
|
|
cl.mu.Lock()
|
|
cl.pending[pvName] = append(cl.pending[pvName], waiter{pvName: pvName, cb: cb})
|
|
cl.mu.Unlock()
|
|
|
|
go func() {
|
|
for attempt := 0; attempt < 10; attempt++ {
|
|
select {
|
|
case <-ctx.Done():
|
|
cl.mu.Lock()
|
|
cl.removeWaiter(pvName, cb)
|
|
cl.mu.Unlock()
|
|
cb("", ctx.Err())
|
|
return
|
|
default:
|
|
}
|
|
cl.sendSearchRequest(seq, pvName)
|
|
// exponential backoff: 100ms, 200ms, 400ms … up to 2s
|
|
delay := time.Duration(100<<min(attempt, 4)) * time.Millisecond
|
|
select {
|
|
case <-time.After(delay):
|
|
case <-ctx.Done():
|
|
cl.mu.Lock()
|
|
cl.removeWaiter(pvName, cb)
|
|
cl.mu.Unlock()
|
|
cb("", ctx.Err())
|
|
return
|
|
}
|
|
}
|
|
// give up
|
|
cl.mu.Lock()
|
|
cl.removeWaiter(pvName, cb)
|
|
cl.mu.Unlock()
|
|
cb("", fmt.Errorf("pva: search timeout for %q", pvName))
|
|
}()
|
|
}
|
|
|
|
func (cl *Client) removeWaiter(pvName string, cb func(string, error)) {
|
|
ws := cl.pending[pvName]
|
|
for i, w := range ws {
|
|
// compare by pointer (closure identity via fmt trick)
|
|
if fmt.Sprintf("%p", w.cb) == fmt.Sprintf("%p", cb) {
|
|
cl.pending[pvName] = append(ws[:i], ws[i+1:]...)
|
|
return
|
|
}
|
|
}
|
|
}
|
|
|
|
// sendSearchRequest broadcasts a PVA SEARCH request.
|
|
func (cl *Client) sendSearchRequest(seq uint32, pvName string) {
|
|
// SEARCH payload (spec §6.3):
|
|
// searchSeqID(4) + flags(1) + reserved(3) + responseAddress(16) + responsePort(2)
|
|
// + transportCount(1) + transports[](1=TCP) + pvCount(2) + pvs[](channelID(4)+name(str))
|
|
var buf bytes.Buffer
|
|
binary.Write(&buf, binary.LittleEndian, seq) // searchSeqID
|
|
buf.WriteByte(0x81) // flags: unicast=0, reply=1, mustReply=1
|
|
buf.Write([]byte{0, 0, 0}) // reserved
|
|
// responseAddress: 16 bytes (IPv4-mapped IPv6: ::ffff:0.0.0.0 = no specific address)
|
|
buf.Write(make([]byte, 12))
|
|
buf.Write([]byte{0, 0, 0, 0}) // 0.0.0.0 → server will reply to source addr
|
|
|
|
// responsePort: our UDP listening port
|
|
laddr := cl.udp.LocalAddr().(*net.UDPAddr)
|
|
binary.Write(&buf, binary.LittleEndian, uint16(laddr.Port))
|
|
|
|
buf.WriteByte(1) // transportCount = 1
|
|
buf.WriteByte(0x01) // transport[0]: TCP
|
|
|
|
binary.Write(&buf, binary.LittleEndian, uint16(1)) // pvCount = 1
|
|
binary.Write(&buf, binary.LittleEndian, uint32(seq)) // channelID (reuse seq)
|
|
pvdata.WriteString(&buf, pvName)
|
|
|
|
msg := BuildMessage(CmdSearchRequest, flagApp, buf.Bytes())
|
|
|
|
// Send to localhost and broadcast on the PVA UDP search port (5076).
|
|
targets := []string{
|
|
fmt.Sprintf("127.0.0.1:%d", DefaultUDPPort),
|
|
fmt.Sprintf("255.255.255.255:%d", DefaultUDPPort),
|
|
}
|
|
// Also check EPICS_PVA_ADDR_LIST if set (common in labs).
|
|
// Entries without an explicit port default to DefaultTCPPort for TCP
|
|
// connections; for the search broadcast we still use the UDP port.
|
|
for _, addr := range addrsFromEnv() {
|
|
targets = append(targets, fmt.Sprintf("%s:%d", addr, DefaultUDPPort))
|
|
}
|
|
for _, addr := range targets {
|
|
dst, err := net.ResolveUDPAddr("udp4", addr)
|
|
if err != nil {
|
|
continue
|
|
}
|
|
if _, err := cl.udp.WriteTo(msg, dst); err != nil {
|
|
slog.Debug("pva search send", "dst", addr, "err", err)
|
|
}
|
|
}
|
|
}
|
|
|
|
// udpReadLoop reads SEARCH_RESPONSE (and BEACON) UDP messages.
|
|
func (cl *Client) udpReadLoop() {
|
|
buf := make([]byte, 65536)
|
|
for {
|
|
n, src, err := cl.udp.ReadFromUDP(buf)
|
|
if err != nil {
|
|
return
|
|
}
|
|
cl.handleUDP(buf[:n], src)
|
|
}
|
|
}
|
|
|
|
func (cl *Client) handleUDP(data []byte, src *net.UDPAddr) {
|
|
if len(data) < headerSize {
|
|
return
|
|
}
|
|
r := bytes.NewReader(data)
|
|
hdr, err := ReadHeader(r)
|
|
if err != nil || hdr.isControl() {
|
|
return
|
|
}
|
|
if hdr.Command != CmdSearchResponse {
|
|
return
|
|
}
|
|
|
|
payload := make([]byte, hdr.Size)
|
|
if _, err := r.Read(payload); err != nil {
|
|
return
|
|
}
|
|
pr := bytes.NewReader(payload)
|
|
|
|
// SEARCH_RESPONSE:
|
|
// guid(12) + searchSeqID(4) + serverAddress(16) + serverPort(2)
|
|
// + protocol(str) + found(1) + pvCount(2) + pvIDs[](4)
|
|
var guid [12]byte
|
|
pr.Read(guid[:])
|
|
var seq uint32
|
|
binary.Read(pr, binary.LittleEndian, &seq)
|
|
var addr [16]byte
|
|
pr.Read(addr[:])
|
|
var port uint16
|
|
binary.Read(pr, binary.LittleEndian, &port)
|
|
proto, _ := readPVAString(pr, binary.LittleEndian)
|
|
if proto != "tcp" {
|
|
return
|
|
}
|
|
found, _ := pr.ReadByte()
|
|
if found == 0 {
|
|
return
|
|
}
|
|
var pvCount uint16
|
|
binary.Read(pr, binary.LittleEndian, &pvCount)
|
|
// pvIDs — we match by searching for waiters by name (seq used as channelID)
|
|
|
|
// Determine server address: use src if serverAddress is 0.0.0.0
|
|
ip := net.IP(addr[12:16]) // last 4 bytes = IPv4
|
|
if ip.Equal(net.IPv4zero) {
|
|
ip = src.IP
|
|
}
|
|
serverAddr := fmt.Sprintf("%s:%d", ip.String(), port)
|
|
|
|
// Notify all pending waiters (simple: we got a response so all PVs are on this server)
|
|
cl.mu.Lock()
|
|
var toNotify []func(string, error)
|
|
for pvName, ws := range cl.pending {
|
|
for _, w := range ws {
|
|
toNotify = append(toNotify, w.cb)
|
|
}
|
|
delete(cl.pending, pvName)
|
|
}
|
|
cl.mu.Unlock()
|
|
|
|
for _, cb := range toNotify {
|
|
go cb(serverAddr, nil)
|
|
}
|
|
}
|
|
|
|
func min(a, b int) int {
|
|
if a < b {
|
|
return a
|
|
}
|
|
return b
|
|
}
|