//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 -Wno-unused-function #include "ca_wrapper.h" #include */ import "C" import ( "context" "fmt" "os" "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 // caCtx is the CA context created in Connect(). Stored as unsafe.Pointer // because the C type (ca_client_context *) is opaque. Every goroutine // that calls CA functions must call caAttachContext(caCtx) first, because // Go goroutines can run on any OS thread and CA contexts are thread-local. caCtx unsafe.Pointer 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). // 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, 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 { // Apply CA address list overrides before creating the context so the // library picks them up during initialisation. // os.Setenv is used instead of C.putenv: putenv takes ownership of the // string pointer, which would race with Go's garbage collector. if e.caAddrList != "" { os.Setenv("EPICS_CA_ADDR_LIST", e.caAddrList) os.Setenv("EPICS_CA_AUTO_ADDR_LIST", "NO") } 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)) } // Save the context so goroutines on other OS threads can attach to it. e.caCtx = C.caCurrentContext() return nil } // attachCAContext attaches the saved CA context to the calling OS thread. // Must be called at the top of every goroutine that uses CA functions, // because Go goroutines can be scheduled onto any OS thread. // Safe to call redundantly (ECA_ISATTACHED is not an error). func (e *EPICS) attachCAContext() { if e.caCtx != nil { C.caAttachContext(e.caCtx) } } // Subscribe creates a CA channel for signal and returns immediately. // Connection and monitor setup happen in a background goroutine so that // the caller is never blocked waiting for the IOC to respond. // A QualityBad value is sent to ch if the channel cannot connect within // 60 seconds. The returned CancelFunc tears down the subscription. func (e *EPICS) Subscribe(ctx context.Context, signal string, ch chan<- datasource.Value) (datasource.CancelFunc, error) { // Attach the CA context to this OS thread before any CA calls. e.attachCAContext() 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 — this call returns immediately; the connection // callback fires asynchronously on CA's internal thread. 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)) } C.ca_flush_io() // Track the channel immediately so Write can find the chid. e.mu.Lock() e.channels[signal] = &caChannel{chid: chid, handle: handle} e.mu.Unlock() // Use a done channel to signal the goroutine to stop. done := make(chan struct{}) var closeOnce sync.Once stopFn := func() { closeOnce.Do(func() { close(done) }) } // Background goroutine: wait for connection, then install the monitor. go func() { // Attach CA context to this OS thread before making any CA calls. e.attachCAContext() var evid C.evid connected := false select { case <-entry.connCh: connected = true case <-time.After(60 * time.Second): // IOC unreachable; report bad quality so the widget shows an error state. select { case ch <- datasource.Value{Timestamp: time.Now(), Data: float64(0), Quality: datasource.QualityBad}: default: } case <-ctx.Done(): case <-done: } if connected { // Refresh metadata now that the channel is connected so type, unit, // and display limits are accurate (the earlier sendMeta call may have // run before the channel was up, leaving an incomplete cache entry). meta := e.fetchMetadata(chid, signal) e.mu.Lock() e.metadata[signal] = meta e.mu.Unlock() // Signal to the broker/dispatcher that metadata has been refreshed. select { case ch <- datasource.Value{MetaUpdate: true}: default: } dbrType := nativeTimeType(chid) st := C.caAddMonitor(chid, C.short(dbrType), C.uintptr_t(handle), &evid) if st != C.ECA_NORMAL { select { case ch <- datasource.Value{Timestamp: time.Now(), Data: float64(0), Quality: datasource.QualityBad}: default: } connected = false } else { C.ca_flush_io() e.mu.Lock() e.channels[signal] = &caChannel{chid: chid, evid: evid, handle: handle} e.mu.Unlock() } } // Wait until the subscription is cancelled. select { case <-done: case <-ctx.Done(): } // Cleanup. if connected && 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 datasource.CancelFunc(stopFn), 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) { // Attach the CA context to this OS thread before any CA calls. e.attachCAContext() // 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 { // In EPICS, write access is governed by CA security (host/user rules), not // by the field type. Default to writable=true; the IOC will reject puts // that violate its security policy. meta := datasource.Metadata{Name: name, Writable: true} 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.caGet(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) } case C.DBR_CTRL_LONG: var buf C.struct_dbr_ctrl_long status := C.caGet(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) } case C.DBR_CTRL_ENUM: var buf C.struct_dbr_ctrl_enum status := C.caGet(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 } } return meta } // ListSignals returns metadata for all currently tracked channels. // Channels with cached metadata return full info; others return a stub entry // with the PV name so the signal tree can display them immediately after // a manual add, before GetMetadata has been called. func (e *EPICS) ListSignals(_ context.Context) ([]datasource.Metadata, error) { e.mu.Lock() defer e.mu.Unlock() // Start with all fully-fetched metadata entries. out := make([]datasource.Metadata, 0, len(e.channels)) seen := make(map[string]bool, len(e.metadata)) for name, m := range e.metadata { out = append(out, m) seen[name] = true } // Add stub entries for channels that are tracked but not yet fetched. for name := range e.channels { if !seen[name] { out = append(out, datasource.Metadata{Name: name, Type: datasource.TypeFloat64}) } } return out, nil } // Write puts a new value onto a CA channel. // If the signal is not currently subscribed (e.g. a button in oneshot mode), // a temporary CA channel is created, used for the put, then torn down. func (e *EPICS) Write(ctx context.Context, signal string, value any) error { e.attachCAContext() // Resolve the chid: use an existing subscribed channel if available, // otherwise create a temporary one (mirrors GetMetadata's approach). e.mu.Lock() caCh, ok := e.channels[signal] e.mu.Unlock() var chid C.chid tempChannel := false if ok { chid = caCh.chid } else { // Create a temporary channel for the put. 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 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 fmt.Errorf("epics: timeout connecting to %q for write", signal) case <-ctx.Done(): C.ca_clear_channel(chid) handleMu.Lock() delete(connTable, handle) handleMu.Unlock() return ctx.Err() } handleMu.Lock() delete(connTable, handle) handleMu.Unlock() tempChannel = true } var status C.int switch v := value.(type) { case float64: cv := C.double(v) status = C.caPut(C.DBR_DOUBLE, chid, unsafe.Pointer(&cv)) case int64: cv := C.long(v) status = C.caPut(C.DBR_LONG, chid, unsafe.Pointer(&cv)) case string: cs := C.CString(v) defer C.free(unsafe.Pointer(cs)) status = C.caPut(C.DBR_STRING, chid, unsafe.Pointer(cs)) case bool: var iv C.short if v { iv = 1 } status = C.caPut(C.DBR_SHORT, chid, unsafe.Pointer(&iv)) default: if tempChannel { C.ca_clear_channel(chid) C.ca_flush_io() } return fmt.Errorf("epics: unsupported value type %T for Write", value) } if status != C.ECA_NORMAL { if tempChannel { C.ca_clear_channel(chid) C.ca_flush_io() } return fmt.Errorf("epics: ca_put(%q) failed: status %d", signal, int(status)) } if tempChannel { C.ca_pend_io(3.0) // flush + wait for put completion before tearing down C.ca_clear_channel(chid) C.ca_flush_io() } else { 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 } }