diff --git a/Makefile b/Makefile index e2c203a..1bfee04 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,4 @@ -.PHONY: all frontend backend backend-epics backend-debug test bench race lint clean run +.PHONY: all frontend backend backend-epics backend-debug release release-epics test bench race lint clean run # --------------------------------------------------------------------------- # # Sources — adding any file here triggers a frontend or backend rebuild # @@ -88,6 +88,76 @@ backend-debug: $(FRONTEND_OUT) @mkdir -p dist go build -o $(BINARY) ./cmd/uopi +# --------------------------------------------------------------------------- # +# Portable release targets # +# --------------------------------------------------------------------------- # +# +# Two flavours depending on whether EPICS is needed: +# +# make release — pure-Go, CGO_ENABLED=0, 100 % static binary. +# No EPICS; works anywhere without extra libs. +# +# make release-epics — CGO build with EPICS CA. +# Tries to link libca/libCom statically (if .a +# archives are present in $(EPICS_LIBDIR)). +# Falls back to bundled shared libs + $ORIGIN +# rpath when only .so files are available. +# The output directory (dist/release/) contains +# everything needed to run on the target machine. +# +# Cross-compilation (no-EPICS only): override RELEASE_OS / RELEASE_ARCH. +# make release RELEASE_OS=linux RELEASE_ARCH=arm64 + +RELEASE_OS ?= linux +RELEASE_ARCH ?= amd64 +RELEASE_DIR := dist/release +EPICS_LIBDIR := $(EPICS_BASE)/lib/$(EPICS_ARCH) + +# Fully static, no CGO — the most portable build possible. +release: $(FRONTEND_OUT) + @mkdir -p $(RELEASE_DIR) + CGO_ENABLED=0 GOOS=$(RELEASE_OS) GOARCH=$(RELEASE_ARCH) \ + go build -ldflags="-s -w" -o $(RELEASE_DIR)/uopi ./cmd/uopi + @echo "Built: $(RELEASE_DIR)/uopi (fully static, no EPICS)" + +# EPICS release — maximum static linking. +# Strategy A (preferred): if libca.a + libCom.a exist, embed them directly +# → single self-contained binary, no extra files needed. +# Strategy B (fallback): copy the shared libs next to the binary and set an +# $ORIGIN rpath so the OS finds them regardless of installation prefix. +release-epics: $(FRONTEND_OUT) + @if [ -z "$(EPICS_BASE)" ]; then \ + echo "ERROR: EPICS_BASE is not set."; \ + echo " export EPICS_BASE=/path/to/epics/base"; \ + exit 1; \ + fi + @mkdir -p $(RELEASE_DIR) + @echo "Building portable EPICS release" + @echo " EPICS_BASE = $(EPICS_BASE)" + @echo " EPICS_ARCH = $(EPICS_ARCH)" + @if [ -f "$(EPICS_LIBDIR)/libca.a" ] && [ -f "$(EPICS_LIBDIR)/libCom.a" ]; then \ + echo " Strategy A: static .a archives found — embedding libca + libCom"; \ + CGO_ENABLED=1 GOOS=linux GOARCH=$(RELEASE_ARCH) \ + CGO_CFLAGS="$(EPICS_CGO_CFLAGS)" \ + CGO_LDFLAGS="-Wl,-Bstatic -L$(EPICS_LIBDIR) -lca -lCom -Wl,-Bdynamic -lm -lpthread -lrt -ldl" \ + go build -tags epics \ + -ldflags="-s -w -linkmode external" \ + -o $(RELEASE_DIR)/uopi ./cmd/uopi; \ + echo "Built: $(RELEASE_DIR)/uopi (EPICS statically embedded)"; \ + else \ + echo " Strategy B: no .a archives — bundling shared libs with \$$ORIGIN rpath"; \ + CGO_ENABLED=1 GOOS=linux GOARCH=$(RELEASE_ARCH) \ + CGO_CFLAGS="$(EPICS_CGO_CFLAGS)" \ + CGO_LDFLAGS="-L$(EPICS_LIBDIR) -lca -lCom -Wl,-rpath,\$$ORIGIN" \ + go build -tags epics \ + -ldflags="-s -w" \ + -o $(RELEASE_DIR)/uopi ./cmd/uopi; \ + cp $(EPICS_LIBDIR)/libca.so* $(RELEASE_DIR)/; \ + cp $(EPICS_LIBDIR)/libCom.so* $(RELEASE_DIR)/; \ + echo "Built: $(RELEASE_DIR)/ (deploy the whole directory together)"; \ + ls $(RELEASE_DIR)/; \ + fi + # --------------------------------------------------------------------------- # # Dev / CI # # --------------------------------------------------------------------------- # diff --git a/internal/datasource/epics/epics.go b/internal/datasource/epics/epics.go index ae4ebb2..0f84632 100644 --- a/internal/datasource/epics/epics.go +++ b/internal/datasource/epics/epics.go @@ -444,41 +444,105 @@ func (e *EPICS) ListSignals(_ context.Context) ([]datasource.Metadata, error) { } // Write puts a new value onto a CA channel. -func (e *EPICS) Write(_ context.Context, signal string, value any) error { +// 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() - if !ok { - return datasource.ErrNotFound + + 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, caCh.chid, unsafe.Pointer(&cv)) + status = C.caPut(C.DBR_DOUBLE, chid, unsafe.Pointer(&cv)) case int64: cv := C.long(v) - status = C.caPut(C.DBR_LONG, caCh.chid, unsafe.Pointer(&cv)) + 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, caCh.chid, 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, caCh.chid, unsafe.Pointer(&iv)) + 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)) } - C.ca_flush_io() + + 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 } diff --git a/uopi b/uopi deleted file mode 100755 index 4908105..0000000 Binary files a/uopi and /dev/null differ