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<