Initial go port of epics

This commit is contained in:
Martino Ferrari
2026-04-28 00:09:22 +02:00
parent 47e481a461
commit 6e51ffc5e1
28 changed files with 5634 additions and 178 deletions
+5 -4
View File
@@ -29,10 +29,11 @@ type StubConfig struct {
}
type EPICSConfig struct {
Enabled bool `toml:"enabled"`
CAAddrList string `toml:"ca_addr_list"`
ArchiveURL string `toml:"archive_url"`
ChannelFinderURL string `toml:"channel_finder_url"`
Enabled bool `toml:"enabled"`
CAAddrList string `toml:"ca_addr_list"`
ArchiveURL string `toml:"archive_url"`
ChannelFinderURL string `toml:"channel_finder_url"`
PVNames []string `toml:"pv_names"`
}
type SyntheticConfig struct {
+124
View File
@@ -0,0 +1,124 @@
package config_test
import (
"os"
"path/filepath"
"testing"
"github.com/uopi/uopi/internal/config"
)
func TestDefault(t *testing.T) {
cfg := config.Default()
if cfg.Server.Listen == "" {
t.Error("Default Listen must not be empty")
}
if cfg.Server.StorageDir == "" {
t.Error("Default StorageDir must not be empty")
}
if !cfg.Datasource.Stub.Enabled {
t.Error("Stub should be enabled by default")
}
if !cfg.Datasource.EPICS.Enabled {
t.Error("EPICS should be enabled by default")
}
if !cfg.Datasource.Synthetic.Enabled {
t.Error("Synthetic should be enabled by default")
}
}
func TestLoadEmptyPath(t *testing.T) {
// Empty path should return defaults without error.
cfg, err := config.Load("")
if err != nil {
t.Fatalf("Load with empty path: %v", err)
}
if cfg.Server.Listen != config.Default().Server.Listen {
t.Errorf("Listen = %q, want default %q", cfg.Server.Listen, config.Default().Server.Listen)
}
}
func TestLoadTOML(t *testing.T) {
toml := `
[server]
listen = ":9090"
storage_dir = "/tmp/uopi-test"
[datasource.stub]
enabled = false
[datasource.epics]
enabled = false
ca_addr_list = "192.168.1.1"
`
f := filepath.Join(t.TempDir(), "uopi.toml")
if err := os.WriteFile(f, []byte(toml), 0o644); err != nil {
t.Fatal(err)
}
cfg, err := config.Load(f)
if err != nil {
t.Fatalf("Load: %v", err)
}
if cfg.Server.Listen != ":9090" {
t.Errorf("Listen = %q, want :9090", cfg.Server.Listen)
}
if cfg.Server.StorageDir != "/tmp/uopi-test" {
t.Errorf("StorageDir = %q", cfg.Server.StorageDir)
}
if cfg.Datasource.Stub.Enabled {
t.Error("Stub.Enabled should be false")
}
if cfg.Datasource.EPICS.Enabled {
t.Error("EPICS.Enabled should be false")
}
if cfg.Datasource.EPICS.CAAddrList != "192.168.1.1" {
t.Errorf("CAAddrList = %q", cfg.Datasource.EPICS.CAAddrList)
}
}
func TestLoadBadPath(t *testing.T) {
_, err := config.Load("/no/such/file.toml")
if err == nil {
t.Error("expected error for missing file")
}
}
func TestEnvOverrides(t *testing.T) {
t.Setenv("UOPI_SERVER_LISTEN", ":7777")
t.Setenv("UOPI_SERVER_STORAGE_DIR", "/env/storage")
t.Setenv("UOPI_EPICS_CA_ADDR_LIST", "10.0.0.1 10.0.0.2")
t.Setenv("UOPI_EPICS_ARCHIVE_URL", "http://archive/")
t.Setenv("UOPI_EPICS_CHANNEL_FINDER_URL", "http://cf/")
cfg, err := config.Load("")
if err != nil {
t.Fatalf("Load: %v", err)
}
if cfg.Server.Listen != ":7777" {
t.Errorf("Listen = %q, want :7777", cfg.Server.Listen)
}
if cfg.Server.StorageDir != "/env/storage" {
t.Errorf("StorageDir = %q", cfg.Server.StorageDir)
}
if cfg.Datasource.EPICS.CAAddrList != "10.0.0.1 10.0.0.2" {
t.Errorf("CAAddrList = %q", cfg.Datasource.EPICS.CAAddrList)
}
if cfg.Datasource.EPICS.ArchiveURL != "http://archive/" {
t.Errorf("ArchiveURL = %q", cfg.Datasource.EPICS.ArchiveURL)
}
if cfg.Datasource.EPICS.ChannelFinderURL != "http://cf/" {
t.Errorf("ChannelFinderURL = %q", cfg.Datasource.EPICS.ChannelFinderURL)
}
}
func TestEnvTrimsWhitespace(t *testing.T) {
t.Setenv("UOPI_SERVER_LISTEN", " :8888 ")
cfg, err := config.Load("")
if err != nil {
t.Fatalf("Load: %v", err)
}
if cfg.Server.Listen != ":8888" {
t.Errorf("Listen = %q, expected whitespace trimmed", cfg.Server.Listen)
}
}
-2
View File
@@ -1,5 +1,3 @@
//go:build epics
package epics
import (
+3 -1
View File
@@ -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)
}
}
+305 -11
View File
@@ -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)
}
+210
View File
@@ -0,0 +1,210 @@
package stub_test
import (
"context"
"testing"
"time"
"github.com/uopi/uopi/internal/datasource"
"github.com/uopi/uopi/internal/datasource/stub"
)
func newStub(t *testing.T) *stub.Stub {
t.Helper()
s := stub.New()
if err := s.Connect(context.Background()); err != nil {
t.Fatalf("Connect: %v", err)
}
return s
}
func TestName(t *testing.T) {
if stub.New().Name() != "stub" {
t.Error("Name() should return 'stub'")
}
}
func TestListSignals(t *testing.T) {
s := newStub(t)
sigs, err := s.ListSignals(context.Background())
if err != nil {
t.Fatalf("ListSignals: %v", err)
}
if len(sigs) == 0 {
t.Fatal("expected at least one signal")
}
names := make(map[string]bool)
for _, m := range sigs {
names[m.Name] = true
}
for _, want := range []string{"sine_1hz", "ramp_10s", "toggle_1hz", "noise", "setpoint"} {
if !names[want] {
t.Errorf("signal %q not in ListSignals", want)
}
}
}
func TestGetMetadata(t *testing.T) {
s := newStub(t)
m, err := s.GetMetadata(context.Background(), "sine_1hz")
if err != nil {
t.Fatalf("GetMetadata: %v", err)
}
if m.Type != datasource.TypeFloat64 {
t.Errorf("Type = %v, want TypeFloat64", m.Type)
}
if m.Unit == "" {
t.Error("expected non-empty Unit")
}
}
func TestGetMetadataNotFound(t *testing.T) {
s := newStub(t)
_, err := s.GetMetadata(context.Background(), "no:such:pv")
if err != datasource.ErrNotFound {
t.Errorf("want ErrNotFound, got %v", err)
}
}
func TestSubscribeReceivesValues(t *testing.T) {
s := newStub(t)
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()
ch := make(chan datasource.Value, 4)
stop, err := s.Subscribe(ctx, "sine_1hz", ch)
if err != nil {
t.Fatalf("Subscribe: %v", err)
}
defer stop()
select {
case v := <-ch:
if v.Quality != datasource.QualityGood {
t.Errorf("Quality = %v, want Good", v.Quality)
}
if _, ok := v.Data.(float64); !ok {
t.Errorf("Data type = %T, want float64", v.Data)
}
if v.Timestamp.IsZero() {
t.Error("Timestamp is zero")
}
case <-ctx.Done():
t.Fatal("timeout waiting for first value")
}
}
func TestSubscribeUnknown(t *testing.T) {
s := newStub(t)
_, err := s.Subscribe(context.Background(), "no:such", make(chan datasource.Value, 1))
if err != datasource.ErrNotFound {
t.Errorf("want ErrNotFound, got %v", err)
}
}
func TestSubscribeCancel(t *testing.T) {
s := newStub(t)
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()
ch := make(chan datasource.Value, 4)
stop, err := s.Subscribe(ctx, "ramp_10s", ch)
if err != nil {
t.Fatalf("Subscribe: %v", err)
}
// Drain one value to confirm it's running.
select {
case <-ch:
case <-ctx.Done():
t.Fatal("timeout waiting for first value")
}
stop() // cancel subscription
// Drain remaining buffered values then confirm channel goes quiet.
time.Sleep(200 * time.Millisecond)
for len(ch) > 0 {
<-ch
}
time.Sleep(300 * time.Millisecond)
if len(ch) > 0 {
t.Error("channel still receiving after stop()")
}
}
func TestWriteSetpoint(t *testing.T) {
s := newStub(t)
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()
if err := s.Write(context.Background(), "setpoint", float64(99.0)); err != nil {
t.Fatalf("Write: %v", err)
}
ch := make(chan datasource.Value, 4)
stop, err := s.Subscribe(ctx, "setpoint", ch)
if err != nil {
t.Fatalf("Subscribe: %v", err)
}
defer stop()
select {
case v := <-ch:
f, ok := v.Data.(float64)
if !ok {
t.Fatalf("Data type = %T, want float64", v.Data)
}
if f != 99.0 {
t.Errorf("setpoint value = %g, want 99.0", f)
}
case <-ctx.Done():
t.Fatal("timeout waiting for setpoint value")
}
}
func TestWriteReadOnly(t *testing.T) {
s := newStub(t)
err := s.Write(context.Background(), "sine_1hz", float64(0))
if err != datasource.ErrNotWritable {
t.Errorf("want ErrNotWritable, got %v", err)
}
}
func TestWriteNotFound(t *testing.T) {
s := newStub(t)
err := s.Write(context.Background(), "no:such", float64(0))
if err != datasource.ErrNotFound {
t.Errorf("want ErrNotFound, got %v", err)
}
}
func TestHistory(t *testing.T) {
s := newStub(t)
_, err := s.History(context.Background(), "sine_1hz", time.Now().Add(-time.Hour), time.Now(), 100)
if err != datasource.ErrHistoryUnavailable {
t.Errorf("want ErrHistoryUnavailable, got %v", err)
}
}
func TestToggleBool(t *testing.T) {
s := newStub(t)
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()
ch := make(chan datasource.Value, 4)
stop, err := s.Subscribe(ctx, "toggle_1hz", ch)
if err != nil {
t.Fatalf("Subscribe: %v", err)
}
defer stop()
select {
case v := <-ch:
if _, ok := v.Data.(bool); !ok {
t.Errorf("toggle_1hz Data type = %T, want bool", v.Data)
}
case <-ctx.Done():
t.Fatal("timeout")
}
}
+69
View File
@@ -0,0 +1,69 @@
package metrics_test
import (
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/uopi/uopi/internal/metrics"
)
func TestCounters(t *testing.T) {
// Counters are package-level atomics; exercise each Inc function.
metrics.IncWsConns()
metrics.IncWsConns()
metrics.DecWsConns()
metrics.IncMsgIn()
metrics.IncMsgOut()
metrics.IncWrites()
metrics.IncHistoryReqs()
// No panic = pass; values are additive across tests but that's fine.
}
func TestHandlerFormat(t *testing.T) {
handler := metrics.Handler(func() int { return 42 })
req := httptest.NewRequest(http.MethodGet, "/metrics", nil)
rec := httptest.NewRecorder()
handler(rec, req)
body := rec.Body.String()
// Must contain all metric names.
required := []string{
"uopi_uptime_seconds",
"uopi_ws_connections",
"uopi_signal_subscriptions",
"uopi_ws_messages_in_total",
"uopi_ws_messages_out_total",
"uopi_write_ops_total",
"uopi_history_requests_total",
}
for _, name := range required {
if !strings.Contains(body, name) {
t.Errorf("metric %q not found in response body", name)
}
}
// Content-Type must be set for Prometheus scrapers.
ct := rec.Header().Get("Content-Type")
if !strings.Contains(ct, "text/plain") {
t.Errorf("Content-Type = %q, want text/plain", ct)
}
// activeSubs value 42 must appear.
if !strings.Contains(body, "42") {
t.Error("activeSubs value 42 not found in body")
}
}
func TestHandlerNilActiveSubs(t *testing.T) {
handler := metrics.Handler(nil)
req := httptest.NewRequest(http.MethodGet, "/metrics", nil)
rec := httptest.NewRecorder()
handler(rec, req) // must not panic
if rec.Code != http.StatusOK {
t.Errorf("status = %d, want 200", rec.Code)
}
}
+232
View File
@@ -0,0 +1,232 @@
package storage_test
import (
"errors"
"testing"
"github.com/uopi/uopi/internal/storage"
)
const sampleXML = `<interface id="test-if" name="Test Interface" version="1"><widget/></interface>`
func newStore(t *testing.T) *storage.Store {
t.Helper()
s, err := storage.New(t.TempDir())
if err != nil {
t.Fatalf("New: %v", err)
}
return s
}
// -------------------------------------------------------------------------- //
// List //
// -------------------------------------------------------------------------- //
func TestListEmpty(t *testing.T) {
s := newStore(t)
list, err := s.List()
if err != nil {
t.Fatalf("List: %v", err)
}
if len(list) != 0 {
t.Errorf("expected empty list, got %d items", len(list))
}
}
func TestListAfterCreate(t *testing.T) {
s := newStore(t)
if _, err := s.Create([]byte(sampleXML)); err != nil {
t.Fatalf("Create: %v", err)
}
list, err := s.List()
if err != nil {
t.Fatalf("List: %v", err)
}
if len(list) != 1 {
t.Fatalf("expected 1 item, got %d", len(list))
}
if list[0].Name != "Test Interface" {
t.Errorf("Name = %q, want %q", list[0].Name, "Test Interface")
}
if list[0].Version != 1 {
t.Errorf("Version = %d, want 1", list[0].Version)
}
}
// -------------------------------------------------------------------------- //
// Create + Get //
// -------------------------------------------------------------------------- //
func TestCreateAndGet(t *testing.T) {
s := newStore(t)
id, err := s.Create([]byte(sampleXML))
if err != nil {
t.Fatalf("Create: %v", err)
}
if id == "" {
t.Fatal("Create returned empty ID")
}
data, err := s.Get(id)
if err != nil {
t.Fatalf("Get(%q): %v", id, err)
}
if string(data) != sampleXML {
t.Errorf("Get returned %q, want %q", data, sampleXML)
}
}
func TestCreateUsesEmbeddedID(t *testing.T) {
s := newStore(t)
id, err := s.Create([]byte(sampleXML))
if err != nil {
t.Fatalf("Create: %v", err)
}
// ID derived from xml id attr or name slug.
if id == "" {
t.Error("ID should not be empty")
}
}
func TestCreateDuplicateGetsUniqueID(t *testing.T) {
s := newStore(t)
id1, err := s.Create([]byte(sampleXML))
if err != nil {
t.Fatalf("first Create: %v", err)
}
id2, err := s.Create([]byte(sampleXML))
if err != nil {
t.Fatalf("second Create: %v", err)
}
if id1 == id2 {
t.Errorf("duplicate Create returned same ID %q", id1)
}
}
func TestCreateInvalidXML(t *testing.T) {
s := newStore(t)
_, err := s.Create([]byte("not xml"))
if err == nil {
t.Error("expected error for invalid XML")
}
}
// -------------------------------------------------------------------------- //
// Get errors //
// -------------------------------------------------------------------------- //
func TestGetNotFound(t *testing.T) {
s := newStore(t)
_, err := s.Get("does-not-exist")
if !errors.Is(err, storage.ErrNotFound) {
t.Errorf("Get missing: want ErrNotFound, got %v", err)
}
}
func TestGetInvalidID(t *testing.T) {
s := newStore(t)
_, err := s.Get("../traversal")
if !errors.Is(err, storage.ErrNotFound) {
t.Errorf("Get path-traversal ID: want ErrNotFound, got %v", err)
}
}
func TestGetEmptyID(t *testing.T) {
s := newStore(t)
_, err := s.Get("")
if !errors.Is(err, storage.ErrNotFound) {
t.Errorf("Get empty ID: want ErrNotFound, got %v", err)
}
}
// -------------------------------------------------------------------------- //
// Update //
// -------------------------------------------------------------------------- //
func TestUpdate(t *testing.T) {
s := newStore(t)
id, err := s.Create([]byte(sampleXML))
if err != nil {
t.Fatalf("Create: %v", err)
}
updated := `<interface id="test-if" name="Updated" version="2"><widget/></interface>`
if err := s.Update(id, []byte(updated)); err != nil {
t.Fatalf("Update: %v", err)
}
data, err := s.Get(id)
if err != nil {
t.Fatalf("Get after update: %v", err)
}
if string(data) != updated {
t.Errorf("post-update content mismatch")
}
}
func TestUpdateNotFound(t *testing.T) {
s := newStore(t)
err := s.Update("no-such-id", []byte(sampleXML))
if !errors.Is(err, storage.ErrNotFound) {
t.Errorf("Update missing: want ErrNotFound, got %v", err)
}
}
// -------------------------------------------------------------------------- //
// Delete //
// -------------------------------------------------------------------------- //
func TestDelete(t *testing.T) {
s := newStore(t)
id, err := s.Create([]byte(sampleXML))
if err != nil {
t.Fatalf("Create: %v", err)
}
if err := s.Delete(id); err != nil {
t.Fatalf("Delete: %v", err)
}
_, err = s.Get(id)
if !errors.Is(err, storage.ErrNotFound) {
t.Errorf("Get after delete: want ErrNotFound, got %v", err)
}
}
func TestDeleteNotFound(t *testing.T) {
s := newStore(t)
err := s.Delete("ghost")
if !errors.Is(err, storage.ErrNotFound) {
t.Errorf("Delete missing: want ErrNotFound, got %v", err)
}
}
// -------------------------------------------------------------------------- //
// Clone //
// -------------------------------------------------------------------------- //
func TestClone(t *testing.T) {
s := newStore(t)
id, err := s.Create([]byte(sampleXML))
if err != nil {
t.Fatalf("Create: %v", err)
}
newID, err := s.Clone(id)
if err != nil {
t.Fatalf("Clone: %v", err)
}
if newID == id {
t.Error("Clone should produce a different ID")
}
orig, _ := s.Get(id)
copy, _ := s.Get(newID)
if string(orig) != string(copy) {
t.Error("Clone content should match original")
}
}
func TestCloneNotFound(t *testing.T) {
s := newStore(t)
_, err := s.Clone("ghost")
if !errors.Is(err, storage.ErrNotFound) {
t.Errorf("Clone missing: want ErrNotFound, got %v", err)
}
}