Files
uopi/internal/datasource/epics/epics.go
T
2026-04-24 15:46:04 +02:00

459 lines
12 KiB
Go

//go:build epics
// Package epics provides an EPICS Channel Access data source for uopi.
//
// # Build requirements
//
// This file and all other files tagged "epics" require CGo and a working EPICS
// Base installation. Set the following environment variables before building:
//
// export EPICS_BASE=/path/to/epics/base
// export CGO_CFLAGS="-I${EPICS_BASE}/include -I${EPICS_BASE}/include/os/Linux"
// export CGO_LDFLAGS="-L${EPICS_BASE}/lib/linux-x86_64 -lca -lCom"
//
// Then build with:
//
// CGO_ENABLED=1 go build -tags epics ./...
package epics
/*
#cgo CFLAGS: -Wall
#include "ca_wrapper.h"
#include <stdlib.h>
*/
import "C"
import (
"context"
"fmt"
"sync"
"sync/atomic"
"time"
"unsafe"
"github.com/uopi/uopi/internal/datasource"
)
// connEntry holds the one-shot channel used during channel creation to wait for
// the initial connection callback.
type connEntry struct {
connCh chan struct{}
}
// handleTable maps Go-side handle IDs to the subscriber's value channel.
// Protected by handleMu.
var (
handleMu sync.Mutex
handleTable = make(map[uintptr]chan<- datasource.Value)
connTable = make(map[uintptr]*connEntry)
)
// handleSeq is an atomically-incremented counter used to generate unique handle
// IDs that are passed through CA as void* user pointers.
var handleSeq atomic.Uintptr
func nextHandle() uintptr {
return handleSeq.Add(1)
}
// caChannel holds the CA resources for a single PV.
type caChannel struct {
chid C.chid
evid C.evid // monitor event ID; zero if no monitor installed
handle uintptr
}
// EPICS is the Channel Access data source.
type EPICS struct {
caAddrList string
archiveURL string
mu sync.Mutex
channels map[string]*caChannel // PV name → CA channel
metadata map[string]datasource.Metadata
}
// New creates a new EPICS Channel Access data source.
//
// 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 {
return &EPICS{
caAddrList: caAddrList,
archiveURL: archiveURL,
channels: make(map[string]*caChannel),
metadata: make(map[string]datasource.Metadata),
}
}
// Available reports whether the EPICS data source is compiled in.
// Always returns true when built with the "epics" build tag.
func Available() bool { return true }
// Name implements datasource.DataSource.
func (e *EPICS) Name() string { return "epics" }
// Connect initialises a CA context with preemptive callbacks so that monitor
// callbacks are delivered on CA's background threads without needing a
// ca_pend_event polling loop.
func (e *EPICS) Connect(_ context.Context) error {
// Optionally override EPICS_CA_ADDR_LIST at runtime.
if e.caAddrList != "" {
cs := C.CString(e.caAddrList)
defer C.free(unsafe.Pointer(cs))
// ca_setenv is not available in all EPICS versions; use putenv via C.
envStr := C.CString("EPICS_CA_ADDR_LIST=" + e.caAddrList)
defer C.free(unsafe.Pointer(envStr))
C.putenv(envStr)
}
status := C.ca_context_create(C.ca_enable_preemptive_callback)
if status != C.ECA_NORMAL {
return fmt.Errorf("epics: ca_context_create failed: status %d", int(status))
}
return nil
}
// Subscribe connects to the named PV (if not already connected), installs a
// monitor, and streams value updates into ch. The returned CancelFunc removes
// the monitor and clears the channel.
func (e *EPICS) Subscribe(ctx context.Context, signal string, ch chan<- datasource.Value) (datasource.CancelFunc, error) {
handle := nextHandle()
// Register the handle → channel mapping before creating the CA channel so
// that any early callbacks are not lost.
handleMu.Lock()
handleTable[handle] = ch
entry := &connEntry{connCh: make(chan struct{}, 1)}
connTable[handle] = entry
handleMu.Unlock()
// Create the CA channel.
pvName := C.CString(signal)
defer C.free(unsafe.Pointer(pvName))
var chid C.chid
status := C.caCreateChannel(pvName, C.uintptr_t(handle), &chid)
if status != C.ECA_NORMAL {
handleMu.Lock()
delete(handleTable, handle)
delete(connTable, handle)
handleMu.Unlock()
return nil, fmt.Errorf("epics: ca_create_channel(%q) failed: status %d", signal, int(status))
}
// Flush the create request and wait for the connection callback.
C.ca_flush_io()
select {
case <-entry.connCh:
// Connected.
case <-time.After(5 * time.Second):
// Timeout; clean up and report error.
C.ca_clear_channel(chid)
handleMu.Lock()
delete(handleTable, handle)
delete(connTable, handle)
handleMu.Unlock()
return nil, fmt.Errorf("epics: timeout waiting for channel %q to connect", signal)
case <-ctx.Done():
C.ca_clear_channel(chid)
handleMu.Lock()
delete(handleTable, handle)
delete(connTable, handle)
handleMu.Unlock()
return nil, ctx.Err()
}
// Determine the native DBR_TIME_* type for the channel.
dbrType := nativeTimeType(chid)
// Add the value monitor.
var evid C.evid
status = C.caAddMonitor(chid, C.short(dbrType), C.uintptr_t(handle), &evid)
if status != C.ECA_NORMAL {
C.ca_clear_channel(chid)
handleMu.Lock()
delete(handleTable, handle)
delete(connTable, handle)
handleMu.Unlock()
return nil, fmt.Errorf("epics: ca_add_event(%q) failed: status %d", signal, int(status))
}
C.ca_flush_io()
caCh := &caChannel{chid: chid, evid: evid, handle: handle}
e.mu.Lock()
e.channels[signal] = caCh
e.mu.Unlock()
cancel := datasource.CancelFunc(func() {
if evid != nil {
C.ca_clear_event(evid)
}
C.ca_clear_channel(chid)
C.ca_flush_io()
handleMu.Lock()
delete(handleTable, handle)
delete(connTable, handle)
handleMu.Unlock()
e.mu.Lock()
delete(e.channels, signal)
e.mu.Unlock()
})
return cancel, nil
}
// GetMetadata performs a synchronous ca_get for DBR_CTRL_DOUBLE (or the
// appropriate control type) to retrieve units, display limits, enum strings,
// and writability information for the named signal.
func (e *EPICS) GetMetadata(ctx context.Context, signal string) (datasource.Metadata, error) {
// Check cache first.
e.mu.Lock()
if m, ok := e.metadata[signal]; ok {
e.mu.Unlock()
return m, nil
}
e.mu.Unlock()
// We need a connected channel to issue a ca_get. Reuse an existing one or
// create a temporary channel.
e.mu.Lock()
existingCh, exists := e.channels[signal]
e.mu.Unlock()
var chid C.chid
tempChannel := false
if exists {
chid = existingCh.chid
} else {
// Create a temporary channel for metadata retrieval.
handle := nextHandle()
entry := &connEntry{connCh: make(chan struct{}, 1)}
handleMu.Lock()
connTable[handle] = entry
handleMu.Unlock()
pvName := C.CString(signal)
defer C.free(unsafe.Pointer(pvName))
status := C.caCreateChannel(pvName, C.uintptr_t(handle), &chid)
if status != C.ECA_NORMAL {
handleMu.Lock()
delete(connTable, handle)
handleMu.Unlock()
return datasource.Metadata{}, fmt.Errorf("epics: ca_create_channel(%q) failed: status %d", signal, int(status))
}
C.ca_flush_io()
select {
case <-entry.connCh:
case <-time.After(5 * time.Second):
C.ca_clear_channel(chid)
handleMu.Lock()
delete(connTable, handle)
handleMu.Unlock()
return datasource.Metadata{}, fmt.Errorf("epics: timeout connecting to %q for metadata", signal)
case <-ctx.Done():
C.ca_clear_channel(chid)
handleMu.Lock()
delete(connTable, handle)
handleMu.Unlock()
return datasource.Metadata{}, ctx.Err()
}
handleMu.Lock()
delete(connTable, handle)
handleMu.Unlock()
tempChannel = true
}
meta := e.fetchMetadata(chid, signal)
if tempChannel {
C.ca_clear_channel(chid)
C.ca_flush_io()
}
e.mu.Lock()
e.metadata[signal] = meta
e.mu.Unlock()
return meta, nil
}
// fetchMetadata retrieves metadata from a connected chid using ca_get.
func (e *EPICS) fetchMetadata(chid C.chid, name string) datasource.Metadata {
meta := datasource.Metadata{Name: name}
fieldType := C.ca_field_type(chid)
count := C.ca_element_count(chid)
// Determine the Go DataType and the DBR_CTRL type to request.
var ctrlType C.chtype
switch int(fieldType) {
case int(C.DBF_DOUBLE), int(C.DBF_FLOAT):
if count > 1 {
meta.Type = datasource.TypeFloat64Array
} else {
meta.Type = datasource.TypeFloat64
}
ctrlType = C.DBR_CTRL_DOUBLE
case int(C.DBF_LONG), int(C.DBF_SHORT), int(C.DBF_CHAR):
meta.Type = datasource.TypeInt64
ctrlType = C.DBR_CTRL_LONG
case int(C.DBF_ENUM):
meta.Type = datasource.TypeEnum
ctrlType = C.DBR_CTRL_ENUM
case int(C.DBF_STRING):
meta.Type = datasource.TypeString
ctrlType = C.DBR_CTRL_STRING
default:
meta.Type = datasource.TypeFloat64
ctrlType = C.DBR_CTRL_DOUBLE
}
// Perform a synchronous ca_get with a 3-second timeout.
switch ctrlType {
case C.DBR_CTRL_DOUBLE:
var buf C.struct_dbr_ctrl_double
status := C.ca_get(C.DBR_CTRL_DOUBLE, chid, unsafe.Pointer(&buf))
if status == C.ECA_NORMAL {
C.ca_pend_io(3.0)
meta.Unit = C.GoString((*C.char)(unsafe.Pointer(&buf.units[0])))
meta.DisplayLow = float64(buf.lower_disp_limit)
meta.DisplayHigh = float64(buf.upper_disp_limit)
meta.DriveLow = float64(buf.lower_ctrl_limit)
meta.DriveHigh = float64(buf.upper_ctrl_limit)
// CA does not expose a writable flag directly; assume writable unless
// it is a read-only field type. Conservatively mark as writable.
meta.Writable = true
}
case C.DBR_CTRL_LONG:
var buf C.struct_dbr_ctrl_long
status := C.ca_get(C.DBR_CTRL_LONG, chid, unsafe.Pointer(&buf))
if status == C.ECA_NORMAL {
C.ca_pend_io(3.0)
meta.Unit = C.GoString((*C.char)(unsafe.Pointer(&buf.units[0])))
meta.DisplayLow = float64(buf.lower_disp_limit)
meta.DisplayHigh = float64(buf.upper_disp_limit)
meta.DriveLow = float64(buf.lower_ctrl_limit)
meta.DriveHigh = float64(buf.upper_ctrl_limit)
meta.Writable = true
}
case C.DBR_CTRL_ENUM:
var buf C.struct_dbr_ctrl_enum
status := C.ca_get(C.DBR_CTRL_ENUM, chid, unsafe.Pointer(&buf))
if status == C.ECA_NORMAL {
C.ca_pend_io(3.0)
n := int(buf.no_str)
strs := make([]string, n)
for i := 0; i < n; i++ {
strs[i] = C.GoString((*C.char)(unsafe.Pointer(&buf.strs[i][0])))
}
meta.EnumStrings = strs
meta.Writable = true
}
}
return meta
}
// ListSignals returns cached metadata for all currently connected channels.
// Full enumeration of all available PVs is deferred to Phase 9.
func (e *EPICS) ListSignals(_ context.Context) ([]datasource.Metadata, error) {
e.mu.Lock()
defer e.mu.Unlock()
out := make([]datasource.Metadata, 0, len(e.metadata))
for _, m := range e.metadata {
out = append(out, m)
}
return out, nil
}
// Write puts a new value onto a CA channel.
func (e *EPICS) Write(_ context.Context, signal string, value any) error {
e.mu.Lock()
caCh, ok := e.channels[signal]
e.mu.Unlock()
if !ok {
return datasource.ErrNotFound
}
var status C.int
switch v := value.(type) {
case float64:
cv := C.double(v)
status = C.ca_put(C.DBR_DOUBLE, caCh.chid, unsafe.Pointer(&cv))
case int64:
cv := C.long(v)
status = C.ca_put(C.DBR_LONG, caCh.chid, unsafe.Pointer(&cv))
case string:
cs := C.CString(v)
defer C.free(unsafe.Pointer(cs))
status = C.ca_put(C.DBR_STRING, caCh.chid, unsafe.Pointer(cs))
case bool:
var iv C.short
if v {
iv = 1
}
status = C.ca_put(C.DBR_SHORT, caCh.chid, unsafe.Pointer(&iv))
default:
return fmt.Errorf("epics: unsupported value type %T for Write", value)
}
if status != C.ECA_NORMAL {
return fmt.Errorf("epics: ca_put(%q) failed: status %d", signal, int(status))
}
C.ca_flush_io()
return nil
}
// 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)
}
// nativeTimeType returns the DBR_TIME_* type corresponding to the native field
// type of the channel.
func nativeTimeType(chid C.chid) C.chtype {
ft := C.ca_field_type(chid)
count := C.ca_element_count(chid)
switch int(ft) {
case int(C.DBF_DOUBLE):
if count > 1 {
return C.DBR_TIME_DOUBLE // waveform; caller assembles []float64 separately
}
return C.DBR_TIME_DOUBLE
case int(C.DBF_FLOAT):
return C.DBR_TIME_FLOAT
case int(C.DBF_LONG):
return C.DBR_TIME_LONG
case int(C.DBF_SHORT), int(C.DBF_CHAR):
return C.DBR_TIME_SHORT
case int(C.DBF_ENUM):
return C.DBR_TIME_ENUM
case int(C.DBF_STRING):
return C.DBR_TIME_STRING
default:
return C.DBR_TIME_DOUBLE
}
}