Initial go port of epics
This commit is contained in:
@@ -1,5 +1,3 @@
|
||||
//go:build epics
|
||||
|
||||
package epics
|
||||
|
||||
import (
|
||||
|
||||
@@ -85,7 +85,9 @@ type EPICS struct {
|
||||
// caAddrList is used to set EPICS_CA_ADDR_LIST at runtime (may be empty to
|
||||
// rely on the environment). archiveURL is the base URL of an EPICS Archive
|
||||
// Appliance instance for history queries (may be empty).
|
||||
func New(caAddrList, archiveURL string) datasource.DataSource {
|
||||
// pvNames is accepted for API compatibility with the pure-Go build but ignored
|
||||
// in the CGo build (the CGo implementation populates ListSignals via callbacks).
|
||||
func New(caAddrList, archiveURL string, pvNames []string) datasource.DataSource {
|
||||
return &EPICS{
|
||||
caAddrList: caAddrList,
|
||||
archiveURL: archiveURL,
|
||||
|
||||
@@ -0,0 +1,350 @@
|
||||
//go:build !epics
|
||||
|
||||
// Integration tests for the pure-Go EPICS datasource adapter.
|
||||
// These tests use the in-process fake CA server from the goca testca package
|
||||
// and therefore do not require a real EPICS installation.
|
||||
package epics_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"math"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/uopi/goca/proto"
|
||||
"github.com/uopi/goca/testca"
|
||||
"github.com/uopi/uopi/internal/datasource"
|
||||
"github.com/uopi/uopi/internal/datasource/epics"
|
||||
)
|
||||
|
||||
// newTestDS creates a datasource.DataSource backed by a fake CA server.
|
||||
func newTestDS(t *testing.T, pvs []testca.PVSpec) (datasource.DataSource, *testca.Server) {
|
||||
t.Helper()
|
||||
srv, err := testca.New(pvs)
|
||||
if err != nil {
|
||||
t.Fatalf("testca.New: %v", err)
|
||||
}
|
||||
t.Cleanup(srv.Close)
|
||||
|
||||
ds := epics.New(srv.UDPAddr(), "", nil)
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
t.Cleanup(cancel)
|
||||
if err := ds.Connect(ctx); err != nil {
|
||||
t.Fatalf("Connect: %v", err)
|
||||
}
|
||||
return ds, srv
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------- //
|
||||
// GetMetadata //
|
||||
// -------------------------------------------------------------------------- //
|
||||
|
||||
func TestPureGo_GetMetadataDouble(t *testing.T) {
|
||||
ds, _ := newTestDS(t, []testca.PVSpec{
|
||||
{Name: "DS:DOUBLE", DBFType: proto.DBFDouble, Count: 1, Value: 1.0},
|
||||
})
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
meta, err := ds.GetMetadata(ctx, "DS:DOUBLE")
|
||||
if err != nil {
|
||||
t.Fatalf("GetMetadata: %v", err)
|
||||
}
|
||||
if meta.Name != "DS:DOUBLE" {
|
||||
t.Errorf("Name = %q, want %q", meta.Name, "DS:DOUBLE")
|
||||
}
|
||||
if meta.Type != datasource.TypeFloat64 {
|
||||
t.Errorf("Type = %v, want TypeFloat64", meta.Type)
|
||||
}
|
||||
if !meta.Writable {
|
||||
t.Error("expected Writable=true")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPureGo_GetMetadataString(t *testing.T) {
|
||||
ds, _ := newTestDS(t, []testca.PVSpec{
|
||||
{Name: "DS:STR", DBFType: proto.DBFString, Count: 1, Value: "hello"},
|
||||
})
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
meta, err := ds.GetMetadata(ctx, "DS:STR")
|
||||
if err != nil {
|
||||
t.Fatalf("GetMetadata: %v", err)
|
||||
}
|
||||
if meta.Type != datasource.TypeString {
|
||||
t.Errorf("Type = %v, want TypeString", meta.Type)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPureGo_GetMetadataEnum(t *testing.T) {
|
||||
ds, _ := newTestDS(t, []testca.PVSpec{
|
||||
{Name: "DS:ENUM", DBFType: proto.DBFEnum, Count: 1, Value: int16(0)},
|
||||
})
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
meta, err := ds.GetMetadata(ctx, "DS:ENUM")
|
||||
if err != nil {
|
||||
t.Fatalf("GetMetadata: %v", err)
|
||||
}
|
||||
if meta.Type != datasource.TypeEnum {
|
||||
t.Errorf("Type = %v, want TypeEnum", meta.Type)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPureGo_GetMetadataReadOnly(t *testing.T) {
|
||||
ds, _ := newTestDS(t, []testca.PVSpec{
|
||||
{Name: "DS:RO", DBFType: proto.DBFDouble, Count: 1, Value: 0.0,
|
||||
Access: proto.AccessRead},
|
||||
})
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
meta, err := ds.GetMetadata(ctx, "DS:RO")
|
||||
if err != nil {
|
||||
t.Fatalf("GetMetadata: %v", err)
|
||||
}
|
||||
if meta.Writable {
|
||||
t.Error("expected Writable=false for read-only PV")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPureGo_GetMetadataCached(t *testing.T) {
|
||||
ds, _ := newTestDS(t, []testca.PVSpec{
|
||||
{Name: "DS:CACHED", DBFType: proto.DBFDouble, Count: 1, Value: 1.0},
|
||||
})
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
m1, err := ds.GetMetadata(ctx, "DS:CACHED")
|
||||
if err != nil {
|
||||
t.Fatalf("first GetMetadata: %v", err)
|
||||
}
|
||||
m2, err := ds.GetMetadata(ctx, "DS:CACHED")
|
||||
if err != nil {
|
||||
t.Fatalf("second GetMetadata: %v", err)
|
||||
}
|
||||
if m1.Name != m2.Name || m1.Type != m2.Type {
|
||||
t.Error("second GetMetadata returned different result from cache")
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------- //
|
||||
// Subscribe //
|
||||
// -------------------------------------------------------------------------- //
|
||||
|
||||
func TestPureGo_SubscribeInitialValue(t *testing.T) {
|
||||
ds, _ := newTestDS(t, []testca.PVSpec{
|
||||
{Name: "DS:SUB", DBFType: proto.DBFDouble, Count: 1, Value: 7.5},
|
||||
})
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
ch := make(chan datasource.Value, 16)
|
||||
unsub, err := ds.Subscribe(ctx, "DS:SUB", ch)
|
||||
if err != nil {
|
||||
t.Fatalf("Subscribe: %v", err)
|
||||
}
|
||||
defer unsub()
|
||||
|
||||
// Drain MetaUpdate and value frames — first numeric value wins.
|
||||
deadline := time.After(5 * time.Second)
|
||||
for {
|
||||
select {
|
||||
case v := <-ch:
|
||||
if v.MetaUpdate {
|
||||
continue
|
||||
}
|
||||
f, ok := v.Data.(float64)
|
||||
if !ok {
|
||||
t.Fatalf("Data type = %T, want float64", v.Data)
|
||||
}
|
||||
if math.Abs(f-7.5) > 1e-6 {
|
||||
t.Errorf("Data = %g, want 7.5", f)
|
||||
}
|
||||
return
|
||||
case <-deadline:
|
||||
t.Fatal("timeout waiting for initial value")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestPureGo_SubscribeUpdates(t *testing.T) {
|
||||
ds, srv := newTestDS(t, []testca.PVSpec{
|
||||
{Name: "DS:UPD", DBFType: proto.DBFDouble, Count: 1, Value: 1.0},
|
||||
})
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
ch := make(chan datasource.Value, 16)
|
||||
unsub, err := ds.Subscribe(ctx, "DS:UPD", ch)
|
||||
if err != nil {
|
||||
t.Fatalf("Subscribe: %v", err)
|
||||
}
|
||||
defer unsub()
|
||||
|
||||
// Drain initial frames.
|
||||
deadline := time.After(3 * time.Second)
|
||||
drainLoop:
|
||||
for {
|
||||
select {
|
||||
case v := <-ch:
|
||||
if !v.MetaUpdate {
|
||||
break drainLoop
|
||||
}
|
||||
case <-deadline:
|
||||
t.Fatal("timeout draining initial frames")
|
||||
}
|
||||
}
|
||||
|
||||
// Push new value via server.
|
||||
srv.SetValue("DS:UPD", 99.9)
|
||||
|
||||
deadline = time.After(3 * time.Second)
|
||||
for {
|
||||
select {
|
||||
case v := <-ch:
|
||||
if v.MetaUpdate {
|
||||
continue
|
||||
}
|
||||
f, ok := v.Data.(float64)
|
||||
if !ok {
|
||||
t.Fatalf("Data type = %T, want float64", v.Data)
|
||||
}
|
||||
if math.Abs(f-99.9) > 1e-6 {
|
||||
t.Errorf("Data = %g, want 99.9", f)
|
||||
}
|
||||
return
|
||||
case <-deadline:
|
||||
t.Fatal("timeout waiting for updated value")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------- //
|
||||
// Write //
|
||||
// -------------------------------------------------------------------------- //
|
||||
|
||||
func TestPureGo_WriteDouble(t *testing.T) {
|
||||
ds, _ := newTestDS(t, []testca.PVSpec{
|
||||
{Name: "DS:W", DBFType: proto.DBFDouble, Count: 1, Value: 0.0},
|
||||
})
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
ch := make(chan datasource.Value, 16)
|
||||
unsub, err := ds.Subscribe(ctx, "DS:W", ch)
|
||||
if err != nil {
|
||||
t.Fatalf("Subscribe: %v", err)
|
||||
}
|
||||
defer unsub()
|
||||
|
||||
// Drain initial.
|
||||
deadline := time.After(3 * time.Second)
|
||||
drainLoop:
|
||||
for {
|
||||
select {
|
||||
case v := <-ch:
|
||||
if !v.MetaUpdate {
|
||||
break drainLoop
|
||||
}
|
||||
case <-deadline:
|
||||
t.Fatal("timeout draining initial frames")
|
||||
}
|
||||
}
|
||||
|
||||
if err := ds.Write(ctx, "DS:W", float64(3.14)); err != nil {
|
||||
t.Fatalf("Write: %v", err)
|
||||
}
|
||||
|
||||
deadline = time.After(3 * time.Second)
|
||||
for {
|
||||
select {
|
||||
case v := <-ch:
|
||||
if v.MetaUpdate {
|
||||
continue
|
||||
}
|
||||
f, ok := v.Data.(float64)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if math.Abs(f-3.14) > 1e-6 {
|
||||
t.Errorf("post-write Data = %g, want 3.14", f)
|
||||
}
|
||||
return
|
||||
case <-deadline:
|
||||
t.Fatal("timeout waiting for post-write monitor update")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestPureGo_WriteReadOnly(t *testing.T) {
|
||||
ds, _ := newTestDS(t, []testca.PVSpec{
|
||||
{Name: "DS:WRO", DBFType: proto.DBFDouble, Count: 1, Value: 0.0,
|
||||
Access: proto.AccessRead},
|
||||
})
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
err := ds.Write(ctx, "DS:WRO", float64(1.0))
|
||||
if err == nil {
|
||||
t.Fatal("expected error writing to read-only PV")
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------- //
|
||||
// ListSignals //
|
||||
// -------------------------------------------------------------------------- //
|
||||
|
||||
func TestPureGo_ListSignals(t *testing.T) {
|
||||
ds, _ := newTestDS(t, []testca.PVSpec{
|
||||
{Name: "DS:LS1", DBFType: proto.DBFDouble, Count: 1, Value: 0.0},
|
||||
{Name: "DS:LS2", DBFType: proto.DBFDouble, Count: 1, Value: 0.0},
|
||||
})
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
// GetMetadata for each PV to populate the cache.
|
||||
for _, name := range []string{"DS:LS1", "DS:LS2"} {
|
||||
if _, err := ds.GetMetadata(ctx, name); err != nil {
|
||||
t.Fatalf("GetMetadata(%q): %v", name, err)
|
||||
}
|
||||
}
|
||||
|
||||
sigs, err := ds.ListSignals(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("ListSignals: %v", err)
|
||||
}
|
||||
if len(sigs) < 2 {
|
||||
t.Errorf("ListSignals returned %d signals, want ≥ 2", len(sigs))
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------- //
|
||||
// History //
|
||||
// -------------------------------------------------------------------------- //
|
||||
|
||||
func TestPureGo_HistoryUnavailable(t *testing.T) {
|
||||
ds, _ := newTestDS(t, []testca.PVSpec{
|
||||
{Name: "DS:HIST", DBFType: proto.DBFDouble, Count: 1, Value: 0.0},
|
||||
})
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
_, err := ds.History(ctx, "DS:HIST", time.Now().Add(-time.Hour), time.Now(), 100)
|
||||
if err != datasource.ErrHistoryUnavailable {
|
||||
t.Errorf("History with no archiveURL: want ErrHistoryUnavailable, got %v", err)
|
||||
}
|
||||
}
|
||||
@@ -1,17 +1,311 @@
|
||||
//go:build !epics
|
||||
|
||||
// Package epics provides an EPICS Channel Access data source.
|
||||
// When built without the "epics" build tag (i.e. without CGo and EPICS Base),
|
||||
// this stub is compiled instead, so that the rest of the codebase can call
|
||||
// epics.Available() to decide whether to register the data source.
|
||||
// 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 "github.com/uopi/uopi/internal/datasource"
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log/slog" //nolint:depguard
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
// Available reports whether the EPICS Channel Access data source is compiled in.
|
||||
// It always returns false when built without the "epics" build tag.
|
||||
func Available() bool { return false }
|
||||
ca "github.com/uopi/goca"
|
||||
"github.com/uopi/goca/proto"
|
||||
"github.com/uopi/uopi/internal/datasource"
|
||||
)
|
||||
|
||||
// New is a placeholder that returns nil when EPICS support is not compiled in.
|
||||
// Callers must check Available() before calling New().
|
||||
func New(caAddrList, archiveURL string) datasource.DataSource { return nil }
|
||||
// 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 {
|
||||
data = tv.Doubles
|
||||
} else if tv.Str != "" {
|
||||
data = tv.Str
|
||||
} else {
|
||||
// Use cached metadata type for correct int64 / float64 distinction.
|
||||
e.mu.RLock()
|
||||
meta, hasMeta := e.metadata[signal]
|
||||
e.mu.RUnlock()
|
||||
if hasMeta && (meta.Type == datasource.TypeInt64 || meta.Type == datasource.TypeEnum) {
|
||||
data = int64(tv.Long)
|
||||
} 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)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user