Implemented epics read/write

This commit is contained in:
Martino Ferrari
2026-04-27 12:42:10 +02:00
parent b76b7f0ba8
commit 1bda25454b
32 changed files with 1553 additions and 281 deletions
+36 -11
View File
@@ -86,23 +86,47 @@ func (b *Broker) Source(name string) (datasource.DataSource, bool) {
// If this is the first subscriber for the signal, the upstream DS subscription
// is started. Subsequent calls for the same signal share the existing one.
// The returned func must be called when the client no longer needs the signal.
//
// The broker mutex is NOT held while the upstream ds.Subscribe call is in
// progress — some data sources (e.g. EPICS) block until the channel connects
// or times out. Releasing the lock allows other goroutines to proceed.
func (b *Broker) Subscribe(ref SignalRef, ch chan<- Update) (func(), error) {
b.mu.Lock()
defer b.mu.Unlock()
sub, exists := b.subs[ref]
if !exists {
ds, found := b.sources[ref.DS]
if !found {
return nil, fmt.Errorf("unknown data source %q", ref.DS)
}
if exists {
// Fast path: already subscribed upstream; just add this client.
sub.mu.Lock()
sub.clients[ch] = struct{}{}
sub.mu.Unlock()
b.mu.Unlock()
return func() { b.unsubscribe(ref, ch) }, nil
}
rawCh := make(chan datasource.Value, 32)
dsCancel, err := ds.Subscribe(b.ctx, ref.Name, rawCh)
if err != nil {
return nil, fmt.Errorf("subscribe %s/%s: %w", ref.DS, ref.Name, err)
}
// Slow path: need to start an upstream subscription.
// Release the lock before the potentially-blocking ds.Subscribe call.
ds, found := b.sources[ref.DS]
b.mu.Unlock()
if !found {
return nil, fmt.Errorf("unknown data source %q", ref.DS)
}
rawCh := make(chan datasource.Value, 32)
dsCancel, err := ds.Subscribe(b.ctx, ref.Name, rawCh)
if err != nil {
return nil, fmt.Errorf("subscribe %s/%s: %w", ref.DS, ref.Name, err)
}
// Re-acquire the lock to register the new subscription.
// Another goroutine might have registered the same ref while the lock
// was released; if so, use that subscription and discard ours.
b.mu.Lock()
if existing, ok := b.subs[ref]; ok {
// Race: someone else subscribed first. Discard duplicate upstream.
dsCancel()
sub = existing
} else {
sub = &signalSub{
clients: make(map[chan<- Update]struct{}),
done: make(chan struct{}),
@@ -116,6 +140,7 @@ func (b *Broker) Subscribe(ref SignalRef, ch chan<- Update) (func(), error) {
sub.mu.Lock()
sub.clients[ch] = struct{}{}
sub.mu.Unlock()
b.mu.Unlock()
return func() { b.unsubscribe(ref, ch) }, nil
}
+19 -16
View File
@@ -10,8 +10,7 @@ import (
type Config struct {
Server ServerConfig `toml:"server"`
EPICS EPICSConfig `toml:"datasource.epics"`
Synthetic SyntheticConfig `toml:"datasource.synthetic"`
Datasource DatasourceConfig `toml:"datasource"`
}
type ServerConfig struct {
@@ -19,6 +18,16 @@ type ServerConfig struct {
StorageDir string `toml:"storage_dir"`
}
type DatasourceConfig struct {
Stub StubConfig `toml:"stub"`
EPICS EPICSConfig `toml:"epics"`
Synthetic SyntheticConfig `toml:"synthetic"`
}
type StubConfig struct {
Enabled bool `toml:"enabled"`
}
type EPICSConfig struct {
Enabled bool `toml:"enabled"`
CAAddrList string `toml:"ca_addr_list"`
@@ -27,8 +36,7 @@ type EPICSConfig struct {
}
type SyntheticConfig struct {
Enabled bool `toml:"enabled"`
DefinitionsFile string `toml:"definitions_file"`
Enabled bool `toml:"enabled"`
}
func Default() Config {
@@ -37,12 +45,10 @@ func Default() Config {
Listen: ":8080",
StorageDir: "./interfaces",
},
EPICS: EPICSConfig{
Enabled: true,
},
Synthetic: SyntheticConfig{
Enabled: true,
DefinitionsFile: "./synthetic.json",
Datasource: DatasourceConfig{
Stub: StubConfig{Enabled: true},
EPICS: EPICSConfig{Enabled: true},
Synthetic: SyntheticConfig{Enabled: true},
},
}
}
@@ -71,16 +77,13 @@ func applyEnv(cfg *Config) {
cfg.Server.StorageDir = v
}
if v := env("UOPI_EPICS_CA_ADDR_LIST"); v != "" {
cfg.EPICS.CAAddrList = v
cfg.Datasource.EPICS.CAAddrList = v
}
if v := env("UOPI_EPICS_ARCHIVE_URL"); v != "" {
cfg.EPICS.ArchiveURL = v
cfg.Datasource.EPICS.ArchiveURL = v
}
if v := env("UOPI_EPICS_CHANNEL_FINDER_URL"); v != "" {
cfg.EPICS.ChannelFinderURL = v
}
if v := env("UOPI_SYNTHETIC_DEFINITIONS_FILE"); v != "" {
cfg.Synthetic.DefinitionsFile = v
cfg.Datasource.EPICS.ChannelFinderURL = v
}
}
+1
View File
@@ -3,6 +3,7 @@
package epics
/*
#cgo CFLAGS: -Wno-unused-function
#include "ca_wrapper.h"
*/
import "C"
+27 -2
View File
@@ -13,7 +13,7 @@
* goCAConnectionCallback is called when a channel connects or disconnects.
*/
extern void goCAMonitorCallback(uintptr_t handle, int dbrType, long count,
const void *dbr, int severity,
void *dbr, int severity,
double epicsTimeSecs);
extern void goCAConnectionCallback(uintptr_t handle, int connected);
@@ -78,7 +78,7 @@ static void caMonitorCallbackShim(struct event_handler_args args) {
}
goCAMonitorCallback((uintptr_t)args.usr, (int)args.type, (long)args.count,
args.dbr, severity, timeSecs);
(void *)args.dbr, severity, timeSecs);
}
/*
@@ -115,4 +115,29 @@ static int caAddMonitor(chid ch, short dbrType, uintptr_t userHandle,
(void *)userHandle, pEvid);
}
/* Wrappers for ca_get / ca_put, which are macros in cadef.h that expand to
* ca_array_get / ca_array_put with count=1. CGo cannot call macros directly,
* so we provide thin static-inline shims here. */
static int caGet(chtype type, chid chan, void *pValue) {
return ca_array_get(type, 1u, chan, pValue);
}
static int caPut(chtype type, chid chan, const void *pValue) {
return ca_array_put(type, 1u, chan, pValue);
}
/* Retrieve the current thread's CA context as an opaque pointer.
* Store the result and pass it to caAttachContext() from other threads. */
static void *caCurrentContext(void) {
return (void *)ca_current_context();
}
/* Attach an existing CA context to the calling thread. Must be called at the
* start of every goroutine (OS thread) that makes CA calls, if that thread did
* not call ca_context_create() itself. Returns ECA_NORMAL on success. */
static int caAttachContext(void *ctx) {
return ca_attach_context((struct ca_client_context *)ctx);
}
#endif /* CA_WRAPPER_H */
+135 -74
View File
@@ -17,7 +17,7 @@
package epics
/*
#cgo CFLAGS: -Wall
#cgo CFLAGS: -Wall -Wno-unused-function
#include "ca_wrapper.h"
#include <stdlib.h>
*/
@@ -26,6 +26,7 @@ import "C"
import (
"context"
"fmt"
"os"
"sync"
"sync/atomic"
"time"
@@ -68,6 +69,12 @@ 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
@@ -98,27 +105,43 @@ func (e *EPICS) Name() string { return "epics" }
// 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.
// 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 != "" {
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)
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
}
// 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.
// 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
@@ -129,7 +152,8 @@ func (e *EPICS) Subscribe(ctx context.Context, signal string, ch chan<- datasour
connTable[handle] = entry
handleMu.Unlock()
// Create the CA channel.
// 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))
@@ -142,54 +166,78 @@ func (e *EPICS) Subscribe(ctx context.Context, signal string, ch chan<- datasour
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}
// Track the channel immediately so Write can find the chid.
e.mu.Lock()
e.channels[signal] = caCh
e.channels[signal] = &caChannel{chid: chid, handle: handle}
e.mu.Unlock()
cancel := datasource.CancelFunc(func() {
if evid != nil {
// 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)
@@ -203,15 +251,18 @@ func (e *EPICS) Subscribe(ctx context.Context, signal string, ch chan<- datasour
e.mu.Lock()
delete(e.channels, signal)
e.mu.Unlock()
})
}()
return cancel, nil
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 {
@@ -289,7 +340,10 @@ func (e *EPICS) GetMetadata(ctx context.Context, signal string) (datasource.Meta
// 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}
// 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)
@@ -326,7 +380,7 @@ func (e *EPICS) fetchMetadata(chid C.chid, name string) datasource.Metadata {
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))
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])))
@@ -334,14 +388,11 @@ func (e *EPICS) fetchMetadata(chid C.chid, name string) datasource.Metadata {
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))
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])))
@@ -349,12 +400,11 @@ func (e *EPICS) fetchMetadata(chid C.chid, name string) datasource.Metadata {
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))
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)
@@ -363,28 +413,39 @@ func (e *EPICS) fetchMetadata(chid C.chid, name string) datasource.Metadata {
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.
// 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()
out := make([]datasource.Metadata, 0, len(e.metadata))
for _, m := range e.metadata {
// 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.
func (e *EPICS) Write(_ context.Context, signal string, value any) error {
e.attachCAContext()
e.mu.Lock()
caCh, ok := e.channels[signal]
e.mu.Unlock()
@@ -396,20 +457,20 @@ func (e *EPICS) Write(_ context.Context, signal string, value any) error {
switch v := value.(type) {
case float64:
cv := C.double(v)
status = C.ca_put(C.DBR_DOUBLE, caCh.chid, unsafe.Pointer(&cv))
status = C.caPut(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))
status = C.caPut(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))
status = C.caPut(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))
status = C.caPut(C.DBR_SHORT, caCh.chid, unsafe.Pointer(&iv))
default:
return fmt.Errorf("epics: unsupported value type %T for Write", value)
}
+4 -3
View File
@@ -42,9 +42,10 @@ const (
// Value is a timestamped signal reading.
type Value struct {
Timestamp time.Time
Data any // float64 | []float64 | string | int64 | bool
Quality Quality
Timestamp time.Time
Data any // float64 | []float64 | string | int64 | bool
Quality Quality
MetaUpdate bool // if true, metadata was refreshed — dispatcher should re-send meta
}
// Metadata describes the static properties of a signal.
+12
View File
@@ -163,6 +163,12 @@ func (c *wsClient) dispatchLoop(ctx context.Context) {
for {
select {
case u := <-c.updateCh:
if u.Value.MetaUpdate {
// Datasource refreshed metadata (e.g. after channel connected).
// Re-send the meta message so the client gets accurate type/unit info.
c.sendMeta(ctx, u.Ref)
continue
}
msg := outMsg{
Type: "update",
DS: u.Ref.DS,
@@ -265,6 +271,7 @@ func (c *wsClient) handleUnsubscribe(refs []sigRef) {
func (c *wsClient) handleWrite(ctx context.Context, msg inMsg) {
ds, ok := c.broker.Source(msg.DS)
if !ok {
c.log.Warn("write: unknown data source", "ds", msg.DS, "name", msg.Name)
c.sendError(ctx, "NOT_FOUND", "unknown data source: "+msg.DS)
return
}
@@ -272,10 +279,12 @@ func (c *wsClient) handleWrite(ctx context.Context, msg inMsg) {
// Enforce write permission before touching the value payload.
meta, err := ds.GetMetadata(ctx, msg.Name)
if err != nil {
c.log.Warn("write: GetMetadata failed", "ds", msg.DS, "name", msg.Name, "err", err)
c.sendError(ctx, "NOT_FOUND", "unknown signal: "+msg.Name)
return
}
if !meta.Writable {
c.log.Warn("write: signal not writable", "ds", msg.DS, "name", msg.Name)
c.sendError(ctx, "NOT_WRITABLE", "signal is read-only: "+msg.Name)
return
}
@@ -283,12 +292,15 @@ func (c *wsClient) handleWrite(ctx context.Context, msg inMsg) {
// Decode the JSON value as the appropriate Go type.
var value any
if err := json.Unmarshal(msg.Value, &value); err != nil {
c.log.Warn("write: parse error", "ds", msg.DS, "name", msg.Name, "err", err)
c.sendError(ctx, "PARSE_ERROR", "invalid value: "+err.Error())
return
}
c.log.Info("write", "ds", msg.DS, "name", msg.Name, "value", value)
metrics.IncWrites()
if err := ds.Write(ctx, msg.Name, value); err != nil {
c.log.Warn("write: ds.Write failed", "ds", msg.DS, "name", msg.Name, "err", err)
c.sendError(ctx, "WRITE_ERROR", err.Error())
}
}