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 } }