//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 pvNames []string // pre-fetched at connect time for ListSignals client *ca.Client mu sync.RWMutex metadata map[string]datasource.Metadata } // 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 string, pvNames []string) datasource.DataSource { return &EPICS{ caAddrList: caAddrList, archiveURL: archiveURL, 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 slog.Info("epics: CA client started", "addrs", cfg.AddrList) // Pre-fetch metadata for any configured PV names so they appear in // ListSignals as soon as the datasource is ready. We open a short-lived // subscription (which drives channel connection) and then fetch CTRL info. if len(e.pvNames) > 0 { go func() { for _, pv := range e.pvNames { if ctx.Err() != nil { return } func() { 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) }() } }() } return nil } // -------------------------------------------------------------------------- // // 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, } } // -------------------------------------------------------------------------- // // 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() 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. func (e *EPICS) Write(ctx context.Context, signal string, value any) error { if err := e.client.Put(ctx, signal, value); err != nil { return fmt.Errorf("epics: write %q: %w", signal, err) } return nil } // -------------------------------------------------------------------------- // // 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) }