//go:build !epics // Package epics provides an EPICS Channel Access data source for uopi using a // pure-Go CA implementation (no CGo, no EPICS Base installation required). // When built WITH the "epics" build tag, the CGo-based implementation in // epics.go is used instead; that version requires CGo and a working EPICS Base. package epics import ( "context" "fmt" "log/slog" //nolint:depguard "strings" "sync" "time" ca "github.com/uopi/goca" "github.com/uopi/goca/proto" "github.com/uopi/uopi/internal/datasource" ) // Available always returns true for the pure-Go build. func Available() bool { return true } // -------------------------------------------------------------------------- // // EPICS data source (pure-Go implementation) // // -------------------------------------------------------------------------- // // EPICS is the pure-Go Channel Access data source. type EPICS struct { caAddrList string archiveURL string cfURL string autoSyncFilter string autoSyncFromArchiver bool pvNames []string // pre-fetched at connect time for ListSignals client *ca.Client mu sync.RWMutex metadata map[string]datasource.Metadata // Per-user CA clients for writes. EPICS Channel Access carries the client // username at the circuit (TCP) level, so attributing a write to a specific // end-user requires a dedicated client whose ClientName is that user. These // are created lazily on first write by each user and evicted when idle. ctx context.Context // datasource lifetime; governs per-user clients baseCfg ca.Config // template config for per-user clients selfName string // the server's own CA client name userMu sync.Mutex userClients map[string]*userClient } // userClient is a per-end-user CA client and its last-use time for idle eviction. type userClient struct { cli *ca.Client lastUsed time.Time } // userClientIdleTTL is how long an idle per-user CA client is kept before being // closed. Writes are bursty, so a few minutes avoids reconnecting on every put // without holding circuits open indefinitely. const userClientIdleTTL = 10 * time.Minute // New creates a new EPICS Channel Access data source. // // caAddrList is the EPICS_CA_ADDR_LIST value (space-separated addresses). // Leave empty to use the environment variable. // archiveURL is the base URL of an EPICS Archive Appliance instance; leave // empty to disable history queries. // pvNames is an optional list of PV names whose metadata will be pre-fetched // at connect time so they appear in ListSignals immediately (useful for the // edit-mode signal tree). func New(caAddrList, archiveURL, cfURL, autoSyncFilter string, autoSyncFromArchiver bool, pvNames []string) datasource.DataSource { return &EPICS{ caAddrList: caAddrList, archiveURL: archiveURL, cfURL: cfURL, autoSyncFilter: autoSyncFilter, autoSyncFromArchiver: autoSyncFromArchiver, pvNames: pvNames, metadata: make(map[string]datasource.Metadata), } } // Name implements datasource.DataSource. func (e *EPICS) Name() string { return "epics" } // Connect creates the CA client and starts background I/O goroutines. // ctx governs the lifetime of the client; cancel it to shut everything down. func (e *EPICS) Connect(ctx context.Context) error { cfg := ca.ConfigFromEnv() if e.caAddrList != "" { cfg.AddrList = strings.Fields(e.caAddrList) cfg.AutoAddrList = false } slog.Info("epics: connecting CA client", "addrs", cfg.AddrList, "auto_addr", cfg.AutoAddrList) cli, err := ca.NewClient(ctx, cfg) if err != nil { return fmt.Errorf("epics: %w", err) } e.client = cli e.ctx = ctx e.baseCfg = cfg e.selfName = cfg.ClientName e.userClients = make(map[string]*userClient) go e.evictIdleUserClients(ctx) slog.Info("epics: CA client started", "addrs", cfg.AddrList, "client_name", cfg.ClientName) // Perform background sync from Channel Finder if configured. if e.cfURL != "" && e.autoSyncFilter != "" { go e.syncFromChannelFinder(ctx) } // Perform background sync from Archive Appliance if configured. if e.archiveURL != "" && e.autoSyncFromArchiver { go e.syncFromArchiver(ctx) } // Pre-fetch metadata for any configured PV names. if len(e.pvNames) > 0 { go func() { for _, pv := range e.pvNames { if ctx.Err() != nil { return } e.prefetchPV(ctx, pv) } }() } return nil } func (e *EPICS) syncFromChannelFinder(ctx context.Context) { time.Sleep(500 * time.Millisecond) slog.Info("epics: syncing signals from Channel Finder", "url", e.cfURL, "filter", e.autoSyncFilter) channels, err := queryChannelFinder(e.cfURL, e.autoSyncFilter) if err != nil { slog.Error("epics: Channel Finder sync failed", "err", err) return } for _, ch := range channels { if ctx.Err() != nil { return } // Convert CFS properties and tags to datasource.Metadata props := make(map[string]string) for _, p := range ch.Properties { props[p.Name] = p.Value } tags := make([]string, len(ch.Tags)) for i, t := range ch.Tags { tags[i] = t.Name } // Initialize metadata with CFS info. Full metadata (type, units, etc.) // will be populated on first access via DBR_CTRL GET. e.mu.Lock() if _, exists := e.metadata[ch.Name]; !exists { e.metadata[ch.Name] = datasource.Metadata{ Name: ch.Name, Properties: props, Tags: tags, } } e.mu.Unlock() // Background pre-fetch of actual record info (type, etc.) go e.prefetchPV(ctx, ch.Name) } slog.Info("epics: synced signals from Channel Finder", "count", len(channels)) } func (e *EPICS) prefetchPV(ctx context.Context, pv string) { tvCh := make(chan proto.TimeValue, 1) subCtx, subCancel := context.WithTimeout(ctx, 15*time.Second) defer subCancel() cancel, err := e.client.Subscribe(subCtx, pv, tvCh) if err != nil { return } defer cancel() // Channel is now connected; fetch CTRL metadata. mctx, mcancel := context.WithTimeout(ctx, 5*time.Second) defer mcancel() _, _ = e.GetMetadata(mctx, pv) } // -------------------------------------------------------------------------- // // Subscribe // // -------------------------------------------------------------------------- // // Subscribe registers ch to receive live value updates for signal. // It blocks until the CA channel is connected (or ctx expires). // A background goroutine forwards proto.TimeValue updates to ch as // datasource.Value. Call the returned CancelFunc to unsubscribe. func (e *EPICS) Subscribe(ctx context.Context, signal string, ch chan<- datasource.Value) (datasource.CancelFunc, error) { slog.Info("epics: subscribing", "pv", signal) tvCh := make(chan proto.TimeValue, 16) clientCancel, err := e.client.Subscribe(ctx, signal, tvCh) if err != nil { slog.Error("epics: subscribe failed", "pv", signal, "err", err) return nil, fmt.Errorf("epics: subscribe %q: %w", signal, err) } slog.Info("epics: subscribed OK", "pv", signal) // Fetch and cache metadata now that the channel is connected. // Send MetaUpdate so the dispatcher re-broadcasts metadata to clients. go func() { // Small delay gives ACCESS_RIGHTS message time to arrive before GetCtrl. time.Sleep(50 * time.Millisecond) mctx, mcancel := context.WithTimeout(ctx, 5*time.Second) defer mcancel() if _, err := e.GetMetadata(mctx, signal); err == nil { select { case ch <- datasource.Value{MetaUpdate: true}: default: } } }() done := make(chan struct{}) // Forward goroutine: convert proto.TimeValue → datasource.Value. go func() { for { select { case tv := <-tvCh: slog.Debug("epics: value received", "pv", signal, "double", tv.Double, "severity", tv.Severity) val := e.timeValueToDS(signal, tv) select { case ch <- val: default: // drop if consumer is slow } case <-done: return } } }() cancel := func() { clientCancel() close(done) } return datasource.CancelFunc(cancel), nil } // timeValueToDS converts a proto.TimeValue to a datasource.Value. func (e *EPICS) timeValueToDS(signal string, tv proto.TimeValue) datasource.Value { var data any if tv.Doubles != nil { if len(tv.Doubles) == 1 { data = tv.Doubles[0] } else { data = tv.Doubles } } else if tv.Str != "" { data = tv.Str } else { data = tv.Double } quality := datasource.QualityGood switch tv.Severity { case proto.SeverityMinor: quality = datasource.QualityUncertain case proto.SeverityMajor, proto.SeverityInvalid: quality = datasource.QualityBad } return datasource.Value{ Timestamp: tv.Timestamp, Data: data, Quality: quality, Severity: int(tv.Severity), Status: int(tv.Status), } } // -------------------------------------------------------------------------- // // GetMetadata // // -------------------------------------------------------------------------- // // GetMetadata returns metadata for signal, using a cached value when available. // On the first call for a signal it performs a DBR_CTRL_* GET to retrieve // units, display limits, enum strings, and access rights. func (e *EPICS) GetMetadata(ctx context.Context, signal string) (datasource.Metadata, error) { e.mu.RLock() if m, ok := e.metadata[signal]; ok { e.mu.RUnlock() return m, nil } e.mu.RUnlock() slog.Info("epics: fetching metadata", "pv", signal) ci, err := e.client.GetCtrl(ctx, signal) if err != nil { slog.Error("epics: GetMetadata failed", "pv", signal, "err", err) return datasource.Metadata{}, fmt.Errorf("epics: GetMetadata %q: %w", signal, err) } m := e.ctrlToMeta(signal, ci) slog.Info("epics: metadata fetched", "pv", signal, "type", m.Type, "unit", m.Unit, "writable", m.Writable) e.mu.Lock() e.metadata[signal] = m e.mu.Unlock() return m, nil } // ctrlToMeta converts a ca.CtrlInfo to a datasource.Metadata. func (e *EPICS) ctrlToMeta(name string, ci ca.CtrlInfo) datasource.Metadata { m := datasource.Metadata{ Name: name, Writable: ci.Access&proto.AccessWrite != 0, } switch { case ci.Double != nil: m.Type = datasource.TypeFloat64 if ci.Count > 1 { m.Type = datasource.TypeFloat64Array } m.Unit = ci.Double.Units m.DisplayLow = ci.Double.LowerDispLimit m.DisplayHigh = ci.Double.UpperDispLimit m.DriveLow = ci.Double.LowerCtrlLimit m.DriveHigh = ci.Double.UpperCtrlLimit case ci.Long != nil: m.Type = datasource.TypeInt64 m.Unit = ci.Long.Units m.DisplayLow = float64(ci.Long.LowerDispLimit) m.DisplayHigh = float64(ci.Long.UpperDispLimit) m.DriveLow = float64(ci.Long.LowerCtrlLimit) m.DriveHigh = float64(ci.Long.UpperCtrlLimit) case ci.Enum != nil: m.Type = datasource.TypeEnum m.EnumStrings = ci.Enum.Strings case ci.Str != nil: m.Type = datasource.TypeString } return m } // -------------------------------------------------------------------------- // // ListSignals // // -------------------------------------------------------------------------- // // ListSignals returns metadata for all signals with cached information. func (e *EPICS) ListSignals(_ context.Context) ([]datasource.Metadata, error) { e.mu.RLock() defer e.mu.RUnlock() slog.Debug("epics: listing signals", "count", len(e.metadata)) out := make([]datasource.Metadata, 0, len(e.metadata)) for _, m := range e.metadata { out = append(out, m) } return out, nil } // -------------------------------------------------------------------------- // // Write // // -------------------------------------------------------------------------- // // Write puts a new value onto the named CA channel. // value must be one of: float64, float32, int64, int32, int, int16, string, bool. // // When the request context carries an end-user identity (see datasource.WithUser) // that differs from the server's own CA client name, the put is performed on a // dedicated CA client whose ClientName is that user, so the IOC's access-security // rules see the actual end-user rather than the server process. func (e *EPICS) Write(ctx context.Context, signal string, value any) error { cli := e.client if user, ok := datasource.UserFrom(ctx); ok && user != e.selfName { uc, err := e.clientForUser(user) if err != nil { return fmt.Errorf("epics: write %q as %q: %w", signal, user, err) } cli = uc } if err := cli.Put(ctx, signal, value); err != nil { return fmt.Errorf("epics: write %q: %w", signal, err) } return nil } // clientForUser returns a CA client whose ClientName is user, creating and // caching one on first use. The client is bound to the datasource lifetime ctx, // not the per-request ctx, so it survives across writes until evicted when idle. func (e *EPICS) clientForUser(user string) (*ca.Client, error) { e.userMu.Lock() defer e.userMu.Unlock() if uc, ok := e.userClients[user]; ok { uc.lastUsed = time.Now() return uc.cli, nil } cfg := e.baseCfg cfg.ClientName = user cli, err := ca.NewClient(e.ctx, cfg) if err != nil { return nil, err } e.userClients[user] = &userClient{cli: cli, lastUsed: time.Now()} slog.Info("epics: created per-user CA client", "user", user) return cli, nil } // evictIdleUserClients closes per-user CA clients that have not been used within // userClientIdleTTL. It runs until ctx is cancelled. func (e *EPICS) evictIdleUserClients(ctx context.Context) { t := time.NewTicker(time.Minute) defer t.Stop() for { select { case <-ctx.Done(): return case now := <-t.C: e.userMu.Lock() for user, uc := range e.userClients { if now.Sub(uc.lastUsed) > userClientIdleTTL { uc.cli.Close() delete(e.userClients, user) slog.Info("epics: evicted idle per-user CA client", "user", user) } } e.userMu.Unlock() } } } // -------------------------------------------------------------------------- // // History // // -------------------------------------------------------------------------- // // History delegates to the Archive Appliance client, or returns // ErrHistoryUnavailable if no archive URL is configured. func (e *EPICS) History(ctx context.Context, signal string, start, end time.Time, maxPoints int) ([]datasource.Value, error) { if e.archiveURL == "" { return nil, datasource.ErrHistoryUnavailable } return fetchArchiveHistory(ctx, e.archiveURL, signal, start, end, maxPoints) } func (e *EPICS) syncFromArchiver(ctx context.Context) { time.Sleep(1 * time.Second) // wait for network slog.Info("epics: syncing signals from Archiver", "url", e.archiveURL) pvs, err := searchArchivePVs(ctx, e.archiveURL, "*") if err != nil { slog.Error("epics: Archiver sync failed", "err", err) return } for _, name := range pvs { if ctx.Err() != nil { return } e.mu.Lock() if _, exists := e.metadata[name]; !exists { e.metadata[name] = datasource.Metadata{Name: name} } e.mu.Unlock() // Background pre-fetch of record info go e.prefetchPV(ctx, name) } slog.Info("epics: synced signals from Archiver", "count", len(pvs)) }