//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 */ 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 } }