diff --git a/Makefile b/Makefile index 1bfee04..bda899c 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,4 @@ -.PHONY: all frontend backend backend-epics backend-debug release release-epics test bench race lint clean run +.PHONY: all frontend backend backend-debug release catools test bench race lint clean run # --------------------------------------------------------------------------- # # Sources — adding any file here triggers a frontend or backend rebuild # @@ -8,7 +8,9 @@ FRONTEND_SRCS := $(shell find web/src -name '*.tsx' -o -name '*.ts' -o -name '*. $(wildcard web/vendor/*.mjs web/vendor/*.js web/vendor/*.css) \ tools/buildfrontend/main.go -GO_SRCS := $(shell find cmd internal -name '*.go') web/embed.go go.mod go.sum +GO_SRCS := $(shell find cmd internal -name '*.go') \ + $(shell find pkg/ca -name '*.go') \ + web/embed.go go.mod go.sum go.work # --------------------------------------------------------------------------- # # Outputs # @@ -16,16 +18,17 @@ GO_SRCS := $(shell find cmd internal -name '*.go') web/embed.go go.mod go.sum FRONTEND_OUT := web/dist/main.js BINARY := dist/uopi +CATOOLS := dist/catools # --------------------------------------------------------------------------- # # Top-level targets # # --------------------------------------------------------------------------- # -all: $(BINARY) +all: $(BINARY) $(CATOOLS) frontend: $(FRONTEND_OUT) -backend: $(BINARY) +backend: $(BINARY) $(CATOOLS) # --------------------------------------------------------------------------- # # Build rules # @@ -36,127 +39,40 @@ $(FRONTEND_OUT): $(FRONTEND_SRCS) @mkdir -p web/dist go run ./tools/buildfrontend/main.go -# Compile the self-contained binary (embeds web/dist at build time) +# Compile the self-contained binary (embeds web/dist at build time). +# Pure-Go CA client (pkg/ca) is always used; CGO_ENABLED=0 produces a fully +# static binary with no external library dependencies. $(BINARY): $(GO_SRCS) $(FRONTEND_OUT) @mkdir -p dist - go build -ldflags="-s -w" -o $(BINARY) ./cmd/uopi + CGO_ENABLED=0 go build -ldflags="-s -w" -o $(BINARY) ./cmd/uopi -# Build with EPICS Channel Access support. -# -# Auto-detection: if EPICS_BASE is set in the environment, CGO flags are -# derived automatically. Override any variable on the command line: -# -# make backend-epics EPICS_BASE=/path/to/epics/base EPICS_ARCH=linux-x86_64 -# -# If EPICS_BASE is not set, set it to the directory that contains -# include/cadef.h and lib//libca.so. - -EPICS_BASE ?= $(EPICS_BASE) - -# Detect host arch from EPICS startup script if not provided. -EPICS_ARCH ?= $(shell \ - if [ -x "$(EPICS_BASE)/startup/EpicsHostArch" ]; then \ - "$(EPICS_BASE)/startup/EpicsHostArch"; \ - elif [ -x "$(EPICS_BASE)/lib" ]; then \ - ls "$(EPICS_BASE)/lib" | grep linux | head -1; \ - else \ - echo "linux-x86_64"; \ - fi) - -EPICS_CGO_CFLAGS ?= -I$(EPICS_BASE)/include -I$(EPICS_BASE)/include/os/Linux -I$(EPICS_BASE)/include/compiler/gcc -EPICS_CGO_LDFLAGS ?= -L$(EPICS_BASE)/lib/$(EPICS_ARCH) -lca -lCom -Wl,-rpath,$(EPICS_BASE)/lib/$(EPICS_ARCH) - -backend-epics: $(FRONTEND_OUT) - @if [ -z "$(EPICS_BASE)" ]; then \ - echo "ERROR: EPICS_BASE is not set."; \ - echo " export EPICS_BASE=/path/to/epics/base"; \ - echo " then re-run: make backend-epics"; \ - exit 1; \ - fi - @echo "Building with EPICS support" - @echo " EPICS_BASE = $(EPICS_BASE)" - @echo " EPICS_ARCH = $(EPICS_ARCH)" +# CA diagnostic tools (caget/caput/cainfo/camonitor equivalents) +$(CATOOLS): $(GO_SRCS) @mkdir -p dist - CGO_ENABLED=1 \ - CGO_CFLAGS="$(EPICS_CGO_CFLAGS)" \ - CGO_LDFLAGS="$(EPICS_CGO_LDFLAGS)" \ - go build -tags epics -ldflags="-s -w" -o $(BINARY) ./cmd/uopi - @echo "Built: $(BINARY) (EPICS CA enabled)" + CGO_ENABLED=0 go build -ldflags="-s -w" -o $(CATOOLS) ./cmd/catools -# Unstripped binary for debugging +# Unstripped binary for debugging (pure-Go CA, CGO_ENABLED=0) backend-debug: $(FRONTEND_OUT) @mkdir -p dist - go build -o $(BINARY) ./cmd/uopi + CGO_ENABLED=0 go build -o $(BINARY) ./cmd/uopi + CGO_ENABLED=0 go build -o $(CATOOLS) ./cmd/catools # --------------------------------------------------------------------------- # -# Portable release targets # +# Release # # --------------------------------------------------------------------------- # # -# 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 +# Fully static, pure-Go CA client, no external library dependencies. +# Cross-compile: 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 + @echo "Built: $(RELEASE_DIR)/uopi (static, pure-Go CA)" # --------------------------------------------------------------------------- # # Dev / CI # @@ -164,10 +80,12 @@ release-epics: $(FRONTEND_OUT) test: go test ./... + cd pkg/ca && go test ./... # Run tests with the race detector enabled. race: go test -race ./... + cd pkg/ca && go test -race ./... # Run all benchmarks and print memory allocations. bench: @@ -179,5 +97,7 @@ lint: run: $(BINARY) $(BINARY) +catools: $(CATOOLS) + clean: rm -rf dist/ web/dist/ diff --git a/cmd/catools/main.go b/cmd/catools/main.go new file mode 100644 index 0000000..8e879b0 --- /dev/null +++ b/cmd/catools/main.go @@ -0,0 +1,336 @@ +// Command catools provides caget/caput/camonitor/cainfo equivalents using +// the pure-Go goca CA client library. +// +// Usage: +// +// catools caget [pv...] # get current value(s) +// catools caput # write a value +// catools cainfo [pv...] # print metadata (units, limits, type) +// catools camonitor [pv...] # stream live updates (Ctrl-C to stop) +// +// Environment: +// +// EPICS_CA_ADDR_LIST — space-separated CA server addresses +// EPICS_CA_AUTO_ADDR_LIST — set to "NO" to disable broadcast search +// UOPI_CA_DEBUG — set to "1" for CA protocol debug logging +package main + +import ( + "context" + "fmt" + "os" + "os/signal" + "strconv" + "strings" + "syscall" + "time" + + ca "github.com/uopi/goca" + "github.com/uopi/goca/proto" +) + +func usage() { + fmt.Fprintln(os.Stderr, `catools — pure-Go EPICS CA command-line tools + +Commands: + caget [pv...] Get current value(s) + caput Write a value + cainfo [pv...] Print metadata (type, units, limits) + camonitor [pv...] Stream live updates (Ctrl-C to stop) + +Environment: + EPICS_CA_ADDR_LIST Space-separated CA server addresses + EPICS_CA_AUTO_ADDR_LIST Set to NO to disable broadcast search + UOPI_CA_DEBUG Set to 1 for CA protocol debug logging`) + os.Exit(1) +} + +func main() { + if len(os.Args) < 2 { + usage() + } + + cmd := os.Args[1] + args := os.Args[2:] + + ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) + defer stop() + + cfg := ca.ConfigFromEnv() + cli, err := ca.NewClient(ctx, cfg) + if err != nil { + fatalf("connect: %v", err) + } + + switch cmd { + case "caget": + if len(args) == 0 { + fatalf("caget: no PV names specified") + } + runCaget(ctx, cli, args) + case "caput": + if len(args) < 2 { + fatalf("caput: usage: catools caput ") + } + runCaput(ctx, cli, args[0], args[1]) + case "cainfo": + if len(args) == 0 { + fatalf("cainfo: no PV names specified") + } + runCainfo(ctx, cli, args) + case "camonitor": + if len(args) == 0 { + fatalf("camonitor: no PV names specified") + } + runCamonitor(ctx, cli, args) + default: + fatalf("unknown command %q — use caget, caput, cainfo, or camonitor", cmd) + } +} + +// -------------------------------------------------------------------------- // +// caget // +// -------------------------------------------------------------------------- // + +func runCaget(ctx context.Context, cli *ca.Client, pvs []string) { + tctx, cancel := context.WithTimeout(ctx, 30*time.Second) + defer cancel() + + for _, pv := range pvs { + tv, err := cli.Get(tctx, pv) + if err != nil { + fmt.Fprintf(os.Stderr, "%s: error: %v\n", pv, err) + continue + } + fmt.Printf("%-30s %s\n", pv, formatValue(tv)) + } +} + +// -------------------------------------------------------------------------- // +// caput // +// -------------------------------------------------------------------------- // + +func runCaput(ctx context.Context, cli *ca.Client, pv, valueStr string) { + tctx, cancel := context.WithTimeout(ctx, 30*time.Second) + defer cancel() + + // First get metadata to determine the right type. + ci, err := cli.GetCtrl(tctx, pv) + if err != nil { + fatalf("%s: get metadata: %v", pv, err) + } + + var value any + switch ci.DBFType { + case proto.DBFString: + value = valueStr + case proto.DBFEnum: + // Try numeric first, then string. + if n, err := strconv.ParseInt(valueStr, 10, 32); err == nil { + value = int32(n) + } else { + // Search enum strings. + if ci.Enum != nil { + for i, s := range ci.Enum.Strings { + if strings.EqualFold(s, valueStr) { + value = int32(i) + break + } + } + } + if value == nil { + fatalf("%s: unknown enum value %q", pv, valueStr) + } + } + default: + if n, err := strconv.ParseFloat(valueStr, 64); err == nil { + value = n + } else if n, err := strconv.ParseInt(valueStr, 10, 64); err == nil { + value = int64(n) + } else { + value = valueStr + } + } + + if err := cli.Put(tctx, pv, value); err != nil { + fatalf("%s: put: %v", pv, err) + } + + // Read back the new value. + tv, err := cli.Get(tctx, pv) + if err != nil { + fmt.Printf("Old: ?\nNew: %s (write sent, readback failed: %v)\n", valueStr, err) + return + } + fmt.Printf("%-30s %s\n", pv, formatValue(tv)) +} + +// -------------------------------------------------------------------------- // +// cainfo // +// -------------------------------------------------------------------------- // + +func runCainfo(ctx context.Context, cli *ca.Client, pvs []string) { + tctx, cancel := context.WithTimeout(ctx, 30*time.Second) + defer cancel() + + for _, pv := range pvs { + ci, err := cli.GetCtrl(tctx, pv) + if err != nil { + fmt.Fprintf(os.Stderr, "%s: error: %v\n", pv, err) + continue + } + printInfo(pv, ci) + fmt.Println() + } +} + +func printInfo(pv string, ci ca.CtrlInfo) { + fmt.Printf("Name : %s\n", pv) + fmt.Printf("DBF type : %s (%d)\n", dbfTypeName(ci.DBFType), ci.DBFType) + fmt.Printf("Count : %d\n", ci.Count) + writable := "No" + if ci.Access&proto.AccessWrite != 0 { + writable = "Yes" + } + fmt.Printf("Writable : %s\n", writable) + + switch { + case ci.Double != nil: + d := ci.Double + fmt.Printf("Units : %s\n", d.Units) + fmt.Printf("Disp range : [%g, %g]\n", d.LowerDispLimit, d.UpperDispLimit) + fmt.Printf("Ctrl range : [%g, %g]\n", d.LowerCtrlLimit, d.UpperCtrlLimit) + fmt.Printf("Alarm LOLO : %g HIHI: %g\n", d.LowerAlarmLimit, d.UpperAlarmLimit) + fmt.Printf("Warn LOW : %g HIGH: %g\n", d.LowerWarnLimit, d.UpperWarnLimit) + case ci.Long != nil: + l := ci.Long + fmt.Printf("Units : %s\n", l.Units) + fmt.Printf("Disp range : [%d, %d]\n", l.LowerDispLimit, l.UpperDispLimit) + fmt.Printf("Ctrl range : [%d, %d]\n", l.LowerCtrlLimit, l.UpperCtrlLimit) + fmt.Printf("Alarm LOLO : %d HIHI: %d\n", l.LowerAlarmLimit, l.UpperAlarmLimit) + fmt.Printf("Warn LOW : %d HIGH: %d\n", l.LowerWarnLimit, l.UpperWarnLimit) + case ci.Enum != nil: + fmt.Printf("Enum states: %d\n", len(ci.Enum.Strings)) + for i, s := range ci.Enum.Strings { + fmt.Printf(" [%d] %s\n", i, s) + } + case ci.Str != nil: + fmt.Printf("Type : string\n") + } +} + +// -------------------------------------------------------------------------- // +// camonitor // +// -------------------------------------------------------------------------- // + +func runCamonitor(ctx context.Context, cli *ca.Client, pvs []string) { + type entry struct { + pv string + ch chan proto.TimeValue + can context.CancelFunc + } + + entries := make([]*entry, 0, len(pvs)) + tctx, cancel := context.WithTimeout(ctx, 30*time.Second) + defer cancel() + + for _, pv := range pvs { + ch := make(chan proto.TimeValue, 32) + cancelFn, err := cli.Subscribe(tctx, pv, ch) + if err != nil { + fmt.Fprintf(os.Stderr, "%s: subscribe error: %v\n", pv, err) + continue + } + entries = append(entries, &entry{pv: pv, ch: ch, can: cancelFn}) + fmt.Printf("Monitoring %s — press Ctrl-C to stop\n", pv) + } + + if len(entries) == 0 { + return + } + + // Fan out: one goroutine per PV, print to stdout. + done := make(chan struct{}) + for _, e := range entries { + go func(e *entry) { + for { + select { + case tv, ok := <-e.ch: + if !ok { + return + } + ts := tv.Timestamp.Local().Format("15:04:05.000") + fmt.Printf("%s %-30s %s\n", ts, e.pv, formatValue(tv)) + case <-ctx.Done(): + return + } + } + }(e) + } + + <-ctx.Done() + close(done) + + for _, e := range entries { + e.can() + } +} + +// -------------------------------------------------------------------------- // +// Formatting helpers // +// -------------------------------------------------------------------------- // + +func formatValue(tv proto.TimeValue) string { + var val string + switch { + case tv.Doubles != nil: + parts := make([]string, len(tv.Doubles)) + for i, v := range tv.Doubles { + parts[i] = strconv.FormatFloat(v, 'g', -1, 64) + } + val = "[" + strings.Join(parts, " ") + "]" + case tv.Str != "": + val = tv.Str + default: + val = strconv.FormatFloat(tv.Double, 'g', -1, 64) + } + + severity := "" + switch tv.Severity { + case proto.SeverityMinor: + severity = " MINOR" + case proto.SeverityMajor: + severity = " MAJOR" + case proto.SeverityInvalid: + severity = " INVALID" + } + + ts := tv.Timestamp.Local().Format("2006-01-02 15:04:05.000") + return fmt.Sprintf("%s%s [%s]", val, severity, ts) +} + +func dbfTypeName(t int) string { + switch t { + case proto.DBFString: + return "DBF_STRING" + case proto.DBFShort: + return "DBF_SHORT" + case proto.DBFFloat: + return "DBF_FLOAT" + case proto.DBFEnum: + return "DBF_ENUM" + case proto.DBFChar: + return "DBF_CHAR" + case proto.DBFLong: + return "DBF_LONG" + case proto.DBFDouble: + return "DBF_DOUBLE" + default: + return "DBF_UNKNOWN" + } +} + +func fatalf(format string, args ...any) { + fmt.Fprintf(os.Stderr, "catools: "+format+"\n", args...) + os.Exit(1) +} diff --git a/cmd/uopi/main.go b/cmd/uopi/main.go index 661a271..b4a2671 100644 --- a/cmd/uopi/main.go +++ b/cmd/uopi/main.go @@ -83,10 +83,11 @@ func main() { } } - // EPICS Channel Access data source (requires -tags epics build). - // epics.Available() returns false when built without the tag. + // EPICS Channel Access data source. + // The pure-Go implementation is used by default (no CGo required). + // Build with -tags epics to use the CGo-based libca implementation instead. if cfg.Datasource.EPICS.Enabled && epics.Available() { - ds := epics.New(cfg.Datasource.EPICS.CAAddrList, cfg.Datasource.EPICS.ArchiveURL) + ds := epics.New(cfg.Datasource.EPICS.CAAddrList, cfg.Datasource.EPICS.ArchiveURL, cfg.Datasource.EPICS.PVNames) if err := ds.Connect(ctx); err != nil { log.Error("epics connect", "err", err) } else { diff --git a/go.work b/go.work new file mode 100644 index 0000000..21738f5 --- /dev/null +++ b/go.work @@ -0,0 +1,6 @@ +go 1.26.2 + +use ( + . + ./pkg/ca +) diff --git a/internal/config/config.go b/internal/config/config.go index 93379d5..e5b1c92 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -29,10 +29,11 @@ type StubConfig struct { } type EPICSConfig struct { - Enabled bool `toml:"enabled"` - CAAddrList string `toml:"ca_addr_list"` - ArchiveURL string `toml:"archive_url"` - ChannelFinderURL string `toml:"channel_finder_url"` + Enabled bool `toml:"enabled"` + CAAddrList string `toml:"ca_addr_list"` + ArchiveURL string `toml:"archive_url"` + ChannelFinderURL string `toml:"channel_finder_url"` + PVNames []string `toml:"pv_names"` } type SyntheticConfig struct { diff --git a/internal/config/config_test.go b/internal/config/config_test.go new file mode 100644 index 0000000..3a6d423 --- /dev/null +++ b/internal/config/config_test.go @@ -0,0 +1,124 @@ +package config_test + +import ( + "os" + "path/filepath" + "testing" + + "github.com/uopi/uopi/internal/config" +) + +func TestDefault(t *testing.T) { + cfg := config.Default() + if cfg.Server.Listen == "" { + t.Error("Default Listen must not be empty") + } + if cfg.Server.StorageDir == "" { + t.Error("Default StorageDir must not be empty") + } + if !cfg.Datasource.Stub.Enabled { + t.Error("Stub should be enabled by default") + } + if !cfg.Datasource.EPICS.Enabled { + t.Error("EPICS should be enabled by default") + } + if !cfg.Datasource.Synthetic.Enabled { + t.Error("Synthetic should be enabled by default") + } +} + +func TestLoadEmptyPath(t *testing.T) { + // Empty path should return defaults without error. + cfg, err := config.Load("") + if err != nil { + t.Fatalf("Load with empty path: %v", err) + } + if cfg.Server.Listen != config.Default().Server.Listen { + t.Errorf("Listen = %q, want default %q", cfg.Server.Listen, config.Default().Server.Listen) + } +} + +func TestLoadTOML(t *testing.T) { + toml := ` +[server] +listen = ":9090" +storage_dir = "/tmp/uopi-test" + +[datasource.stub] +enabled = false + +[datasource.epics] +enabled = false +ca_addr_list = "192.168.1.1" +` + f := filepath.Join(t.TempDir(), "uopi.toml") + if err := os.WriteFile(f, []byte(toml), 0o644); err != nil { + t.Fatal(err) + } + + cfg, err := config.Load(f) + if err != nil { + t.Fatalf("Load: %v", err) + } + if cfg.Server.Listen != ":9090" { + t.Errorf("Listen = %q, want :9090", cfg.Server.Listen) + } + if cfg.Server.StorageDir != "/tmp/uopi-test" { + t.Errorf("StorageDir = %q", cfg.Server.StorageDir) + } + if cfg.Datasource.Stub.Enabled { + t.Error("Stub.Enabled should be false") + } + if cfg.Datasource.EPICS.Enabled { + t.Error("EPICS.Enabled should be false") + } + if cfg.Datasource.EPICS.CAAddrList != "192.168.1.1" { + t.Errorf("CAAddrList = %q", cfg.Datasource.EPICS.CAAddrList) + } +} + +func TestLoadBadPath(t *testing.T) { + _, err := config.Load("/no/such/file.toml") + if err == nil { + t.Error("expected error for missing file") + } +} + +func TestEnvOverrides(t *testing.T) { + t.Setenv("UOPI_SERVER_LISTEN", ":7777") + t.Setenv("UOPI_SERVER_STORAGE_DIR", "/env/storage") + t.Setenv("UOPI_EPICS_CA_ADDR_LIST", "10.0.0.1 10.0.0.2") + t.Setenv("UOPI_EPICS_ARCHIVE_URL", "http://archive/") + t.Setenv("UOPI_EPICS_CHANNEL_FINDER_URL", "http://cf/") + + cfg, err := config.Load("") + if err != nil { + t.Fatalf("Load: %v", err) + } + if cfg.Server.Listen != ":7777" { + t.Errorf("Listen = %q, want :7777", cfg.Server.Listen) + } + if cfg.Server.StorageDir != "/env/storage" { + t.Errorf("StorageDir = %q", cfg.Server.StorageDir) + } + if cfg.Datasource.EPICS.CAAddrList != "10.0.0.1 10.0.0.2" { + t.Errorf("CAAddrList = %q", cfg.Datasource.EPICS.CAAddrList) + } + if cfg.Datasource.EPICS.ArchiveURL != "http://archive/" { + t.Errorf("ArchiveURL = %q", cfg.Datasource.EPICS.ArchiveURL) + } + if cfg.Datasource.EPICS.ChannelFinderURL != "http://cf/" { + t.Errorf("ChannelFinderURL = %q", cfg.Datasource.EPICS.ChannelFinderURL) + } +} + +func TestEnvTrimsWhitespace(t *testing.T) { + t.Setenv("UOPI_SERVER_LISTEN", " :8888 ") + cfg, err := config.Load("") + if err != nil { + t.Fatalf("Load: %v", err) + } + if cfg.Server.Listen != ":8888" { + t.Errorf("Listen = %q, expected whitespace trimmed", cfg.Server.Listen) + } +} diff --git a/internal/datasource/epics/archive.go b/internal/datasource/epics/archive.go index 17f3b4e..5c7ff80 100644 --- a/internal/datasource/epics/archive.go +++ b/internal/datasource/epics/archive.go @@ -1,5 +1,3 @@ -//go:build epics - package epics import ( diff --git a/internal/datasource/epics/epics.go b/internal/datasource/epics/epics.go index 0f84632..fc0a005 100644 --- a/internal/datasource/epics/epics.go +++ b/internal/datasource/epics/epics.go @@ -85,7 +85,9 @@ type EPICS struct { // 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 { +// 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, diff --git a/internal/datasource/epics/epics_purego_test.go b/internal/datasource/epics/epics_purego_test.go new file mode 100644 index 0000000..b9dc540 --- /dev/null +++ b/internal/datasource/epics/epics_purego_test.go @@ -0,0 +1,350 @@ +//go:build !epics + +// Integration tests for the pure-Go EPICS datasource adapter. +// These tests use the in-process fake CA server from the goca testca package +// and therefore do not require a real EPICS installation. +package epics_test + +import ( + "context" + "math" + "testing" + "time" + + "github.com/uopi/goca/proto" + "github.com/uopi/goca/testca" + "github.com/uopi/uopi/internal/datasource" + "github.com/uopi/uopi/internal/datasource/epics" +) + +// newTestDS creates a datasource.DataSource backed by a fake CA server. +func newTestDS(t *testing.T, pvs []testca.PVSpec) (datasource.DataSource, *testca.Server) { + t.Helper() + srv, err := testca.New(pvs) + if err != nil { + t.Fatalf("testca.New: %v", err) + } + t.Cleanup(srv.Close) + + ds := epics.New(srv.UDPAddr(), "", nil) + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + t.Cleanup(cancel) + if err := ds.Connect(ctx); err != nil { + t.Fatalf("Connect: %v", err) + } + return ds, srv +} + +// -------------------------------------------------------------------------- // +// GetMetadata // +// -------------------------------------------------------------------------- // + +func TestPureGo_GetMetadataDouble(t *testing.T) { + ds, _ := newTestDS(t, []testca.PVSpec{ + {Name: "DS:DOUBLE", DBFType: proto.DBFDouble, Count: 1, Value: 1.0}, + }) + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + meta, err := ds.GetMetadata(ctx, "DS:DOUBLE") + if err != nil { + t.Fatalf("GetMetadata: %v", err) + } + if meta.Name != "DS:DOUBLE" { + t.Errorf("Name = %q, want %q", meta.Name, "DS:DOUBLE") + } + if meta.Type != datasource.TypeFloat64 { + t.Errorf("Type = %v, want TypeFloat64", meta.Type) + } + if !meta.Writable { + t.Error("expected Writable=true") + } +} + +func TestPureGo_GetMetadataString(t *testing.T) { + ds, _ := newTestDS(t, []testca.PVSpec{ + {Name: "DS:STR", DBFType: proto.DBFString, Count: 1, Value: "hello"}, + }) + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + meta, err := ds.GetMetadata(ctx, "DS:STR") + if err != nil { + t.Fatalf("GetMetadata: %v", err) + } + if meta.Type != datasource.TypeString { + t.Errorf("Type = %v, want TypeString", meta.Type) + } +} + +func TestPureGo_GetMetadataEnum(t *testing.T) { + ds, _ := newTestDS(t, []testca.PVSpec{ + {Name: "DS:ENUM", DBFType: proto.DBFEnum, Count: 1, Value: int16(0)}, + }) + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + meta, err := ds.GetMetadata(ctx, "DS:ENUM") + if err != nil { + t.Fatalf("GetMetadata: %v", err) + } + if meta.Type != datasource.TypeEnum { + t.Errorf("Type = %v, want TypeEnum", meta.Type) + } +} + +func TestPureGo_GetMetadataReadOnly(t *testing.T) { + ds, _ := newTestDS(t, []testca.PVSpec{ + {Name: "DS:RO", DBFType: proto.DBFDouble, Count: 1, Value: 0.0, + Access: proto.AccessRead}, + }) + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + meta, err := ds.GetMetadata(ctx, "DS:RO") + if err != nil { + t.Fatalf("GetMetadata: %v", err) + } + if meta.Writable { + t.Error("expected Writable=false for read-only PV") + } +} + +func TestPureGo_GetMetadataCached(t *testing.T) { + ds, _ := newTestDS(t, []testca.PVSpec{ + {Name: "DS:CACHED", DBFType: proto.DBFDouble, Count: 1, Value: 1.0}, + }) + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + m1, err := ds.GetMetadata(ctx, "DS:CACHED") + if err != nil { + t.Fatalf("first GetMetadata: %v", err) + } + m2, err := ds.GetMetadata(ctx, "DS:CACHED") + if err != nil { + t.Fatalf("second GetMetadata: %v", err) + } + if m1.Name != m2.Name || m1.Type != m2.Type { + t.Error("second GetMetadata returned different result from cache") + } +} + +// -------------------------------------------------------------------------- // +// Subscribe // +// -------------------------------------------------------------------------- // + +func TestPureGo_SubscribeInitialValue(t *testing.T) { + ds, _ := newTestDS(t, []testca.PVSpec{ + {Name: "DS:SUB", DBFType: proto.DBFDouble, Count: 1, Value: 7.5}, + }) + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + ch := make(chan datasource.Value, 16) + unsub, err := ds.Subscribe(ctx, "DS:SUB", ch) + if err != nil { + t.Fatalf("Subscribe: %v", err) + } + defer unsub() + + // Drain MetaUpdate and value frames — first numeric value wins. + deadline := time.After(5 * time.Second) + for { + select { + case v := <-ch: + if v.MetaUpdate { + continue + } + f, ok := v.Data.(float64) + if !ok { + t.Fatalf("Data type = %T, want float64", v.Data) + } + if math.Abs(f-7.5) > 1e-6 { + t.Errorf("Data = %g, want 7.5", f) + } + return + case <-deadline: + t.Fatal("timeout waiting for initial value") + } + } +} + +func TestPureGo_SubscribeUpdates(t *testing.T) { + ds, srv := newTestDS(t, []testca.PVSpec{ + {Name: "DS:UPD", DBFType: proto.DBFDouble, Count: 1, Value: 1.0}, + }) + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + ch := make(chan datasource.Value, 16) + unsub, err := ds.Subscribe(ctx, "DS:UPD", ch) + if err != nil { + t.Fatalf("Subscribe: %v", err) + } + defer unsub() + + // Drain initial frames. + deadline := time.After(3 * time.Second) +drainLoop: + for { + select { + case v := <-ch: + if !v.MetaUpdate { + break drainLoop + } + case <-deadline: + t.Fatal("timeout draining initial frames") + } + } + + // Push new value via server. + srv.SetValue("DS:UPD", 99.9) + + deadline = time.After(3 * time.Second) + for { + select { + case v := <-ch: + if v.MetaUpdate { + continue + } + f, ok := v.Data.(float64) + if !ok { + t.Fatalf("Data type = %T, want float64", v.Data) + } + if math.Abs(f-99.9) > 1e-6 { + t.Errorf("Data = %g, want 99.9", f) + } + return + case <-deadline: + t.Fatal("timeout waiting for updated value") + } + } +} + +// -------------------------------------------------------------------------- // +// Write // +// -------------------------------------------------------------------------- // + +func TestPureGo_WriteDouble(t *testing.T) { + ds, _ := newTestDS(t, []testca.PVSpec{ + {Name: "DS:W", DBFType: proto.DBFDouble, Count: 1, Value: 0.0}, + }) + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + ch := make(chan datasource.Value, 16) + unsub, err := ds.Subscribe(ctx, "DS:W", ch) + if err != nil { + t.Fatalf("Subscribe: %v", err) + } + defer unsub() + + // Drain initial. + deadline := time.After(3 * time.Second) +drainLoop: + for { + select { + case v := <-ch: + if !v.MetaUpdate { + break drainLoop + } + case <-deadline: + t.Fatal("timeout draining initial frames") + } + } + + if err := ds.Write(ctx, "DS:W", float64(3.14)); err != nil { + t.Fatalf("Write: %v", err) + } + + deadline = time.After(3 * time.Second) + for { + select { + case v := <-ch: + if v.MetaUpdate { + continue + } + f, ok := v.Data.(float64) + if !ok { + continue + } + if math.Abs(f-3.14) > 1e-6 { + t.Errorf("post-write Data = %g, want 3.14", f) + } + return + case <-deadline: + t.Fatal("timeout waiting for post-write monitor update") + } + } +} + +func TestPureGo_WriteReadOnly(t *testing.T) { + ds, _ := newTestDS(t, []testca.PVSpec{ + {Name: "DS:WRO", DBFType: proto.DBFDouble, Count: 1, Value: 0.0, + Access: proto.AccessRead}, + }) + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + err := ds.Write(ctx, "DS:WRO", float64(1.0)) + if err == nil { + t.Fatal("expected error writing to read-only PV") + } +} + +// -------------------------------------------------------------------------- // +// ListSignals // +// -------------------------------------------------------------------------- // + +func TestPureGo_ListSignals(t *testing.T) { + ds, _ := newTestDS(t, []testca.PVSpec{ + {Name: "DS:LS1", DBFType: proto.DBFDouble, Count: 1, Value: 0.0}, + {Name: "DS:LS2", DBFType: proto.DBFDouble, Count: 1, Value: 0.0}, + }) + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + // GetMetadata for each PV to populate the cache. + for _, name := range []string{"DS:LS1", "DS:LS2"} { + if _, err := ds.GetMetadata(ctx, name); err != nil { + t.Fatalf("GetMetadata(%q): %v", name, err) + } + } + + sigs, err := ds.ListSignals(ctx) + if err != nil { + t.Fatalf("ListSignals: %v", err) + } + if len(sigs) < 2 { + t.Errorf("ListSignals returned %d signals, want ≥ 2", len(sigs)) + } +} + +// -------------------------------------------------------------------------- // +// History // +// -------------------------------------------------------------------------- // + +func TestPureGo_HistoryUnavailable(t *testing.T) { + ds, _ := newTestDS(t, []testca.PVSpec{ + {Name: "DS:HIST", DBFType: proto.DBFDouble, Count: 1, Value: 0.0}, + }) + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + _, err := ds.History(ctx, "DS:HIST", time.Now().Add(-time.Hour), time.Now(), 100) + if err != datasource.ErrHistoryUnavailable { + t.Errorf("History with no archiveURL: want ErrHistoryUnavailable, got %v", err) + } +} diff --git a/internal/datasource/epics/noop.go b/internal/datasource/epics/noop.go index 596e915..902c008 100644 --- a/internal/datasource/epics/noop.go +++ b/internal/datasource/epics/noop.go @@ -1,17 +1,311 @@ //go:build !epics -// Package epics provides an EPICS Channel Access data source. -// When built without the "epics" build tag (i.e. without CGo and EPICS Base), -// this stub is compiled instead, so that the rest of the codebase can call -// epics.Available() to decide whether to register the data source. +// Package epics provides an EPICS Channel Access data source for uopi using a +// pure-Go CA implementation (no CGo, no EPICS Base installation required). +// When built WITH the "epics" build tag, the CGo-based implementation in +// epics.go is used instead; that version requires CGo and a working EPICS Base. package epics -import "github.com/uopi/uopi/internal/datasource" +import ( + "context" + "fmt" + "log/slog" //nolint:depguard + "strings" + "sync" + "time" -// Available reports whether the EPICS Channel Access data source is compiled in. -// It always returns false when built without the "epics" build tag. -func Available() bool { return false } + ca "github.com/uopi/goca" + "github.com/uopi/goca/proto" + "github.com/uopi/uopi/internal/datasource" +) -// New is a placeholder that returns nil when EPICS support is not compiled in. -// Callers must check Available() before calling New(). -func New(caAddrList, archiveURL string) datasource.DataSource { return nil } +// Available always returns true for the pure-Go build. +func Available() bool { return true } + +// -------------------------------------------------------------------------- // +// EPICS data source (pure-Go implementation) // +// -------------------------------------------------------------------------- // + +// EPICS is the pure-Go Channel Access data source. +type EPICS struct { + caAddrList string + archiveURL string + pvNames []string // pre-fetched at connect time for ListSignals + + client *ca.Client + + mu sync.RWMutex + metadata map[string]datasource.Metadata +} + +// New creates a new EPICS Channel Access data source. +// +// caAddrList is the EPICS_CA_ADDR_LIST value (space-separated addresses). +// Leave empty to use the environment variable. +// archiveURL is the base URL of an EPICS Archive Appliance instance; leave +// empty to disable history queries. +// pvNames is an optional list of PV names whose metadata will be pre-fetched +// at connect time so they appear in ListSignals immediately (useful for the +// edit-mode signal tree). +func New(caAddrList, archiveURL string, pvNames []string) datasource.DataSource { + return &EPICS{ + caAddrList: caAddrList, + archiveURL: archiveURL, + pvNames: pvNames, + metadata: make(map[string]datasource.Metadata), + } +} + +// Name implements datasource.DataSource. +func (e *EPICS) Name() string { return "epics" } + +// Connect creates the CA client and starts background I/O goroutines. +// ctx governs the lifetime of the client; cancel it to shut everything down. +func (e *EPICS) Connect(ctx context.Context) error { + cfg := ca.ConfigFromEnv() + if e.caAddrList != "" { + cfg.AddrList = strings.Fields(e.caAddrList) + cfg.AutoAddrList = false + } + slog.Info("epics: connecting CA client", "addrs", cfg.AddrList, "auto_addr", cfg.AutoAddrList) + cli, err := ca.NewClient(ctx, cfg) + if err != nil { + return fmt.Errorf("epics: %w", err) + } + e.client = cli + slog.Info("epics: CA client started", "addrs", cfg.AddrList) + + // Pre-fetch metadata for any configured PV names so they appear in + // ListSignals as soon as the datasource is ready. We open a short-lived + // subscription (which drives channel connection) and then fetch CTRL info. + if len(e.pvNames) > 0 { + go func() { + for _, pv := range e.pvNames { + if ctx.Err() != nil { + return + } + func() { + tvCh := make(chan proto.TimeValue, 1) + subCtx, subCancel := context.WithTimeout(ctx, 15*time.Second) + defer subCancel() + cancel, err := e.client.Subscribe(subCtx, pv, tvCh) + if err != nil { + return + } + defer cancel() + // Channel is now connected; fetch CTRL metadata. + mctx, mcancel := context.WithTimeout(ctx, 5*time.Second) + defer mcancel() + _, _ = e.GetMetadata(mctx, pv) + }() + } + }() + } + + return nil +} + +// -------------------------------------------------------------------------- // +// Subscribe // +// -------------------------------------------------------------------------- // + +// Subscribe registers ch to receive live value updates for signal. +// It blocks until the CA channel is connected (or ctx expires). +// A background goroutine forwards proto.TimeValue updates to ch as +// datasource.Value. Call the returned CancelFunc to unsubscribe. +func (e *EPICS) Subscribe(ctx context.Context, signal string, ch chan<- datasource.Value) (datasource.CancelFunc, error) { + slog.Info("epics: subscribing", "pv", signal) + tvCh := make(chan proto.TimeValue, 16) + + clientCancel, err := e.client.Subscribe(ctx, signal, tvCh) + if err != nil { + slog.Error("epics: subscribe failed", "pv", signal, "err", err) + return nil, fmt.Errorf("epics: subscribe %q: %w", signal, err) + } + slog.Info("epics: subscribed OK", "pv", signal) + + // Fetch and cache metadata now that the channel is connected. + // Send MetaUpdate so the dispatcher re-broadcasts metadata to clients. + go func() { + // Small delay gives ACCESS_RIGHTS message time to arrive before GetCtrl. + time.Sleep(50 * time.Millisecond) + mctx, mcancel := context.WithTimeout(ctx, 5*time.Second) + defer mcancel() + if _, err := e.GetMetadata(mctx, signal); err == nil { + select { + case ch <- datasource.Value{MetaUpdate: true}: + default: + } + } + }() + + done := make(chan struct{}) + + // Forward goroutine: convert proto.TimeValue → datasource.Value. + go func() { + for { + select { + case tv := <-tvCh: + slog.Debug("epics: value received", "pv", signal, "double", tv.Double, "severity", tv.Severity) + val := e.timeValueToDS(signal, tv) + select { + case ch <- val: + default: // drop if consumer is slow + } + case <-done: + return + } + } + }() + + cancel := func() { + clientCancel() + close(done) + } + return datasource.CancelFunc(cancel), nil +} + +// timeValueToDS converts a proto.TimeValue to a datasource.Value. +func (e *EPICS) timeValueToDS(signal string, tv proto.TimeValue) datasource.Value { + var data any + if tv.Doubles != nil { + data = tv.Doubles + } else if tv.Str != "" { + data = tv.Str + } else { + // Use cached metadata type for correct int64 / float64 distinction. + e.mu.RLock() + meta, hasMeta := e.metadata[signal] + e.mu.RUnlock() + if hasMeta && (meta.Type == datasource.TypeInt64 || meta.Type == datasource.TypeEnum) { + data = int64(tv.Long) + } else { + data = tv.Double + } + } + + quality := datasource.QualityGood + switch tv.Severity { + case proto.SeverityMinor: + quality = datasource.QualityUncertain + case proto.SeverityMajor, proto.SeverityInvalid: + quality = datasource.QualityBad + } + + return datasource.Value{ + Timestamp: tv.Timestamp, + Data: data, + Quality: quality, + } +} + +// -------------------------------------------------------------------------- // +// GetMetadata // +// -------------------------------------------------------------------------- // + +// GetMetadata returns metadata for signal, using a cached value when available. +// On the first call for a signal it performs a DBR_CTRL_* GET to retrieve +// units, display limits, enum strings, and access rights. +func (e *EPICS) GetMetadata(ctx context.Context, signal string) (datasource.Metadata, error) { + e.mu.RLock() + if m, ok := e.metadata[signal]; ok { + e.mu.RUnlock() + return m, nil + } + e.mu.RUnlock() + + slog.Info("epics: fetching metadata", "pv", signal) + ci, err := e.client.GetCtrl(ctx, signal) + if err != nil { + slog.Error("epics: GetMetadata failed", "pv", signal, "err", err) + return datasource.Metadata{}, fmt.Errorf("epics: GetMetadata %q: %w", signal, err) + } + + m := e.ctrlToMeta(signal, ci) + slog.Info("epics: metadata fetched", "pv", signal, "type", m.Type, "unit", m.Unit, "writable", m.Writable) + + e.mu.Lock() + e.metadata[signal] = m + e.mu.Unlock() + + return m, nil +} + +// ctrlToMeta converts a ca.CtrlInfo to a datasource.Metadata. +func (e *EPICS) ctrlToMeta(name string, ci ca.CtrlInfo) datasource.Metadata { + m := datasource.Metadata{ + Name: name, + Writable: ci.Access&proto.AccessWrite != 0, + } + + switch { + case ci.Double != nil: + m.Type = datasource.TypeFloat64 + if ci.Count > 1 { + m.Type = datasource.TypeFloat64Array + } + m.Unit = ci.Double.Units + m.DisplayLow = ci.Double.LowerDispLimit + m.DisplayHigh = ci.Double.UpperDispLimit + m.DriveLow = ci.Double.LowerCtrlLimit + m.DriveHigh = ci.Double.UpperCtrlLimit + + case ci.Long != nil: + m.Type = datasource.TypeInt64 + m.Unit = ci.Long.Units + m.DisplayLow = float64(ci.Long.LowerDispLimit) + m.DisplayHigh = float64(ci.Long.UpperDispLimit) + m.DriveLow = float64(ci.Long.LowerCtrlLimit) + m.DriveHigh = float64(ci.Long.UpperCtrlLimit) + + case ci.Enum != nil: + m.Type = datasource.TypeEnum + m.EnumStrings = ci.Enum.Strings + + case ci.Str != nil: + m.Type = datasource.TypeString + } + + return m +} + +// -------------------------------------------------------------------------- // +// ListSignals // +// -------------------------------------------------------------------------- // + +// ListSignals returns metadata for all signals with cached information. +func (e *EPICS) ListSignals(_ context.Context) ([]datasource.Metadata, error) { + e.mu.RLock() + defer e.mu.RUnlock() + out := make([]datasource.Metadata, 0, len(e.metadata)) + for _, m := range e.metadata { + out = append(out, m) + } + return out, nil +} + +// -------------------------------------------------------------------------- // +// Write // +// -------------------------------------------------------------------------- // + +// Write puts a new value onto the named CA channel. +// value must be one of: float64, float32, int64, int32, int, int16, string, bool. +func (e *EPICS) Write(ctx context.Context, signal string, value any) error { + if err := e.client.Put(ctx, signal, value); err != nil { + return fmt.Errorf("epics: write %q: %w", signal, err) + } + return nil +} + +// -------------------------------------------------------------------------- // +// History // +// -------------------------------------------------------------------------- // + +// 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) +} diff --git a/internal/datasource/stub/stub_test.go b/internal/datasource/stub/stub_test.go new file mode 100644 index 0000000..3dc91a4 --- /dev/null +++ b/internal/datasource/stub/stub_test.go @@ -0,0 +1,210 @@ +package stub_test + +import ( + "context" + "testing" + "time" + + "github.com/uopi/uopi/internal/datasource" + "github.com/uopi/uopi/internal/datasource/stub" +) + +func newStub(t *testing.T) *stub.Stub { + t.Helper() + s := stub.New() + if err := s.Connect(context.Background()); err != nil { + t.Fatalf("Connect: %v", err) + } + return s +} + +func TestName(t *testing.T) { + if stub.New().Name() != "stub" { + t.Error("Name() should return 'stub'") + } +} + +func TestListSignals(t *testing.T) { + s := newStub(t) + sigs, err := s.ListSignals(context.Background()) + if err != nil { + t.Fatalf("ListSignals: %v", err) + } + if len(sigs) == 0 { + t.Fatal("expected at least one signal") + } + names := make(map[string]bool) + for _, m := range sigs { + names[m.Name] = true + } + for _, want := range []string{"sine_1hz", "ramp_10s", "toggle_1hz", "noise", "setpoint"} { + if !names[want] { + t.Errorf("signal %q not in ListSignals", want) + } + } +} + +func TestGetMetadata(t *testing.T) { + s := newStub(t) + m, err := s.GetMetadata(context.Background(), "sine_1hz") + if err != nil { + t.Fatalf("GetMetadata: %v", err) + } + if m.Type != datasource.TypeFloat64 { + t.Errorf("Type = %v, want TypeFloat64", m.Type) + } + if m.Unit == "" { + t.Error("expected non-empty Unit") + } +} + +func TestGetMetadataNotFound(t *testing.T) { + s := newStub(t) + _, err := s.GetMetadata(context.Background(), "no:such:pv") + if err != datasource.ErrNotFound { + t.Errorf("want ErrNotFound, got %v", err) + } +} + +func TestSubscribeReceivesValues(t *testing.T) { + s := newStub(t) + ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) + defer cancel() + + ch := make(chan datasource.Value, 4) + stop, err := s.Subscribe(ctx, "sine_1hz", ch) + if err != nil { + t.Fatalf("Subscribe: %v", err) + } + defer stop() + + select { + case v := <-ch: + if v.Quality != datasource.QualityGood { + t.Errorf("Quality = %v, want Good", v.Quality) + } + if _, ok := v.Data.(float64); !ok { + t.Errorf("Data type = %T, want float64", v.Data) + } + if v.Timestamp.IsZero() { + t.Error("Timestamp is zero") + } + case <-ctx.Done(): + t.Fatal("timeout waiting for first value") + } +} + +func TestSubscribeUnknown(t *testing.T) { + s := newStub(t) + _, err := s.Subscribe(context.Background(), "no:such", make(chan datasource.Value, 1)) + if err != datasource.ErrNotFound { + t.Errorf("want ErrNotFound, got %v", err) + } +} + +func TestSubscribeCancel(t *testing.T) { + s := newStub(t) + ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) + defer cancel() + + ch := make(chan datasource.Value, 4) + stop, err := s.Subscribe(ctx, "ramp_10s", ch) + if err != nil { + t.Fatalf("Subscribe: %v", err) + } + + // Drain one value to confirm it's running. + select { + case <-ch: + case <-ctx.Done(): + t.Fatal("timeout waiting for first value") + } + + stop() // cancel subscription + + // Drain remaining buffered values then confirm channel goes quiet. + time.Sleep(200 * time.Millisecond) + for len(ch) > 0 { + <-ch + } + time.Sleep(300 * time.Millisecond) + if len(ch) > 0 { + t.Error("channel still receiving after stop()") + } +} + +func TestWriteSetpoint(t *testing.T) { + s := newStub(t) + ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) + defer cancel() + + if err := s.Write(context.Background(), "setpoint", float64(99.0)); err != nil { + t.Fatalf("Write: %v", err) + } + + ch := make(chan datasource.Value, 4) + stop, err := s.Subscribe(ctx, "setpoint", ch) + if err != nil { + t.Fatalf("Subscribe: %v", err) + } + defer stop() + + select { + case v := <-ch: + f, ok := v.Data.(float64) + if !ok { + t.Fatalf("Data type = %T, want float64", v.Data) + } + if f != 99.0 { + t.Errorf("setpoint value = %g, want 99.0", f) + } + case <-ctx.Done(): + t.Fatal("timeout waiting for setpoint value") + } +} + +func TestWriteReadOnly(t *testing.T) { + s := newStub(t) + err := s.Write(context.Background(), "sine_1hz", float64(0)) + if err != datasource.ErrNotWritable { + t.Errorf("want ErrNotWritable, got %v", err) + } +} + +func TestWriteNotFound(t *testing.T) { + s := newStub(t) + err := s.Write(context.Background(), "no:such", float64(0)) + if err != datasource.ErrNotFound { + t.Errorf("want ErrNotFound, got %v", err) + } +} + +func TestHistory(t *testing.T) { + s := newStub(t) + _, err := s.History(context.Background(), "sine_1hz", time.Now().Add(-time.Hour), time.Now(), 100) + if err != datasource.ErrHistoryUnavailable { + t.Errorf("want ErrHistoryUnavailable, got %v", err) + } +} + +func TestToggleBool(t *testing.T) { + s := newStub(t) + ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) + defer cancel() + + ch := make(chan datasource.Value, 4) + stop, err := s.Subscribe(ctx, "toggle_1hz", ch) + if err != nil { + t.Fatalf("Subscribe: %v", err) + } + defer stop() + + select { + case v := <-ch: + if _, ok := v.Data.(bool); !ok { + t.Errorf("toggle_1hz Data type = %T, want bool", v.Data) + } + case <-ctx.Done(): + t.Fatal("timeout") + } +} diff --git a/internal/metrics/metrics_test.go b/internal/metrics/metrics_test.go new file mode 100644 index 0000000..6b984c1 --- /dev/null +++ b/internal/metrics/metrics_test.go @@ -0,0 +1,69 @@ +package metrics_test + +import ( + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/uopi/uopi/internal/metrics" +) + +func TestCounters(t *testing.T) { + // Counters are package-level atomics; exercise each Inc function. + metrics.IncWsConns() + metrics.IncWsConns() + metrics.DecWsConns() + metrics.IncMsgIn() + metrics.IncMsgOut() + metrics.IncWrites() + metrics.IncHistoryReqs() + // No panic = pass; values are additive across tests but that's fine. +} + +func TestHandlerFormat(t *testing.T) { + handler := metrics.Handler(func() int { return 42 }) + + req := httptest.NewRequest(http.MethodGet, "/metrics", nil) + rec := httptest.NewRecorder() + handler(rec, req) + + body := rec.Body.String() + + // Must contain all metric names. + required := []string{ + "uopi_uptime_seconds", + "uopi_ws_connections", + "uopi_signal_subscriptions", + "uopi_ws_messages_in_total", + "uopi_ws_messages_out_total", + "uopi_write_ops_total", + "uopi_history_requests_total", + } + for _, name := range required { + if !strings.Contains(body, name) { + t.Errorf("metric %q not found in response body", name) + } + } + + // Content-Type must be set for Prometheus scrapers. + ct := rec.Header().Get("Content-Type") + if !strings.Contains(ct, "text/plain") { + t.Errorf("Content-Type = %q, want text/plain", ct) + } + + // activeSubs value 42 must appear. + if !strings.Contains(body, "42") { + t.Error("activeSubs value 42 not found in body") + } +} + +func TestHandlerNilActiveSubs(t *testing.T) { + handler := metrics.Handler(nil) + req := httptest.NewRequest(http.MethodGet, "/metrics", nil) + rec := httptest.NewRecorder() + handler(rec, req) // must not panic + if rec.Code != http.StatusOK { + t.Errorf("status = %d, want 200", rec.Code) + } +} diff --git a/internal/storage/store_test.go b/internal/storage/store_test.go new file mode 100644 index 0000000..6f7a594 --- /dev/null +++ b/internal/storage/store_test.go @@ -0,0 +1,232 @@ +package storage_test + +import ( + "errors" + "testing" + + "github.com/uopi/uopi/internal/storage" +) + +const sampleXML = `` + +func newStore(t *testing.T) *storage.Store { + t.Helper() + s, err := storage.New(t.TempDir()) + if err != nil { + t.Fatalf("New: %v", err) + } + return s +} + +// -------------------------------------------------------------------------- // +// List // +// -------------------------------------------------------------------------- // + +func TestListEmpty(t *testing.T) { + s := newStore(t) + list, err := s.List() + if err != nil { + t.Fatalf("List: %v", err) + } + if len(list) != 0 { + t.Errorf("expected empty list, got %d items", len(list)) + } +} + +func TestListAfterCreate(t *testing.T) { + s := newStore(t) + if _, err := s.Create([]byte(sampleXML)); err != nil { + t.Fatalf("Create: %v", err) + } + list, err := s.List() + if err != nil { + t.Fatalf("List: %v", err) + } + if len(list) != 1 { + t.Fatalf("expected 1 item, got %d", len(list)) + } + if list[0].Name != "Test Interface" { + t.Errorf("Name = %q, want %q", list[0].Name, "Test Interface") + } + if list[0].Version != 1 { + t.Errorf("Version = %d, want 1", list[0].Version) + } +} + +// -------------------------------------------------------------------------- // +// Create + Get // +// -------------------------------------------------------------------------- // + +func TestCreateAndGet(t *testing.T) { + s := newStore(t) + id, err := s.Create([]byte(sampleXML)) + if err != nil { + t.Fatalf("Create: %v", err) + } + if id == "" { + t.Fatal("Create returned empty ID") + } + + data, err := s.Get(id) + if err != nil { + t.Fatalf("Get(%q): %v", id, err) + } + if string(data) != sampleXML { + t.Errorf("Get returned %q, want %q", data, sampleXML) + } +} + +func TestCreateUsesEmbeddedID(t *testing.T) { + s := newStore(t) + id, err := s.Create([]byte(sampleXML)) + if err != nil { + t.Fatalf("Create: %v", err) + } + // ID derived from xml id attr or name slug. + if id == "" { + t.Error("ID should not be empty") + } +} + +func TestCreateDuplicateGetsUniqueID(t *testing.T) { + s := newStore(t) + id1, err := s.Create([]byte(sampleXML)) + if err != nil { + t.Fatalf("first Create: %v", err) + } + id2, err := s.Create([]byte(sampleXML)) + if err != nil { + t.Fatalf("second Create: %v", err) + } + if id1 == id2 { + t.Errorf("duplicate Create returned same ID %q", id1) + } +} + +func TestCreateInvalidXML(t *testing.T) { + s := newStore(t) + _, err := s.Create([]byte("not xml")) + if err == nil { + t.Error("expected error for invalid XML") + } +} + +// -------------------------------------------------------------------------- // +// Get errors // +// -------------------------------------------------------------------------- // + +func TestGetNotFound(t *testing.T) { + s := newStore(t) + _, err := s.Get("does-not-exist") + if !errors.Is(err, storage.ErrNotFound) { + t.Errorf("Get missing: want ErrNotFound, got %v", err) + } +} + +func TestGetInvalidID(t *testing.T) { + s := newStore(t) + _, err := s.Get("../traversal") + if !errors.Is(err, storage.ErrNotFound) { + t.Errorf("Get path-traversal ID: want ErrNotFound, got %v", err) + } +} + +func TestGetEmptyID(t *testing.T) { + s := newStore(t) + _, err := s.Get("") + if !errors.Is(err, storage.ErrNotFound) { + t.Errorf("Get empty ID: want ErrNotFound, got %v", err) + } +} + +// -------------------------------------------------------------------------- // +// Update // +// -------------------------------------------------------------------------- // + +func TestUpdate(t *testing.T) { + s := newStore(t) + id, err := s.Create([]byte(sampleXML)) + if err != nil { + t.Fatalf("Create: %v", err) + } + + updated := `` + if err := s.Update(id, []byte(updated)); err != nil { + t.Fatalf("Update: %v", err) + } + + data, err := s.Get(id) + if err != nil { + t.Fatalf("Get after update: %v", err) + } + if string(data) != updated { + t.Errorf("post-update content mismatch") + } +} + +func TestUpdateNotFound(t *testing.T) { + s := newStore(t) + err := s.Update("no-such-id", []byte(sampleXML)) + if !errors.Is(err, storage.ErrNotFound) { + t.Errorf("Update missing: want ErrNotFound, got %v", err) + } +} + +// -------------------------------------------------------------------------- // +// Delete // +// -------------------------------------------------------------------------- // + +func TestDelete(t *testing.T) { + s := newStore(t) + id, err := s.Create([]byte(sampleXML)) + if err != nil { + t.Fatalf("Create: %v", err) + } + if err := s.Delete(id); err != nil { + t.Fatalf("Delete: %v", err) + } + _, err = s.Get(id) + if !errors.Is(err, storage.ErrNotFound) { + t.Errorf("Get after delete: want ErrNotFound, got %v", err) + } +} + +func TestDeleteNotFound(t *testing.T) { + s := newStore(t) + err := s.Delete("ghost") + if !errors.Is(err, storage.ErrNotFound) { + t.Errorf("Delete missing: want ErrNotFound, got %v", err) + } +} + +// -------------------------------------------------------------------------- // +// Clone // +// -------------------------------------------------------------------------- // + +func TestClone(t *testing.T) { + s := newStore(t) + id, err := s.Create([]byte(sampleXML)) + if err != nil { + t.Fatalf("Create: %v", err) + } + newID, err := s.Clone(id) + if err != nil { + t.Fatalf("Clone: %v", err) + } + if newID == id { + t.Error("Clone should produce a different ID") + } + orig, _ := s.Get(id) + copy, _ := s.Get(newID) + if string(orig) != string(copy) { + t.Error("Clone content should match original") + } +} + +func TestCloneNotFound(t *testing.T) { + s := newStore(t) + _, err := s.Clone("ghost") + if !errors.Is(err, storage.ErrNotFound) { + t.Errorf("Clone missing: want ErrNotFound, got %v", err) + } +} diff --git a/pkg/ca/client.go b/pkg/ca/client.go new file mode 100644 index 0000000..8678803 --- /dev/null +++ b/pkg/ca/client.go @@ -0,0 +1,421 @@ +package ca + +import ( + "context" + "fmt" + "os" + "path/filepath" + "strings" + "sync" + + "github.com/uopi/goca/proto" +) + +// -------------------------------------------------------------------------- // +// Config // +// -------------------------------------------------------------------------- // + +// Config holds the configuration for a Client. +// All fields are optional; sensible defaults are applied by NewClient. +type Config struct { + // AddrList is the list of CA server addresses ("host" or "host:port"). + // Corresponds to EPICS_CA_ADDR_LIST. + AddrList []string + + // AutoAddrList, when true, appends the IPv4 broadcast address of every + // local network interface to AddrList. Defaults to true. + // Corresponds to EPICS_CA_AUTO_ADDR_LIST. + AutoAddrList bool + + // ClientName is announced to every CA server in the CLIENT_NAME message. + // Defaults to the executable base name. + ClientName string + + // HostName is announced to every CA server in the HOST_NAME message. + // Defaults to the OS hostname. + HostName string +} + +// ConfigFromEnv reads the standard EPICS CA environment variables and returns +// a ready-to-use Config. +// +// EPICS_CA_ADDR_LIST — space-separated list of server addresses +// EPICS_CA_AUTO_ADDR_LIST — "NO" disables automatic broadcast addresses +func ConfigFromEnv() Config { + name := filepath.Base(os.Args[0]) + host, _ := os.Hostname() + cfg := Config{ + AutoAddrList: true, + ClientName: name, + HostName: host, + } + if v := os.Getenv("EPICS_CA_ADDR_LIST"); v != "" { + cfg.AddrList = strings.Fields(v) + } + if strings.EqualFold(os.Getenv("EPICS_CA_AUTO_ADDR_LIST"), "no") { + cfg.AutoAddrList = false + } + return cfg +} + +// -------------------------------------------------------------------------- // +// Client // +// -------------------------------------------------------------------------- // + +// Client is a thread-safe EPICS Channel Access client. +// +// A single Client should serve an entire application. It maintains a pool of +// persistent TCP circuits (one per IOC) and a shared UDP search engine. +// Channels and subscriptions survive IOC restarts automatically. +// +// Usage: +// +// cli, err := ca.NewClient(ctx, ca.ConfigFromEnv()) +// defer cli.Close() +// +// ch := make(chan proto.TimeValue, 16) +// cancel, err := cli.Subscribe(ctx, "MY:PV", ch) +// defer cancel() +// +// for tv := range ch { fmt.Println(tv.Double) } +type Client struct { + cfg Config + ctx context.Context + cancel context.CancelFunc + search *searchEngine + + mu sync.Mutex + circuits map[string]*circuit // IOC TCP addr → circuit +} + +// NewClient creates a new CA client and starts background I/O. +// ctx governs the lifetime of the client; cancelling it is equivalent to +// calling Close. +// +// Returns an error only if no search addresses can be derived from cfg. +func NewClient(ctx context.Context, cfg Config) (*Client, error) { + addrs := resolveAddrs(cfg.AddrList, proto.DefaultPort) + if cfg.AutoAddrList { + addrs = append(addrs, localBroadcastAddrs(proto.DefaultPort)...) + } + if len(addrs) == 0 { + return nil, fmt.Errorf("ca: no search addresses (set EPICS_CA_ADDR_LIST or enable AutoAddrList)") + } + + if cfg.ClientName == "" { + cfg.ClientName = filepath.Base(os.Args[0]) + } + if cfg.HostName == "" { + cfg.HostName, _ = os.Hostname() + } + + cctx, cancel := context.WithCancel(ctx) + se := newSearchEngine(addrs) + if err := se.start(cctx); err != nil { + cancel() + return nil, err + } + + return &Client{ + cfg: cfg, + ctx: cctx, + cancel: cancel, + search: se, + circuits: make(map[string]*circuit), + }, nil +} + +// Close shuts down all circuits and background goroutines. +// Any in-flight Subscribe, Get, or Put calls will unblock with an error. +func (c *Client) Close() { + c.cancel() +} + +// -------------------------------------------------------------------------- // +// Internal helpers // +// -------------------------------------------------------------------------- // + +// getOrCreateCircuit returns the circuit for addr, creating one if necessary. +func (c *Client) getOrCreateCircuit(addr string) *circuit { + c.mu.Lock() + defer c.mu.Unlock() + if circ, ok := c.circuits[addr]; ok { + return circ + } + circ := newCircuit(c.ctx, addr, c.cfg.ClientName, c.cfg.HostName) + c.circuits[addr] = circ + return circ +} + +// resolve finds the IOC for pvName via UDP search, then waits for the TCP +// channel to be fully established. Returns (circuit, chanState) on success. +func (c *Client) resolve(ctx context.Context, pvName string) (*circuit, *chanState, error) { + addr, err := c.search.lookup(ctx, pvName) + if err != nil { + return nil, nil, err + } + circ := c.getOrCreateCircuit(addr) + cs := circ.getOrCreateChannel(pvName) + if err := cs.waitReady(ctx); err != nil { + return nil, nil, fmt.Errorf("ca: %q: %w", pvName, err) + } + return circ, cs, nil +} + +// -------------------------------------------------------------------------- // +// Public API // +// -------------------------------------------------------------------------- // + +// Subscribe registers ch to receive live monitor updates for pvName. +// +// Values are pushed as proto.TimeValue structs (DBR_TIME_* encoding). +// ch should be buffered; slow consumers will have updates silently dropped. +// +// The returned CancelFunc unsubscribes from the PV and must always be called +// to avoid leaking resources. +// +// Subscribe blocks until the channel is connected or ctx expires. +func (c *Client) Subscribe(ctx context.Context, pvName string, ch chan<- proto.TimeValue) (context.CancelFunc, error) { + circ, cs, err := c.resolve(ctx, pvName) + if err != nil { + return nil, err + } + + cs.mu.RLock() + dbfType := cs.dbfType + count := cs.count + cs.mu.RUnlock() + + ms := &monState{ + subID: circ.nextID(), + dbrType: proto.NativeTimeType(dbfType, int(count)), + count: count, + ch: ch, + } + circ.addMonitor(cs, ms) + + return func() { circ.removeMonitor(cs, ms.subID) }, nil +} + +// Get performs a one-shot READ_NOTIFY for pvName and returns its current value. +// It blocks until the reply arrives or ctx expires. +func (c *Client) Get(ctx context.Context, pvName string) (proto.TimeValue, error) { + circ, cs, err := c.resolve(ctx, pvName) + if err != nil { + return proto.TimeValue{}, err + } + + cs.mu.RLock() + dbfType := cs.dbfType + count := cs.count + cs.mu.RUnlock() + + dbrType := proto.NativeTimeType(dbfType, int(count)) + return circ.get(ctx, cs, dbrType, count) +} + +// Put writes value to pvName using CA_PROTO_WRITE (fire-and-forget). +// +// value is automatically encoded to match the PV's native field type. +// Supported Go types: float64, float32, int64, int32, int, int16, string, bool. +// +// Put blocks only until the message is queued; it does not wait for an IOC +// acknowledgement. Use a timeout context to bound the wait for connection. +func (c *Client) Put(ctx context.Context, pvName string, value any) error { + circ, cs, err := c.resolve(ctx, pvName) + if err != nil { + return err + } + + cs.mu.RLock() + dbfType := cs.dbfType + cs.mu.RUnlock() + + dbrType, payload, err := encodePut(dbfType, value) + if err != nil { + return fmt.Errorf("ca: put %q: %w", pvName, err) + } + return circ.put(ctx, cs, dbrType, payload) +} + +// -------------------------------------------------------------------------- // +// Control metadata // +// -------------------------------------------------------------------------- // + +// CtrlInfo holds the full control-block metadata for a PV. +// Exactly one of the Double, Long, Enum, Str pointer fields is non-nil, +// depending on the PV's native field type. +type CtrlInfo struct { + DBFType int // proto.DBF* constant + Count uint32 // element count + Access uint32 // proto.AccessRead | proto.AccessWrite bitmask + + Double *proto.CtrlDouble // non-nil for DBFDouble / DBFFloat + Long *proto.CtrlLong // non-nil for DBFLong / DBFShort / DBFChar + Enum *proto.CtrlEnum // non-nil for DBFEnum + Str *proto.CtrlString // non-nil for DBFString +} + +// GetCtrl performs a READ_NOTIFY with a DBR_CTRL_* type and returns the full +// control-block metadata (units, display limits, enum strings, etc.) for pvName. +// +// GetCtrl blocks until the reply arrives or ctx expires. +func (c *Client) GetCtrl(ctx context.Context, pvName string) (CtrlInfo, error) { + circ, cs, err := c.resolve(ctx, pvName) + if err != nil { + return CtrlInfo{}, err + } + + cs.mu.RLock() + dbfType := cs.dbfType + count := cs.count + access := cs.access + cs.mu.RUnlock() + + ctrlType := proto.NativeCtrlType(dbfType) + _, payload, err := circ.getRaw(ctx, cs, ctrlType, count) + if err != nil { + return CtrlInfo{}, err + } + + ci := CtrlInfo{DBFType: dbfType, Count: count, Access: access} + + switch ctrlType { + case proto.DBRCtrlDouble: + cd, ok := proto.DecodeCtrlDouble(payload) + if !ok { + return CtrlInfo{}, fmt.Errorf("ca: %q: DecodeCtrlDouble failed (payload len %d)", pvName, len(payload)) + } + ci.Double = &cd + + case proto.DBRCtrlLong: + cl, ok := proto.DecodeCtrlLong(payload) + if !ok { + return CtrlInfo{}, fmt.Errorf("ca: %q: DecodeCtrlLong failed (payload len %d)", pvName, len(payload)) + } + ci.Long = &cl + + case proto.DBRCtrlEnum: + ce, ok := proto.DecodeCtrlEnum(payload) + if !ok { + return CtrlInfo{}, fmt.Errorf("ca: %q: DecodeCtrlEnum failed (payload len %d)", pvName, len(payload)) + } + ci.Enum = &ce + + case proto.DBRCtrlString: + cs2, ok := proto.DecodeCtrlString(payload) + if !ok { + return CtrlInfo{}, fmt.Errorf("ca: %q: DecodeCtrlString failed (payload len %d)", pvName, len(payload)) + } + ci.Str = &cs2 + + default: + return CtrlInfo{}, fmt.Errorf("ca: %q: unhandled ctrl type %d", pvName, ctrlType) + } + + return ci, nil +} + +// -------------------------------------------------------------------------- // +// Value encoding for Put // +// -------------------------------------------------------------------------- // + +// encodePut converts a Go value to a DBR wire payload matching the PV's +// native field type. +func encodePut(dbfType int, value any) (dbrType uint16, payload []byte, err error) { + switch dbfType { + case proto.DBFDouble, proto.DBFFloat: + f, e := toFloat64(value) + if e != nil { + return 0, nil, e + } + return proto.DBRDouble, proto.EncodeDouble(f), nil + + case proto.DBFLong: + n, e := toInt32(value) + if e != nil { + return 0, nil, e + } + return proto.DBRLong, proto.EncodeLong(n), nil + + case proto.DBFShort, proto.DBFChar, proto.DBFEnum: + n, e := toInt32(value) + if e != nil { + return 0, nil, e + } + return proto.DBRShort, proto.EncodeShort(int16(n)), nil + + case proto.DBFString: + s, e := toString(value) + if e != nil { + return 0, nil, e + } + return proto.DBRString, proto.EncodeString(s), nil + + default: + return 0, nil, fmt.Errorf("unsupported DBF type %d", dbfType) + } +} + +func toFloat64(v any) (float64, error) { + switch x := v.(type) { + case float64: + return x, nil + case float32: + return float64(x), nil + case int: + return float64(x), nil + case int64: + return float64(x), nil + case int32: + return float64(x), nil + case int16: + return float64(x), nil + case uint64: + return float64(x), nil + case uint32: + return float64(x), nil + case bool: + if x { + return 1, nil + } + return 0, nil + default: + return 0, fmt.Errorf("cannot convert %T to float64", v) + } +} + +func toInt32(v any) (int32, error) { + switch x := v.(type) { + case int: + return int32(x), nil + case int64: + return int32(x), nil + case int32: + return x, nil + case int16: + return int32(x), nil + case float64: + return int32(x), nil + case float32: + return int32(x), nil + case bool: + if x { + return 1, nil + } + return 0, nil + default: + return 0, fmt.Errorf("cannot convert %T to int32", v) + } +} + +func toString(v any) (string, error) { + switch x := v.(type) { + case string: + return x, nil + case []byte: + return string(x), nil + default: + return fmt.Sprintf("%v", x), nil + } +} diff --git a/pkg/ca/client_test.go b/pkg/ca/client_test.go new file mode 100644 index 0000000..e43c904 --- /dev/null +++ b/pkg/ca/client_test.go @@ -0,0 +1,397 @@ +package ca_test + +import ( + "context" + "math" + "testing" + "time" + + "github.com/uopi/goca" + "github.com/uopi/goca/testca" + "github.com/uopi/goca/proto" +) + +// newTestClient creates a Client pointing only at the fake server's addresses. +func newTestClient(t *testing.T, srv *testca.Server) *ca.Client { + t.Helper() + cfg := ca.Config{ + AddrList: []string{srv.UDPAddr()}, + AutoAddrList: false, + ClientName: "testclient", + HostName: "localhost", + } + cli, err := ca.NewClient(context.Background(), cfg) + if err != nil { + t.Fatalf("NewClient: %v", err) + } + t.Cleanup(cli.Close) + return cli +} + +// newTestServer creates a fake CA server with standard test PVs. +func newTestServer(t *testing.T) *testca.Server { + t.Helper() + srv, err := testca.New([]testca.PVSpec{ + {Name: "TEST:DOUBLE", DBFType: proto.DBFDouble, Count: 1, Value: 3.14}, + {Name: "TEST:LONG", DBFType: proto.DBFLong, Count: 1, Value: int32(42)}, + {Name: "TEST:STRING", DBFType: proto.DBFString, Count: 1, Value: "hello"}, + {Name: "TEST:ENUM", DBFType: proto.DBFEnum, Count: 1, Value: int16(1)}, + {Name: "TEST:READONLY", DBFType: proto.DBFDouble, Count: 1, Value: 1.0, + Access: proto.AccessRead}, + }) + if err != nil { + t.Fatalf("testca.New: %v", err) + } + t.Cleanup(srv.Close) + return srv +} + +// -------------------------------------------------------------------------- // +// Get tests // +// -------------------------------------------------------------------------- // + +func TestGetDouble(t *testing.T) { + srv := newTestServer(t) + cli := newTestClient(t, srv) + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + tv, err := cli.Get(ctx, "TEST:DOUBLE") + if err != nil { + t.Fatalf("Get: %v", err) + } + if math.Abs(tv.Double-3.14) > 1e-6 { + t.Errorf("Double = %g, want ~3.14", tv.Double) + } + if tv.Timestamp.IsZero() { + t.Error("Timestamp is zero") + } +} + +func TestGetLong(t *testing.T) { + srv := newTestServer(t) + cli := newTestClient(t, srv) + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + tv, err := cli.Get(ctx, "TEST:LONG") + if err != nil { + t.Fatalf("Get: %v", err) + } + if tv.Long != 42 { + t.Errorf("Long = %d, want 42", tv.Long) + } +} + +func TestGetString(t *testing.T) { + srv := newTestServer(t) + cli := newTestClient(t, srv) + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + tv, err := cli.Get(ctx, "TEST:STRING") + if err != nil { + t.Fatalf("Get: %v", err) + } + if tv.Str != "hello" { + t.Errorf("Str = %q, want %q", tv.Str, "hello") + } +} + +func TestGetNotFound(t *testing.T) { + srv := newTestServer(t) + cli := newTestClient(t, srv) + + ctx, cancel := context.WithTimeout(context.Background(), 500*time.Millisecond) + defer cancel() + + _, err := cli.Get(ctx, "NO:SUCH:PV") + if err == nil { + t.Fatal("expected error for missing PV, got nil") + } +} + +// -------------------------------------------------------------------------- // +// Subscribe tests // +// -------------------------------------------------------------------------- // + +func TestSubscribeReceivesInitialValue(t *testing.T) { + srv := newTestServer(t) + cli := newTestClient(t, srv) + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + ch := make(chan proto.TimeValue, 8) + unsub, err := cli.Subscribe(ctx, "TEST:DOUBLE", ch) + if err != nil { + t.Fatalf("Subscribe: %v", err) + } + defer unsub() + + select { + case tv := <-ch: + if math.Abs(tv.Double-3.14) > 1e-6 { + t.Errorf("initial Double = %g, want ~3.14", tv.Double) + } + case <-ctx.Done(): + t.Fatal("timeout waiting for initial monitor value") + } +} + +func TestSubscribeReceivesUpdates(t *testing.T) { + srv := newTestServer(t) + cli := newTestClient(t, srv) + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + ch := make(chan proto.TimeValue, 8) + unsub, err := cli.Subscribe(ctx, "TEST:DOUBLE", ch) + if err != nil { + t.Fatalf("Subscribe: %v", err) + } + defer unsub() + + // Drain initial value. + select { + case <-ch: + case <-ctx.Done(): + t.Fatal("timeout waiting for initial value") + } + + // Push a new value from the server side. + if err := srv.SetValue("TEST:DOUBLE", 99.9); err != nil { + t.Fatalf("SetValue: %v", err) + } + + select { + case tv := <-ch: + if math.Abs(tv.Double-99.9) > 1e-6 { + t.Errorf("updated Double = %g, want ~99.9", tv.Double) + } + case <-ctx.Done(): + t.Fatal("timeout waiting for updated monitor value") + } +} + +func TestUnsubscribeStopsUpdates(t *testing.T) { + srv := newTestServer(t) + cli := newTestClient(t, srv) + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + ch := make(chan proto.TimeValue, 8) + unsub, err := cli.Subscribe(ctx, "TEST:DOUBLE", ch) + if err != nil { + t.Fatalf("Subscribe: %v", err) + } + + // Drain initial value. + select { + case <-ch: + case <-ctx.Done(): + t.Fatal("timeout waiting for initial value") + } + + unsub() // unsubscribe + + // Give the EVENT_CANCEL message time to be delivered. + time.Sleep(50 * time.Millisecond) + + // Push a value; channel should NOT receive it. + srv.SetValue("TEST:DOUBLE", 777.0) + + select { + case tv := <-ch: + // It's possible one update slipped through before cancel was processed. + // Accept it, but not a second one. + t.Logf("received one update after unsub (value=%g), checking for second...", tv.Double) + select { + case <-ch: + t.Error("received second update after unsubscribe") + case <-time.After(100 * time.Millisecond): + // Good — no second update. + } + case <-time.After(150 * time.Millisecond): + // No update received — correct. + } +} + +// -------------------------------------------------------------------------- // +// Put tests // +// -------------------------------------------------------------------------- // + +func TestPutDouble(t *testing.T) { + srv := newTestServer(t) + cli := newTestClient(t, srv) + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + // Subscribe to observe the effect of Put. + ch := make(chan proto.TimeValue, 8) + unsub, err := cli.Subscribe(ctx, "TEST:DOUBLE", ch) + if err != nil { + t.Fatalf("Subscribe: %v", err) + } + defer unsub() + + // Drain initial value. + select { + case <-ch: + case <-ctx.Done(): + t.Fatal("timeout waiting for initial value") + } + + if err := cli.Put(ctx, "TEST:DOUBLE", float64(2.718)); err != nil { + t.Fatalf("Put: %v", err) + } + + select { + case tv := <-ch: + if math.Abs(tv.Double-2.718) > 1e-6 { + t.Errorf("post-put Double = %g, want ~2.718", tv.Double) + } + case <-ctx.Done(): + t.Fatal("timeout waiting for post-put monitor value") + } +} + +func TestPutLong(t *testing.T) { + srv := newTestServer(t) + cli := newTestClient(t, srv) + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + ch := make(chan proto.TimeValue, 8) + unsub, err := cli.Subscribe(ctx, "TEST:LONG", ch) + if err != nil { + t.Fatalf("Subscribe: %v", err) + } + defer unsub() + <-ch // drain initial + + if err := cli.Put(ctx, "TEST:LONG", int(100)); err != nil { + t.Fatalf("Put: %v", err) + } + + select { + case tv := <-ch: + if tv.Long != 100 { + t.Errorf("post-put Long = %d, want 100", tv.Long) + } + case <-ctx.Done(): + t.Fatal("timeout waiting for post-put value") + } +} + +func TestPutString(t *testing.T) { + srv := newTestServer(t) + cli := newTestClient(t, srv) + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + ch := make(chan proto.TimeValue, 8) + unsub, err := cli.Subscribe(ctx, "TEST:STRING", ch) + if err != nil { + t.Fatalf("Subscribe: %v", err) + } + defer unsub() + <-ch // drain initial + + if err := cli.Put(ctx, "TEST:STRING", "world"); err != nil { + t.Fatalf("Put: %v", err) + } + + select { + case tv := <-ch: + if tv.Str != "world" { + t.Errorf("post-put Str = %q, want %q", tv.Str, "world") + } + case <-ctx.Done(): + t.Fatal("timeout waiting for post-put value") + } +} + +func TestPutReadOnly(t *testing.T) { + srv := newTestServer(t) + cli := newTestClient(t, srv) + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + err := cli.Put(ctx, "TEST:READONLY", float64(1.0)) + if err == nil { + t.Fatal("expected error writing to read-only PV") + } +} + +// -------------------------------------------------------------------------- // +// Multiple subscribers on the same PV // +// -------------------------------------------------------------------------- // + +func TestMultipleSubscribers(t *testing.T) { + srv := newTestServer(t) + cli := newTestClient(t, srv) + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + const n = 3 + chs := make([]chan proto.TimeValue, n) + for i := range chs { + chs[i] = make(chan proto.TimeValue, 8) + unsub, err := cli.Subscribe(ctx, "TEST:DOUBLE", chs[i]) + if err != nil { + t.Fatalf("Subscribe[%d]: %v", i, err) + } + defer unsub() + } + + // Drain initial values. + for i, ch := range chs { + select { + case <-ch: + case <-ctx.Done(): + t.Fatalf("timeout waiting for initial value on subscriber %d", i) + } + } + + // Push a new value. + srv.SetValue("TEST:DOUBLE", 55.5) + + for i, ch := range chs { + select { + case tv := <-ch: + if math.Abs(tv.Double-55.5) > 1e-6 { + t.Errorf("subscriber %d: Double = %g, want 55.5", i, tv.Double) + } + case <-ctx.Done(): + t.Fatalf("timeout waiting for update on subscriber %d", i) + } + } +} + +// -------------------------------------------------------------------------- // +// Context cancellation // +// -------------------------------------------------------------------------- // + +func TestGetContextCancelled(t *testing.T) { + srv := newTestServer(t) + cli := newTestClient(t, srv) + + ctx, cancel := context.WithCancel(context.Background()) + cancel() // already cancelled + + _, err := cli.Get(ctx, "TEST:DOUBLE") + if err == nil { + t.Fatal("expected error with cancelled context") + } +} diff --git a/pkg/ca/conn.go b/pkg/ca/conn.go new file mode 100644 index 0000000..fedc141 --- /dev/null +++ b/pkg/ca/conn.go @@ -0,0 +1,743 @@ +package ca + +import ( + "context" + "fmt" + "io" + "net" + "sync" + "sync/atomic" + "time" + + "github.com/uopi/goca/proto" +) + +const ( + writeQueueDepth = 256 + echoInterval = 15 * time.Second + connectTimeout = 5 * time.Second + maxReconnDelay = 60 * time.Second +) + +// -------------------------------------------------------------------------- // +// reply — async GET callback payload // +// -------------------------------------------------------------------------- // + +// reply is delivered to a pending GET waiter via a buffered channel. +// If ok is false the circuit died before the reply arrived. +type reply struct { + hdr proto.Header + payload []byte + ok bool +} + +// -------------------------------------------------------------------------- // +// monState — one active EVENT_ADD subscription // +// -------------------------------------------------------------------------- // + +// monState survives reconnections; the circuit re-sends EVENT_ADD with the +// new SID each time a CREATE_CHAN reply is received. +type monState struct { + subID uint32 + dbrType uint16 + count uint32 + ch chan<- proto.TimeValue // caller-owned, never closed here +} + +// -------------------------------------------------------------------------- // +// chanState — one CA channel (persists across reconnections) // +// -------------------------------------------------------------------------- // + +// chanState field invariants: +// - cid, pvName: immutable after construction. +// - sid, dbfType, count, access: valid only while readyC is closed. +// - readyC: closed when CREATE_CHAN reply received; replaced on reconnect. +// - monitors: append-only (except removeMonitor); never cleared on reconnect. +// - pending: ioid → GET reply channel; drained (channels closed) on reconnect. +type chanState struct { + cid uint32 + pvName string + + mu sync.RWMutex + sid uint32 // server-assigned channel ID (0 until CREATE_CHAN reply) + dbfType int // native DBF field type + count uint32 // element count + access uint32 // AccessRead | AccessWrite bitmask + readyC chan struct{} // closed once CREATE_CHAN reply is received + monitors []*monState // active subscriptions + pending map[uint32]chan reply // ioid → one-shot GET callback +} + +func newChanState(cid uint32, pvName string) *chanState { + return &chanState{ + cid: cid, + pvName: pvName, + readyC: make(chan struct{}), + pending: make(map[uint32]chan reply), + } +} + +// resetForReconnect prepares cs for a new TCP connection. +// It closes the current readyC (waking any waitReady callers so they can +// re-wait on the fresh channel) and then replaces it. +// Must be called before the circuit sends CREATE_CHAN on the new conn. +func (cs *chanState) resetForReconnect() { + cs.mu.Lock() + cs.sid = 0 + old := cs.readyC + cs.readyC = make(chan struct{}) + // Drain pending GETs — they will receive zero reply (ok=false). + for id, ch := range cs.pending { + close(ch) + delete(cs.pending, id) + } + cs.mu.Unlock() + // Close the old readyC outside the lock to avoid deadlock with waitReady. + select { + case <-old: + // Already closed (CmdCreateFail path or double-reset guard). + default: + close(old) + } +} + +// waitReady blocks until sid != 0 (CREATE_CHAN reply received for the current +// connection) or ctx expires. It loops through reconnections automatically: +// when resetForReconnect closes the current readyC the goroutine wakes, sees +// sid == 0, and waits on the freshly created channel. +func (cs *chanState) waitReady(ctx context.Context) error { + for { + cs.mu.RLock() + sid := cs.sid + ready := cs.readyC + cs.mu.RUnlock() + + if sid != 0 { + return nil + } + + select { + case <-ready: + // State changed — either CREATE_CHAN reply (sid set) or reconnect + // started (sid cleared, new readyC installed). Loop to check. + case <-ctx.Done(): + return ctx.Err() + } + } +} + +// -------------------------------------------------------------------------- // +// circuit — persistent TCP connection to one CA server // +// -------------------------------------------------------------------------- // + +// circuit manages a persistent, auto-reconnecting TCP connection to a single +// CA server. All channels and monitors created on a circuit survive +// reconnections; the circuit re-creates them transparently. +// +// Goroutine model: +// - run() goroutine: reconnect loop; drives the write loop for each conn. +// - readLoop() goroutine: one per active conn; dispatches inbound messages. +// +// Lock ordering: circuit.mu → chanState.mu (never the reverse). +type circuit struct { + addr string + clientName string + hostName string + + ctx context.Context + cancel context.CancelFunc + + mu sync.RWMutex + channels []*chanState // append-only; survive reconnect + byCID map[uint32]*chanState // cid → chanState (fast lookup) + bySubID map[uint32]*monState // subID → monState (fast event dispatch) + + writeQ chan []byte // serialised outbound message queue + seq atomic.Uint32 // shared ID sequence (cid, ioid, subID) +} + +func newCircuit(ctx context.Context, addr, clientName, hostName string) *circuit { + cctx, cancel := context.WithCancel(ctx) + c := &circuit{ + addr: addr, + clientName: clientName, + hostName: hostName, + ctx: cctx, + cancel: cancel, + byCID: make(map[uint32]*chanState), + bySubID: make(map[uint32]*monState), + writeQ: make(chan []byte, writeQueueDepth), + } + go c.run() + return c +} + +func (c *circuit) close() { c.cancel() } + +func (c *circuit) nextID() uint32 { return c.seq.Add(1) } + +// -------------------------------------------------------------------------- // +// Reconnect loop // +// -------------------------------------------------------------------------- // + +func (c *circuit) run() { + delay := time.Second + for { + if c.ctx.Err() != nil { + return + } + + dbg("CA circuit connecting", "addr", c.addr) + conn, err := c.dial() + if err != nil { + dbg("CA circuit dial failed", "addr", c.addr, "err", err) + select { + case <-time.After(delay): + case <-c.ctx.Done(): + return + } + delay = min(delay*2, maxReconnDelay) + continue + } + dbg("CA circuit connected", "addr", c.addr) + delay = time.Second // reset back-off on successful connect + + // Snapshot channels and reset their per-connection state. + c.mu.Lock() + for _, cs := range c.channels { + cs.resetForReconnect() + } + chs := make([]*chanState, len(c.channels)) + copy(chs, c.channels) + c.mu.Unlock() + + // Drain stale messages from the write queue (they carry old SIDs). + for len(c.writeQ) > 0 { + <-c.writeQ + } + + // Send VERSION + HOST_NAME + CLIENT_NAME. + if err := c.sendHandshake(conn); err != nil { + conn.Close() + continue + } + + // Re-create all known channels on the new connection. + setupOK := true + for _, cs := range chs { + if _, err := conn.Write(buildCreateChanMsg(cs.cid, cs.pvName)); err != nil { + setupOK = false + break + } + } + if !setupOK { + conn.Close() + continue + } + + dbg("CA handshake sent", "addr", c.addr, "channels", len(chs)) + + // Start read loop. + readDone := make(chan struct{}) + go func() { + c.readLoop(conn) + dbg("CA read loop exited", "addr", c.addr) + close(readDone) + }() + + // Write loop (run goroutine acts as the write pump). + c.writeLoop(conn, readDone) + + conn.Close() + <-readDone // drain read goroutine before reconnecting + + if c.ctx.Err() != nil { + return + } + + select { + case <-time.After(delay): + case <-c.ctx.Done(): + return + } + delay = min(delay*2, maxReconnDelay) + } +} + +func (c *circuit) writeLoop(conn net.Conn, readDone <-chan struct{}) { + ticker := time.NewTicker(echoInterval) + defer ticker.Stop() + for { + select { + case msg := <-c.writeQ: + if _, err := conn.Write(msg); err != nil { + return + } + case <-ticker.C: + // Periodic heartbeat; server echoes it back. + hb := proto.BuildMessage(proto.Header{Command: proto.CmdEcho}, nil) + if _, err := conn.Write(hb); err != nil { + return + } + case <-readDone: + return + case <-c.ctx.Done(): + return + } + } +} + +// -------------------------------------------------------------------------- // +// Dial + handshake // +// -------------------------------------------------------------------------- // + +func (c *circuit) dial() (net.Conn, error) { + d := net.Dialer{Timeout: connectTimeout} + conn, err := d.DialContext(c.ctx, "tcp4", c.addr) + if err != nil { + return nil, fmt.Errorf("ca: dial %s: %w", c.addr, err) + } + return conn, nil +} + +func (c *circuit) sendHandshake(conn net.Conn) error { + ver := proto.BuildMessage(proto.Header{ + Command: proto.CmdVersion, + DataCount: proto.MinorVersion, + }, nil) + host := proto.BuildMessage( + proto.Header{Command: proto.CmdHostName}, + proto.BuildStringPayload(c.hostName), + ) + client := proto.BuildMessage( + proto.Header{Command: proto.CmdClientName}, + proto.BuildStringPayload(c.clientName), + ) + out := make([]byte, 0, len(ver)+len(host)+len(client)) + out = append(out, ver...) + out = append(out, host...) + out = append(out, client...) + _, err := conn.Write(out) + return err +} + +// -------------------------------------------------------------------------- // +// Read loop + message dispatch // +// -------------------------------------------------------------------------- // + +func (c *circuit) readLoop(conn net.Conn) { + for { + hdr, _, err := proto.DecodeHeader(conn) + if err != nil { + if c.ctx.Err() == nil { + dbg("CA read loop error (header)", "addr", c.addr, "err", err) + } + return + } + dbg("CA recv", "addr", c.addr, "cmd", hdr.Command, "dataType", hdr.DataType, + "dataCount", hdr.DataCount, "payloadSize", hdr.PayloadSize, + "p1", hdr.Parameter1, "p2", hdr.Parameter2) + var payload []byte + if hdr.PayloadSize > 0 { + payload = make([]byte, hdr.PayloadSize) + if _, err = io.ReadFull(conn, payload); err != nil { + if c.ctx.Err() == nil { + dbg("CA read loop error (payload)", "addr", c.addr, + "cmd", hdr.Command, "wantBytes", hdr.PayloadSize, "err", err) + } + return + } + } + c.dispatch(conn, hdr, payload) + } +} + +// dispatch handles one inbound CA message. +func (c *circuit) dispatch(conn net.Conn, hdr proto.Header, payload []byte) { + switch hdr.Command { + + case proto.CmdVersion: + // Server version negotiation reply — nothing to do. + + case proto.CmdCreateChan: + // Parameter1 = cid (echoed), Parameter2 = SID assigned by server. + c.mu.RLock() + cs, ok := c.byCID[hdr.Parameter1] + c.mu.RUnlock() + if !ok { + return + } + cs.mu.Lock() + cs.sid = hdr.Parameter2 + cs.dbfType = int(hdr.DataType) + cs.count = hdr.DataCount + ready := cs.readyC + // Snapshot monitors for EVENT_ADD re-subscription. + mons := make([]*monState, len(cs.monitors)) + copy(mons, cs.monitors) + cs.mu.Unlock() + + dbg("CA CREATE_CHAN reply", "pv", cs.pvName, "sid", hdr.Parameter2, + "dbfType", hdr.DataType, "count", hdr.DataCount, "monitors", len(mons)) + + // Re-subscribe all monitors with the new SID. + for _, ms := range mons { + msg := buildEventAddMsg(ms.subID, hdr.Parameter2, ms.dbrType, ms.count) + dbg("CA EVENT_ADD sent", "pv", cs.pvName, "subID", ms.subID, + "sid", hdr.Parameter2, "dbrType", ms.dbrType, "count", ms.count) + select { + case c.writeQ <- msg: + default: + } + } + close(ready) // unblock waitReady callers + + case proto.CmdCreateFail: + // Server does not host this PV (or quota exceeded). + c.mu.RLock() + cs, ok := c.byCID[hdr.Parameter1] + c.mu.RUnlock() + if !ok { + return + } + dbg("CA CREATE_FAIL", "pv", cs.pvName, "cid", hdr.Parameter1) + cs.mu.RLock() + ready := cs.readyC + cs.mu.RUnlock() + // readyC is only closed once per connection cycle by this goroutine, + // so the select here is safe (no concurrent close). + select { + case <-ready: + // Already closed (shouldn't happen, but guard against it). + default: + close(ready) + } + + case proto.CmdAccessRights: + // Parameter1 = cid, Parameter2 = access bitmask. + c.mu.RLock() + cs, ok := c.byCID[hdr.Parameter1] + c.mu.RUnlock() + if ok { + cs.mu.Lock() + cs.access = hdr.Parameter2 + cs.mu.Unlock() + } + + case proto.CmdEventAdd: + // Monitor update: Parameter1 = subscriptionID. + c.mu.RLock() + ms, ok := c.bySubID[hdr.Parameter1] + c.mu.RUnlock() + if !ok { + dbg("CA EVENT_ADD for unknown subID", "subID", hdr.Parameter1) + return + } + tv, ok := proto.DecodeTimeValue(hdr.DataType, hdr.DataCount, payload) + if !ok { + dbg("CA EVENT_ADD decode failed", "subID", hdr.Parameter1, + "dbrType", hdr.DataType, "count", hdr.DataCount, "payloadLen", len(payload)) + return + } + dbg("CA EVENT_ADD value", "subID", hdr.Parameter1, "dbrType", hdr.DataType, + "double", tv.Double, "severity", tv.Severity) + select { + case ms.ch <- tv: + default: // drop if consumer is slow + } + + case proto.CmdReadNotify: + // GET reply: Parameter1 = ioid. + ioid := hdr.Parameter1 + c.mu.RLock() + var replyCh chan reply + var found bool + for _, cs := range c.channels { + cs.mu.Lock() + if ch, exists := cs.pending[ioid]; exists { + delete(cs.pending, ioid) + replyCh = ch + found = true + } + cs.mu.Unlock() + if found { + break + } + } + c.mu.RUnlock() + if found { + select { + case replyCh <- reply{hdr: hdr, payload: payload, ok: true}: + default: + } + } + + case proto.CmdEcho: + // Server heartbeat — no response needed (client sends its own echoes). + + case proto.CmdServerDisc: + // Server is shutting down — close conn to trigger reconnect. + conn.Close() + } +} + +// -------------------------------------------------------------------------- // +// Message builders // +// -------------------------------------------------------------------------- // + +func buildCreateChanMsg(cid uint32, pvName string) []byte { + return proto.BuildMessage(proto.Header{ + Command: proto.CmdCreateChan, + Parameter1: cid, + Parameter2: proto.MinorVersion, + }, proto.BuildStringPayload(pvName)) +} + +func buildEventAddMsg(subID, sid uint32, dbrType uint16, count uint32) []byte { + return proto.BuildMessage(proto.Header{ + Command: proto.CmdEventAdd, + DataType: dbrType, + DataCount: count, + Parameter1: sid, // m_cid = channel SID (per CA spec) + Parameter2: subID, // m_available = client subscription ID + }, proto.EncodeEventMask(proto.DBEDefault)) +} + +func buildEventCancelMsg(subID, sid uint32, dbrType uint16) []byte { + return proto.BuildMessage(proto.Header{ + Command: proto.CmdEventCancel, + DataType: dbrType, + Parameter1: sid, // m_cid = channel SID + Parameter2: subID, // m_available = client subscription ID + }, nil) +} + +// -------------------------------------------------------------------------- // +// Channel and monitor management // +// -------------------------------------------------------------------------- // + +// getOrCreateChannel returns the chanState for pvName, creating it if needed. +// If the circuit is currently connected, CREATE_CHAN is queued immediately; +// otherwise the reconnect loop will send it when it next connects. +func (c *circuit) getOrCreateChannel(pvName string) *chanState { + c.mu.Lock() + defer c.mu.Unlock() + for _, cs := range c.channels { + if cs.pvName == pvName { + return cs + } + } + cid := c.nextID() + cs := newChanState(cid, pvName) + c.channels = append(c.channels, cs) + c.byCID[cid] = cs + // Best-effort: deliver CREATE_CHAN if circuit is up. + msg := buildCreateChanMsg(cid, pvName) + select { + case c.writeQ <- msg: + default: + } + return cs +} + +// addMonitor registers ms on cs and queues EVENT_ADD if the channel is ready. +// If the channel is not yet ready the CREATE_CHAN reply handler will send +// EVENT_ADD when the connection is established. +func (c *circuit) addMonitor(cs *chanState, ms *monState) { + c.mu.Lock() + c.bySubID[ms.subID] = ms + c.mu.Unlock() + + cs.mu.Lock() + cs.monitors = append(cs.monitors, ms) + sid := cs.sid + cs.mu.Unlock() + + if sid != 0 { + dbg("CA EVENT_ADD sent (addMonitor)", "pv", cs.pvName, "subID", ms.subID, + "sid", sid, "dbrType", ms.dbrType, "count", ms.count) + msg := buildEventAddMsg(ms.subID, sid, ms.dbrType, ms.count) + select { + case c.writeQ <- msg: + default: + } + } +} + +// removeMonitor unregisters a monitor and queues EVENT_CANCEL if connected. +func (c *circuit) removeMonitor(cs *chanState, subID uint32) { + c.mu.Lock() + delete(c.bySubID, subID) + c.mu.Unlock() + + cs.mu.Lock() + var dbrType uint16 + remaining := cs.monitors[:0:0] // fresh backing array + for _, ms := range cs.monitors { + if ms.subID == subID { + dbrType = ms.dbrType + } else { + remaining = append(remaining, ms) + } + } + cs.monitors = remaining + sid := cs.sid + cs.mu.Unlock() + + if sid != 0 { + msg := buildEventCancelMsg(subID, sid, dbrType) + select { + case c.writeQ <- msg: + default: + } + } +} + +// -------------------------------------------------------------------------- // +// Channel operations: get and put // +// -------------------------------------------------------------------------- // + +// getRaw performs a READ_NOTIFY and returns the undecoded header + payload. +// Use this when you need to decode a type that get() does not handle +// (e.g. DBR_CTRL_* for metadata retrieval). +func (c *circuit) getRaw(ctx context.Context, cs *chanState, dbrType uint16, count uint32) (proto.Header, []byte, error) { + if err := cs.waitReady(ctx); err != nil { + return proto.Header{}, nil, fmt.Errorf("ca: %q: %w", cs.pvName, err) + } + + cs.mu.RLock() + sid := cs.sid + cs.mu.RUnlock() + if sid == 0 { + return proto.Header{}, nil, fmt.Errorf("ca: %q: channel not connected", cs.pvName) + } + + ioid := c.nextID() + replyCh := make(chan reply, 1) + + cs.mu.Lock() + cs.pending[ioid] = replyCh + cs.mu.Unlock() + + msg := proto.BuildMessage(proto.Header{ + Command: proto.CmdReadNotify, + DataType: dbrType, + DataCount: count, + Parameter1: sid, // m_cid = channel SID (per CA spec) + Parameter2: ioid, // m_available = request IOId + }, nil) + + select { + case c.writeQ <- msg: + case <-ctx.Done(): + cs.mu.Lock() + delete(cs.pending, ioid) + cs.mu.Unlock() + return proto.Header{}, nil, ctx.Err() + } + + select { + case r := <-replyCh: + if !r.ok { + return proto.Header{}, nil, fmt.Errorf("ca: %q: circuit disconnected", cs.pvName) + } + return r.hdr, r.payload, nil + case <-ctx.Done(): + cs.mu.Lock() + delete(cs.pending, ioid) + cs.mu.Unlock() + return proto.Header{}, nil, ctx.Err() + } +} + +// get performs a READ_NOTIFY (async GET) and returns the decoded value. +// It blocks until the reply arrives, the channel is not yet ready, or ctx expires. +func (c *circuit) get(ctx context.Context, cs *chanState, dbrType uint16, count uint32) (proto.TimeValue, error) { + if err := cs.waitReady(ctx); err != nil { + return proto.TimeValue{}, fmt.Errorf("ca: %q: %w", cs.pvName, err) + } + + cs.mu.RLock() + sid := cs.sid + cs.mu.RUnlock() + if sid == 0 { + return proto.TimeValue{}, fmt.Errorf("ca: %q: channel not connected", cs.pvName) + } + + ioid := c.nextID() + replyCh := make(chan reply, 1) + + cs.mu.Lock() + cs.pending[ioid] = replyCh + cs.mu.Unlock() + + msg := proto.BuildMessage(proto.Header{ + Command: proto.CmdReadNotify, + DataType: dbrType, + DataCount: count, + Parameter1: sid, // m_cid = channel SID (per CA spec) + Parameter2: ioid, // m_available = request IOId + }, nil) + + select { + case c.writeQ <- msg: + case <-ctx.Done(): + cs.mu.Lock() + delete(cs.pending, ioid) + cs.mu.Unlock() + return proto.TimeValue{}, ctx.Err() + } + + select { + case r := <-replyCh: + if !r.ok { + return proto.TimeValue{}, fmt.Errorf("ca: %q: circuit disconnected during GET", cs.pvName) + } + tv, ok := proto.DecodeTimeValue(r.hdr.DataType, r.hdr.DataCount, r.payload) + if !ok { + return proto.TimeValue{}, fmt.Errorf("ca: %q: failed to decode GET reply", cs.pvName) + } + return tv, nil + case <-ctx.Done(): + cs.mu.Lock() + delete(cs.pending, ioid) + cs.mu.Unlock() + return proto.TimeValue{}, ctx.Err() + } +} + +// put queues a CA_PROTO_WRITE (fire-and-forget put). +// It waits for the channel to be ready and access rights to be confirmed. +func (c *circuit) put(ctx context.Context, cs *chanState, dbrType uint16, payload []byte) error { + if err := cs.waitReady(ctx); err != nil { + return fmt.Errorf("ca: %q: %w", cs.pvName, err) + } + + cs.mu.RLock() + sid := cs.sid + access := cs.access + cs.mu.RUnlock() + + if sid == 0 { + return fmt.Errorf("ca: %q: channel not connected", cs.pvName) + } + if access&proto.AccessWrite == 0 { + return fmt.Errorf("ca: %q: read-only", cs.pvName) + } + + // CA_PROTO_WRITE: DataType=dbrType, DataCount=1, Parameter1=SID, Parameter2=0 (no ioid). + msg := proto.BuildMessage(proto.Header{ + Command: proto.CmdWrite, + DataType: dbrType, + DataCount: 1, + Parameter1: sid, + Parameter2: 0, + }, payload) + + select { + case c.writeQ <- msg: + return nil + case <-ctx.Done(): + return ctx.Err() + } +} diff --git a/pkg/ca/debug.go b/pkg/ca/debug.go new file mode 100644 index 0000000..99521e4 --- /dev/null +++ b/pkg/ca/debug.go @@ -0,0 +1,26 @@ +package ca + +import ( + "log/slog" + "os" +) + +// calog is the package-level debug logger. +// It is active only when the environment variable UOPI_CA_DEBUG is set to a +// non-empty value at program startup. All output goes to stderr. +var calog *slog.Logger + +func init() { + if os.Getenv("UOPI_CA_DEBUG") != "" { + calog = slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{ + Level: slog.LevelDebug, + })) + } +} + +// dbg emits a DEBUG log if UOPI_CA_DEBUG is set. It is a no-op otherwise. +func dbg(msg string, args ...any) { + if calog != nil { + calog.Debug(msg, args...) + } +} diff --git a/pkg/ca/doc.go b/pkg/ca/doc.go new file mode 100644 index 0000000..b4dd56a --- /dev/null +++ b/pkg/ca/doc.go @@ -0,0 +1,65 @@ +// Package ca implements an EPICS Channel Access (CA) client in pure Go. +// +// It requires no CGo, no libca, and no EPICS installation on the build machine. +// A single [Client] manages connections to one or more IOCs, multiplexing +// channels over shared TCP virtual circuits that survive IOC restarts. +// +// # Quick start +// +// // Configure from standard EPICS environment variables. +// cli, err := ca.NewClient(ctx, ca.ConfigFromEnv()) +// if err != nil { ... } +// defer cli.Close() +// +// // Subscribe to live monitor updates. +// ch := make(chan proto.TimeValue, 16) +// cancel, err := cli.Subscribe(ctx, "MY:PV", ch) +// if err != nil { ... } +// defer cancel() +// for tv := range ch { +// fmt.Println(tv.Timestamp, tv.Double) +// } +// +// // One-shot read (current value). +// tv, err := cli.Get(ctx, "MY:PV") +// +// // Retrieve full control-block metadata (units, display limits, enum strings). +// ci, err := cli.GetCtrl(ctx, "MY:PV") +// if ci.Double != nil { +// fmt.Println(ci.Double.Units, ci.Double.UpperDispLimit) +// } +// +// // Write a new value (fire-and-forget). +// err = cli.Put(ctx, "MY:PV", 42.0) +// +// # Configuration +// +// [ConfigFromEnv] reads the standard EPICS CA environment variables: +// +// EPICS_CA_ADDR_LIST space-separated list of server addresses +// EPICS_CA_AUTO_ADDR_LIST set to "NO" to disable local broadcast +// +// Alternatively, construct [Config] directly and pass to [NewClient]. +// +// # Protocol +// +// The library speaks Channel Access protocol version 4.13 over UDP (search, +// port 5064) and TCP (data, port 5064). It does not use the CA Repeater; +// it sends search requests directly to the addresses in [Config.AddrList]. +// +// UDP search uses exponential back-off (100 ms → 30 s) and retries until the +// PV is found or the context is cancelled. +// +// # Reconnection +// +// When an IOC restarts, the library detects the broken TCP connection and +// reconnects automatically. All active subscriptions are re-established +// transparently after the new CREATE_CHAN handshake completes. +// Callers blocked in [Client.Get] or [Client.Put] during a reconnect will +// receive an error and should retry. +// +// # Thread safety +// +// All exported methods are safe for concurrent use from multiple goroutines. +// A single [Client] can be shared across the entire application. +package ca diff --git a/pkg/ca/go.mod b/pkg/ca/go.mod new file mode 100644 index 0000000..6f0ae0b --- /dev/null +++ b/pkg/ca/go.mod @@ -0,0 +1,3 @@ +module github.com/uopi/goca + +go 1.26 diff --git a/pkg/ca/proto/dbr.go b/pkg/ca/proto/dbr.go new file mode 100644 index 0000000..d639f49 --- /dev/null +++ b/pkg/ca/proto/dbr.go @@ -0,0 +1,441 @@ +package proto + +import ( + "encoding/binary" + "math" + "strings" + "time" +) + +// epicsEpochOffset is the number of Unix seconds between the Unix epoch +// (1970-01-01 00:00:00 UTC) and the EPICS epoch (1990-01-01 00:00:00 UTC). +const epicsEpochOffset = 631152000 + +// maxStringSize is the fixed size of a CA string value (MAX_STRING_SIZE). +const maxStringSize = 40 + +// maxEnumStates is the maximum number of enum strings (MAX_ENUM_STATES). +const maxEnumStates = 16 + +// maxEnumStringSize is the fixed size of each enum string (MAX_ENUM_STRING_SIZE). +const maxEnumStringSize = 26 + +// -------------------------------------------------------------------------- // +// Alarm types // +// -------------------------------------------------------------------------- // + +// AlarmSeverity mirrors epicsAlarmSeverity. +type AlarmSeverity int + +const ( + SeverityNone AlarmSeverity = 0 + SeverityMinor AlarmSeverity = 1 + SeverityMajor AlarmSeverity = 2 + SeverityInvalid AlarmSeverity = 3 +) + +func (s AlarmSeverity) String() string { + switch s { + case SeverityNone: + return "NO_ALARM" + case SeverityMinor: + return "MINOR" + case SeverityMajor: + return "MAJOR" + case SeverityInvalid: + return "INVALID" + default: + return "UNKNOWN" + } +} + +// -------------------------------------------------------------------------- // +// TimeValue — decoded DBR_TIME_* payload // +// -------------------------------------------------------------------------- // + +// TimeValue is the decoded form of any DBR_TIME_* payload. +// Exactly one of the value fields is populated based on the DBR type. +type TimeValue struct { + Timestamp time.Time + Status int16 + Severity AlarmSeverity + // Value fields — only one is set. + Double float64 + Float float32 + Long int32 + Short int16 + Enum uint16 + Char uint8 + Str string + // Waveform values (when element count > 1). + Doubles []float64 +} + +// decodeTimestamp converts EPICS secPastEpoch+nsec to a Go time.Time. +func decodeTimestamp(secPastEpoch, nsec uint32) time.Time { + return time.Unix(int64(secPastEpoch)+epicsEpochOffset, int64(nsec)).UTC() +} + +// caString extracts a null-terminated string from a fixed-size byte slice. +func caString(b []byte) string { + idx := strings.IndexByte(string(b), 0) + if idx < 0 { + return string(b) + } + return string(b[:idx]) +} + +// DBR_TIME_* wire layouts (all big-endian): +// +// Common header (12 bytes): +// [0:2] int16 status +// [2:4] int16 severity +// [4:8] uint32 secPastEpoch +// [8:12] uint32 nsec +// +// DBR_TIME_DOUBLE (type 25): 4-byte RISC pad at [12:16], float64 at [16:24]. Total=24. +// DBR_TIME_FLOAT (type 20): float32 at [12:16]. Total=16. +// DBR_TIME_LONG (type 22): int32 at [12:16]. Total=16. +// DBR_TIME_SHORT (type 19): int16 at [12:14], 2-byte pad [14:16]. Total=16. +// DBR_TIME_ENUM (type 23): uint16 at [12:14], 2-byte pad [14:16]. Total=16. +// DBR_TIME_CHAR (type 24): uint8 at [12:13], 3-byte pad [13:16]. Total=16. +// DBR_TIME_STRING (type 21): [40]byte at [12:52]. Total=52. + +// DecodeTimeValue decodes a DBR_TIME_* payload. +// dbrType is one of the DBRTime* constants; payload is the full message payload +// (may include multiple elements for waveforms; count is the element count). +func DecodeTimeValue(dbrType uint16, count uint32, payload []byte) (TimeValue, bool) { + if len(payload) < 12 { + return TimeValue{}, false + } + + status := int16(binary.BigEndian.Uint16(payload[0:])) + severity := AlarmSeverity(binary.BigEndian.Uint16(payload[2:])) + sec := binary.BigEndian.Uint32(payload[4:]) + nsec := binary.BigEndian.Uint32(payload[8:]) + + tv := TimeValue{ + Timestamp: decodeTimestamp(sec, nsec), + Status: status, + Severity: severity, + } + + switch dbrType { + case DBRTimeDouble: + // 4-byte RISC pad at [12:16], value at [16:24] + if count > 1 { + if len(payload) < 16+int(count)*8 { + return TimeValue{}, false + } + vals := make([]float64, count) + for i := range vals { + bits := binary.BigEndian.Uint64(payload[16+i*8:]) + vals[i] = math.Float64frombits(bits) + } + tv.Doubles = vals + tv.Double = vals[0] + } else { + if len(payload) < 24 { + return TimeValue{}, false + } + bits := binary.BigEndian.Uint64(payload[16:]) + tv.Double = math.Float64frombits(bits) + } + + case DBRTimeFloat: + if len(payload) < 16 { + return TimeValue{}, false + } + bits := binary.BigEndian.Uint32(payload[12:]) + tv.Float = math.Float32frombits(bits) + tv.Double = float64(tv.Float) + + case DBRTimeLong: + if len(payload) < 16 { + return TimeValue{}, false + } + tv.Long = int32(binary.BigEndian.Uint32(payload[12:])) + tv.Double = float64(tv.Long) + + case DBRTimeShort: + if len(payload) < 16 { + return TimeValue{}, false + } + tv.Short = int16(binary.BigEndian.Uint16(payload[12:])) + tv.Double = float64(tv.Short) + + case DBRTimeEnum: + if len(payload) < 16 { + return TimeValue{}, false + } + tv.Enum = binary.BigEndian.Uint16(payload[12:]) + tv.Double = float64(tv.Enum) + + case DBRTimeChar: + if len(payload) < 16 { + return TimeValue{}, false + } + tv.Char = payload[12] + tv.Double = float64(tv.Char) + + case DBRTimeString: + if len(payload) < 12+maxStringSize { + return TimeValue{}, false + } + tv.Str = caString(payload[12 : 12+maxStringSize]) + tv.Double = 0 + + default: + return TimeValue{}, false + } + + return tv, true +} + +// -------------------------------------------------------------------------- // +// CtrlDouble — decoded DBR_CTRL_DOUBLE payload (type 34) // +// -------------------------------------------------------------------------- // +// +// Wire layout (88 bytes total, all big-endian): +// +// [0:2] int16 status +// [2:4] int16 severity +// [4:6] int16 precision +// [6:8] uint16 RISC_pad0 +// [8:16] [8]byte units +// [16:24] float64 upper_disp_limit +// [24:32] float64 lower_disp_limit +// [32:40] float64 upper_alarm_limit +// [40:48] float64 upper_warning_limit +// [48:56] float64 lower_warning_limit +// [56:64] float64 lower_alarm_limit +// [64:72] float64 upper_ctrl_limit +// [72:80] float64 lower_ctrl_limit +// [80:88] float64 value + +// CtrlDouble is the decoded form of a DBR_CTRL_DOUBLE payload. +type CtrlDouble struct { + Status int16 + Severity AlarmSeverity + Precision int16 + Units string + UpperDispLimit float64 + LowerDispLimit float64 + UpperAlarmLimit float64 + UpperWarnLimit float64 + LowerWarnLimit float64 + LowerAlarmLimit float64 + UpperCtrlLimit float64 + LowerCtrlLimit float64 + Value float64 +} + +// DecodeCtrlDouble decodes a DBR_CTRL_DOUBLE payload (minimum 88 bytes). +func DecodeCtrlDouble(p []byte) (CtrlDouble, bool) { + if len(p) < 88 { + return CtrlDouble{}, false + } + f64 := func(off int) float64 { + return math.Float64frombits(binary.BigEndian.Uint64(p[off:])) + } + return CtrlDouble{ + Status: int16(binary.BigEndian.Uint16(p[0:])), + Severity: AlarmSeverity(binary.BigEndian.Uint16(p[2:])), + Precision: int16(binary.BigEndian.Uint16(p[4:])), + Units: caString(p[8:16]), + UpperDispLimit: f64(16), + LowerDispLimit: f64(24), + UpperAlarmLimit: f64(32), + UpperWarnLimit: f64(40), + LowerWarnLimit: f64(48), + LowerAlarmLimit: f64(56), + UpperCtrlLimit: f64(64), + LowerCtrlLimit: f64(72), + Value: f64(80), + }, true +} + +// -------------------------------------------------------------------------- // +// CtrlLong — decoded DBR_CTRL_LONG payload (type 33) // +// -------------------------------------------------------------------------- // +// +// Wire layout (48 bytes total): +// +// [0:2] int16 status +// [2:4] int16 severity +// [4:12] [8]byte units +// [12:16] int32 upper_disp_limit +// [16:20] int32 lower_disp_limit +// [20:24] int32 upper_alarm_limit +// [24:28] int32 upper_warning_limit +// [28:32] int32 lower_warning_limit +// [32:36] int32 lower_alarm_limit +// [36:40] int32 upper_ctrl_limit +// [40:44] int32 lower_ctrl_limit +// [44:48] int32 value + +// CtrlLong is the decoded form of a DBR_CTRL_LONG payload. +type CtrlLong struct { + Status int16 + Severity AlarmSeverity + Units string + UpperDispLimit int32 + LowerDispLimit int32 + UpperAlarmLimit int32 + UpperWarnLimit int32 + LowerWarnLimit int32 + LowerAlarmLimit int32 + UpperCtrlLimit int32 + LowerCtrlLimit int32 + Value int32 +} + +// DecodeCtrlLong decodes a DBR_CTRL_LONG payload (minimum 48 bytes). +func DecodeCtrlLong(p []byte) (CtrlLong, bool) { + if len(p) < 48 { + return CtrlLong{}, false + } + i32 := func(off int) int32 { + return int32(binary.BigEndian.Uint32(p[off:])) + } + return CtrlLong{ + Status: int16(binary.BigEndian.Uint16(p[0:])), + Severity: AlarmSeverity(binary.BigEndian.Uint16(p[2:])), + Units: caString(p[4:12]), + UpperDispLimit: i32(12), + LowerDispLimit: i32(16), + UpperAlarmLimit: i32(20), + UpperWarnLimit: i32(24), + LowerWarnLimit: i32(28), + LowerAlarmLimit: i32(32), + UpperCtrlLimit: i32(36), + LowerCtrlLimit: i32(40), + Value: i32(44), + }, true +} + +// -------------------------------------------------------------------------- // +// CtrlEnum — decoded DBR_CTRL_ENUM payload (type 31) // +// -------------------------------------------------------------------------- // +// +// Wire layout (424 bytes total): +// +// [0:2] int16 status +// [2:4] int16 severity +// [4:6] int16 no_str (number of valid enum strings, max 16) +// [6:422] [16][26]byte strs (enum string table) +// [422:424] uint16 value + +// CtrlEnum is the decoded form of a DBR_CTRL_ENUM payload. +type CtrlEnum struct { + Status int16 + Severity AlarmSeverity + Strings []string // len == no_str + Value uint16 +} + +const ctrlEnumSize = 2 + 2 + 2 + maxEnumStates*maxEnumStringSize + 2 // 424 + +// DecodeCtrlEnum decodes a DBR_CTRL_ENUM payload (minimum 424 bytes). +func DecodeCtrlEnum(p []byte) (CtrlEnum, bool) { + if len(p) < ctrlEnumSize { + return CtrlEnum{}, false + } + noStr := int(binary.BigEndian.Uint16(p[4:])) + noStr = min(noStr, maxEnumStates) + strs := make([]string, noStr) + for i := range strs { + off := 6 + i*maxEnumStringSize + strs[i] = caString(p[off : off+maxEnumStringSize]) + } + return CtrlEnum{ + Status: int16(binary.BigEndian.Uint16(p[0:])), + Severity: AlarmSeverity(binary.BigEndian.Uint16(p[2:])), + Strings: strs, + Value: binary.BigEndian.Uint16(p[422:]), + }, true +} + +// -------------------------------------------------------------------------- // +// CtrlString — decoded DBR_CTRL_STRING payload (type 28) // +// -------------------------------------------------------------------------- // +// +// Wire layout (44 bytes): +// +// [0:2] int16 status +// [2:4] int16 severity +// [4:44] [40]byte value + +// CtrlString is the decoded form of a DBR_CTRL_STRING payload. +type CtrlString struct { + Status int16 + Severity AlarmSeverity + Value string +} + +// DecodeCtrlString decodes a DBR_CTRL_STRING payload (minimum 44 bytes). +func DecodeCtrlString(p []byte) (CtrlString, bool) { + if len(p) < 4+maxStringSize { + return CtrlString{}, false + } + return CtrlString{ + Status: int16(binary.BigEndian.Uint16(p[0:])), + Severity: AlarmSeverity(binary.BigEndian.Uint16(p[2:])), + Value: caString(p[4 : 4+maxStringSize]), + }, true +} + +// -------------------------------------------------------------------------- // +// Put payload encoders // +// -------------------------------------------------------------------------- // + +// EncodeDouble encodes a float64 value as a big-endian DBR_DOUBLE payload +// padded to 8 bytes. +func EncodeDouble(v float64) []byte { + b := make([]byte, 8) + binary.BigEndian.PutUint64(b, math.Float64bits(v)) + return b +} + +// EncodeLong encodes an int32 value as a big-endian DBR_LONG payload +// padded to 8 bytes. +func EncodeLong(v int32) []byte { + b := make([]byte, 8) // 4 bytes value + 4 bytes pad + binary.BigEndian.PutUint32(b, uint32(v)) + return b +} + +// EncodeShort encodes an int16 value as a big-endian DBR_SHORT payload +// padded to 8 bytes. +func EncodeShort(v int16) []byte { + b := make([]byte, 8) + binary.BigEndian.PutUint16(b, uint16(v)) + return b +} + +// EncodeString encodes a string as a null-terminated, 8-byte padded +// DBR_STRING payload (capped at maxStringSize characters). +func EncodeString(v string) []byte { + if len(v) >= maxStringSize { + v = v[:maxStringSize-1] + } + b := make([]byte, maxStringSize) + copy(b, v) + return PadBytes(b) +} + +// EncodeEventMask builds the 16-byte payload for a CA_PROTO_EVENT_ADD request. +// mask is typically DBEDefault (DBEValue | DBEAlarm). +// +// Wire layout (16 bytes, all big-endian): +// +// [0:4] float32 m_lval (not used, zero) +// [4:8] float32 p_delta (delta trigger, zero = disabled) +// [8:12] float32 p_final (final trigger, zero = disabled) +// [12:14] int16 p_count (element count, zero = use PV's count) +// [14:16] int16 m_mask (DBE_VALUE | DBE_ALARM | ...) +func EncodeEventMask(mask uint16) []byte { + b := make([]byte, 16) + binary.BigEndian.PutUint16(b[14:], mask) // m_mask is at offset 14, not 12 + return b +} diff --git a/pkg/ca/proto/header.go b/pkg/ca/proto/header.go new file mode 100644 index 0000000..208134b --- /dev/null +++ b/pkg/ca/proto/header.go @@ -0,0 +1,108 @@ +package proto + +import ( + "encoding/binary" + "fmt" + "io" +) + +// Header is the decoded form of a 16-byte CA message header. +// All integer fields are stored in host byte order after decoding. +type Header struct { + Command uint16 + PayloadSize uint32 // actual payload size (resolved from extended form if needed) + DataType uint16 + DataCount uint32 // actual element count (resolved from extended form if needed) + Parameter1 uint32 + Parameter2 uint32 +} + +// HeaderSize is the wire size of a standard CA header. +const HeaderSize = 16 + +// Encode writes the header to buf (must be at least HeaderSize bytes). +// If PayloadSize > 0xFFFE or DataCount > 0xFFFF the extended encoding is used +// and the caller must prepend an extra 8 bytes; use EncodeExtended instead. +func (h Header) Encode(buf []byte) { + binary.BigEndian.PutUint16(buf[0:], h.Command) + binary.BigEndian.PutUint16(buf[2:], uint16(h.PayloadSize)) + binary.BigEndian.PutUint16(buf[4:], h.DataType) + binary.BigEndian.PutUint16(buf[6:], uint16(h.DataCount)) + binary.BigEndian.PutUint32(buf[8:], h.Parameter1) + binary.BigEndian.PutUint32(buf[12:], h.Parameter2) +} + +// Bytes returns the 16-byte wire encoding of h. +func (h Header) Bytes() []byte { + buf := make([]byte, HeaderSize) + h.Encode(buf) + return buf +} + +// DecodeHeader reads exactly one CA header from r, handling the extended +// encoding (payload_size == 0xFFFF) transparently. +// Returns the decoded Header and the number of bytes read (16 or 24). +func DecodeHeader(r io.Reader) (Header, int, error) { + var raw [HeaderSize]byte + if _, err := io.ReadFull(r, raw[:]); err != nil { + return Header{}, 0, fmt.Errorf("ca: read header: %w", err) + } + + h := Header{ + Command: binary.BigEndian.Uint16(raw[0:]), + DataType: binary.BigEndian.Uint16(raw[4:]), + Parameter1: binary.BigEndian.Uint32(raw[8:]), + Parameter2: binary.BigEndian.Uint32(raw[12:]), + } + + rawPayload := binary.BigEndian.Uint16(raw[2:]) + rawCount := binary.BigEndian.Uint16(raw[6:]) + + // Extended message: payload_size == 0xFFFF means real sizes are in the + // next 8 bytes (parameter1 = real payload size, parameter2 = real count). + if rawPayload == 0xFFFF && rawCount == 0x0000 { + var ext [8]byte + if _, err := io.ReadFull(r, ext[:]); err != nil { + return Header{}, 16, fmt.Errorf("ca: read extended header: %w", err) + } + h.PayloadSize = binary.BigEndian.Uint32(ext[0:]) + h.DataCount = binary.BigEndian.Uint32(ext[4:]) + // Note: parameter1/2 in the base header are overwritten by the extended values. + // In practice, the original parameter1/2 are still valid — the extended header + // only carries sizes, not the original parameters. We already decoded parameter1/2 + // above from the base header. + return h, 24, nil + } + + h.PayloadSize = uint32(rawPayload) + h.DataCount = uint32(rawCount) + return h, 16, nil +} + +// PadTo8 returns n rounded up to the nearest multiple of 8. +// CA requires all message payloads to be padded to 8-byte boundaries. +func PadTo8(n int) int { + return (n + 7) &^ 7 +} + +// PadBytes appends zero bytes to b until len(b) is a multiple of 8. +func PadBytes(b []byte) []byte { + need := PadTo8(len(b)) - len(b) + return append(b, make([]byte, need)...) +} + +// BuildMessage assembles a complete CA message (header + payload). +// payload may be nil for zero-length messages. +func BuildMessage(h Header, payload []byte) []byte { + h.PayloadSize = uint32(len(payload)) + msg := make([]byte, HeaderSize+len(payload)) + h.Encode(msg) + copy(msg[HeaderSize:], payload) + return msg +} + +// BuildStringPayload encodes a string as a null-terminated, 8-byte padded payload. +func BuildStringPayload(s string) []byte { + b := append([]byte(s), 0) // null terminator + return PadBytes(b) +} diff --git a/pkg/ca/proto/opcodes.go b/pkg/ca/proto/opcodes.go new file mode 100644 index 0000000..62d6327 --- /dev/null +++ b/pkg/ca/proto/opcodes.go @@ -0,0 +1,139 @@ +// Package proto contains the low-level Channel Access wire-protocol constants, +// header codec, and DBR type decode functions. All types are big-endian as +// required by the CA specification. +package proto + +// CA command opcodes (uint16, first field of every message header). +const ( + CmdVersion = 0 // version negotiation (first message on every TCP connection) + CmdEventAdd = 1 // subscribe to value updates / server event push + CmdEventCancel = 2 // cancel subscription + CmdWrite = 4 // put value (fire-and-forget, no server reply) + CmdSearch = 6 // UDP: locate IOC hosting a PV + CmdNotFound = 14 // UDP: IOC does not host the requested PV + CmdReadNotify = 15 // get value with callback (async GET) + CmdCreateChan = 18 // create channel on TCP circuit + CmdWriteNotify = 19 // put value with acknowledgement + CmdClientName = 20 // announce client username (sent after VERSION) + CmdHostName = 21 // announce client hostname (sent after VERSION) + CmdAccessRights = 22 // server → client: access rights bitmask + CmdEcho = 23 // heartbeat ping/pong + CmdCreateFail = 26 // server → client: CREATE_CHAN failed + CmdServerDisc = 27 // server → client: server shutting down +) + +// CA minor protocol revision announced during the VERSION handshake. +// Corresponds to CA 4.13, supported by EPICS 3.14+ and all EPICS 7 servers. +const MinorVersion = 13 + +// Default CA port (TCP and UDP). +const DefaultPort = 5064 + +// Search reply data_type values. +const ( + SearchReply = 10 // DO_REPLY: ask server to respond + SearchNoReply = 5 // DONT_REPLY: suppress response (used by repeater) +) + +// AccessRights bitmask values (parameter2 of CmdAccessRights message). +const ( + AccessRead = 1 << 0 + AccessWrite = 1 << 1 +) + +// DBE event mask bits used in the 16-byte EVENT_ADD payload. +const ( + DBEValue = 0x01 // trigger on any value change + DBEAlarm = 0x04 // trigger on alarm state change + DBEDefault = DBEValue | DBEAlarm +) + +// ---- DBF field types (native IOC field type, reported in CREATE_CHAN reply) ---- + +const ( + DBFString = 0 + DBFShort = 1 // also DBFInt + DBFFloat = 2 + DBFEnum = 3 + DBFChar = 4 + DBFLong = 5 + DBFDouble = 6 +) + +// ---- DBR request types ---- + +// Plain value types (used in WRITE). +const ( + DBRString = 0 + DBRShort = 1 + DBRFloat = 2 + DBREnum = 3 + DBRChar = 4 + DBRLong = 5 + DBRDouble = 6 +) + +// DBR_TIME_* types (value + timestamp + alarm status; used in EVENT_ADD). +const ( + DBRTimeString = 21 + DBRTimeShort = 19 + DBRTimeFloat = 20 + DBRTimeEnum = 23 + DBRTimeChar = 24 + DBRTimeLong = 22 + DBRTimeDouble = 25 +) + +// DBR_CTRL_* types (full control info: units, limits, enum strings; used in READ_NOTIFY). +const ( + DBRCtrlString = 28 + DBRCtrlShort = 29 + DBRCtrlFloat = 30 + DBRCtrlEnum = 31 + DBRCtrlChar = 32 + DBRCtrlLong = 33 + DBRCtrlDouble = 34 +) + +// NativeTimeType maps a DBF field type to the corresponding DBR_TIME_* type +// that should be used for monitor subscriptions. +func NativeTimeType(dbfType int, elementCount int) uint16 { + switch dbfType { + case DBFDouble: + return DBRTimeDouble + case DBFFloat: + return DBRTimeFloat + case DBFLong: + return DBRTimeLong + case DBFShort: + return DBRTimeShort + case DBFEnum: + return DBRTimeEnum + case DBFString: + return DBRTimeString + case DBFChar: + if elementCount > 1 { + return DBRTimeString // waveform of chars treated as string + } + return DBRTimeChar + default: + return DBRTimeDouble + } +} + +// NativeCtrlType maps a DBF field type to the corresponding DBR_CTRL_* type +// that should be used for metadata gets. +func NativeCtrlType(dbfType int) uint16 { + switch dbfType { + case DBFDouble, DBFFloat: + return DBRCtrlDouble + case DBFLong, DBFShort, DBFChar: + return DBRCtrlLong + case DBFEnum: + return DBRCtrlEnum + case DBFString: + return DBRCtrlString + default: + return DBRCtrlDouble + } +} diff --git a/pkg/ca/proto/proto_test.go b/pkg/ca/proto/proto_test.go new file mode 100644 index 0000000..17360ae --- /dev/null +++ b/pkg/ca/proto/proto_test.go @@ -0,0 +1,387 @@ +package proto_test + +import ( + "bytes" + "encoding/binary" + "math" + "testing" + "time" + + "github.com/uopi/goca/proto" +) + +// -------------------------------------------------------------------------- // +// Header round-trip // +// -------------------------------------------------------------------------- // + +func TestHeaderRoundTrip(t *testing.T) { + h := proto.Header{ + Command: proto.CmdCreateChan, + PayloadSize: 16, + DataType: proto.DBFDouble, + DataCount: 1, + Parameter1: 0xDEAD, + Parameter2: 0xBEEF, + } + wire := h.Bytes() + if len(wire) != proto.HeaderSize { + t.Fatalf("Bytes() len = %d, want %d", len(wire), proto.HeaderSize) + } + + got, n, err := proto.DecodeHeader(bytes.NewReader(wire)) + if err != nil { + t.Fatalf("DecodeHeader: %v", err) + } + if n != proto.HeaderSize { + t.Errorf("bytes consumed = %d, want %d", n, proto.HeaderSize) + } + if got.Command != h.Command { + t.Errorf("Command = %d, want %d", got.Command, h.Command) + } + if got.DataType != h.DataType { + t.Errorf("DataType = %d, want %d", got.DataType, h.DataType) + } + if got.Parameter1 != h.Parameter1 { + t.Errorf("Parameter1 = %d, want %d", got.Parameter1, h.Parameter1) + } + if got.Parameter2 != h.Parameter2 { + t.Errorf("Parameter2 = %d, want %d", got.Parameter2, h.Parameter2) + } +} + +func TestBuildMessage(t *testing.T) { + payload := []byte("hello\x00\x00\x00") // 8 bytes (padded) + h := proto.Header{Command: proto.CmdHostName, PayloadSize: 0} + msg := proto.BuildMessage(h, payload) + if len(msg) != proto.HeaderSize+len(payload) { + t.Fatalf("len(msg) = %d, want %d", len(msg), proto.HeaderSize+len(payload)) + } + // PayloadSize in wire should equal len(payload). + wireSize := binary.BigEndian.Uint16(msg[2:4]) + if int(wireSize) != len(payload) { + t.Errorf("wire PayloadSize = %d, want %d", wireSize, len(payload)) + } +} + +func TestPadTo8(t *testing.T) { + cases := [][2]int{{0, 0}, {1, 8}, {7, 8}, {8, 8}, {9, 16}, {16, 16}, {17, 24}} + for _, c := range cases { + if got := proto.PadTo8(c[0]); got != c[1] { + t.Errorf("PadTo8(%d) = %d, want %d", c[0], got, c[1]) + } + } +} + +func TestBuildStringPayload(t *testing.T) { + p := proto.BuildStringPayload("TEST") + if len(p)%8 != 0 { + t.Errorf("payload length %d not padded to 8", len(p)) + } + if p[4] != 0 { + t.Errorf("expected null terminator at index 4, got %d", p[4]) + } +} + +// -------------------------------------------------------------------------- // +// DBR_TIME_* decoding // +// -------------------------------------------------------------------------- // + +// buildTimeHeader builds the 12-byte common DBR_TIME header. +func buildTimeHeader(status, severity int16, sec, nsec uint32) []byte { + b := make([]byte, 12) + binary.BigEndian.PutUint16(b[0:], uint16(status)) + binary.BigEndian.PutUint16(b[2:], uint16(severity)) + binary.BigEndian.PutUint32(b[4:], sec) + binary.BigEndian.PutUint32(b[8:], nsec) + return b +} + +func TestDecodeTimeDouble(t *testing.T) { + const sec = 1_000_000 + const nsec = 500_000_000 + const want = 3.14159 + + hdr := buildTimeHeader(0, 0, sec, nsec) + pad := make([]byte, 4) // RISC pad + val := make([]byte, 8) + binary.BigEndian.PutUint64(val, math.Float64bits(want)) + payload := append(append(hdr, pad...), val...) + + tv, ok := proto.DecodeTimeValue(proto.DBRTimeDouble, 1, payload) + if !ok { + t.Fatal("DecodeTimeValue returned false") + } + if math.Abs(tv.Double-want) > 1e-10 { + t.Errorf("Double = %g, want %g", tv.Double, want) + } + // EPICS epoch offset: 1990-01-01 00:00:00 UTC = Unix 631152000. + wantUnix := int64(sec) + 631152000 + if tv.Timestamp.Unix() != wantUnix { + t.Errorf("Timestamp.Unix() = %d, want %d", tv.Timestamp.Unix(), wantUnix) + } + if tv.Timestamp.Nanosecond() != int(nsec) { + t.Errorf("Timestamp.Nanosecond() = %d, want %d", tv.Timestamp.Nanosecond(), nsec) + } +} + +func TestDecodeTimeLong(t *testing.T) { + hdr := buildTimeHeader(0, 1, 0, 0) + val := make([]byte, 4) + binary.BigEndian.PutUint32(val, 0xFFFFFFD6) // -42 as two's complement + payload := append(hdr, val...) + + tv, ok := proto.DecodeTimeValue(proto.DBRTimeLong, 1, payload) + if !ok { + t.Fatal("DecodeTimeValue returned false") + } + if tv.Long != -42 { + t.Errorf("Long = %d, want -42", tv.Long) + } + if tv.Severity != proto.SeverityMinor { + t.Errorf("Severity = %v, want Minor", tv.Severity) + } +} + +func TestDecodeTimeString(t *testing.T) { + hdr := buildTimeHeader(0, 0, 0, 0) + str := make([]byte, 40) + copy(str, "hello") + payload := append(hdr, str...) + + tv, ok := proto.DecodeTimeValue(proto.DBRTimeString, 1, payload) + if !ok { + t.Fatal("DecodeTimeValue returned false") + } + if tv.Str != "hello" { + t.Errorf("Str = %q, want %q", tv.Str, "hello") + } +} + +func TestDecodeTimeEnum(t *testing.T) { + hdr := buildTimeHeader(0, 0, 0, 0) + val := []byte{0x00, 0x03, 0x00, 0x00} // enum=3 + 2-byte pad + payload := append(hdr, val...) + + tv, ok := proto.DecodeTimeValue(proto.DBRTimeEnum, 1, payload) + if !ok { + t.Fatal("DecodeTimeValue returned false") + } + if tv.Enum != 3 { + t.Errorf("Enum = %d, want 3", tv.Enum) + } +} + +func TestDecodeTimeWaveform(t *testing.T) { + hdr := buildTimeHeader(0, 0, 0, 0) + pad := make([]byte, 4) + vals := []float64{1.0, 2.5, -0.5} + valbytes := make([]byte, len(vals)*8) + for i, v := range vals { + binary.BigEndian.PutUint64(valbytes[i*8:], math.Float64bits(v)) + } + payload := append(append(hdr, pad...), valbytes...) + + tv, ok := proto.DecodeTimeValue(proto.DBRTimeDouble, 3, payload) + if !ok { + t.Fatal("DecodeTimeValue returned false") + } + if len(tv.Doubles) != 3 { + t.Fatalf("len(Doubles) = %d, want 3", len(tv.Doubles)) + } + for i, want := range vals { + if math.Abs(tv.Doubles[i]-want) > 1e-12 { + t.Errorf("Doubles[%d] = %g, want %g", i, tv.Doubles[i], want) + } + } +} + +func TestDecodeTimeTooShort(t *testing.T) { + _, ok := proto.DecodeTimeValue(proto.DBRTimeDouble, 1, []byte{0, 1, 2}) + if ok { + t.Error("expected false for short payload") + } +} + +// -------------------------------------------------------------------------- // +// DBR_CTRL_DOUBLE decoding // +// -------------------------------------------------------------------------- // + +func TestDecodeCtrlDouble(t *testing.T) { + p := make([]byte, 88) + // status=0, severity=0, precision=3, pad=0, units="mA\0..." + binary.BigEndian.PutUint16(p[4:], 3) // precision + copy(p[8:], "mA") + f64 := func(off int, v float64) { + binary.BigEndian.PutUint64(p[off:], math.Float64bits(v)) + } + f64(16, 100.0) // upper_disp + f64(24, 0.0) // lower_disp + f64(32, 90.0) // upper_alarm + f64(40, 80.0) // upper_warn + f64(48, 20.0) // lower_warn + f64(56, 10.0) // lower_alarm + f64(64, 95.0) // upper_ctrl + f64(72, 5.0) // lower_ctrl + f64(80, 55.5) // value + + cd, ok := proto.DecodeCtrlDouble(p) + if !ok { + t.Fatal("DecodeCtrlDouble returned false") + } + if cd.Units != "mA" { + t.Errorf("Units = %q, want %q", cd.Units, "mA") + } + if cd.Precision != 3 { + t.Errorf("Precision = %d, want 3", cd.Precision) + } + if math.Abs(cd.Value-55.5) > 1e-10 { + t.Errorf("Value = %g, want 55.5", cd.Value) + } + if math.Abs(cd.UpperDispLimit-100.0) > 1e-10 { + t.Errorf("UpperDispLimit = %g, want 100.0", cd.UpperDispLimit) + } +} + +func TestDecodeCtrlEnum(t *testing.T) { + p := make([]byte, 424) + binary.BigEndian.PutUint16(p[4:], 3) // no_str = 3 + strs := []string{"OFF", "ON", "FAULT"} + for i, s := range strs { + copy(p[6+i*26:], s) + } + binary.BigEndian.PutUint16(p[422:], 1) // value = 1 + + ce, ok := proto.DecodeCtrlEnum(p) + if !ok { + t.Fatal("DecodeCtrlEnum returned false") + } + if len(ce.Strings) != 3 { + t.Fatalf("len(Strings) = %d, want 3", len(ce.Strings)) + } + if ce.Strings[2] != "FAULT" { + t.Errorf("Strings[2] = %q, want FAULT", ce.Strings[2]) + } + if ce.Value != 1 { + t.Errorf("Value = %d, want 1", ce.Value) + } +} + +// -------------------------------------------------------------------------- // +// Put payload encoders // +// -------------------------------------------------------------------------- // + +func TestEncodeDouble(t *testing.T) { + b := proto.EncodeDouble(3.14) + if len(b) != 8 { + t.Fatalf("len = %d, want 8", len(b)) + } + got := math.Float64frombits(binary.BigEndian.Uint64(b)) + if math.Abs(got-3.14) > 1e-15 { + t.Errorf("decoded = %g, want 3.14", got) + } +} + +func TestEncodeLong(t *testing.T) { + b := proto.EncodeLong(-1) + if len(b) != 8 { + t.Fatalf("len = %d, want 8", len(b)) + } + got := int32(binary.BigEndian.Uint32(b)) + if got != -1 { + t.Errorf("decoded = %d, want -1", got) + } +} + +func TestEncodeString(t *testing.T) { + b := proto.EncodeString("hello") + if len(b)%8 != 0 { + t.Errorf("len %d not padded to 8", len(b)) + } + if string(b[:5]) != "hello" { + t.Errorf("content = %q, want %q", b[:5], "hello") + } +} + +func TestEncodeEventMask(t *testing.T) { + b := proto.EncodeEventMask(proto.DBEDefault) + if len(b) != 16 { + t.Fatalf("len = %d, want 16", len(b)) + } + // m_mask is at offset 14 (after m_lval[0:4], p_delta[4:8], p_final[8:12], p_count[12:14]). + mask := binary.BigEndian.Uint16(b[14:]) + if mask != proto.DBEDefault { + t.Errorf("mask = 0x%02X, want 0x%02X", mask, proto.DBEDefault) + } + // p_count at [12:14] must be zero. + if pc := binary.BigEndian.Uint16(b[12:]); pc != 0 { + t.Errorf("p_count = %d, want 0", pc) + } +} + +// -------------------------------------------------------------------------- // +// NativeTimeType / NativeCtrlType // +// -------------------------------------------------------------------------- // + +func TestNativeTimeType(t *testing.T) { + cases := []struct { + dbf int + n int + want uint16 + }{ + {proto.DBFDouble, 1, proto.DBRTimeDouble}, + {proto.DBFFloat, 1, proto.DBRTimeFloat}, + {proto.DBFLong, 1, proto.DBRTimeLong}, + {proto.DBFShort, 1, proto.DBRTimeShort}, + {proto.DBFEnum, 1, proto.DBRTimeEnum}, + {proto.DBFString, 1, proto.DBRTimeString}, + {proto.DBFChar, 1, proto.DBRTimeChar}, + {proto.DBFChar, 10, proto.DBRTimeString}, // char waveform → string + } + for _, c := range cases { + got := proto.NativeTimeType(c.dbf, c.n) + if got != c.want { + t.Errorf("NativeTimeType(%d,%d) = %d, want %d", c.dbf, c.n, got, c.want) + } + } +} + +// -------------------------------------------------------------------------- // +// AlarmSeverity.String // +// -------------------------------------------------------------------------- // + +func TestAlarmSeverityString(t *testing.T) { + cases := []struct { + s proto.AlarmSeverity + want string + }{ + {proto.SeverityNone, "NO_ALARM"}, + {proto.SeverityMinor, "MINOR"}, + {proto.SeverityMajor, "MAJOR"}, + {proto.SeverityInvalid, "INVALID"}, + } + for _, c := range cases { + if got := c.s.String(); got != c.want { + t.Errorf("Severity(%d).String() = %q, want %q", c.s, got, c.want) + } + } +} + +// -------------------------------------------------------------------------- // +// Timestamp sanity check // +// -------------------------------------------------------------------------- // + +func TestEPICSEpoch(t *testing.T) { + // secPastEpoch=0 should decode to 1990-01-01 00:00:00 UTC. + hdr := buildTimeHeader(0, 0, 0, 0) + val := make([]byte, 8) + payload := append(append(hdr, make([]byte, 4)...), val...) + + tv, ok := proto.DecodeTimeValue(proto.DBRTimeDouble, 1, payload) + if !ok { + t.Fatal("DecodeTimeValue returned false") + } + want := time.Date(1990, 1, 1, 0, 0, 0, 0, time.UTC) + if !tv.Timestamp.Equal(want) { + t.Errorf("timestamp = %v, want %v", tv.Timestamp, want) + } +} diff --git a/pkg/ca/search.go b/pkg/ca/search.go new file mode 100644 index 0000000..f9aee86 --- /dev/null +++ b/pkg/ca/search.go @@ -0,0 +1,395 @@ +package ca + +import ( + "context" + "encoding/binary" + "fmt" + "net" + "sync" + "sync/atomic" + "time" + + "github.com/uopi/goca/proto" +) + +// searchResult is returned to a waiter when a search succeeds. +type searchResult struct { + addr string // "ip:port" TCP address of the IOC +} + +// searchWaiter tracks an outstanding PV search request. +type searchWaiter struct { + pvName string + ch chan searchResult // closed or sent to exactly once +} + +// searchEngine performs UDP-based PV name → IOC address resolution. +// It sends CA_PROTO_SEARCH datagrams to all configured server addresses and +// listens for replies, matching them to pending waiters via a searchID. +type searchEngine struct { + addrs []string // "ip:port" strings to search + + mu sync.Mutex + waiters map[uint32]*searchWaiter // searchID → waiter + + idSeq atomic.Uint32 + conn *net.UDPConn // shared UDP socket (nil if not started) +} + +func newSearchEngine(addrs []string) *searchEngine { + return &searchEngine{ + addrs: addrs, + waiters: make(map[uint32]*searchWaiter), + } +} + +// start opens the shared UDP socket and launches the receive goroutine. +// ctx cancellation stops the receive loop and closes the socket. +func (se *searchEngine) start(ctx context.Context) error { + conn, err := net.ListenUDP("udp4", &net.UDPAddr{}) + if err != nil { + return fmt.Errorf("ca search: open UDP socket: %w", err) + } + se.conn = conn + + go se.recvLoop(ctx) + return nil +} + +// lookup resolves pvName to an IOC TCP address. +// It retries with exponential back-off until ctx is cancelled or done. +func (se *searchEngine) lookup(ctx context.Context, pvName string) (string, error) { + id := se.idSeq.Add(1) + w := &searchWaiter{ + pvName: pvName, + ch: make(chan searchResult, 1), + } + + se.mu.Lock() + se.waiters[id] = w + se.mu.Unlock() + + defer func() { + se.mu.Lock() + delete(se.waiters, id) + se.mu.Unlock() + }() + + delay := 100 * time.Millisecond + const maxDelay = 30 * time.Second + + for { + if err := se.sendSearch(id, pvName); err != nil { + return "", err + } + + select { + case res := <-w.ch: + return res.addr, nil + case <-time.After(delay): + delay *= 2 + if delay > maxDelay { + delay = maxDelay + } + case <-ctx.Done(): + return "", fmt.Errorf("ca search %q: %w", pvName, ctx.Err()) + } + } +} + +// sendSearch sends a CA_PROTO_VERSION + CA_PROTO_SEARCH burst to all addresses. +// The CA protocol requires both messages to be in the same UDP datagram. +func (se *searchEngine) sendSearch(id uint32, pvName string) error { + payload := proto.BuildStringPayload(pvName) + + // VERSION message (no payload). + verHdr := proto.Header{ + Command: proto.CmdVersion, + DataCount: proto.MinorVersion, + } + + // SEARCH message. + searchHdr := proto.Header{ + Command: proto.CmdSearch, + DataType: proto.SearchReply, + DataCount: proto.MinorVersion, + Parameter1: id, + Parameter2: id, + } + searchMsg := proto.BuildMessage(searchHdr, payload) + + // Bundle into a single datagram — softIoc (and the CA Repeater) require + // VERSION and SEARCH to arrive together in one UDP packet. + pkt := make([]byte, 0, proto.HeaderSize+len(searchMsg)) + pkt = append(pkt, verHdr.Bytes()...) + pkt = append(pkt, searchMsg...) + + for _, addr := range se.addrs { + udpAddr, err := net.ResolveUDPAddr("udp4", addr) + if err != nil { + continue + } + _, _ = se.conn.WriteTo(pkt, udpAddr) + dbg("CA search sent", "pv", pvName, "id", id, "dst", udpAddr) + } + return nil +} + +// recvLoop reads UDP datagrams and dispatches search replies. +func (se *searchEngine) recvLoop(ctx context.Context) { + buf := make([]byte, 65536) + for { + // Use a short read deadline so we can check ctx.Done() periodically. + _ = se.conn.SetReadDeadline(time.Now().Add(500 * time.Millisecond)) + n, srcAddr, err := se.conn.ReadFromUDP(buf) + if err != nil { + if ctx.Err() != nil { + return + } + if netErr, ok := err.(net.Error); ok && netErr.Timeout() { + continue + } + continue + } + + dbg("CA UDP datagram received", "src", srcAddr, "bytes", n) + se.parseReply(buf[:n], srcAddr) + } +} + +// parseReply scans a UDP datagram for CA_PROTO_SEARCH response messages and +// wakes the matching waiter. +func (se *searchEngine) parseReply(data []byte, src *net.UDPAddr) { + for len(data) >= proto.HeaderSize { + h, n, err := proto.DecodeHeader(newBytesReader(data)) + if err != nil { + return + } + payloadEnd := n + int(h.PayloadSize) + if payloadEnd > len(data) { + return + } + payload := data[n:payloadEnd] + data = data[payloadEnd:] + + if h.Command != proto.CmdSearch { + continue + } + + // data_type field = TCP port of the CA server. + tcpPort := int(h.DataType) + searchID := h.Parameter2 + + // Resolve server IP. + // + // Two payload formats exist in the wild: + // + // New (≥ CA 4.9): [IP(4)][pad(4)] + // Old (< CA 4.9): [minor_version(2)][pad(2)][IP(4)] + // + // The old format is identified by payload[0] == 0x00 (the high byte of a + // uint16 minor version is always zero for CA versions ≤ 255, whereas a + // real IPv4 address never starts with 0x00). + // + // In both formats, 0xFFFFFFFF in the IP position means "use sender IP". + var serverIP net.IP + if len(payload) >= 8 && payload[0] == 0x00 { + // Old format: minor version at [0:2], IP at [4:8]. + ipSlice := payload[4:8] + if useSenderIP(ipSlice) { + serverIP = src.IP + } else { + ip := make(net.IP, 4) + copy(ip, ipSlice) + serverIP = ip + } + } else if len(payload) >= 4 { + // New format: IP at [0:4]. + if useSenderIP(payload[:4]) { + serverIP = src.IP + } else { + ip := make(net.IP, 4) + copy(ip, payload[:4]) + serverIP = ip + } + } else { + serverIP = src.IP + } + + tcpAddr := fmt.Sprintf("%s:%d", serverIP.String(), tcpPort) + dbg("CA search reply", "searchID", searchID, "tcpAddr", tcpAddr, "src", src) + + se.mu.Lock() + w, ok := se.waiters[searchID] + if ok { + delete(se.waiters, searchID) + } + se.mu.Unlock() + + if ok { + dbg("CA search matched", "pv", w.pvName, "ioc", tcpAddr) + select { + case w.ch <- searchResult{addr: tcpAddr}: + default: + } + } else { + dbg("CA search reply unmatched", "searchID", searchID, "known_waiters", func() int { + se.mu.Lock() + defer se.mu.Unlock() + return len(se.waiters) + }()) + } + } +} + +// useSenderIP reports whether an IP field in a CA search reply means +// "use the sender's address". Both 0.0.0.0 (all-zeros) and 255.255.255.255 +// (all-ones / 0xFFFFFFFF) are used by different EPICS Base versions. +func useSenderIP(b []byte) bool { + allFF := true + allZero := true + for _, v := range b { + if v != 0xFF { + allFF = false + } + if v != 0x00 { + allZero = false + } + } + return allFF || allZero +} + +func isAllFF(b []byte) bool { + for _, v := range b { + if v != 0xFF { + return false + } + } + return true +} + +// -------------------------------------------------------------------------- // +// Address list helpers // +// -------------------------------------------------------------------------- // + +// resolveAddrs converts a list of "host" or "host:port" strings to +// "host:port" strings using defaultPort when no port is specified. +func resolveAddrs(addrs []string, defaultPort int) []string { + out := make([]string, 0, len(addrs)) + for _, a := range addrs { + if a == "" { + continue + } + _, _, err := net.SplitHostPort(a) + if err != nil { + // No port specified; append default. + a = fmt.Sprintf("%s:%d", a, defaultPort) + } + out = append(out, a) + } + return out +} + +// localBroadcastAddrs returns broadcast addresses for all local network +// interfaces, used when AutoAddrList is true. +func localBroadcastAddrs(port int) []string { + ifaces, err := net.Interfaces() + if err != nil { + return nil + } + var out []string + for _, iface := range ifaces { + if iface.Flags&net.FlagBroadcast == 0 { + continue + } + addrs, err := iface.Addrs() + if err != nil { + continue + } + for _, a := range addrs { + ipNet, ok := a.(*net.IPNet) + if !ok { + continue + } + ip4 := ipNet.IP.To4() + if ip4 == nil { + continue + } + // Compute broadcast: IP | ^mask + mask := ipNet.Mask + bcast := make(net.IP, 4) + for i := range bcast { + bcast[i] = ip4[i] | ^mask[i] + } + out = append(out, fmt.Sprintf("%s:%d", bcast.String(), port)) + } + } + return out +} + +// -------------------------------------------------------------------------- // +// Minimal bytes.Reader-like for proto.DecodeHeader // +// -------------------------------------------------------------------------- // + +type bytesReader struct { + b []byte + i int +} + +func newBytesReader(b []byte) *bytesReader { return &bytesReader{b: b} } + +func (r *bytesReader) Read(p []byte) (int, error) { + if r.i >= len(r.b) { + return 0, fmt.Errorf("EOF") + } + n := copy(p, r.b[r.i:]) + r.i += n + return n, nil +} + +// -------------------------------------------------------------------------- // +// searchAddrFromReply extracts server IP from a CA_PROTO_SEARCH response. // +// Exported for testability. // +// -------------------------------------------------------------------------- // + +// EncodedSearchPair builds the VERSION + SEARCH UDP datagram pair for pvName +// with the given searchID. Exported for use in tests. +func EncodedSearchPair(pvName string, searchID uint32) []byte { + verHdr := proto.Header{ + Command: proto.CmdVersion, + DataCount: proto.MinorVersion, + } + payload := proto.BuildStringPayload(pvName) + searchHdr := proto.Header{ + Command: proto.CmdSearch, + DataType: proto.SearchReply, + DataCount: proto.MinorVersion, + Parameter1: searchID, + Parameter2: searchID, + } + searchMsg := proto.BuildMessage(searchHdr, payload) + out := make([]byte, 0, proto.HeaderSize+len(searchMsg)) + out = append(out, verHdr.Bytes()...) + out = append(out, searchMsg...) + return out +} + +// EncodeSearchReply builds a CA_PROTO_SEARCH UDP reply. +// serverIP may be nil to use 0xFFFFFFFF (sender IP). port is the CA TCP port. +func EncodeSearchReply(searchID uint32, serverIP net.IP, port int) []byte { + var payload []byte + if serverIP != nil { + payload = make([]byte, 8) + copy(payload[:4], serverIP.To4()) + binary.BigEndian.PutUint32(payload[4:], 0) + } else { + payload = []byte{0xFF, 0xFF, 0xFF, 0xFF, 0x00, 0x00, 0x00, 0x00} + } + h := proto.Header{ + Command: proto.CmdSearch, + DataType: uint16(port), + Parameter1: proto.MinorVersion, + Parameter2: searchID, + } + return proto.BuildMessage(h, payload) +} diff --git a/pkg/ca/search_test.go b/pkg/ca/search_test.go new file mode 100644 index 0000000..0cea19d --- /dev/null +++ b/pkg/ca/search_test.go @@ -0,0 +1,137 @@ +package ca + +import ( + "net" + "testing" + + "github.com/uopi/goca/proto" +) + +// buildOldSearchReply builds a CA_PROTO_SEARCH UDP reply using the old +// payload format (CA < 4.9): [minor_version(2)][pad(2)][IP(4)]. +// When ip is nil, the IP field is 0xFFFFFFFF (use-sender convention). +func buildOldSearchReply(searchID uint32, serverIP net.IP, port int) []byte { + payload := make([]byte, 8) + payload[0] = 0x00 + payload[1] = proto.MinorVersion // e.g. 0x0D = 13 + // payload[2:4] = 0x00 0x00 (pad) + if serverIP == nil { + payload[4] = 0xFF + payload[5] = 0xFF + payload[6] = 0xFF + payload[7] = 0xFF + } else { + copy(payload[4:], serverIP.To4()) + } + h := proto.Header{ + Command: proto.CmdSearch, + DataType: uint16(port), + Parameter1: proto.MinorVersion, + Parameter2: searchID, + } + return proto.BuildMessage(h, payload) +} + +// TestParseReplyOldFormatAllFF verifies the old format with 0xFFFFFFFF IP. +func TestParseReplyOldFormatAllFF(t *testing.T) { + se := &searchEngine{waiters: make(map[uint32]*searchWaiter)} + const searchID = uint32(42) + result := make(chan searchResult, 1) + se.mu.Lock() + se.waiters[searchID] = &searchWaiter{pvName: "TEST:PV", ch: result} + se.mu.Unlock() + + pkt := buildOldSearchReply(searchID, nil, 5064) // nil → 0xFFFFFFFF + src := &net.UDPAddr{IP: net.ParseIP("127.0.0.1"), Port: 5064} + se.parseReply(pkt, src) + + select { + case r := <-result: + if r.addr != "127.0.0.1:5064" { + t.Errorf("addr = %q, want 127.0.0.1:5064", r.addr) + } + default: + t.Fatal("parseReply did not deliver a result") + } +} + +// TestParseReplyOldFormatAllZero verifies the old format with 0.0.0.0 IP +// (used by some EPICS Base versions to also mean "use sender IP"). +func TestParseReplyOldFormatAllZero(t *testing.T) { + se := &searchEngine{waiters: make(map[uint32]*searchWaiter)} + const searchID = uint32(43) + result := make(chan searchResult, 1) + se.mu.Lock() + se.waiters[searchID] = &searchWaiter{pvName: "TEST:PV", ch: result} + se.mu.Unlock() + + pkt := buildOldSearchReply(searchID, net.IPv4(0, 0, 0, 0).To4(), 5064) + src := &net.UDPAddr{IP: net.ParseIP("127.0.0.1"), Port: 5064} + se.parseReply(pkt, src) + + select { + case r := <-result: + if r.addr != "127.0.0.1:5064" { + t.Errorf("addr = %q, want 127.0.0.1:5064", r.addr) + } + default: + t.Fatal("parseReply did not deliver a result") + } +} + +// TestParseReplyOldFormatExplicitIP verifies that an explicit server IP in +// the old payload format is read from the correct offset (bytes [4:8]). +func TestParseReplyOldFormatExplicitIP(t *testing.T) { + se := &searchEngine{ + waiters: make(map[uint32]*searchWaiter), + } + + const searchID = uint32(7) + result := make(chan searchResult, 1) + se.mu.Lock() + se.waiters[searchID] = &searchWaiter{pvName: "TEST:PV2", ch: result} + se.mu.Unlock() + + explicitIP := net.ParseIP("10.0.0.5").To4() + pkt := buildOldSearchReply(searchID, explicitIP, 5064) + src := &net.UDPAddr{IP: net.ParseIP("10.0.0.5"), Port: 5064} + + se.parseReply(pkt, src) + + select { + case r := <-result: + if r.addr != "10.0.0.5:5064" { + t.Errorf("addr = %q, want 10.0.0.5:5064", r.addr) + } + default: + t.Fatal("parseReply did not deliver a result") + } +} + +// TestParseReplyNewFormat verifies the new payload format (IP at [0:4]). +func TestParseReplyNewFormat(t *testing.T) { + se := &searchEngine{ + waiters: make(map[uint32]*searchWaiter), + } + + const searchID = uint32(99) + result := make(chan searchResult, 1) + se.mu.Lock() + se.waiters[searchID] = &searchWaiter{pvName: "TEST:PV3", ch: result} + se.mu.Unlock() + + // New format: IP = 0xFFFFFFFF at [0:4]. + pkt := EncodeSearchReply(searchID, nil, 5064) + src := &net.UDPAddr{IP: net.ParseIP("192.168.1.10"), Port: 5064} + + se.parseReply(pkt, src) + + select { + case r := <-result: + if r.addr != "192.168.1.10:5064" { + t.Errorf("addr = %q, want 192.168.1.10:5064", r.addr) + } + default: + t.Fatal("parseReply did not deliver a result") + } +} diff --git a/pkg/ca/testca/server.go b/pkg/ca/testca/server.go new file mode 100644 index 0000000..d0afe12 --- /dev/null +++ b/pkg/ca/testca/server.go @@ -0,0 +1,601 @@ +// Package testca provides an in-process fake CA server for use in tests. +// It supports a small, fixed set of PVs and is compatible with the goca Client. +package testca + +import ( + "encoding/binary" + "fmt" + "io" + "math" + "net" + "sync" + "time" + + "github.com/uopi/goca/proto" +) + +// PVSpec describes one test PV hosted by the fake server. +type PVSpec struct { + Name string + DBFType int // proto.DBF* constant + Count uint32 // element count (1 for scalars) + Value any // initial value + Access uint32 // proto.AccessRead | proto.AccessWrite +} + +// Server is an in-process fake CA server. It listens on a random TCP port and +// an ephemeral UDP port. Use Addr() to get the addresses for client config. +type Server struct { + tcpLn net.Listener + udpCn *net.UDPConn + + mu sync.RWMutex + pvs map[string]*serverPV + subs map[uint32]*serverSub // subID → sub + + done chan struct{} +} + +type serverPV struct { + spec PVSpec + mu sync.RWMutex + val any +} + +type serverSub struct { + pvName string + subID uint32 + cid uint32 + sid uint32 + conn net.Conn + dbrType uint16 + count uint32 +} + +// New creates and starts a fake CA server hosting the given PVs. +func New(pvs []PVSpec) (*Server, error) { + tcpLn, err := net.Listen("tcp4", "127.0.0.1:0") + if err != nil { + return nil, fmt.Errorf("testca: tcp listen: %w", err) + } + udpCn, err := net.ListenUDP("udp4", &net.UDPAddr{IP: net.ParseIP("127.0.0.1")}) + if err != nil { + tcpLn.Close() + return nil, fmt.Errorf("testca: udp listen: %w", err) + } + + s := &Server{ + tcpLn: tcpLn, + udpCn: udpCn, + pvs: make(map[string]*serverPV, len(pvs)), + subs: make(map[uint32]*serverSub), + done: make(chan struct{}), + } + for _, spec := range pvs { + s.pvs[spec.Name] = &serverPV{spec: spec, val: spec.Value} + } + + go s.acceptLoop() + go s.udpLoop() + return s, nil +} + +// TCPAddr returns the TCP "host:port" address clients should connect to. +func (s *Server) TCPAddr() string { return s.tcpLn.Addr().String() } + +// UDPAddr returns the UDP "host:port" address clients should search against. +func (s *Server) UDPAddr() string { return s.udpCn.LocalAddr().String() } + +// Close shuts down the server. +func (s *Server) Close() { + close(s.done) + s.tcpLn.Close() + s.udpCn.Close() +} + +// SetValue updates a PV's value and pushes a monitor event to all subscribers. +func (s *Server) SetValue(pvName string, val any) error { + s.mu.RLock() + pv, ok := s.pvs[pvName] + s.mu.RUnlock() + if !ok { + return fmt.Errorf("testca: unknown PV %q", pvName) + } + pv.mu.Lock() + pv.val = val + pv.mu.Unlock() + + // Push to all subscribers of this PV. + s.mu.RLock() + for _, sub := range s.subs { + if sub.pvName == pvName { + if msg := s.buildEventMsg(sub, pv); msg != nil { + _, _ = sub.conn.Write(msg) + } + } + } + s.mu.RUnlock() + return nil +} + +// -------------------------------------------------------------------------- // +// UDP search loop // +// -------------------------------------------------------------------------- // + +func (s *Server) udpLoop() { + buf := make([]byte, 65536) + for { + _ = s.udpCn.SetReadDeadline(time.Now().Add(200 * time.Millisecond)) + n, src, err := s.udpCn.ReadFromUDP(buf) + if err != nil { + select { + case <-s.done: + return + default: + continue + } + } + s.handleUDP(buf[:n], src) + } +} + +func (s *Server) handleUDP(data []byte, src *net.UDPAddr) { + for len(data) >= proto.HeaderSize { + hdr, n, err := proto.DecodeHeader(newBytesReader(data)) + if err != nil { + return + } + payEnd := n + int(hdr.PayloadSize) + if payEnd > len(data) { + return + } + payload := data[n:payEnd] + data = data[payEnd:] + + if hdr.Command != proto.CmdSearch { + continue + } + // Extract PV name from payload (null-terminated). + pvName := nullStr(payload) + + s.mu.RLock() + _, ok := s.pvs[pvName] + s.mu.RUnlock() + if !ok { + continue + } + + // Reply: data_type = TCP port, parameter2 = searchID. + tcpPort := s.tcpLn.Addr().(*net.TCPAddr).Port + // Use 0xFFFFFFFF IP to tell client to use sender's IP. + reply := buildSearchReply(hdr.Parameter2, tcpPort) + _, _ = s.udpCn.WriteToUDP(reply, src) + } +} + +func buildSearchReply(searchID uint32, port int) []byte { + payload := []byte{0xFF, 0xFF, 0xFF, 0xFF, 0x00, 0x00, 0x00, 0x00} + h := proto.Header{ + Command: proto.CmdSearch, + DataType: uint16(port), + Parameter1: proto.MinorVersion, + Parameter2: searchID, + } + return proto.BuildMessage(h, payload) +} + +// -------------------------------------------------------------------------- // +// TCP accept + per-connection handler // +// -------------------------------------------------------------------------- // + +func (s *Server) acceptLoop() { + for { + conn, err := s.tcpLn.Accept() + if err != nil { + select { + case <-s.done: + return + default: + continue + } + } + go s.handleConn(conn) + } +} + +func (s *Server) handleConn(conn net.Conn) { + defer conn.Close() + + // Track CID → (pvName, SID). + type chanInfo struct { + pvName string + sid uint32 + } + sidSeq := uint32(1000) + channels := make(map[uint32]*chanInfo) // cid → info + + for { + hdr, _, err := proto.DecodeHeader(conn) + if err != nil { + return + } + var payload []byte + if hdr.PayloadSize > 0 { + payload = make([]byte, hdr.PayloadSize) + if _, err = io.ReadFull(conn, payload); err != nil { + return + } + } + + switch hdr.Command { + case proto.CmdVersion: + // Respond with VERSION. + reply := proto.BuildMessage(proto.Header{ + Command: proto.CmdVersion, + DataCount: proto.MinorVersion, + }, nil) + conn.Write(reply) + + case proto.CmdHostName, proto.CmdClientName: + // Ignore client identity. + + case proto.CmdCreateChan: + cid := hdr.Parameter1 + pvName := nullStr(payload) + + s.mu.RLock() + pv, ok := s.pvs[pvName] + s.mu.RUnlock() + + if !ok { + // CREATE_FAIL + fail := proto.BuildMessage(proto.Header{ + Command: proto.CmdCreateFail, + Parameter1: cid, + }, nil) + conn.Write(fail) + continue + } + + sid := sidSeq + sidSeq++ + channels[cid] = &chanInfo{pvName: pvName, sid: sid} + + // CREATE_CHAN reply: DataType=dbfType, DataCount=count, p1=cid, p2=sid. + pv.mu.RLock() + dbfType := pv.spec.DBFType + count := pv.spec.Count + pv.mu.RUnlock() + + reply := proto.BuildMessage(proto.Header{ + Command: proto.CmdCreateChan, + DataType: uint16(dbfType), + DataCount: count, + Parameter1: cid, + Parameter2: sid, + }, nil) + conn.Write(reply) + + // ACCESS_RIGHTS: p1=cid, p2=access. + access := pv.spec.Access + if access == 0 { + access = proto.AccessRead | proto.AccessWrite + } + ar := proto.BuildMessage(proto.Header{ + Command: proto.CmdAccessRights, + Parameter1: cid, + Parameter2: access, + }, nil) + conn.Write(ar) + + case proto.CmdEventAdd: + // p1=SID (channel), p2=subscriptionID (client-assigned). + sid := hdr.Parameter1 + subID := hdr.Parameter2 + + // Find channel by SID. + var pvName string + for _, info := range channels { + if info.sid == sid { + pvName = info.pvName + break + } + } + if pvName == "" { + continue + } + + s.mu.Lock() + sub := &serverSub{ + pvName: pvName, + subID: subID, + sid: sid, + conn: conn, + dbrType: hdr.DataType, + count: hdr.DataCount, + } + s.subs[subID] = sub + s.mu.Unlock() + + // Send initial value. + s.mu.RLock() + pv := s.pvs[pvName] + s.mu.RUnlock() + if msg := s.buildEventMsg(sub, pv); msg != nil { + conn.Write(msg) + } + + case proto.CmdEventCancel: + // p1=SID, p2=subscriptionID. + subID := hdr.Parameter2 + s.mu.Lock() + delete(s.subs, subID) + s.mu.Unlock() + + case proto.CmdReadNotify: + // p1=SID, p2=ioid (per CA spec). + sid := hdr.Parameter1 + ioid := hdr.Parameter2 + + var pvName string + for _, info := range channels { + if info.sid == sid { + pvName = info.pvName + break + } + } + if pvName == "" { + continue + } + + s.mu.RLock() + pv := s.pvs[pvName] + s.mu.RUnlock() + + pv.mu.RLock() + val := pv.val + pv.mu.RUnlock() + + var replyPayload []byte + switch hdr.DataType { + case proto.DBRCtrlDouble, proto.DBRCtrlLong, proto.DBRCtrlEnum, proto.DBRCtrlString, + proto.DBRCtrlFloat, proto.DBRCtrlShort, proto.DBRCtrlChar: + replyPayload = s.encodeCtrlValue(hdr.DataType, pv) + default: + replyPayload = s.encodeTimeValue(hdr.DataType, hdr.DataCount, val) + } + reply := proto.BuildMessage(proto.Header{ + Command: proto.CmdReadNotify, + DataType: hdr.DataType, + DataCount: hdr.DataCount, + Parameter1: ioid, + }, replyPayload) + conn.Write(reply) + + case proto.CmdWrite: + // p1=SID, p2=0. + sid := hdr.Parameter1 + var pvName string + for _, info := range channels { + if info.sid == sid { + pvName = info.pvName + break + } + } + if pvName == "" { + continue + } + s.mu.RLock() + pv := s.pvs[pvName] + s.mu.RUnlock() + + val := decodeWritePayload(hdr.DataType, payload) + if val == nil { + continue + } + pv.mu.Lock() + pv.val = val + pv.mu.Unlock() + + // Push update to all subscribers. + s.mu.RLock() + for _, sub := range s.subs { + if sub.pvName == pvName { + if msg := s.buildEventMsg(sub, pv); msg != nil { + sub.conn.Write(msg) + } + } + } + s.mu.RUnlock() + + case proto.CmdEcho: + // No response needed (server echoes are optional). + } + } +} + +// -------------------------------------------------------------------------- // +// Value encoding helpers // +// -------------------------------------------------------------------------- // + +func (s *Server) buildEventMsg(sub *serverSub, pv *serverPV) []byte { + pv.mu.RLock() + val := pv.val + pv.mu.RUnlock() + payload := s.encodeTimeValue(sub.dbrType, sub.count, val) + if payload == nil { + return nil + } + return proto.BuildMessage(proto.Header{ + Command: proto.CmdEventAdd, + DataType: sub.dbrType, + DataCount: sub.count, + Parameter1: sub.subID, + }, payload) +} + +// encodeTimeValue builds a DBR_TIME_* payload for val. +func (s *Server) encodeTimeValue(dbrType uint16, _ uint32, val any) []byte { + now := time.Now().UTC() + sec := uint32(now.Unix() - 631152000) // EPICS epoch offset + nsec := uint32(now.Nanosecond()) + + hdr := make([]byte, 12) + // status=0, severity=0 + binary.BigEndian.PutUint32(hdr[4:], sec) + binary.BigEndian.PutUint32(hdr[8:], nsec) + + var body []byte + switch dbrType { + case proto.DBRTimeDouble: + body = make([]byte, 12) // 4 pad + 8 value + f := toF64(val) + binary.BigEndian.PutUint64(body[4:], math.Float64bits(f)) + + case proto.DBRTimeFloat: + body = make([]byte, 4) + binary.BigEndian.PutUint32(body, math.Float32bits(float32(toF64(val)))) + + case proto.DBRTimeLong: + body = make([]byte, 4) + binary.BigEndian.PutUint32(body, uint32(int32(toF64(val)))) + + case proto.DBRTimeShort, proto.DBRTimeEnum: + body = make([]byte, 4) // 2 value + 2 pad + binary.BigEndian.PutUint16(body, uint16(int16(toF64(val)))) + + case proto.DBRTimeChar: + body = make([]byte, 4) // 1 value + 3 pad + body[0] = byte(uint8(toF64(val))) + + case proto.DBRTimeString: + body = make([]byte, 40) + if str, ok := val.(string); ok { + copy(body, str) + } + + default: + return nil + } + + return append(hdr, body...) +} + +// encodeCtrlValue builds a DBR_CTRL_* payload for a GET reply. +// The fake server returns minimal but structurally correct payloads so that +// the client-side decode functions succeed. +func (s *Server) encodeCtrlValue(dbrType uint16, pv *serverPV) []byte { + pv.mu.RLock() + val := pv.val + access := pv.spec.Access + pv.mu.RUnlock() + + switch dbrType { + case proto.DBRCtrlDouble: // 88 bytes + p := make([]byte, 88) + if access == proto.AccessRead { + // status=0, severity=0 (read-only signalled via ACCESS_RIGHTS, not here) + } + // units at [8:16] (empty string = "") + // value at [80:88] + binary.BigEndian.PutUint64(p[80:], math.Float64bits(toF64(val))) + return p + + case proto.DBRCtrlFloat, proto.DBRCtrlShort, proto.DBRCtrlChar: // map to Long-style + // Treat as CtrlLong (48 bytes); caller asked for these types but our + // NativeCtrlType maps them to Double/Long anyway. Return a valid stub. + p := make([]byte, 48) + binary.BigEndian.PutUint32(p[44:], uint32(int32(toF64(val)))) + return p + + case proto.DBRCtrlLong: // 48 bytes + p := make([]byte, 48) + binary.BigEndian.PutUint32(p[44:], uint32(int32(toF64(val)))) + return p + + case proto.DBRCtrlEnum: // 424 bytes + p := make([]byte, 424) + // no_str at [4:6] = 0 (no enum strings in fake server) + binary.BigEndian.PutUint16(p[422:], uint16(int16(toF64(val)))) + return p + + case proto.DBRCtrlString: // 44 bytes: status(2)+severity(2)+value[40] + p := make([]byte, 44) + if str, ok := val.(string); ok { + copy(p[4:], str) + } + return p + + default: + return nil + } +} + +func decodeWritePayload(dbrType uint16, payload []byte) any { + switch dbrType { + case proto.DBRDouble: + if len(payload) < 8 { + return nil + } + return math.Float64frombits(binary.BigEndian.Uint64(payload)) + case proto.DBRLong: + if len(payload) < 4 { + return nil + } + return int32(binary.BigEndian.Uint32(payload)) + case proto.DBRShort, proto.DBREnum: + if len(payload) < 2 { + return nil + } + return int16(binary.BigEndian.Uint16(payload)) + case proto.DBRString: + return nullStr(payload) + default: + return nil + } +} + +func toF64(v any) float64 { + switch x := v.(type) { + case float64: + return x + case float32: + return float64(x) + case int: + return float64(x) + case int32: + return float64(x) + case int16: + return float64(x) + case int64: + return float64(x) + default: + return 0 + } +} + +func nullStr(b []byte) string { + for i, c := range b { + if c == 0 { + return string(b[:i]) + } + } + return string(b) +} + +// -------------------------------------------------------------------------- // +// Minimal io.Reader for proto.DecodeHeader // +// -------------------------------------------------------------------------- // + +type bytesReader struct{ b []byte; i int } + +func newBytesReader(b []byte) *bytesReader { return &bytesReader{b: b} } + +func (r *bytesReader) Read(p []byte) (int, error) { + if r.i >= len(r.b) { + return 0, io.EOF + } + n := copy(p, r.b[r.i:]) + r.i += n + return n, nil +} diff --git a/workspace/run.sh b/workspace/run.sh index 378938f..110f2ed 100755 --- a/workspace/run.sh +++ b/workspace/run.sh @@ -5,9 +5,11 @@ # bash workspace/run.sh # # Requirements: -# • EPICS Base installed (EPICS_BASE set, or auto-detected below) -# • softIoc binary available -# • uopi built with EPICS support (script builds it if missing) +# • EPICS Base installed (softIoc binary available in PATH or EPICS_BASE) +# • uopi built (script builds it if missing) +# +# uopi connects to EPICS via its built-in pure-Go CA client — no libca needed. +# Set UOPI_CA_DEBUG=1 (default below) to enable CA-level debug logging. set -euo pipefail @@ -15,32 +17,24 @@ PROJECT_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" WORKSPACE="$PROJECT_ROOT/workspace" BINARY="$PROJECT_ROOT/dist/uopi" -# ── Locate EPICS Base ───────────────────────────────────────────────────────── +# ── Locate EPICS tools ──────────────────────────────────────────────────────── -if [ -z "${EPICS_BASE:-}" ]; then - for candidate in \ - /usr/lib/epics \ - /usr/local/epics \ - /opt/epics/base \ - /opt/epics; do - if [ -f "$candidate/include/cadef.h" ]; then - export EPICS_BASE="$candidate" - break - fi - done +# If EPICS_BASE is set, add its bin directory to PATH automatically. +if [ -n "${EPICS_BASE:-}" ]; then + if [ -z "${EPICS_ARCH:-}" ]; then + EPICS_ARCH="$(ls "$EPICS_BASE/bin" 2>/dev/null | grep -i linux | head -1 || true)" + EPICS_ARCH="${EPICS_ARCH:-linux-x86_64}" + fi + export PATH="$EPICS_BASE/bin/$EPICS_ARCH:$PATH" + echo "EPICS_BASE=$EPICS_BASE EPICS_ARCH=$EPICS_ARCH" fi -if [ -z "${EPICS_BASE:-}" ]; then - echo "ERROR: EPICS_BASE is not set and could not be auto-detected." - echo " export EPICS_BASE=/path/to/epics/base" +if ! command -v softIoc &>/dev/null; then + echo "ERROR: softIoc not found in PATH." + echo " Set EPICS_BASE=/path/to/epics/base, or add softIoc to your PATH." exit 1 fi - -# Detect architecture -EPICS_ARCH="${EPICS_ARCH:-$(ls "$EPICS_BASE/lib" | grep linux | head -1)}" -if [ -z "$EPICS_ARCH" ]; then - EPICS_ARCH="linux-x86_64" -fi +echo "softIoc: $(command -v softIoc)" # ── Environment ─────────────────────────────────────────────────────────────── @@ -48,24 +42,17 @@ fi export EPICS_CA_ADDR_LIST="127.0.0.1" export EPICS_CA_AUTO_ADDR_LIST="NO" -# Ensure the EPICS shared libraries are found at runtime. -export LD_LIBRARY_PATH="$EPICS_BASE/lib/$EPICS_ARCH${LD_LIBRARY_PATH:+:$LD_LIBRARY_PATH}" - -# Make EPICS tools (softIoc, caget, caput…) available. -export PATH="$EPICS_BASE/bin/$EPICS_ARCH:$PATH" - -# ── Locate softIoc ──────────────────────────────────────────────────────────── - -if ! command -v softIoc &>/dev/null; then - echo "ERROR: softIoc not found in $EPICS_BASE/bin/$EPICS_ARCH or PATH." - exit 1 -fi +# Enable CA-level debug logging in uopi (set to "" to disable). +export UOPI_CA_DEBUG="${UOPI_CA_DEBUG:-1}" # ── Build uopi ──────────────────────────────────────────────────────────────── -echo "Building uopi with EPICS support..." +echo "Building uopi + catools..." cd "$PROJECT_ROOT" -make backend-epics 2>&1 +make backend 2>&1 +echo "" +echo " catools: $PROJECT_ROOT/dist/catools" +echo " Usage: dist/catools caget UOPI:TICK" echo "" # ── Cleanup handler ─────────────────────────────────────────────────────────── @@ -85,39 +72,66 @@ trap cleanup EXIT INT TERM pkill -x softIoc 2>/dev/null || true # Give the OS a moment to release the CA port (5064/tcp+udp). -sleep 0.3 +sleep 0.5 # ── Start softIOC ───────────────────────────────────────────────────────────── echo "Starting softIOC..." +echo " DB: $WORKSPACE/softioc/uopi_test.db" +echo " Log: $WORKSPACE/softioc.log" softIoc -S -d "$WORKSPACE/softioc/uopi_test.db" \ >"$WORKSPACE/softioc.log" 2>&1 & IOC_PID=$! PIDS+=("$IOC_PID") -# Wait for the IOC to accept Channel Access connections (up to 10 s). -echo -n "Waiting for IOC to be ready" +# Wait for the IOC to start listening on TCP 5064 (up to 10 s). +# We probe with nc (netcat) first — no EPICS tools required. +echo -n "Waiting for IOC port 5064" +IOC_READY=0 for i in $(seq 1 20); do - if caget -w 1 UOPI:TICK >/dev/null 2>&1; then - echo " OK" + if nc -z 127.0.0.1 5064 2>/dev/null; then + echo " OK (TCP 5064 open)" + IOC_READY=1 break fi echo -n "." sleep 0.5 - if [ "$i" -eq 20 ]; then - echo "" - echo "ERROR: IOC did not become ready within 10 seconds." - echo "Check $WORKSPACE/softioc.log for details." - exit 1 - fi done +if [ "$IOC_READY" -eq 0 ]; then + echo "" + echo "ERROR: IOC port 5064 not open after 10 seconds." + echo "softIoc log:" + cat "$WORKSPACE/softioc.log" || true + exit 1 +fi + +# Optional deeper check with caget if it's available. +if command -v caget &>/dev/null; then + echo -n "Verifying UOPI:TICK with caget" + for i in $(seq 1 10); do + if caget -w 1 UOPI:TICK >/dev/null 2>&1; then + echo " OK" + break + fi + echo -n "." + sleep 0.5 + if [ "$i" -eq 10 ]; then + echo " (timeout — continuing anyway)" + fi + done +else + echo "caget not in PATH — skipping PV check (softIoc assumed ready)" + sleep 1 +fi + # ── Start uopi ──────────────────────────────────────────────────────────────── +echo "" echo "Starting uopi (http://localhost:8080)..." -echo " Open the browser, switch to Edit mode, load workspace/data/epics_test.xml" -echo " or select it from the interface list in View mode." -echo " Press Ctrl-C to stop." +echo " UOPI_CA_DEBUG=$UOPI_CA_DEBUG" +echo " Config: $WORKSPACE/uopi.toml" +echo " Log will appear below. Press Ctrl-C to stop." echo "" cd "$PROJECT_ROOT" diff --git a/workspace/softioc.log b/workspace/softioc.log index 5e88df6..e9cbd71 100644 --- a/workspace/softioc.log +++ b/workspace/softioc.log @@ -1,2 +1,38 @@ Starting iocInit iocRun: All initialization complete +CAS: request from 127.0.0.1:56104 => bad resource ID +CAS: Request from 127.0.0.1:56104 => cmmd=1 cid=0x2 type=25 count=1 postsize=16 +CAS: Request from 127.0.0.1:56104 => available=0x1 N=0 paddr=(nil) +CAS: forcing disconnect from 127.0.0.1:56104 +CAS: request from 127.0.0.1:56106 => bad resource ID +CAS: Request from 127.0.0.1:56106 => cmmd=1 cid=0x5 type=25 count=1 postsize=16 +CAS: Request from 127.0.0.1:56106 => available=0x3 N=0 paddr=(nil) +CAS: forcing disconnect from 127.0.0.1:56106 +CAS: request from 127.0.0.1:56120 => bad resource ID +CAS: Request from 127.0.0.1:56120 => cmmd=1 cid=0x8 type=25 count=1 postsize=16 +CAS: Request from 127.0.0.1:56120 => available=0x6 N=0 paddr=(nil) +CAS: forcing disconnect from 127.0.0.1:56120 +CAS: request from 127.0.0.1:56126 => bad resource ID +CAS: Request from 127.0.0.1:56126 => cmmd=1 cid=0xb type=25 count=1 postsize=16 +CAS: Request from 127.0.0.1:56126 => available=0xa N=0 paddr=(nil) +CAS: forcing disconnect from 127.0.0.1:56126 +CAS: request from 127.0.0.1:56130 => no memory to add subscription to db +CAS: Request from 127.0.0.1:56130 => cmmd=1 cid=0xe type=25 count=1 postsize=16 +CAS: Request from 127.0.0.1:56130 => available=0xf N=0 paddr=0x7fae3800d838 +CAS: forcing disconnect from 127.0.0.1:56130 +CAS: request from 127.0.0.1:56132 => no memory to add subscription to db +CAS: Request from 127.0.0.1:56132 => cmmd=1 cid=0x10 type=25 count=1 postsize=16 +CAS: Request from 127.0.0.1:56132 => available=0x11 N=0 paddr=0x7fae3800d768 +CAS: forcing disconnect from 127.0.0.1:56132 +CAS: request from 127.0.0.1:56144 => bad resource ID +CAS: Request from 127.0.0.1:56144 => cmmd=1 cid=0x10 type=25 count=1 postsize=16 +CAS: Request from 127.0.0.1:56144 => available=0x17 N=0 paddr=(nil) +CAS: forcing disconnect from 127.0.0.1:56144 +CAS: request from 127.0.0.1:56148 => bad resource ID +CAS: Request from 127.0.0.1:56148 => cmmd=1 cid=0x10 type=25 count=1 postsize=16 +CAS: Request from 127.0.0.1:56148 => available=0x1d N=0 paddr=(nil) +CAS: forcing disconnect from 127.0.0.1:56148 +CAS: request from 127.0.0.1:60292 => bad resource ID +CAS: Request from 127.0.0.1:60292 => cmmd=1 cid=0x10 type=25 count=1 postsize=16 +CAS: Request from 127.0.0.1:60292 => available=0x24 N=0 paddr=(nil) +CAS: forcing disconnect from 127.0.0.1:60292