366 lines
9.4 KiB
Go
366 lines
9.4 KiB
Go
// Package pva provides an EPICS PV Access data source for uopi using the
|
|
// pure-Go gopva library.
|
|
package pva
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"log/slog"
|
|
"os"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
gopva "github.com/uopi/gopva"
|
|
"github.com/uopi/gopva/pvdata"
|
|
"github.com/uopi/uopi/internal/datasource"
|
|
)
|
|
|
|
// PVA is the PV Access data source.
|
|
type PVA struct {
|
|
client *gopva.Client
|
|
|
|
mu sync.RWMutex
|
|
metadata map[string]datasource.Metadata
|
|
}
|
|
|
|
// New creates a new PV Access data source.
|
|
// Server addresses are read from the EPICS_PVA_ADDR_LIST environment variable;
|
|
// addrList overrides that when non-empty.
|
|
func New(addrList []string) datasource.DataSource {
|
|
if len(addrList) > 0 {
|
|
// Inject addresses into the environment so gopva.NewClient picks them up.
|
|
_ = os.Setenv("EPICS_PVA_ADDR_LIST", strings.Join(addrList, " "))
|
|
}
|
|
return &PVA{
|
|
metadata: make(map[string]datasource.Metadata),
|
|
}
|
|
}
|
|
|
|
// Name implements datasource.DataSource.
|
|
func (p *PVA) Name() string { return "pva" }
|
|
|
|
// Connect creates the PVA client.
|
|
func (p *PVA) Connect(_ context.Context) error {
|
|
cl, err := gopva.NewClient()
|
|
if err != nil {
|
|
return fmt.Errorf("pva: connect: %w", err)
|
|
}
|
|
p.client = cl
|
|
slog.Info("pva: client started")
|
|
return nil
|
|
}
|
|
|
|
// -------------------------------------------------------------------------- //
|
|
// Subscribe //
|
|
// -------------------------------------------------------------------------- //
|
|
|
|
// Subscribe registers ch to receive live PVA value updates for signal.
|
|
func (p *PVA) Subscribe(ctx context.Context, signal string, ch chan<- datasource.Value) (datasource.CancelFunc, error) {
|
|
slog.Info("pva: subscribing", "pv", signal)
|
|
|
|
monCh := p.client.Monitor(ctx, signal)
|
|
|
|
// Wait for the first event to confirm the subscription is live.
|
|
// We do this in the goroutine to avoid blocking the caller.
|
|
done := make(chan struct{})
|
|
go func() {
|
|
defer close(done)
|
|
for evt := range monCh {
|
|
if evt.Err != nil {
|
|
slog.Error("pva: monitor error", "pv", signal, "err", evt.Err)
|
|
select {
|
|
case ch <- datasource.Value{
|
|
Timestamp: time.Now(),
|
|
Data: nil,
|
|
Quality: datasource.QualityBad,
|
|
}:
|
|
default:
|
|
}
|
|
return
|
|
}
|
|
// Cache metadata from the first value.
|
|
p.updateMetadata(signal, evt.Value)
|
|
|
|
val := structToValue(evt.Value)
|
|
select {
|
|
case ch <- val:
|
|
default:
|
|
}
|
|
}
|
|
}()
|
|
|
|
cancel := func() {
|
|
// cancelling the context passed to Monitor stops it; the channel will be closed.
|
|
// The goroutine will exit when monCh is closed.
|
|
<-done
|
|
}
|
|
return datasource.CancelFunc(cancel), nil
|
|
}
|
|
|
|
// -------------------------------------------------------------------------- //
|
|
// GetMetadata //
|
|
// -------------------------------------------------------------------------- //
|
|
|
|
// GetMetadata returns metadata for signal. It performs a one-shot GET if
|
|
// metadata has not been cached yet.
|
|
func (p *PVA) GetMetadata(ctx context.Context, signal string) (datasource.Metadata, error) {
|
|
p.mu.RLock()
|
|
if m, ok := p.metadata[signal]; ok {
|
|
p.mu.RUnlock()
|
|
return m, nil
|
|
}
|
|
p.mu.RUnlock()
|
|
|
|
tctx, cancel := context.WithTimeout(ctx, 15*time.Second)
|
|
defer cancel()
|
|
|
|
sv, err := p.client.Get(tctx, signal)
|
|
if err != nil {
|
|
return datasource.Metadata{}, fmt.Errorf("pva: GetMetadata %q: %w", signal, err)
|
|
}
|
|
|
|
p.updateMetadata(signal, sv)
|
|
|
|
p.mu.RLock()
|
|
m := p.metadata[signal]
|
|
p.mu.RUnlock()
|
|
return m, nil
|
|
}
|
|
|
|
// updateMetadata derives and caches metadata from a StructValue.
|
|
func (p *PVA) updateMetadata(signal string, sv pvdata.StructValue) {
|
|
m := structToMeta(signal, sv)
|
|
p.mu.Lock()
|
|
p.metadata[signal] = m
|
|
p.mu.Unlock()
|
|
}
|
|
|
|
// -------------------------------------------------------------------------- //
|
|
// ListSignals //
|
|
// -------------------------------------------------------------------------- //
|
|
|
|
// ListSignals returns metadata for all signals with cached information.
|
|
func (p *PVA) ListSignals(_ context.Context) ([]datasource.Metadata, error) {
|
|
p.mu.RLock()
|
|
defer p.mu.RUnlock()
|
|
out := make([]datasource.Metadata, 0, len(p.metadata))
|
|
for _, m := range p.metadata {
|
|
out = append(out, m)
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
// -------------------------------------------------------------------------- //
|
|
// Write //
|
|
// -------------------------------------------------------------------------- //
|
|
|
|
// Write is not yet implemented for PVA.
|
|
func (p *PVA) Write(_ context.Context, _ string, _ any) error {
|
|
return fmt.Errorf("pva: write not yet implemented")
|
|
}
|
|
|
|
// -------------------------------------------------------------------------- //
|
|
// History //
|
|
// -------------------------------------------------------------------------- //
|
|
|
|
// History is not supported for PVA sources.
|
|
func (p *PVA) History(_ context.Context, _ string, _, _ time.Time, _ int) ([]datasource.Value, error) {
|
|
return nil, datasource.ErrHistoryUnavailable
|
|
}
|
|
|
|
// -------------------------------------------------------------------------- //
|
|
// Helpers //
|
|
// -------------------------------------------------------------------------- //
|
|
|
|
// structToValue converts a PVA StructValue to a datasource.Value.
|
|
// It follows NTScalar/NTScalarArray conventions: looks for a "value" field
|
|
// and an optional "alarm" struct for quality, and "timeStamp" for the timestamp.
|
|
func structToValue(sv pvdata.StructValue) datasource.Value {
|
|
val := fieldByName(sv, "value")
|
|
ts := extractTimestamp(sv)
|
|
quality := extractQuality(sv)
|
|
|
|
var data any
|
|
if val != nil {
|
|
data = normaliseValue(val)
|
|
}
|
|
|
|
return datasource.Value{
|
|
Timestamp: ts,
|
|
Data: data,
|
|
Quality: quality,
|
|
}
|
|
}
|
|
|
|
// structToMeta derives datasource.Metadata from a PVA StructValue.
|
|
func structToMeta(name string, sv pvdata.StructValue) datasource.Metadata {
|
|
m := datasource.Metadata{
|
|
Name: name,
|
|
Writable: true, // PVA channels are assumed writable until proven otherwise
|
|
}
|
|
|
|
val := fieldByName(sv, "value")
|
|
if val == nil && len(sv.Fields) > 0 {
|
|
val = sv.Fields[0].Value
|
|
}
|
|
|
|
if val != nil {
|
|
switch val.(type) {
|
|
case float64, float32:
|
|
m.Type = datasource.TypeFloat64
|
|
case int64, int32, int16, int8, uint64, uint32, uint16, uint8:
|
|
m.Type = datasource.TypeInt64
|
|
case string:
|
|
m.Type = datasource.TypeString
|
|
case bool:
|
|
m.Type = datasource.TypeBool
|
|
case []float64, []float32:
|
|
m.Type = datasource.TypeFloat64Array
|
|
default:
|
|
m.Type = datasource.TypeFloat64
|
|
}
|
|
}
|
|
|
|
// Extract display limits from NTScalar display structure.
|
|
if disp := structByName(sv, "display"); disp != nil {
|
|
if v := fieldByName(*disp, "limitLow"); v != nil {
|
|
m.DisplayLow, _ = toFloat64(v)
|
|
}
|
|
if v := fieldByName(*disp, "limitHigh"); v != nil {
|
|
m.DisplayHigh, _ = toFloat64(v)
|
|
}
|
|
if v := fieldByName(*disp, "units"); v != nil {
|
|
m.Unit, _ = v.(string)
|
|
}
|
|
}
|
|
|
|
// Extract control limits from NTScalar control structure.
|
|
if ctrl := structByName(sv, "control"); ctrl != nil {
|
|
if v := fieldByName(*ctrl, "limitLow"); v != nil {
|
|
m.DriveLow, _ = toFloat64(v)
|
|
}
|
|
if v := fieldByName(*ctrl, "limitHigh"); v != nil {
|
|
m.DriveHigh, _ = toFloat64(v)
|
|
}
|
|
}
|
|
|
|
return m
|
|
}
|
|
|
|
// normaliseValue converts any PVA scalar/array value to datasource-compatible types.
|
|
func normaliseValue(v any) any {
|
|
switch x := v.(type) {
|
|
case float64:
|
|
return x
|
|
case float32:
|
|
return float64(x)
|
|
case int64:
|
|
return x
|
|
case int32:
|
|
return int64(x)
|
|
case int16:
|
|
return int64(x)
|
|
case int8:
|
|
return int64(x)
|
|
case uint64:
|
|
return int64(x)
|
|
case uint32:
|
|
return int64(x)
|
|
case uint16:
|
|
return int64(x)
|
|
case uint8:
|
|
return int64(x)
|
|
case bool:
|
|
return x
|
|
case string:
|
|
return x
|
|
case []float64:
|
|
return x
|
|
case []float32:
|
|
out := make([]float64, len(x))
|
|
for i, f := range x {
|
|
out[i] = float64(f)
|
|
}
|
|
return out
|
|
default:
|
|
return fmt.Sprintf("%v", v)
|
|
}
|
|
}
|
|
|
|
func extractTimestamp(sv pvdata.StructValue) time.Time {
|
|
ts := structByName(sv, "timeStamp")
|
|
if ts == nil {
|
|
return time.Now()
|
|
}
|
|
sec := int64(0)
|
|
nsec := int32(0)
|
|
if v := fieldByName(*ts, "secondsPastEpoch"); v != nil {
|
|
sec, _ = v.(int64)
|
|
}
|
|
if v := fieldByName(*ts, "nanoseconds"); v != nil {
|
|
nsec, _ = v.(int32)
|
|
}
|
|
if sec == 0 {
|
|
return time.Now()
|
|
}
|
|
// EPICS epoch: 1990-01-01 00:00:00 UTC = Unix 631152000
|
|
const epicsEpoch = 631152000
|
|
return time.Unix(sec+epicsEpoch, int64(nsec)).UTC()
|
|
}
|
|
|
|
func extractQuality(sv pvdata.StructValue) datasource.Quality {
|
|
alarm := structByName(sv, "alarm")
|
|
if alarm == nil {
|
|
return datasource.QualityGood
|
|
}
|
|
sev := fieldByName(*alarm, "severity")
|
|
if sev == nil {
|
|
return datasource.QualityGood
|
|
}
|
|
s, _ := sev.(int32)
|
|
switch s {
|
|
case 1:
|
|
return datasource.QualityUncertain
|
|
case 2, 3:
|
|
return datasource.QualityBad
|
|
default:
|
|
return datasource.QualityGood
|
|
}
|
|
}
|
|
|
|
func toFloat64(v any) (float64, bool) {
|
|
switch x := v.(type) {
|
|
case float64:
|
|
return x, true
|
|
case float32:
|
|
return float64(x), true
|
|
case int64:
|
|
return float64(x), true
|
|
case int32:
|
|
return float64(x), true
|
|
default:
|
|
return 0, false
|
|
}
|
|
}
|
|
|
|
func fieldByName(sv pvdata.StructValue, name string) any {
|
|
for _, f := range sv.Fields {
|
|
if f.Name == name {
|
|
return f.Value
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func structByName(sv pvdata.StructValue, name string) *pvdata.StructValue {
|
|
for _, f := range sv.Fields {
|
|
if f.Name == name {
|
|
if s, ok := f.Value.(pvdata.StructValue); ok {
|
|
return &s
|
|
}
|
|
}
|
|
}
|
|
return nil
|
|
}
|