Initial go port of epics
This commit is contained in:
@@ -0,0 +1,421 @@
|
||||
package ca
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"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 {
|
||||
name := filepath.Base(os.Args[0])
|
||||
host, _ := os.Hostname()
|
||||
cfg := Config{
|
||||
AutoAddrList: true,
|
||||
ClientName: name,
|
||||
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 == "" {
|
||||
cfg.ClientName = filepath.Base(os.Args[0])
|
||||
}
|
||||
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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user