Initial working fully go release
This commit is contained in:
@@ -21,6 +21,7 @@ type ServerConfig struct {
|
||||
type DatasourceConfig struct {
|
||||
Stub StubConfig `toml:"stub"`
|
||||
EPICS EPICSConfig `toml:"epics"`
|
||||
PVA PVAConfig `toml:"pva"`
|
||||
Synthetic SyntheticConfig `toml:"synthetic"`
|
||||
}
|
||||
|
||||
@@ -36,6 +37,11 @@ type EPICSConfig struct {
|
||||
PVNames []string `toml:"pv_names"`
|
||||
}
|
||||
|
||||
type PVAConfig struct {
|
||||
Enabled bool `toml:"enabled"`
|
||||
AddrList []string `toml:"addr_list"`
|
||||
}
|
||||
|
||||
type SyntheticConfig struct {
|
||||
Enabled bool `toml:"enabled"`
|
||||
}
|
||||
@@ -49,6 +55,7 @@ func Default() Config {
|
||||
Datasource: DatasourceConfig{
|
||||
Stub: StubConfig{Enabled: true},
|
||||
EPICS: EPICSConfig{Enabled: true},
|
||||
PVA: PVAConfig{Enabled: true},
|
||||
Synthetic: SyntheticConfig{Enabled: true},
|
||||
},
|
||||
}
|
||||
@@ -86,6 +93,9 @@ func applyEnv(cfg *Config) {
|
||||
if v := env("UOPI_EPICS_CHANNEL_FINDER_URL"); v != "" {
|
||||
cfg.Datasource.EPICS.ChannelFinderURL = v
|
||||
}
|
||||
if v := env("EPICS_PVA_ADDR_LIST"); v != "" {
|
||||
cfg.Datasource.PVA.AddrList = strings.Fields(v)
|
||||
}
|
||||
}
|
||||
|
||||
func env(key string) string {
|
||||
|
||||
@@ -169,19 +169,15 @@ func (e *EPICS) Subscribe(ctx context.Context, signal string, ch chan<- datasour
|
||||
func (e *EPICS) timeValueToDS(signal string, tv proto.TimeValue) datasource.Value {
|
||||
var data any
|
||||
if tv.Doubles != nil {
|
||||
data = tv.Doubles
|
||||
if len(tv.Doubles) == 1 {
|
||||
data = tv.Doubles[0]
|
||||
} else {
|
||||
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
|
||||
}
|
||||
data = tv.Double
|
||||
}
|
||||
|
||||
quality := datasource.QualityGood
|
||||
|
||||
@@ -0,0 +1,365 @@
|
||||
// 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
|
||||
}
|
||||
Reference in New Issue
Block a user