435 lines
12 KiB
Go
435 lines
12 KiB
Go
package ca
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"os"
|
|
"os/user"
|
|
"strings"
|
|
"sync"
|
|
|
|
"github.com/uopi/goca/proto"
|
|
)
|
|
|
|
// -------------------------------------------------------------------------- //
|
|
// Config //
|
|
// -------------------------------------------------------------------------- //
|
|
|
|
// Config holds the configuration for a Client.
|
|
// All fields are optional; sensible defaults are applied by NewClient.
|
|
type Config struct {
|
|
// AddrList is the list of CA server addresses ("host" or "host:port").
|
|
// Corresponds to EPICS_CA_ADDR_LIST.
|
|
AddrList []string
|
|
|
|
// AutoAddrList, when true, appends the IPv4 broadcast address of every
|
|
// local network interface to AddrList. Defaults to true.
|
|
// Corresponds to EPICS_CA_AUTO_ADDR_LIST.
|
|
AutoAddrList bool
|
|
|
|
// ClientName is announced to every CA server in the CLIENT_NAME message.
|
|
// Defaults to the executable base name.
|
|
ClientName string
|
|
|
|
// HostName is announced to every CA server in the HOST_NAME message.
|
|
// Defaults to the OS hostname.
|
|
HostName string
|
|
}
|
|
|
|
// ConfigFromEnv reads the standard EPICS CA environment variables and returns
|
|
// a ready-to-use Config.
|
|
//
|
|
// EPICS_CA_ADDR_LIST — space-separated list of server addresses
|
|
// EPICS_CA_AUTO_ADDR_LIST — "NO" disables automatic broadcast addresses
|
|
func ConfigFromEnv() Config {
|
|
// CA security ACLs match on the client username, not the program name.
|
|
// Use the OS username (same as libca), falling back to $USER, then "user".
|
|
clientName := "user"
|
|
if u, err := user.Current(); err == nil && u.Username != "" {
|
|
clientName = u.Username
|
|
} else if v := os.Getenv("USER"); v != "" {
|
|
clientName = v
|
|
}
|
|
host, _ := os.Hostname()
|
|
cfg := Config{
|
|
AutoAddrList: true,
|
|
ClientName: clientName,
|
|
HostName: host,
|
|
}
|
|
if v := os.Getenv("EPICS_CA_ADDR_LIST"); v != "" {
|
|
cfg.AddrList = strings.Fields(v)
|
|
}
|
|
if strings.EqualFold(os.Getenv("EPICS_CA_AUTO_ADDR_LIST"), "no") {
|
|
cfg.AutoAddrList = false
|
|
}
|
|
return cfg
|
|
}
|
|
|
|
// -------------------------------------------------------------------------- //
|
|
// Client //
|
|
// -------------------------------------------------------------------------- //
|
|
|
|
// Client is a thread-safe EPICS Channel Access client.
|
|
//
|
|
// A single Client should serve an entire application. It maintains a pool of
|
|
// persistent TCP circuits (one per IOC) and a shared UDP search engine.
|
|
// Channels and subscriptions survive IOC restarts automatically.
|
|
//
|
|
// Usage:
|
|
//
|
|
// cli, err := ca.NewClient(ctx, ca.ConfigFromEnv())
|
|
// defer cli.Close()
|
|
//
|
|
// ch := make(chan proto.TimeValue, 16)
|
|
// cancel, err := cli.Subscribe(ctx, "MY:PV", ch)
|
|
// defer cancel()
|
|
//
|
|
// for tv := range ch { fmt.Println(tv.Double) }
|
|
type Client struct {
|
|
cfg Config
|
|
ctx context.Context
|
|
cancel context.CancelFunc
|
|
search *searchEngine
|
|
|
|
mu sync.Mutex
|
|
circuits map[string]*circuit // IOC TCP addr → circuit
|
|
}
|
|
|
|
// NewClient creates a new CA client and starts background I/O.
|
|
// ctx governs the lifetime of the client; cancelling it is equivalent to
|
|
// calling Close.
|
|
//
|
|
// Returns an error only if no search addresses can be derived from cfg.
|
|
func NewClient(ctx context.Context, cfg Config) (*Client, error) {
|
|
addrs := resolveAddrs(cfg.AddrList, proto.DefaultPort)
|
|
if cfg.AutoAddrList {
|
|
addrs = append(addrs, localBroadcastAddrs(proto.DefaultPort)...)
|
|
}
|
|
if len(addrs) == 0 {
|
|
return nil, fmt.Errorf("ca: no search addresses (set EPICS_CA_ADDR_LIST or enable AutoAddrList)")
|
|
}
|
|
|
|
if cfg.ClientName == "" {
|
|
if u, err := user.Current(); err == nil && u.Username != "" {
|
|
cfg.ClientName = u.Username
|
|
} else if v := os.Getenv("USER"); v != "" {
|
|
cfg.ClientName = v
|
|
} else {
|
|
cfg.ClientName = "user"
|
|
}
|
|
}
|
|
if cfg.HostName == "" {
|
|
cfg.HostName, _ = os.Hostname()
|
|
}
|
|
|
|
cctx, cancel := context.WithCancel(ctx)
|
|
se := newSearchEngine(addrs)
|
|
if err := se.start(cctx); err != nil {
|
|
cancel()
|
|
return nil, err
|
|
}
|
|
|
|
return &Client{
|
|
cfg: cfg,
|
|
ctx: cctx,
|
|
cancel: cancel,
|
|
search: se,
|
|
circuits: make(map[string]*circuit),
|
|
}, nil
|
|
}
|
|
|
|
// Close shuts down all circuits and background goroutines.
|
|
// Any in-flight Subscribe, Get, or Put calls will unblock with an error.
|
|
func (c *Client) Close() {
|
|
c.cancel()
|
|
}
|
|
|
|
// -------------------------------------------------------------------------- //
|
|
// Internal helpers //
|
|
// -------------------------------------------------------------------------- //
|
|
|
|
// getOrCreateCircuit returns the circuit for addr, creating one if necessary.
|
|
func (c *Client) getOrCreateCircuit(addr string) *circuit {
|
|
c.mu.Lock()
|
|
defer c.mu.Unlock()
|
|
if circ, ok := c.circuits[addr]; ok {
|
|
return circ
|
|
}
|
|
circ := newCircuit(c.ctx, addr, c.cfg.ClientName, c.cfg.HostName)
|
|
c.circuits[addr] = circ
|
|
return circ
|
|
}
|
|
|
|
// resolve finds the IOC for pvName via UDP search, then waits for the TCP
|
|
// channel to be fully established. Returns (circuit, chanState) on success.
|
|
func (c *Client) resolve(ctx context.Context, pvName string) (*circuit, *chanState, error) {
|
|
addr, err := c.search.lookup(ctx, pvName)
|
|
if err != nil {
|
|
return nil, nil, err
|
|
}
|
|
circ := c.getOrCreateCircuit(addr)
|
|
cs := circ.getOrCreateChannel(pvName)
|
|
if err := cs.waitReady(ctx); err != nil {
|
|
return nil, nil, fmt.Errorf("ca: %q: %w", pvName, err)
|
|
}
|
|
return circ, cs, nil
|
|
}
|
|
|
|
// -------------------------------------------------------------------------- //
|
|
// Public API //
|
|
// -------------------------------------------------------------------------- //
|
|
|
|
// Subscribe registers ch to receive live monitor updates for pvName.
|
|
//
|
|
// Values are pushed as proto.TimeValue structs (DBR_TIME_* encoding).
|
|
// ch should be buffered; slow consumers will have updates silently dropped.
|
|
//
|
|
// The returned CancelFunc unsubscribes from the PV and must always be called
|
|
// to avoid leaking resources.
|
|
//
|
|
// Subscribe blocks until the channel is connected or ctx expires.
|
|
func (c *Client) Subscribe(ctx context.Context, pvName string, ch chan<- proto.TimeValue) (context.CancelFunc, error) {
|
|
circ, cs, err := c.resolve(ctx, pvName)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
cs.mu.RLock()
|
|
dbfType := cs.dbfType
|
|
count := cs.count
|
|
cs.mu.RUnlock()
|
|
|
|
ms := &monState{
|
|
subID: circ.nextID(),
|
|
dbrType: proto.NativeTimeType(dbfType, int(count)),
|
|
count: count,
|
|
ch: ch,
|
|
}
|
|
circ.addMonitor(cs, ms)
|
|
|
|
return func() { circ.removeMonitor(cs, ms.subID) }, nil
|
|
}
|
|
|
|
// Get performs a one-shot READ_NOTIFY for pvName and returns its current value.
|
|
// It blocks until the reply arrives or ctx expires.
|
|
func (c *Client) Get(ctx context.Context, pvName string) (proto.TimeValue, error) {
|
|
circ, cs, err := c.resolve(ctx, pvName)
|
|
if err != nil {
|
|
return proto.TimeValue{}, err
|
|
}
|
|
|
|
cs.mu.RLock()
|
|
dbfType := cs.dbfType
|
|
count := cs.count
|
|
cs.mu.RUnlock()
|
|
|
|
dbrType := proto.NativeTimeType(dbfType, int(count))
|
|
return circ.get(ctx, cs, dbrType, count)
|
|
}
|
|
|
|
// Put writes value to pvName using CA_PROTO_WRITE (fire-and-forget).
|
|
//
|
|
// value is automatically encoded to match the PV's native field type.
|
|
// Supported Go types: float64, float32, int64, int32, int, int16, string, bool.
|
|
//
|
|
// Put blocks only until the message is queued; it does not wait for an IOC
|
|
// acknowledgement. Use a timeout context to bound the wait for connection.
|
|
func (c *Client) Put(ctx context.Context, pvName string, value any) error {
|
|
circ, cs, err := c.resolve(ctx, pvName)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
cs.mu.RLock()
|
|
dbfType := cs.dbfType
|
|
cs.mu.RUnlock()
|
|
|
|
dbrType, payload, err := encodePut(dbfType, value)
|
|
if err != nil {
|
|
return fmt.Errorf("ca: put %q: %w", pvName, err)
|
|
}
|
|
return circ.put(ctx, cs, dbrType, payload)
|
|
}
|
|
|
|
// -------------------------------------------------------------------------- //
|
|
// Control metadata //
|
|
// -------------------------------------------------------------------------- //
|
|
|
|
// CtrlInfo holds the full control-block metadata for a PV.
|
|
// Exactly one of the Double, Long, Enum, Str pointer fields is non-nil,
|
|
// depending on the PV's native field type.
|
|
type CtrlInfo struct {
|
|
DBFType int // proto.DBF* constant
|
|
Count uint32 // element count
|
|
Access uint32 // proto.AccessRead | proto.AccessWrite bitmask
|
|
|
|
Double *proto.CtrlDouble // non-nil for DBFDouble / DBFFloat
|
|
Long *proto.CtrlLong // non-nil for DBFLong / DBFShort / DBFChar
|
|
Enum *proto.CtrlEnum // non-nil for DBFEnum
|
|
Str *proto.CtrlString // non-nil for DBFString
|
|
}
|
|
|
|
// GetCtrl performs a READ_NOTIFY with a DBR_CTRL_* type and returns the full
|
|
// control-block metadata (units, display limits, enum strings, etc.) for pvName.
|
|
//
|
|
// GetCtrl blocks until the reply arrives or ctx expires.
|
|
func (c *Client) GetCtrl(ctx context.Context, pvName string) (CtrlInfo, error) {
|
|
circ, cs, err := c.resolve(ctx, pvName)
|
|
if err != nil {
|
|
return CtrlInfo{}, err
|
|
}
|
|
|
|
cs.mu.RLock()
|
|
dbfType := cs.dbfType
|
|
count := cs.count
|
|
access := cs.access
|
|
cs.mu.RUnlock()
|
|
|
|
ctrlType := proto.NativeCtrlType(dbfType)
|
|
_, payload, err := circ.getRaw(ctx, cs, ctrlType, count)
|
|
if err != nil {
|
|
return CtrlInfo{}, err
|
|
}
|
|
|
|
ci := CtrlInfo{DBFType: dbfType, Count: count, Access: access}
|
|
|
|
switch ctrlType {
|
|
case proto.DBRCtrlDouble:
|
|
cd, ok := proto.DecodeCtrlDouble(payload)
|
|
if !ok {
|
|
return CtrlInfo{}, fmt.Errorf("ca: %q: DecodeCtrlDouble failed (payload len %d)", pvName, len(payload))
|
|
}
|
|
ci.Double = &cd
|
|
|
|
case proto.DBRCtrlLong:
|
|
cl, ok := proto.DecodeCtrlLong(payload)
|
|
if !ok {
|
|
return CtrlInfo{}, fmt.Errorf("ca: %q: DecodeCtrlLong failed (payload len %d)", pvName, len(payload))
|
|
}
|
|
ci.Long = &cl
|
|
|
|
case proto.DBRCtrlEnum:
|
|
ce, ok := proto.DecodeCtrlEnum(payload)
|
|
if !ok {
|
|
return CtrlInfo{}, fmt.Errorf("ca: %q: DecodeCtrlEnum failed (payload len %d)", pvName, len(payload))
|
|
}
|
|
ci.Enum = &ce
|
|
|
|
case proto.DBRCtrlString:
|
|
cs2, ok := proto.DecodeCtrlString(payload)
|
|
if !ok {
|
|
return CtrlInfo{}, fmt.Errorf("ca: %q: DecodeCtrlString failed (payload len %d)", pvName, len(payload))
|
|
}
|
|
ci.Str = &cs2
|
|
|
|
default:
|
|
return CtrlInfo{}, fmt.Errorf("ca: %q: unhandled ctrl type %d", pvName, ctrlType)
|
|
}
|
|
|
|
return ci, nil
|
|
}
|
|
|
|
// -------------------------------------------------------------------------- //
|
|
// Value encoding for Put //
|
|
// -------------------------------------------------------------------------- //
|
|
|
|
// encodePut converts a Go value to a DBR wire payload matching the PV's
|
|
// native field type.
|
|
func encodePut(dbfType int, value any) (dbrType uint16, payload []byte, err error) {
|
|
switch dbfType {
|
|
case proto.DBFDouble, proto.DBFFloat:
|
|
f, e := toFloat64(value)
|
|
if e != nil {
|
|
return 0, nil, e
|
|
}
|
|
return proto.DBRDouble, proto.EncodeDouble(f), nil
|
|
|
|
case proto.DBFLong:
|
|
n, e := toInt32(value)
|
|
if e != nil {
|
|
return 0, nil, e
|
|
}
|
|
return proto.DBRLong, proto.EncodeLong(n), nil
|
|
|
|
case proto.DBFShort, proto.DBFChar, proto.DBFEnum:
|
|
n, e := toInt32(value)
|
|
if e != nil {
|
|
return 0, nil, e
|
|
}
|
|
return proto.DBRShort, proto.EncodeShort(int16(n)), nil
|
|
|
|
case proto.DBFString:
|
|
s, e := toString(value)
|
|
if e != nil {
|
|
return 0, nil, e
|
|
}
|
|
return proto.DBRString, proto.EncodeString(s), nil
|
|
|
|
default:
|
|
return 0, nil, fmt.Errorf("unsupported DBF type %d", dbfType)
|
|
}
|
|
}
|
|
|
|
func toFloat64(v any) (float64, error) {
|
|
switch x := v.(type) {
|
|
case float64:
|
|
return x, nil
|
|
case float32:
|
|
return float64(x), nil
|
|
case int:
|
|
return float64(x), nil
|
|
case int64:
|
|
return float64(x), nil
|
|
case int32:
|
|
return float64(x), nil
|
|
case int16:
|
|
return float64(x), nil
|
|
case uint64:
|
|
return float64(x), nil
|
|
case uint32:
|
|
return float64(x), nil
|
|
case bool:
|
|
if x {
|
|
return 1, nil
|
|
}
|
|
return 0, nil
|
|
default:
|
|
return 0, fmt.Errorf("cannot convert %T to float64", v)
|
|
}
|
|
}
|
|
|
|
func toInt32(v any) (int32, error) {
|
|
switch x := v.(type) {
|
|
case int:
|
|
return int32(x), nil
|
|
case int64:
|
|
return int32(x), nil
|
|
case int32:
|
|
return x, nil
|
|
case int16:
|
|
return int32(x), nil
|
|
case float64:
|
|
return int32(x), nil
|
|
case float32:
|
|
return int32(x), nil
|
|
case bool:
|
|
if x {
|
|
return 1, nil
|
|
}
|
|
return 0, nil
|
|
default:
|
|
return 0, fmt.Errorf("cannot convert %T to int32", v)
|
|
}
|
|
}
|
|
|
|
func toString(v any) (string, error) {
|
|
switch x := v.(type) {
|
|
case string:
|
|
return x, nil
|
|
case []byte:
|
|
return string(x), nil
|
|
default:
|
|
return fmt.Sprintf("%v", x), nil
|
|
}
|
|
}
|