Files
uopi/internal/datasource/epics/noop.go
T
2026-05-21 07:41:56 +02:00

390 lines
12 KiB
Go

//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
}
// 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
slog.Info("epics: CA client started", "addrs", cfg.AddrList)
// 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,
}
}
// -------------------------------------------------------------------------- //
// 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.
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)
}
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))
}