Compare commits
2 Commits
47e481a461
...
90669c5fd6
| Author | SHA1 | Date | |
|---|---|---|---|
| 90669c5fd6 | |||
| 6e51ffc5e1 |
@@ -5,3 +5,10 @@ interfaces/
|
|||||||
synthetic.json
|
synthetic.json
|
||||||
uopi.toml
|
uopi.toml
|
||||||
*.local.toml
|
*.local.toml
|
||||||
|
resources
|
||||||
|
.claude
|
||||||
|
.github
|
||||||
|
.goreleaser.yaml
|
||||||
|
.iocsh_history
|
||||||
|
go.work.sum
|
||||||
|
*.log
|
||||||
|
|||||||
@@ -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 #
|
# 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) \
|
$(wildcard web/vendor/*.mjs web/vendor/*.js web/vendor/*.css) \
|
||||||
tools/buildfrontend/main.go
|
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 #
|
# 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
|
FRONTEND_OUT := web/dist/main.js
|
||||||
BINARY := dist/uopi
|
BINARY := dist/uopi
|
||||||
|
CATOOLS := dist/catools
|
||||||
|
|
||||||
# --------------------------------------------------------------------------- #
|
# --------------------------------------------------------------------------- #
|
||||||
# Top-level targets #
|
# Top-level targets #
|
||||||
# --------------------------------------------------------------------------- #
|
# --------------------------------------------------------------------------- #
|
||||||
|
|
||||||
all: $(BINARY)
|
all: $(BINARY) $(CATOOLS)
|
||||||
|
|
||||||
frontend: $(FRONTEND_OUT)
|
frontend: $(FRONTEND_OUT)
|
||||||
|
|
||||||
backend: $(BINARY)
|
backend: $(BINARY) $(CATOOLS)
|
||||||
|
|
||||||
# --------------------------------------------------------------------------- #
|
# --------------------------------------------------------------------------- #
|
||||||
# Build rules #
|
# Build rules #
|
||||||
@@ -36,127 +39,40 @@ $(FRONTEND_OUT): $(FRONTEND_SRCS)
|
|||||||
@mkdir -p web/dist
|
@mkdir -p web/dist
|
||||||
go run ./tools/buildfrontend/main.go
|
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)
|
$(BINARY): $(GO_SRCS) $(FRONTEND_OUT)
|
||||||
@mkdir -p dist
|
@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.
|
# CA diagnostic tools (caget/caput/cainfo/camonitor equivalents)
|
||||||
#
|
$(CATOOLS): $(GO_SRCS)
|
||||||
# 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/<arch>/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)"
|
|
||||||
@mkdir -p dist
|
@mkdir -p dist
|
||||||
CGO_ENABLED=1 \
|
CGO_ENABLED=0 go build -ldflags="-s -w" -o $(CATOOLS) ./cmd/catools
|
||||||
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)"
|
|
||||||
|
|
||||||
# Unstripped binary for debugging
|
# Unstripped binary for debugging (pure-Go CA, CGO_ENABLED=0)
|
||||||
backend-debug: $(FRONTEND_OUT)
|
backend-debug: $(FRONTEND_OUT)
|
||||||
@mkdir -p dist
|
@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:
|
# Fully static, pure-Go CA client, no external library dependencies.
|
||||||
#
|
# Cross-compile: make release RELEASE_OS=linux RELEASE_ARCH=arm64
|
||||||
# make release — pure-Go, CGO_ENABLED=0, 100 % static binary.
|
|
||||||
# No EPICS; works anywhere without extra libs.
|
|
||||||
#
|
|
||||||
# make release-epics — CGO build with EPICS CA.
|
|
||||||
# Tries to link libca/libCom statically (if .a
|
|
||||||
# archives are present in $(EPICS_LIBDIR)).
|
|
||||||
# Falls back to bundled shared libs + $ORIGIN
|
|
||||||
# rpath when only .so files are available.
|
|
||||||
# The output directory (dist/release/) contains
|
|
||||||
# everything needed to run on the target machine.
|
|
||||||
#
|
|
||||||
# Cross-compilation (no-EPICS only): override RELEASE_OS / RELEASE_ARCH.
|
|
||||||
# make release RELEASE_OS=linux RELEASE_ARCH=arm64
|
|
||||||
|
|
||||||
RELEASE_OS ?= linux
|
RELEASE_OS ?= linux
|
||||||
RELEASE_ARCH ?= amd64
|
RELEASE_ARCH ?= amd64
|
||||||
RELEASE_DIR := dist/release
|
RELEASE_DIR := dist/release
|
||||||
EPICS_LIBDIR := $(EPICS_BASE)/lib/$(EPICS_ARCH)
|
|
||||||
|
|
||||||
# Fully static, no CGO — the most portable build possible.
|
|
||||||
release: $(FRONTEND_OUT)
|
release: $(FRONTEND_OUT)
|
||||||
@mkdir -p $(RELEASE_DIR)
|
@mkdir -p $(RELEASE_DIR)
|
||||||
CGO_ENABLED=0 GOOS=$(RELEASE_OS) GOARCH=$(RELEASE_ARCH) \
|
CGO_ENABLED=0 GOOS=$(RELEASE_OS) GOARCH=$(RELEASE_ARCH) \
|
||||||
go build -ldflags="-s -w" -o $(RELEASE_DIR)/uopi ./cmd/uopi
|
go build -ldflags="-s -w" -o $(RELEASE_DIR)/uopi ./cmd/uopi
|
||||||
@echo "Built: $(RELEASE_DIR)/uopi (fully static, no EPICS)"
|
@echo "Built: $(RELEASE_DIR)/uopi (static, pure-Go CA)"
|
||||||
|
|
||||||
# EPICS release — maximum static linking.
|
|
||||||
# Strategy A (preferred): if libca.a + libCom.a exist, embed them directly
|
|
||||||
# → single self-contained binary, no extra files needed.
|
|
||||||
# Strategy B (fallback): copy the shared libs next to the binary and set an
|
|
||||||
# $ORIGIN rpath so the OS finds them regardless of installation prefix.
|
|
||||||
release-epics: $(FRONTEND_OUT)
|
|
||||||
@if [ -z "$(EPICS_BASE)" ]; then \
|
|
||||||
echo "ERROR: EPICS_BASE is not set."; \
|
|
||||||
echo " export EPICS_BASE=/path/to/epics/base"; \
|
|
||||||
exit 1; \
|
|
||||||
fi
|
|
||||||
@mkdir -p $(RELEASE_DIR)
|
|
||||||
@echo "Building portable EPICS release"
|
|
||||||
@echo " EPICS_BASE = $(EPICS_BASE)"
|
|
||||||
@echo " EPICS_ARCH = $(EPICS_ARCH)"
|
|
||||||
@if [ -f "$(EPICS_LIBDIR)/libca.a" ] && [ -f "$(EPICS_LIBDIR)/libCom.a" ]; then \
|
|
||||||
echo " Strategy A: static .a archives found — embedding libca + libCom"; \
|
|
||||||
CGO_ENABLED=1 GOOS=linux GOARCH=$(RELEASE_ARCH) \
|
|
||||||
CGO_CFLAGS="$(EPICS_CGO_CFLAGS)" \
|
|
||||||
CGO_LDFLAGS="-Wl,-Bstatic -L$(EPICS_LIBDIR) -lca -lCom -Wl,-Bdynamic -lm -lpthread -lrt -ldl" \
|
|
||||||
go build -tags epics \
|
|
||||||
-ldflags="-s -w -linkmode external" \
|
|
||||||
-o $(RELEASE_DIR)/uopi ./cmd/uopi; \
|
|
||||||
echo "Built: $(RELEASE_DIR)/uopi (EPICS statically embedded)"; \
|
|
||||||
else \
|
|
||||||
echo " Strategy B: no .a archives — bundling shared libs with \$$ORIGIN rpath"; \
|
|
||||||
CGO_ENABLED=1 GOOS=linux GOARCH=$(RELEASE_ARCH) \
|
|
||||||
CGO_CFLAGS="$(EPICS_CGO_CFLAGS)" \
|
|
||||||
CGO_LDFLAGS="-L$(EPICS_LIBDIR) -lca -lCom -Wl,-rpath,\$$ORIGIN" \
|
|
||||||
go build -tags epics \
|
|
||||||
-ldflags="-s -w" \
|
|
||||||
-o $(RELEASE_DIR)/uopi ./cmd/uopi; \
|
|
||||||
cp $(EPICS_LIBDIR)/libca.so* $(RELEASE_DIR)/; \
|
|
||||||
cp $(EPICS_LIBDIR)/libCom.so* $(RELEASE_DIR)/; \
|
|
||||||
echo "Built: $(RELEASE_DIR)/ (deploy the whole directory together)"; \
|
|
||||||
ls $(RELEASE_DIR)/; \
|
|
||||||
fi
|
|
||||||
|
|
||||||
# --------------------------------------------------------------------------- #
|
# --------------------------------------------------------------------------- #
|
||||||
# Dev / CI #
|
# Dev / CI #
|
||||||
@@ -164,10 +80,12 @@ release-epics: $(FRONTEND_OUT)
|
|||||||
|
|
||||||
test:
|
test:
|
||||||
go test ./...
|
go test ./...
|
||||||
|
cd pkg/ca && go test ./...
|
||||||
|
|
||||||
# Run tests with the race detector enabled.
|
# Run tests with the race detector enabled.
|
||||||
race:
|
race:
|
||||||
go test -race ./...
|
go test -race ./...
|
||||||
|
cd pkg/ca && go test -race ./...
|
||||||
|
|
||||||
# Run all benchmarks and print memory allocations.
|
# Run all benchmarks and print memory allocations.
|
||||||
bench:
|
bench:
|
||||||
@@ -179,5 +97,7 @@ lint:
|
|||||||
run: $(BINARY)
|
run: $(BINARY)
|
||||||
$(BINARY)
|
$(BINARY)
|
||||||
|
|
||||||
|
catools: $(CATOOLS)
|
||||||
|
|
||||||
clean:
|
clean:
|
||||||
rm -rf dist/ web/dist/
|
rm -rf dist/ web/dist/
|
||||||
|
|||||||
@@ -0,0 +1,327 @@
|
|||||||
|
// Command catools provides CA (Channel Access) command-line utilities using
|
||||||
|
// the pure-Go goca library.
|
||||||
|
//
|
||||||
|
// Usage:
|
||||||
|
//
|
||||||
|
// catools get <pv> [pv...] Get current value(s)
|
||||||
|
// catools put <pv> <value> Write a value
|
||||||
|
// catools monitor <pv> [pv...] Stream live updates (Ctrl-C to stop)
|
||||||
|
// catools info <pv> [pv...] Print metadata (type, units, limits)
|
||||||
|
//
|
||||||
|
// 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 — EPICS Channel Access command-line tool
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
catools get <pv> [pv...] Get current value(s)
|
||||||
|
catools put <pv> <value> Write a value
|
||||||
|
catools monitor <pv> [pv...] Stream live updates (Ctrl-C to stop)
|
||||||
|
catools info <pv> [pv...] Print metadata (type, units, limits)
|
||||||
|
|
||||||
|
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 "get":
|
||||||
|
if len(args) == 0 {
|
||||||
|
fatalf("get: no PV names specified")
|
||||||
|
}
|
||||||
|
runGet(ctx, cli, args)
|
||||||
|
case "put":
|
||||||
|
if len(args) < 2 {
|
||||||
|
fatalf("put: usage: catools put <pv> <value>")
|
||||||
|
}
|
||||||
|
runPut(ctx, cli, args[0], args[1])
|
||||||
|
case "info":
|
||||||
|
if len(args) == 0 {
|
||||||
|
fatalf("info: no PV names specified")
|
||||||
|
}
|
||||||
|
runInfo(ctx, cli, args)
|
||||||
|
case "monitor":
|
||||||
|
if len(args) == 0 {
|
||||||
|
fatalf("monitor: no PV names specified")
|
||||||
|
}
|
||||||
|
runMonitor(ctx, cli, args)
|
||||||
|
default:
|
||||||
|
fatalf("unknown command %q — use get, put, monitor, or info", cmd)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------- //
|
||||||
|
// get //
|
||||||
|
// -------------------------------------------------------------------------- //
|
||||||
|
|
||||||
|
func runGet(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))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------- //
|
||||||
|
// put //
|
||||||
|
// -------------------------------------------------------------------------- //
|
||||||
|
|
||||||
|
func runPut(ctx context.Context, cli *ca.Client, pv, valueStr string) {
|
||||||
|
tctx, cancel := context.WithTimeout(ctx, 30*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
// 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:
|
||||||
|
if n, err := strconv.ParseInt(valueStr, 10, 32); err == nil {
|
||||||
|
value = int32(n)
|
||||||
|
} else 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)
|
||||||
|
}
|
||||||
|
|
||||||
|
tv, err := cli.Get(tctx, pv)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Printf("%-30s %s (write sent, readback failed: %v)\n", pv, valueStr, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
fmt.Printf("%-30s %s\n", pv, formatValue(tv))
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------- //
|
||||||
|
// info //
|
||||||
|
// -------------------------------------------------------------------------- //
|
||||||
|
|
||||||
|
func runInfo(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")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------- //
|
||||||
|
// monitor //
|
||||||
|
// -------------------------------------------------------------------------- //
|
||||||
|
|
||||||
|
func runMonitor(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
|
||||||
|
}
|
||||||
|
|
||||||
|
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()
|
||||||
|
for _, e := range entries {
|
||||||
|
e.can()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------- //
|
||||||
|
// Formatting helpers //
|
||||||
|
// -------------------------------------------------------------------------- //
|
||||||
|
|
||||||
|
func formatValue(tv proto.TimeValue) string {
|
||||||
|
var val string
|
||||||
|
switch {
|
||||||
|
case tv.Str != "":
|
||||||
|
val = tv.Str
|
||||||
|
case len(tv.Doubles) > 1:
|
||||||
|
parts := make([]string, len(tv.Doubles))
|
||||||
|
for i, v := range tv.Doubles {
|
||||||
|
parts[i] = strconv.FormatFloat(v, 'g', -1, 64)
|
||||||
|
}
|
||||||
|
val = "[" + strings.Join(parts, " ") + "]"
|
||||||
|
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)
|
||||||
|
}
|
||||||
@@ -0,0 +1,253 @@
|
|||||||
|
// Command pvtools provides PV Access command-line utilities using
|
||||||
|
// the pure-Go gopva library.
|
||||||
|
//
|
||||||
|
// Usage:
|
||||||
|
//
|
||||||
|
// pvtools get <pv> [pv...] Get current value(s) via PVA
|
||||||
|
// pvtools put <pv> <value> Write a value via PVA
|
||||||
|
// pvtools monitor <pv> [pv...] Stream live updates (Ctrl-C to stop)
|
||||||
|
// pvtools info <pv> [pv...] Print PVA structure info
|
||||||
|
//
|
||||||
|
// Environment:
|
||||||
|
//
|
||||||
|
// EPICS_PVA_ADDR_LIST — space-separated PVA server addresses
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"os/signal"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"syscall"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
pva "github.com/uopi/gopva"
|
||||||
|
"github.com/uopi/gopva/pvdata"
|
||||||
|
)
|
||||||
|
|
||||||
|
func usage() {
|
||||||
|
fmt.Fprintln(os.Stderr, `pvtools — EPICS PV Access command-line tool
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
pvtools get <pv> [pv...] Get current value(s)
|
||||||
|
pvtools put <pv> <value> Write a value
|
||||||
|
pvtools monitor <pv> [pv...] Stream live updates (Ctrl-C to stop)
|
||||||
|
pvtools info <pv> [pv...] Print structure info
|
||||||
|
|
||||||
|
Environment:
|
||||||
|
EPICS_PVA_ADDR_LIST Space-separated PVA server addresses`)
|
||||||
|
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()
|
||||||
|
|
||||||
|
switch cmd {
|
||||||
|
case "get":
|
||||||
|
if len(args) == 0 {
|
||||||
|
fatalf("get: no PV names specified")
|
||||||
|
}
|
||||||
|
runGet(ctx, args)
|
||||||
|
case "put":
|
||||||
|
if len(args) < 2 {
|
||||||
|
fatalf("put: usage: pvtools put <pv> <value>")
|
||||||
|
}
|
||||||
|
fatalf("put: PVA write not yet implemented")
|
||||||
|
case "monitor":
|
||||||
|
if len(args) == 0 {
|
||||||
|
fatalf("monitor: no PV names specified")
|
||||||
|
}
|
||||||
|
runMonitor(ctx, args)
|
||||||
|
case "info":
|
||||||
|
if len(args) == 0 {
|
||||||
|
fatalf("info: no PV names specified")
|
||||||
|
}
|
||||||
|
runInfo(ctx, args)
|
||||||
|
default:
|
||||||
|
fatalf("unknown command %q — use get, put, monitor, or info", cmd)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------- //
|
||||||
|
// get //
|
||||||
|
// -------------------------------------------------------------------------- //
|
||||||
|
|
||||||
|
func runGet(ctx context.Context, pvs []string) {
|
||||||
|
cl, err := pva.NewClient()
|
||||||
|
if err != nil {
|
||||||
|
fatalf("connect: %v", err)
|
||||||
|
}
|
||||||
|
defer cl.Close()
|
||||||
|
|
||||||
|
tctx, cancel := context.WithTimeout(ctx, 30*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
for _, name := range pvs {
|
||||||
|
sv, err := cl.Get(tctx, name)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "%s: error: %v\n", name, err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
fmt.Printf("%-30s %s\n", name, formatValue(sv))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------- //
|
||||||
|
// monitor //
|
||||||
|
// -------------------------------------------------------------------------- //
|
||||||
|
|
||||||
|
func runMonitor(ctx context.Context, pvs []string) {
|
||||||
|
cl, err := pva.NewClient()
|
||||||
|
if err != nil {
|
||||||
|
fatalf("connect: %v", err)
|
||||||
|
}
|
||||||
|
defer cl.Close()
|
||||||
|
|
||||||
|
for _, name := range pvs {
|
||||||
|
ch := cl.Monitor(ctx, name)
|
||||||
|
go func(pvName string, ch <-chan pva.MonitorEvent) {
|
||||||
|
for evt := range ch {
|
||||||
|
if evt.Err != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "%s: %v\n", pvName, evt.Err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ts := time.Now().Local().Format("15:04:05.000")
|
||||||
|
fmt.Printf("%s %-30s %s\n", ts, pvName, formatValue(evt.Value))
|
||||||
|
}
|
||||||
|
}(name, ch)
|
||||||
|
fmt.Printf("Monitoring %s (PVA) — press Ctrl-C to stop\n", name)
|
||||||
|
}
|
||||||
|
<-ctx.Done()
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------- //
|
||||||
|
// info //
|
||||||
|
// -------------------------------------------------------------------------- //
|
||||||
|
|
||||||
|
func runInfo(ctx context.Context, pvs []string) {
|
||||||
|
cl, err := pva.NewClient()
|
||||||
|
if err != nil {
|
||||||
|
fatalf("connect: %v", err)
|
||||||
|
}
|
||||||
|
defer cl.Close()
|
||||||
|
|
||||||
|
tctx, cancel := context.WithTimeout(ctx, 30*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
for _, name := range pvs {
|
||||||
|
sv, err := cl.Get(tctx, name)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "%s: error: %v\n", name, err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
printInfo(name, sv)
|
||||||
|
fmt.Println()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func printInfo(name string, sv pvdata.StructValue) {
|
||||||
|
fmt.Printf("Name : %s\n", name)
|
||||||
|
fmt.Printf("Fields : %d\n", len(sv.Fields))
|
||||||
|
printStruct(sv, " ")
|
||||||
|
}
|
||||||
|
|
||||||
|
func printStruct(sv pvdata.StructValue, indent string) {
|
||||||
|
for _, f := range sv.Fields {
|
||||||
|
switch v := f.Value.(type) {
|
||||||
|
case pvdata.StructValue:
|
||||||
|
fmt.Printf("%s%s (struct):\n", indent, f.Name)
|
||||||
|
printStruct(v, indent+" ")
|
||||||
|
default:
|
||||||
|
fmt.Printf("%s%s = %v\n", indent, f.Name, v)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------- //
|
||||||
|
// Formatting helpers //
|
||||||
|
// -------------------------------------------------------------------------- //
|
||||||
|
|
||||||
|
func formatValue(sv pvdata.StructValue) string {
|
||||||
|
if len(sv.Fields) == 0 {
|
||||||
|
return "<empty>"
|
||||||
|
}
|
||||||
|
val := fieldByName(sv, "value")
|
||||||
|
alarm := ""
|
||||||
|
if a := structByName(sv, "alarm"); a != nil {
|
||||||
|
if sev := fieldByName(*a, "severity"); sev != nil {
|
||||||
|
if s, ok := sev.(int32); ok && s != 0 {
|
||||||
|
alarm = fmt.Sprintf(" (alarm sev=%d)", s)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ts := ""
|
||||||
|
if t := structByName(sv, "timeStamp"); t != nil {
|
||||||
|
sec := int64(0)
|
||||||
|
nsec := int32(0)
|
||||||
|
if v := fieldByName(*t, "secondsPastEpoch"); v != nil {
|
||||||
|
sec, _ = v.(int64)
|
||||||
|
}
|
||||||
|
if v := fieldByName(*t, "nanoseconds"); v != nil {
|
||||||
|
nsec, _ = v.(int32)
|
||||||
|
}
|
||||||
|
if sec != 0 {
|
||||||
|
const epicsEpoch = 631152000
|
||||||
|
ut := time.Unix(sec+epicsEpoch, int64(nsec)).Local()
|
||||||
|
ts = " [" + ut.Format("2006-01-02 15:04:05.000") + "]"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if val == nil {
|
||||||
|
val = sv.Fields[0].Value
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("%v%s%s", formatScalar(val), alarm, ts)
|
||||||
|
}
|
||||||
|
|
||||||
|
func formatScalar(v any) string {
|
||||||
|
switch x := v.(type) {
|
||||||
|
case float64:
|
||||||
|
return strconv.FormatFloat(x, 'g', -1, 64)
|
||||||
|
case float32:
|
||||||
|
return strconv.FormatFloat(float64(x), 'g', -1, 32)
|
||||||
|
case string:
|
||||||
|
return x
|
||||||
|
case []string:
|
||||||
|
return strings.Join(x, " ")
|
||||||
|
default:
|
||||||
|
return fmt.Sprintf("%v", v)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func fieldByName(sv pvdata.StructValue, name string) interface{} {
|
||||||
|
for _, f := range sv.Fields {
|
||||||
|
if f.Name == name {
|
||||||
|
return f.Value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func structByName(sv pvdata.StructValue, name string) *pvdata.StructValue {
|
||||||
|
for _, f := range sv.Fields {
|
||||||
|
if f.Name == name {
|
||||||
|
if s, ok := f.Value.(pvdata.StructValue); ok {
|
||||||
|
return &s
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func fatalf(format string, args ...any) {
|
||||||
|
fmt.Fprintf(os.Stderr, "pvtools: "+format+"\n", args...)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
+15
-3
@@ -14,6 +14,7 @@ import (
|
|||||||
"github.com/uopi/uopi/internal/broker"
|
"github.com/uopi/uopi/internal/broker"
|
||||||
"github.com/uopi/uopi/internal/config"
|
"github.com/uopi/uopi/internal/config"
|
||||||
"github.com/uopi/uopi/internal/datasource/epics"
|
"github.com/uopi/uopi/internal/datasource/epics"
|
||||||
|
"github.com/uopi/uopi/internal/datasource/pva"
|
||||||
"github.com/uopi/uopi/internal/datasource/stub"
|
"github.com/uopi/uopi/internal/datasource/stub"
|
||||||
"github.com/uopi/uopi/internal/datasource/synthetic"
|
"github.com/uopi/uopi/internal/datasource/synthetic"
|
||||||
"github.com/uopi/uopi/internal/server"
|
"github.com/uopi/uopi/internal/server"
|
||||||
@@ -83,10 +84,11 @@ func main() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// EPICS Channel Access data source (requires -tags epics build).
|
// EPICS Channel Access data source.
|
||||||
// epics.Available() returns false when built without the tag.
|
// 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() {
|
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 {
|
if err := ds.Connect(ctx); err != nil {
|
||||||
log.Error("epics connect", "err", err)
|
log.Error("epics connect", "err", err)
|
||||||
} else {
|
} else {
|
||||||
@@ -94,6 +96,16 @@ func main() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// PV Access data source.
|
||||||
|
if cfg.Datasource.PVA.Enabled {
|
||||||
|
pvaDS := pva.New(cfg.Datasource.PVA.AddrList)
|
||||||
|
if err := pvaDS.Connect(ctx); err != nil {
|
||||||
|
log.Error("pva connect", "err", err)
|
||||||
|
} else {
|
||||||
|
brk.Register(pvaDS)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
srv := server.New(cfg.Server.Listen, webFS, brk, synthDS, store, cfg.Datasource.EPICS.ChannelFinderURL, log)
|
srv := server.New(cfg.Server.Listen, webFS, brk, synthDS, store, cfg.Datasource.EPICS.ChannelFinderURL, log)
|
||||||
|
|
||||||
if err := srv.Start(ctx); err != nil {
|
if err := srv.Start(ctx); err != nil {
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ type ServerConfig struct {
|
|||||||
type DatasourceConfig struct {
|
type DatasourceConfig struct {
|
||||||
Stub StubConfig `toml:"stub"`
|
Stub StubConfig `toml:"stub"`
|
||||||
EPICS EPICSConfig `toml:"epics"`
|
EPICS EPICSConfig `toml:"epics"`
|
||||||
|
PVA PVAConfig `toml:"pva"`
|
||||||
Synthetic SyntheticConfig `toml:"synthetic"`
|
Synthetic SyntheticConfig `toml:"synthetic"`
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -29,10 +30,16 @@ type StubConfig struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type EPICSConfig struct {
|
type EPICSConfig struct {
|
||||||
Enabled bool `toml:"enabled"`
|
Enabled bool `toml:"enabled"`
|
||||||
CAAddrList string `toml:"ca_addr_list"`
|
CAAddrList string `toml:"ca_addr_list"`
|
||||||
ArchiveURL string `toml:"archive_url"`
|
ArchiveURL string `toml:"archive_url"`
|
||||||
ChannelFinderURL string `toml:"channel_finder_url"`
|
ChannelFinderURL string `toml:"channel_finder_url"`
|
||||||
|
PVNames []string `toml:"pv_names"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type PVAConfig struct {
|
||||||
|
Enabled bool `toml:"enabled"`
|
||||||
|
AddrList []string `toml:"addr_list"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type SyntheticConfig struct {
|
type SyntheticConfig struct {
|
||||||
@@ -48,6 +55,7 @@ func Default() Config {
|
|||||||
Datasource: DatasourceConfig{
|
Datasource: DatasourceConfig{
|
||||||
Stub: StubConfig{Enabled: true},
|
Stub: StubConfig{Enabled: true},
|
||||||
EPICS: EPICSConfig{Enabled: true},
|
EPICS: EPICSConfig{Enabled: true},
|
||||||
|
PVA: PVAConfig{Enabled: true},
|
||||||
Synthetic: SyntheticConfig{Enabled: true},
|
Synthetic: SyntheticConfig{Enabled: true},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
@@ -85,6 +93,9 @@ func applyEnv(cfg *Config) {
|
|||||||
if v := env("UOPI_EPICS_CHANNEL_FINDER_URL"); v != "" {
|
if v := env("UOPI_EPICS_CHANNEL_FINDER_URL"); v != "" {
|
||||||
cfg.Datasource.EPICS.ChannelFinderURL = v
|
cfg.Datasource.EPICS.ChannelFinderURL = v
|
||||||
}
|
}
|
||||||
|
if v := env("EPICS_PVA_ADDR_LIST"); v != "" {
|
||||||
|
cfg.Datasource.PVA.AddrList = strings.Fields(v)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func env(key string) string {
|
func env(key string) string {
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,5 +1,3 @@
|
|||||||
//go:build epics
|
|
||||||
|
|
||||||
package epics
|
package epics
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
|||||||
@@ -85,7 +85,9 @@ type EPICS struct {
|
|||||||
// caAddrList is used to set EPICS_CA_ADDR_LIST at runtime (may be empty to
|
// 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
|
// rely on the environment). archiveURL is the base URL of an EPICS Archive
|
||||||
// Appliance instance for history queries (may be empty).
|
// 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{
|
return &EPICS{
|
||||||
caAddrList: caAddrList,
|
caAddrList: caAddrList,
|
||||||
archiveURL: archiveURL,
|
archiveURL: archiveURL,
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,17 +1,307 @@
|
|||||||
//go:build !epics
|
//go:build !epics
|
||||||
|
|
||||||
// Package epics provides an EPICS Channel Access data source.
|
// Package epics provides an EPICS Channel Access data source for uopi using a
|
||||||
// When built without the "epics" build tag (i.e. without CGo and EPICS Base),
|
// pure-Go CA implementation (no CGo, no EPICS Base installation required).
|
||||||
// this stub is compiled instead, so that the rest of the codebase can call
|
// When built WITH the "epics" build tag, the CGo-based implementation in
|
||||||
// epics.Available() to decide whether to register the data source.
|
// epics.go is used instead; that version requires CGo and a working EPICS Base.
|
||||||
package epics
|
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.
|
ca "github.com/uopi/goca"
|
||||||
// It always returns false when built without the "epics" build tag.
|
"github.com/uopi/goca/proto"
|
||||||
func Available() bool { return false }
|
"github.com/uopi/uopi/internal/datasource"
|
||||||
|
)
|
||||||
|
|
||||||
// New is a placeholder that returns nil when EPICS support is not compiled in.
|
// Available always returns true for the pure-Go build.
|
||||||
// Callers must check Available() before calling New().
|
func Available() bool { return true }
|
||||||
func New(caAddrList, archiveURL string) datasource.DataSource { return nil }
|
|
||||||
|
// -------------------------------------------------------------------------- //
|
||||||
|
// 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 {
|
||||||
|
if len(tv.Doubles) == 1 {
|
||||||
|
data = tv.Doubles[0]
|
||||||
|
} else {
|
||||||
|
data = tv.Doubles
|
||||||
|
}
|
||||||
|
} else if tv.Str != "" {
|
||||||
|
data = tv.Str
|
||||||
|
} 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)
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,365 @@
|
|||||||
|
// Package pva provides an EPICS PV Access data source for uopi using the
|
||||||
|
// pure-Go gopva library.
|
||||||
|
package pva
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"log/slog"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
gopva "github.com/uopi/gopva"
|
||||||
|
"github.com/uopi/gopva/pvdata"
|
||||||
|
"github.com/uopi/uopi/internal/datasource"
|
||||||
|
)
|
||||||
|
|
||||||
|
// PVA is the PV Access data source.
|
||||||
|
type PVA struct {
|
||||||
|
client *gopva.Client
|
||||||
|
|
||||||
|
mu sync.RWMutex
|
||||||
|
metadata map[string]datasource.Metadata
|
||||||
|
}
|
||||||
|
|
||||||
|
// New creates a new PV Access data source.
|
||||||
|
// Server addresses are read from the EPICS_PVA_ADDR_LIST environment variable;
|
||||||
|
// addrList overrides that when non-empty.
|
||||||
|
func New(addrList []string) datasource.DataSource {
|
||||||
|
if len(addrList) > 0 {
|
||||||
|
// Inject addresses into the environment so gopva.NewClient picks them up.
|
||||||
|
_ = os.Setenv("EPICS_PVA_ADDR_LIST", strings.Join(addrList, " "))
|
||||||
|
}
|
||||||
|
return &PVA{
|
||||||
|
metadata: make(map[string]datasource.Metadata),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Name implements datasource.DataSource.
|
||||||
|
func (p *PVA) Name() string { return "pva" }
|
||||||
|
|
||||||
|
// Connect creates the PVA client.
|
||||||
|
func (p *PVA) Connect(_ context.Context) error {
|
||||||
|
cl, err := gopva.NewClient()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("pva: connect: %w", err)
|
||||||
|
}
|
||||||
|
p.client = cl
|
||||||
|
slog.Info("pva: client started")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------- //
|
||||||
|
// Subscribe //
|
||||||
|
// -------------------------------------------------------------------------- //
|
||||||
|
|
||||||
|
// Subscribe registers ch to receive live PVA value updates for signal.
|
||||||
|
func (p *PVA) Subscribe(ctx context.Context, signal string, ch chan<- datasource.Value) (datasource.CancelFunc, error) {
|
||||||
|
slog.Info("pva: subscribing", "pv", signal)
|
||||||
|
|
||||||
|
monCh := p.client.Monitor(ctx, signal)
|
||||||
|
|
||||||
|
// Wait for the first event to confirm the subscription is live.
|
||||||
|
// We do this in the goroutine to avoid blocking the caller.
|
||||||
|
done := make(chan struct{})
|
||||||
|
go func() {
|
||||||
|
defer close(done)
|
||||||
|
for evt := range monCh {
|
||||||
|
if evt.Err != nil {
|
||||||
|
slog.Error("pva: monitor error", "pv", signal, "err", evt.Err)
|
||||||
|
select {
|
||||||
|
case ch <- datasource.Value{
|
||||||
|
Timestamp: time.Now(),
|
||||||
|
Data: nil,
|
||||||
|
Quality: datasource.QualityBad,
|
||||||
|
}:
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// Cache metadata from the first value.
|
||||||
|
p.updateMetadata(signal, evt.Value)
|
||||||
|
|
||||||
|
val := structToValue(evt.Value)
|
||||||
|
select {
|
||||||
|
case ch <- val:
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
cancel := func() {
|
||||||
|
// cancelling the context passed to Monitor stops it; the channel will be closed.
|
||||||
|
// The goroutine will exit when monCh is closed.
|
||||||
|
<-done
|
||||||
|
}
|
||||||
|
return datasource.CancelFunc(cancel), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------- //
|
||||||
|
// GetMetadata //
|
||||||
|
// -------------------------------------------------------------------------- //
|
||||||
|
|
||||||
|
// GetMetadata returns metadata for signal. It performs a one-shot GET if
|
||||||
|
// metadata has not been cached yet.
|
||||||
|
func (p *PVA) GetMetadata(ctx context.Context, signal string) (datasource.Metadata, error) {
|
||||||
|
p.mu.RLock()
|
||||||
|
if m, ok := p.metadata[signal]; ok {
|
||||||
|
p.mu.RUnlock()
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
|
p.mu.RUnlock()
|
||||||
|
|
||||||
|
tctx, cancel := context.WithTimeout(ctx, 15*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
sv, err := p.client.Get(tctx, signal)
|
||||||
|
if err != nil {
|
||||||
|
return datasource.Metadata{}, fmt.Errorf("pva: GetMetadata %q: %w", signal, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
p.updateMetadata(signal, sv)
|
||||||
|
|
||||||
|
p.mu.RLock()
|
||||||
|
m := p.metadata[signal]
|
||||||
|
p.mu.RUnlock()
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// updateMetadata derives and caches metadata from a StructValue.
|
||||||
|
func (p *PVA) updateMetadata(signal string, sv pvdata.StructValue) {
|
||||||
|
m := structToMeta(signal, sv)
|
||||||
|
p.mu.Lock()
|
||||||
|
p.metadata[signal] = m
|
||||||
|
p.mu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------- //
|
||||||
|
// ListSignals //
|
||||||
|
// -------------------------------------------------------------------------- //
|
||||||
|
|
||||||
|
// ListSignals returns metadata for all signals with cached information.
|
||||||
|
func (p *PVA) ListSignals(_ context.Context) ([]datasource.Metadata, error) {
|
||||||
|
p.mu.RLock()
|
||||||
|
defer p.mu.RUnlock()
|
||||||
|
out := make([]datasource.Metadata, 0, len(p.metadata))
|
||||||
|
for _, m := range p.metadata {
|
||||||
|
out = append(out, m)
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------- //
|
||||||
|
// Write //
|
||||||
|
// -------------------------------------------------------------------------- //
|
||||||
|
|
||||||
|
// Write is not yet implemented for PVA.
|
||||||
|
func (p *PVA) Write(_ context.Context, _ string, _ any) error {
|
||||||
|
return fmt.Errorf("pva: write not yet implemented")
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------- //
|
||||||
|
// History //
|
||||||
|
// -------------------------------------------------------------------------- //
|
||||||
|
|
||||||
|
// History is not supported for PVA sources.
|
||||||
|
func (p *PVA) History(_ context.Context, _ string, _, _ time.Time, _ int) ([]datasource.Value, error) {
|
||||||
|
return nil, datasource.ErrHistoryUnavailable
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------- //
|
||||||
|
// Helpers //
|
||||||
|
// -------------------------------------------------------------------------- //
|
||||||
|
|
||||||
|
// structToValue converts a PVA StructValue to a datasource.Value.
|
||||||
|
// It follows NTScalar/NTScalarArray conventions: looks for a "value" field
|
||||||
|
// and an optional "alarm" struct for quality, and "timeStamp" for the timestamp.
|
||||||
|
func structToValue(sv pvdata.StructValue) datasource.Value {
|
||||||
|
val := fieldByName(sv, "value")
|
||||||
|
ts := extractTimestamp(sv)
|
||||||
|
quality := extractQuality(sv)
|
||||||
|
|
||||||
|
var data any
|
||||||
|
if val != nil {
|
||||||
|
data = normaliseValue(val)
|
||||||
|
}
|
||||||
|
|
||||||
|
return datasource.Value{
|
||||||
|
Timestamp: ts,
|
||||||
|
Data: data,
|
||||||
|
Quality: quality,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// structToMeta derives datasource.Metadata from a PVA StructValue.
|
||||||
|
func structToMeta(name string, sv pvdata.StructValue) datasource.Metadata {
|
||||||
|
m := datasource.Metadata{
|
||||||
|
Name: name,
|
||||||
|
Writable: true, // PVA channels are assumed writable until proven otherwise
|
||||||
|
}
|
||||||
|
|
||||||
|
val := fieldByName(sv, "value")
|
||||||
|
if val == nil && len(sv.Fields) > 0 {
|
||||||
|
val = sv.Fields[0].Value
|
||||||
|
}
|
||||||
|
|
||||||
|
if val != nil {
|
||||||
|
switch val.(type) {
|
||||||
|
case float64, float32:
|
||||||
|
m.Type = datasource.TypeFloat64
|
||||||
|
case int64, int32, int16, int8, uint64, uint32, uint16, uint8:
|
||||||
|
m.Type = datasource.TypeInt64
|
||||||
|
case string:
|
||||||
|
m.Type = datasource.TypeString
|
||||||
|
case bool:
|
||||||
|
m.Type = datasource.TypeBool
|
||||||
|
case []float64, []float32:
|
||||||
|
m.Type = datasource.TypeFloat64Array
|
||||||
|
default:
|
||||||
|
m.Type = datasource.TypeFloat64
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract display limits from NTScalar display structure.
|
||||||
|
if disp := structByName(sv, "display"); disp != nil {
|
||||||
|
if v := fieldByName(*disp, "limitLow"); v != nil {
|
||||||
|
m.DisplayLow, _ = toFloat64(v)
|
||||||
|
}
|
||||||
|
if v := fieldByName(*disp, "limitHigh"); v != nil {
|
||||||
|
m.DisplayHigh, _ = toFloat64(v)
|
||||||
|
}
|
||||||
|
if v := fieldByName(*disp, "units"); v != nil {
|
||||||
|
m.Unit, _ = v.(string)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract control limits from NTScalar control structure.
|
||||||
|
if ctrl := structByName(sv, "control"); ctrl != nil {
|
||||||
|
if v := fieldByName(*ctrl, "limitLow"); v != nil {
|
||||||
|
m.DriveLow, _ = toFloat64(v)
|
||||||
|
}
|
||||||
|
if v := fieldByName(*ctrl, "limitHigh"); v != nil {
|
||||||
|
m.DriveHigh, _ = toFloat64(v)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return m
|
||||||
|
}
|
||||||
|
|
||||||
|
// normaliseValue converts any PVA scalar/array value to datasource-compatible types.
|
||||||
|
func normaliseValue(v any) any {
|
||||||
|
switch x := v.(type) {
|
||||||
|
case float64:
|
||||||
|
return x
|
||||||
|
case float32:
|
||||||
|
return float64(x)
|
||||||
|
case int64:
|
||||||
|
return x
|
||||||
|
case int32:
|
||||||
|
return int64(x)
|
||||||
|
case int16:
|
||||||
|
return int64(x)
|
||||||
|
case int8:
|
||||||
|
return int64(x)
|
||||||
|
case uint64:
|
||||||
|
return int64(x)
|
||||||
|
case uint32:
|
||||||
|
return int64(x)
|
||||||
|
case uint16:
|
||||||
|
return int64(x)
|
||||||
|
case uint8:
|
||||||
|
return int64(x)
|
||||||
|
case bool:
|
||||||
|
return x
|
||||||
|
case string:
|
||||||
|
return x
|
||||||
|
case []float64:
|
||||||
|
return x
|
||||||
|
case []float32:
|
||||||
|
out := make([]float64, len(x))
|
||||||
|
for i, f := range x {
|
||||||
|
out[i] = float64(f)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
default:
|
||||||
|
return fmt.Sprintf("%v", v)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func extractTimestamp(sv pvdata.StructValue) time.Time {
|
||||||
|
ts := structByName(sv, "timeStamp")
|
||||||
|
if ts == nil {
|
||||||
|
return time.Now()
|
||||||
|
}
|
||||||
|
sec := int64(0)
|
||||||
|
nsec := int32(0)
|
||||||
|
if v := fieldByName(*ts, "secondsPastEpoch"); v != nil {
|
||||||
|
sec, _ = v.(int64)
|
||||||
|
}
|
||||||
|
if v := fieldByName(*ts, "nanoseconds"); v != nil {
|
||||||
|
nsec, _ = v.(int32)
|
||||||
|
}
|
||||||
|
if sec == 0 {
|
||||||
|
return time.Now()
|
||||||
|
}
|
||||||
|
// EPICS epoch: 1990-01-01 00:00:00 UTC = Unix 631152000
|
||||||
|
const epicsEpoch = 631152000
|
||||||
|
return time.Unix(sec+epicsEpoch, int64(nsec)).UTC()
|
||||||
|
}
|
||||||
|
|
||||||
|
func extractQuality(sv pvdata.StructValue) datasource.Quality {
|
||||||
|
alarm := structByName(sv, "alarm")
|
||||||
|
if alarm == nil {
|
||||||
|
return datasource.QualityGood
|
||||||
|
}
|
||||||
|
sev := fieldByName(*alarm, "severity")
|
||||||
|
if sev == nil {
|
||||||
|
return datasource.QualityGood
|
||||||
|
}
|
||||||
|
s, _ := sev.(int32)
|
||||||
|
switch s {
|
||||||
|
case 1:
|
||||||
|
return datasource.QualityUncertain
|
||||||
|
case 2, 3:
|
||||||
|
return datasource.QualityBad
|
||||||
|
default:
|
||||||
|
return datasource.QualityGood
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func toFloat64(v any) (float64, bool) {
|
||||||
|
switch x := v.(type) {
|
||||||
|
case float64:
|
||||||
|
return x, true
|
||||||
|
case float32:
|
||||||
|
return float64(x), true
|
||||||
|
case int64:
|
||||||
|
return float64(x), true
|
||||||
|
case int32:
|
||||||
|
return float64(x), true
|
||||||
|
default:
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func fieldByName(sv pvdata.StructValue, name string) any {
|
||||||
|
for _, f := range sv.Fields {
|
||||||
|
if f.Name == name {
|
||||||
|
return f.Value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func structByName(sv pvdata.StructValue, name string) *pvdata.StructValue {
|
||||||
|
for _, f := range sv.Fields {
|
||||||
|
if f.Name == name {
|
||||||
|
if s, ok := f.Value.(pvdata.StructValue); ok {
|
||||||
|
return &s
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,232 @@
|
|||||||
|
package storage_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/uopi/uopi/internal/storage"
|
||||||
|
)
|
||||||
|
|
||||||
|
const sampleXML = `<interface id="test-if" name="Test Interface" version="1"><widget/></interface>`
|
||||||
|
|
||||||
|
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 := `<interface id="test-if" name="Updated" version="2"><widget/></interface>`
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
+747
@@ -0,0 +1,747 @@
|
|||||||
|
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.
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
func newChanState(cid uint32, pvName string) *chanState {
|
||||||
|
return &chanState{
|
||||||
|
cid: cid,
|
||||||
|
pvName: pvName,
|
||||||
|
readyC: make(chan struct{}),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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.
|
||||||
|
// In-flight GET requests are tracked in circuit.byIOID and cleared there.
|
||||||
|
func (cs *chanState) resetForReconnect() {
|
||||||
|
cs.mu.Lock()
|
||||||
|
cs.sid = 0
|
||||||
|
old := cs.readyC
|
||||||
|
cs.readyC = make(chan struct{})
|
||||||
|
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)
|
||||||
|
bySID map[uint32]*chanState // sid → chanState (fast lookup)
|
||||||
|
bySubID map[uint32]*monState // subID → monState (fast event dispatch)
|
||||||
|
byIOID map[uint32]chan reply // ioid → GET/PUT callback (circuit-wide)
|
||||||
|
|
||||||
|
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),
|
||||||
|
bySID: make(map[uint32]*chanState),
|
||||||
|
bySubID: make(map[uint32]*monState),
|
||||||
|
byIOID: make(map[uint32]chan reply),
|
||||||
|
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()
|
||||||
|
c.bySID = make(map[uint32]*chanState)
|
||||||
|
// Close all in-flight GET reply channels so waiters unblock with ok=false.
|
||||||
|
for _, ch := range c.byIOID {
|
||||||
|
close(ch)
|
||||||
|
}
|
||||||
|
c.byIOID = make(map[uint32]chan reply)
|
||||||
|
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.Lock()
|
||||||
|
cs, ok := c.byCID[hdr.Parameter1]
|
||||||
|
if ok {
|
||||||
|
c.bySID[hdr.Parameter2] = cs
|
||||||
|
}
|
||||||
|
c.mu.Unlock()
|
||||||
|
|
||||||
|
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: server puts subscription ID in Parameter2 (m_available).
|
||||||
|
// Parameter1 carries ECA_NORMAL (1) as a status code.
|
||||||
|
subID := hdr.Parameter2
|
||||||
|
c.mu.RLock()
|
||||||
|
ms, ok := c.bySubID[subID]
|
||||||
|
c.mu.RUnlock()
|
||||||
|
if !ok {
|
||||||
|
dbg("CA EVENT_ADD for unknown subID", "subID", subID)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
tv, ok := proto.DecodeTimeValue(hdr.DataType, hdr.DataCount, payload)
|
||||||
|
if !ok {
|
||||||
|
dbg("CA EVENT_ADD decode failed", "subID", subID,
|
||||||
|
"dbrType", hdr.DataType, "count", hdr.DataCount, "payloadLen", len(payload))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
dbg("CA EVENT_ADD value", "subID", subID, "dbrType", hdr.DataType,
|
||||||
|
"double", tv.Double, "severity", tv.Severity)
|
||||||
|
select {
|
||||||
|
case ms.ch <- tv:
|
||||||
|
default: // drop if consumer is slow
|
||||||
|
}
|
||||||
|
|
||||||
|
case proto.CmdReadNotify:
|
||||||
|
// Parameter2 = ioid (our request ID echoed back by server).
|
||||||
|
ioid := hdr.Parameter2
|
||||||
|
|
||||||
|
c.mu.Lock()
|
||||||
|
replyCh, ok := c.byIOID[ioid]
|
||||||
|
if ok {
|
||||||
|
delete(c.byIOID, ioid)
|
||||||
|
}
|
||||||
|
c.mu.Unlock()
|
||||||
|
|
||||||
|
dbg("CA CmdReadNotify", "ioid", ioid, "found", ok, "dbrType", hdr.DataType,
|
||||||
|
"count", hdr.DataCount, "payloadSize", hdr.PayloadSize)
|
||||||
|
|
||||||
|
if ok {
|
||||||
|
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)
|
||||||
|
|
||||||
|
c.mu.Lock()
|
||||||
|
c.byIOID[ioid] = replyCh
|
||||||
|
c.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():
|
||||||
|
c.mu.Lock()
|
||||||
|
delete(c.byIOID, ioid)
|
||||||
|
c.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():
|
||||||
|
c.mu.Lock()
|
||||||
|
delete(c.byIOID, ioid)
|
||||||
|
c.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)
|
||||||
|
|
||||||
|
c.mu.Lock()
|
||||||
|
c.byIOID[ioid] = replyCh
|
||||||
|
c.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():
|
||||||
|
c.mu.Lock()
|
||||||
|
delete(c.byIOID, ioid)
|
||||||
|
c.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():
|
||||||
|
c.mu.Lock()
|
||||||
|
delete(c.byIOID, ioid)
|
||||||
|
c.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()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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...)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
module github.com/uopi/goca
|
||||||
|
|
||||||
|
go 1.26
|
||||||
@@ -0,0 +1,444 @@
|
|||||||
|
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, matching EPICS db_access.h structs):
|
||||||
|
//
|
||||||
|
// Common header (12 bytes):
|
||||||
|
// [0:2] int16 status
|
||||||
|
// [2:4] int16 severity
|
||||||
|
// [4:8] uint32 secPastEpoch
|
||||||
|
// [8:12] uint32 nsec
|
||||||
|
//
|
||||||
|
// DBR_TIME_STRING (type 14): [40]byte at [12:52]. Total=52.
|
||||||
|
// DBR_TIME_SHORT (type 15): 2-byte RISC pad at [12:14], int16 at [14:16]. Total=16.
|
||||||
|
// DBR_TIME_FLOAT (type 16): float32 at [12:16]. Total=16.
|
||||||
|
// DBR_TIME_ENUM (type 17): 2-byte RISC pad at [12:14], uint16 at [14:16]. Total=16.
|
||||||
|
// DBR_TIME_CHAR (type 18): RISC_pad0[12:14], RISC_pad1[14], uint8 at [15]. Total=16.
|
||||||
|
// DBR_TIME_LONG (type 19): int32 at [12:16]. Total=16.
|
||||||
|
// DBR_TIME_DOUBLE (type 20): 4-byte RISC pad at [12:16], float64 at [16:24]. Total=24.
|
||||||
|
|
||||||
|
// 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:
|
||||||
|
// 2-byte RISC pad at [12:14], value at [14:16]
|
||||||
|
if len(payload) < 16 {
|
||||||
|
return TimeValue{}, false
|
||||||
|
}
|
||||||
|
tv.Short = int16(binary.BigEndian.Uint16(payload[14:]))
|
||||||
|
tv.Double = float64(tv.Short)
|
||||||
|
|
||||||
|
case DBRTimeEnum:
|
||||||
|
// 2-byte RISC pad at [12:14], value at [14:16]
|
||||||
|
if len(payload) < 16 {
|
||||||
|
return TimeValue{}, false
|
||||||
|
}
|
||||||
|
tv.Enum = binary.BigEndian.Uint16(payload[14:])
|
||||||
|
tv.Double = float64(tv.Enum)
|
||||||
|
|
||||||
|
case DBRTimeChar:
|
||||||
|
// RISC_pad0[12:14], RISC_pad1[14], value[15] per db_access.h dbr_time_char
|
||||||
|
if len(payload) < 16 {
|
||||||
|
return TimeValue{}, false
|
||||||
|
}
|
||||||
|
tv.Char = payload[15]
|
||||||
|
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 matches caProto.h struct mon_info (all big-endian):
|
||||||
|
//
|
||||||
|
// [0:4] float32 m_lval (low delta, zero = disabled)
|
||||||
|
// [4:8] float32 m_hval (high delta, zero = disabled)
|
||||||
|
// [8:12] float32 m_toval (period between samples, zero = disabled)
|
||||||
|
// [12:14] uint16 m_mask (DBE_VALUE | DBE_ALARM | ...)
|
||||||
|
// [14:16] uint16 m_pad (alignment padding)
|
||||||
|
func EncodeEventMask(mask uint16) []byte {
|
||||||
|
b := make([]byte, 16)
|
||||||
|
binary.BigEndian.PutUint16(b[12:], mask) // m_mask is at offset 12 per caProto.h
|
||||||
|
return b
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -0,0 +1,140 @@
|
|||||||
|
// 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).
|
||||||
|
// Numbers from db_access.h: STRING=14, SHORT/INT=15, FLOAT=16, ENUM=17, CHAR=18, LONG=19, DOUBLE=20.
|
||||||
|
const (
|
||||||
|
DBRTimeString = 14
|
||||||
|
DBRTimeShort = 15
|
||||||
|
DBRTimeFloat = 16
|
||||||
|
DBRTimeEnum = 17
|
||||||
|
DBRTimeChar = 18
|
||||||
|
DBRTimeLong = 19
|
||||||
|
DBRTimeDouble = 20
|
||||||
|
)
|
||||||
|
|
||||||
|
// 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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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, 0x00, 0x00, 0x03} // 2-byte RISC pad + enum=3
|
||||||
|
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 12 per caProto.h struct mon_info.
|
||||||
|
mask := binary.BigEndian.Uint16(b[12:])
|
||||||
|
if mask != proto.DBEDefault {
|
||||||
|
t.Errorf("mask = 0x%02X, want 0x%02X", mask, proto.DBEDefault)
|
||||||
|
}
|
||||||
|
// m_pad at [14:16] must be zero.
|
||||||
|
if pad := binary.BigEndian.Uint16(b[14:]); pad != 0 {
|
||||||
|
t.Errorf("m_pad = %d, want 0", pad)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------- //
|
||||||
|
// 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,605 @@
|
|||||||
|
// 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
|
||||||
|
var cid uint32
|
||||||
|
for c, info := range channels {
|
||||||
|
if info.sid == sid {
|
||||||
|
pvName = info.pvName
|
||||||
|
cid = c
|
||||||
|
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: cid, // echo client channel ID (matches real EPICS IOC)
|
||||||
|
Parameter2: ioid, // echo ioid so client can match the reply
|
||||||
|
}, 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: 1, // ECA_NORMAL
|
||||||
|
Parameter2: 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-byte RISC pad + 2-byte value
|
||||||
|
binary.BigEndian.PutUint16(body[2:], uint16(int16(toF64(val))))
|
||||||
|
|
||||||
|
case proto.DBRTimeChar:
|
||||||
|
body = make([]byte, 4) // RISC_pad0[0:2] + RISC_pad1[2] + value[3]
|
||||||
|
body[3] = 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
|
||||||
|
}
|
||||||
@@ -0,0 +1,340 @@
|
|||||||
|
package pva
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"encoding/binary"
|
||||||
|
"fmt"
|
||||||
|
"log/slog"
|
||||||
|
"net"
|
||||||
|
"sync"
|
||||||
|
"sync/atomic"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/uopi/gopva/pvdata"
|
||||||
|
)
|
||||||
|
|
||||||
|
// DefaultTCPPort is the standard PVA TCP port for channel connections.
|
||||||
|
const DefaultTCPPort = 5075
|
||||||
|
|
||||||
|
// DefaultUDPPort is the PVA broadcast/search port (distinct from TCP).
|
||||||
|
const DefaultUDPPort = 5076
|
||||||
|
|
||||||
|
// Client is a PVA client that manages connections to one or more servers.
|
||||||
|
// It performs UDP search to locate PVs, then opens TCP connections on demand.
|
||||||
|
type Client struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
conns map[string]*conn // server addr → connection
|
||||||
|
pending map[string][]waiter // pvName → waiters for address
|
||||||
|
|
||||||
|
searchSeq atomic.Uint32
|
||||||
|
udp *net.UDPConn
|
||||||
|
|
||||||
|
ctx context.Context
|
||||||
|
cancel context.CancelFunc
|
||||||
|
}
|
||||||
|
|
||||||
|
type waiter struct {
|
||||||
|
pvName string
|
||||||
|
cb func(addr string, err error)
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewClient creates a PVA client. Call Close when done.
|
||||||
|
func NewClient() (*Client, error) {
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
cl := &Client{
|
||||||
|
conns: make(map[string]*conn),
|
||||||
|
pending: make(map[string][]waiter),
|
||||||
|
ctx: ctx,
|
||||||
|
cancel: cancel,
|
||||||
|
}
|
||||||
|
if err := cl.startUDP(); err != nil {
|
||||||
|
cancel()
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return cl, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Close shuts down the client and all connections.
|
||||||
|
func (cl *Client) Close() {
|
||||||
|
cl.cancel()
|
||||||
|
if cl.udp != nil {
|
||||||
|
cl.udp.Close()
|
||||||
|
}
|
||||||
|
cl.mu.Lock()
|
||||||
|
defer cl.mu.Unlock()
|
||||||
|
for _, c := range cl.conns {
|
||||||
|
c.close()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get issues a one-shot GET for pvName and returns the decoded structure.
|
||||||
|
func (cl *Client) Get(ctx context.Context, pvName string) (pvdata.StructValue, error) {
|
||||||
|
ch := make(chan struct {
|
||||||
|
v pvdata.StructValue
|
||||||
|
err error
|
||||||
|
}, 1)
|
||||||
|
cl.withConn(ctx, pvName, func(c *conn, err error) {
|
||||||
|
if err != nil {
|
||||||
|
ch <- struct {
|
||||||
|
v pvdata.StructValue
|
||||||
|
err error
|
||||||
|
}{err: err}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.get(pvName, func(v pvdata.StructValue, e error) {
|
||||||
|
ch <- struct {
|
||||||
|
v pvdata.StructValue
|
||||||
|
err error
|
||||||
|
}{v, e}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
select {
|
||||||
|
case res := <-ch:
|
||||||
|
return res.v, res.err
|
||||||
|
case <-ctx.Done():
|
||||||
|
return pvdata.StructValue{}, ctx.Err()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Monitor subscribes to pvName and returns a channel that receives updates.
|
||||||
|
// The channel is closed when ctx is cancelled or a fatal error occurs.
|
||||||
|
func (cl *Client) Monitor(ctx context.Context, pvName string) <-chan MonitorEvent {
|
||||||
|
ch := make(chan MonitorEvent, 16)
|
||||||
|
cl.withConn(ctx, pvName, func(c *conn, err error) {
|
||||||
|
if err != nil {
|
||||||
|
ch <- MonitorEvent{Err: err}
|
||||||
|
close(ch)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.subscribe(ctx, pvName, ch)
|
||||||
|
})
|
||||||
|
return ch
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Internal --------------------------------------------------------
|
||||||
|
|
||||||
|
// withConn locates (or creates) a connection to the server hosting pvName
|
||||||
|
// and calls cb on it. cb may be called from a goroutine.
|
||||||
|
func (cl *Client) withConn(ctx context.Context, pvName string, cb func(*conn, error)) {
|
||||||
|
cl.search(ctx, pvName, func(addr string, err error) {
|
||||||
|
if err != nil {
|
||||||
|
cb(nil, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
cl.mu.Lock()
|
||||||
|
c, ok := cl.conns[addr]
|
||||||
|
cl.mu.Unlock()
|
||||||
|
if ok {
|
||||||
|
cb(c, nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// open new TCP connection
|
||||||
|
tc, err := dial(ctx, addr)
|
||||||
|
if err != nil {
|
||||||
|
cb(nil, fmt.Errorf("pva: connect %s: %w", addr, err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
cl.mu.Lock()
|
||||||
|
cl.conns[addr] = tc
|
||||||
|
cl.mu.Unlock()
|
||||||
|
cb(tc, nil)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- UDP search -------------------------------------------------------
|
||||||
|
|
||||||
|
func (cl *Client) startUDP() error {
|
||||||
|
conn, err := net.ListenUDP("udp4", &net.UDPAddr{Port: 0})
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("pva: UDP listen: %w", err)
|
||||||
|
}
|
||||||
|
cl.udp = conn
|
||||||
|
go cl.udpReadLoop()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// search sends a PVA search request and calls cb when a response arrives.
|
||||||
|
// Currently does broadcast + localhost; production code would also check
|
||||||
|
// EPICS_PVA_ADDR_LIST environment variable.
|
||||||
|
func (cl *Client) search(ctx context.Context, pvName string, cb func(string, error)) {
|
||||||
|
seq := cl.searchSeq.Add(1)
|
||||||
|
|
||||||
|
cl.mu.Lock()
|
||||||
|
cl.pending[pvName] = append(cl.pending[pvName], waiter{pvName: pvName, cb: cb})
|
||||||
|
cl.mu.Unlock()
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
for attempt := 0; attempt < 10; attempt++ {
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
cl.mu.Lock()
|
||||||
|
cl.removeWaiter(pvName, cb)
|
||||||
|
cl.mu.Unlock()
|
||||||
|
cb("", ctx.Err())
|
||||||
|
return
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
cl.sendSearchRequest(seq, pvName)
|
||||||
|
// exponential backoff: 100ms, 200ms, 400ms … up to 2s
|
||||||
|
delay := time.Duration(100<<min(attempt, 4)) * time.Millisecond
|
||||||
|
select {
|
||||||
|
case <-time.After(delay):
|
||||||
|
case <-ctx.Done():
|
||||||
|
cl.mu.Lock()
|
||||||
|
cl.removeWaiter(pvName, cb)
|
||||||
|
cl.mu.Unlock()
|
||||||
|
cb("", ctx.Err())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// give up
|
||||||
|
cl.mu.Lock()
|
||||||
|
cl.removeWaiter(pvName, cb)
|
||||||
|
cl.mu.Unlock()
|
||||||
|
cb("", fmt.Errorf("pva: search timeout for %q", pvName))
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (cl *Client) removeWaiter(pvName string, cb func(string, error)) {
|
||||||
|
ws := cl.pending[pvName]
|
||||||
|
for i, w := range ws {
|
||||||
|
// compare by pointer (closure identity via fmt trick)
|
||||||
|
if fmt.Sprintf("%p", w.cb) == fmt.Sprintf("%p", cb) {
|
||||||
|
cl.pending[pvName] = append(ws[:i], ws[i+1:]...)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// sendSearchRequest broadcasts a PVA SEARCH request.
|
||||||
|
func (cl *Client) sendSearchRequest(seq uint32, pvName string) {
|
||||||
|
// SEARCH payload (spec §6.3):
|
||||||
|
// searchSeqID(4) + flags(1) + reserved(3) + responseAddress(16) + responsePort(2)
|
||||||
|
// + transportCount(1) + transports[](1=TCP) + pvCount(2) + pvs[](channelID(4)+name(str))
|
||||||
|
var buf bytes.Buffer
|
||||||
|
binary.Write(&buf, binary.LittleEndian, seq) // searchSeqID
|
||||||
|
buf.WriteByte(0x81) // flags: unicast=0, reply=1, mustReply=1
|
||||||
|
buf.Write([]byte{0, 0, 0}) // reserved
|
||||||
|
// responseAddress: 16 bytes (IPv4-mapped IPv6: ::ffff:0.0.0.0 = no specific address)
|
||||||
|
buf.Write(make([]byte, 12))
|
||||||
|
buf.Write([]byte{0, 0, 0, 0}) // 0.0.0.0 → server will reply to source addr
|
||||||
|
|
||||||
|
// responsePort: our UDP listening port
|
||||||
|
laddr := cl.udp.LocalAddr().(*net.UDPAddr)
|
||||||
|
binary.Write(&buf, binary.LittleEndian, uint16(laddr.Port))
|
||||||
|
|
||||||
|
buf.WriteByte(1) // transportCount = 1
|
||||||
|
buf.WriteByte(0x01) // transport[0]: TCP
|
||||||
|
|
||||||
|
binary.Write(&buf, binary.LittleEndian, uint16(1)) // pvCount = 1
|
||||||
|
binary.Write(&buf, binary.LittleEndian, uint32(seq)) // channelID (reuse seq)
|
||||||
|
pvdata.WriteString(&buf, pvName)
|
||||||
|
|
||||||
|
msg := BuildMessage(CmdSearchRequest, flagApp, buf.Bytes())
|
||||||
|
|
||||||
|
// Send to localhost and broadcast on the PVA UDP search port (5076).
|
||||||
|
targets := []string{
|
||||||
|
fmt.Sprintf("127.0.0.1:%d", DefaultUDPPort),
|
||||||
|
fmt.Sprintf("255.255.255.255:%d", DefaultUDPPort),
|
||||||
|
}
|
||||||
|
// Also check EPICS_PVA_ADDR_LIST if set (common in labs).
|
||||||
|
// Entries without an explicit port default to DefaultTCPPort for TCP
|
||||||
|
// connections; for the search broadcast we still use the UDP port.
|
||||||
|
for _, addr := range addrsFromEnv() {
|
||||||
|
targets = append(targets, fmt.Sprintf("%s:%d", addr, DefaultUDPPort))
|
||||||
|
}
|
||||||
|
for _, addr := range targets {
|
||||||
|
dst, err := net.ResolveUDPAddr("udp4", addr)
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if _, err := cl.udp.WriteTo(msg, dst); err != nil {
|
||||||
|
slog.Debug("pva search send", "dst", addr, "err", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// udpReadLoop reads SEARCH_RESPONSE (and BEACON) UDP messages.
|
||||||
|
func (cl *Client) udpReadLoop() {
|
||||||
|
buf := make([]byte, 65536)
|
||||||
|
for {
|
||||||
|
n, src, err := cl.udp.ReadFromUDP(buf)
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
cl.handleUDP(buf[:n], src)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (cl *Client) handleUDP(data []byte, src *net.UDPAddr) {
|
||||||
|
if len(data) < headerSize {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
r := bytes.NewReader(data)
|
||||||
|
hdr, err := ReadHeader(r)
|
||||||
|
if err != nil || hdr.isControl() {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if hdr.Command != CmdSearchResponse {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
payload := make([]byte, hdr.Size)
|
||||||
|
if _, err := r.Read(payload); err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
pr := bytes.NewReader(payload)
|
||||||
|
|
||||||
|
// SEARCH_RESPONSE:
|
||||||
|
// guid(12) + searchSeqID(4) + serverAddress(16) + serverPort(2)
|
||||||
|
// + protocol(str) + found(1) + pvCount(2) + pvIDs[](4)
|
||||||
|
var guid [12]byte
|
||||||
|
pr.Read(guid[:])
|
||||||
|
var seq uint32
|
||||||
|
binary.Read(pr, binary.LittleEndian, &seq)
|
||||||
|
var addr [16]byte
|
||||||
|
pr.Read(addr[:])
|
||||||
|
var port uint16
|
||||||
|
binary.Read(pr, binary.LittleEndian, &port)
|
||||||
|
proto, _ := readPVAString(pr, binary.LittleEndian)
|
||||||
|
if proto != "tcp" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
found, _ := pr.ReadByte()
|
||||||
|
if found == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var pvCount uint16
|
||||||
|
binary.Read(pr, binary.LittleEndian, &pvCount)
|
||||||
|
// pvIDs — we match by searching for waiters by name (seq used as channelID)
|
||||||
|
|
||||||
|
// Determine server address: use src if serverAddress is 0.0.0.0
|
||||||
|
ip := net.IP(addr[12:16]) // last 4 bytes = IPv4
|
||||||
|
if ip.Equal(net.IPv4zero) {
|
||||||
|
ip = src.IP
|
||||||
|
}
|
||||||
|
serverAddr := fmt.Sprintf("%s:%d", ip.String(), port)
|
||||||
|
|
||||||
|
// Notify all pending waiters (simple: we got a response so all PVs are on this server)
|
||||||
|
cl.mu.Lock()
|
||||||
|
var toNotify []func(string, error)
|
||||||
|
for pvName, ws := range cl.pending {
|
||||||
|
for _, w := range ws {
|
||||||
|
toNotify = append(toNotify, w.cb)
|
||||||
|
}
|
||||||
|
delete(cl.pending, pvName)
|
||||||
|
}
|
||||||
|
cl.mu.Unlock()
|
||||||
|
|
||||||
|
for _, cb := range toNotify {
|
||||||
|
go cb(serverAddr, nil)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func min(a, b int) int {
|
||||||
|
if a < b {
|
||||||
|
return a
|
||||||
|
}
|
||||||
|
return b
|
||||||
|
}
|
||||||
+576
@@ -0,0 +1,576 @@
|
|||||||
|
package pva
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bufio"
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"encoding/binary"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"log/slog"
|
||||||
|
"net"
|
||||||
|
"sync"
|
||||||
|
"sync/atomic"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/uopi/gopva/pvdata"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ---- Channel/Request state machine -----------------------------------
|
||||||
|
|
||||||
|
type chanState int
|
||||||
|
|
||||||
|
const (
|
||||||
|
chanPending chanState = iota // CREATE_CHANNEL sent
|
||||||
|
chanCreated // server ack'd
|
||||||
|
chanFailed // CREATE_CHANNEL failed
|
||||||
|
)
|
||||||
|
|
||||||
|
type serverChannel struct {
|
||||||
|
pvName string
|
||||||
|
cid uint32 // client channel ID
|
||||||
|
sid uint32 // server channel ID (assigned on creation)
|
||||||
|
state chanState
|
||||||
|
|
||||||
|
// pending callbacks waiting for channel creation
|
||||||
|
onCreate []func(sid uint32, err error)
|
||||||
|
}
|
||||||
|
|
||||||
|
type monitorSub struct {
|
||||||
|
ioid uint32
|
||||||
|
sid uint32 // server channel ID — needed for pipeline ACKs and DESTROY
|
||||||
|
cid uint32
|
||||||
|
desc pvdata.FieldDesc // structure descriptor (received on INIT response)
|
||||||
|
updates chan MonitorEvent
|
||||||
|
}
|
||||||
|
|
||||||
|
// MonitorEvent carries a decoded monitor update.
|
||||||
|
type MonitorEvent struct {
|
||||||
|
// Changed is the set of top-level field indices that changed.
|
||||||
|
Changed pvdata.BitSet
|
||||||
|
// Value is the full decoded structure value.
|
||||||
|
Value pvdata.StructValue
|
||||||
|
// Err is non-nil if this is a terminal error event.
|
||||||
|
Err error
|
||||||
|
}
|
||||||
|
|
||||||
|
// conn is a single PVA TCP connection to one server.
|
||||||
|
type conn struct {
|
||||||
|
nc net.Conn
|
||||||
|
br *bufio.Reader // shared reader — used by handshake then readLoop
|
||||||
|
bw *bufio.Writer
|
||||||
|
mu sync.Mutex // protects writes + state maps
|
||||||
|
|
||||||
|
// auto-incrementing IDs
|
||||||
|
nextCID atomic.Uint32
|
||||||
|
nextIOID atomic.Uint32
|
||||||
|
|
||||||
|
// state
|
||||||
|
chans map[uint32]*serverChannel // keyed by client CID
|
||||||
|
byPV map[string]*serverChannel // keyed by PV name
|
||||||
|
monitors map[uint32]*monitorSub // keyed by IOID
|
||||||
|
|
||||||
|
done chan struct{}
|
||||||
|
}
|
||||||
|
|
||||||
|
func dial(ctx context.Context, addr string) (*conn, error) {
|
||||||
|
nc, err := (&net.Dialer{}).DialContext(ctx, "tcp", addr)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
c := &conn{
|
||||||
|
nc: nc,
|
||||||
|
br: bufio.NewReader(nc),
|
||||||
|
bw: bufio.NewWriter(nc),
|
||||||
|
chans: make(map[uint32]*serverChannel),
|
||||||
|
byPV: make(map[string]*serverChannel),
|
||||||
|
monitors: make(map[uint32]*monitorSub),
|
||||||
|
done: make(chan struct{}),
|
||||||
|
}
|
||||||
|
if err := c.handshake(); err != nil {
|
||||||
|
nc.Close()
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
go c.readLoop()
|
||||||
|
return c, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Handshake --------------------------------------------------------
|
||||||
|
|
||||||
|
// handshake completes the PVA connection setup.
|
||||||
|
//
|
||||||
|
// Protocol sequence (spec §4.2):
|
||||||
|
// 1. Client → server: SET_BYTE_ORDER control (announces little-endian)
|
||||||
|
// 2. Server → client: SET_BYTE_ORDER control (may be omitted by some servers)
|
||||||
|
// 3. Server → client: CONNECTION_VALIDATION (0x01) — server's auth options
|
||||||
|
// 4. Client → server: CONNECTION_VALIDATION (0x01) — client selects auth
|
||||||
|
//
|
||||||
|
// The client MUST NOT send step 4 before receiving step 3.
|
||||||
|
func (c *conn) handshake() error {
|
||||||
|
// Step 1: announce our byte order.
|
||||||
|
if _, err := c.nc.Write(BuildMessage(CtrlSetByteOrder, flagControl, nil)); err != nil {
|
||||||
|
return fmt.Errorf("pva handshake: send byte-order: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 2+3: read server messages until CONNECTION_VALIDATION arrives.
|
||||||
|
// Servers typically send SET_BYTE_ORDER first, then CONNECTION_VALIDATION.
|
||||||
|
for {
|
||||||
|
hdr, err := ReadHeader(c.br)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("pva handshake: read server message: %w", err)
|
||||||
|
}
|
||||||
|
payload := make([]byte, hdr.Size)
|
||||||
|
if _, err := io.ReadFull(c.br, payload); err != nil {
|
||||||
|
return fmt.Errorf("pva handshake: read server payload: %w", err)
|
||||||
|
}
|
||||||
|
if hdr.isControl() {
|
||||||
|
// SET_BYTE_ORDER from server — we always use LE, ignore.
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if hdr.Command == CmdConnectionValid {
|
||||||
|
break // server is ready for our reply
|
||||||
|
}
|
||||||
|
// Ignore unexpected messages (e.g. BEACON on same port).
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 4: send CONNECTION_VALIDATION response.
|
||||||
|
// payload: clientReceiveBufferSize(4) + clientIntrospectionRegistryMaxSize(4) + authNZ(str)
|
||||||
|
var buf bytes.Buffer
|
||||||
|
binary.Write(&buf, binary.LittleEndian, uint32(0x00800000)) // 8 MiB receive buffer
|
||||||
|
binary.Write(&buf, binary.LittleEndian, uint32(0x00000000)) // introspection registry size
|
||||||
|
buf.WriteByte(0) // authNZ = "" (anonymous)
|
||||||
|
if _, err := c.nc.Write(BuildMessage(CmdConnectionValid, flagApp, buf.Bytes())); err != nil {
|
||||||
|
return fmt.Errorf("pva handshake: send connection_valid: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Write helpers ----------------------------------------------------
|
||||||
|
|
||||||
|
func (c *conn) send(msg []byte) error {
|
||||||
|
c.mu.Lock()
|
||||||
|
defer c.mu.Unlock()
|
||||||
|
_, err := c.nc.Write(msg)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Channel creation -------------------------------------------------
|
||||||
|
|
||||||
|
// openChannel ensures a channel for pvName exists and calls cb when ready.
|
||||||
|
func (c *conn) openChannel(pvName string, cb func(sid uint32, err error)) {
|
||||||
|
c.mu.Lock()
|
||||||
|
if ch, ok := c.byPV[pvName]; ok {
|
||||||
|
switch ch.state {
|
||||||
|
case chanCreated:
|
||||||
|
sid := ch.sid
|
||||||
|
c.mu.Unlock()
|
||||||
|
cb(sid, nil)
|
||||||
|
return
|
||||||
|
case chanFailed:
|
||||||
|
c.mu.Unlock()
|
||||||
|
cb(0, fmt.Errorf("pva: channel %q creation failed", pvName))
|
||||||
|
return
|
||||||
|
default:
|
||||||
|
ch.onCreate = append(ch.onCreate, cb)
|
||||||
|
c.mu.Unlock()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
cid := c.nextCID.Add(1)
|
||||||
|
ch := &serverChannel{pvName: pvName, cid: cid, state: chanPending, onCreate: []func(uint32, error){cb}}
|
||||||
|
c.chans[cid] = ch
|
||||||
|
c.byPV[pvName] = ch
|
||||||
|
c.mu.Unlock()
|
||||||
|
|
||||||
|
// send CREATE_CHANNEL
|
||||||
|
// payload: count(2) + cid(4) + pvName(string)
|
||||||
|
var payload bytes.Buffer
|
||||||
|
binary.Write(&payload, binary.LittleEndian, uint16(1)) // channel count = 1
|
||||||
|
binary.Write(&payload, binary.LittleEndian, cid)
|
||||||
|
pvdata.WriteString(&payload, pvName)
|
||||||
|
if err := c.send(BuildMessage(CmdCreateChannel, flagApp, payload.Bytes())); err != nil {
|
||||||
|
c.mu.Lock()
|
||||||
|
delete(c.chans, cid)
|
||||||
|
delete(c.byPV, pvName)
|
||||||
|
c.mu.Unlock()
|
||||||
|
cb(0, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Monitor ----------------------------------------------------------
|
||||||
|
|
||||||
|
// subscribe sends an EVENT_ADD equivalent (MONITOR INIT) for pvName.
|
||||||
|
func (c *conn) subscribe(ctx context.Context, pvName string, ch chan MonitorEvent) {
|
||||||
|
c.openChannel(pvName, func(sid uint32, err error) {
|
||||||
|
if err != nil {
|
||||||
|
ch <- MonitorEvent{Err: err}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ioid := c.nextIOID.Add(1)
|
||||||
|
|
||||||
|
// Build MONITOR INIT payload:
|
||||||
|
// sid(4) + ioid(4) + subCmd(1=INIT) + pvRequest(FieldDesc+Value)
|
||||||
|
var payload bytes.Buffer
|
||||||
|
binary.Write(&payload, binary.LittleEndian, sid)
|
||||||
|
binary.Write(&payload, binary.LittleEndian, ioid)
|
||||||
|
payload.WriteByte(SubCmdInit) // subCmd = INIT
|
||||||
|
|
||||||
|
// pvRequest: empty structure (field "field" selecting all)
|
||||||
|
// Encoded as: TypeCodeStruct + typeID("") + nfields(0)
|
||||||
|
// This selects the whole top-level structure.
|
||||||
|
payload.WriteByte(pvdata.TypeCodeStruct) // FieldDesc type
|
||||||
|
pvdata.WriteString(&payload, "") // typeID
|
||||||
|
pvdata.WriteSize(&payload, 0) // no sub-fields = select all
|
||||||
|
|
||||||
|
c.mu.Lock()
|
||||||
|
var cid uint32
|
||||||
|
for _, sch := range c.chans {
|
||||||
|
if sch.sid == sid {
|
||||||
|
cid = sch.cid
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ms := &monitorSub{ioid: ioid, sid: sid, cid: cid, updates: ch}
|
||||||
|
c.monitors[ioid] = ms
|
||||||
|
c.mu.Unlock()
|
||||||
|
|
||||||
|
if err := c.send(BuildMessage(CmdMonitor, flagApp, payload.Bytes())); err != nil {
|
||||||
|
ch <- MonitorEvent{Err: err}
|
||||||
|
}
|
||||||
|
|
||||||
|
// watch context cancellation → send MONITOR DESTROY
|
||||||
|
go func() {
|
||||||
|
<-ctx.Done()
|
||||||
|
c.cancelMonitor(ioid, sid)
|
||||||
|
}()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *conn) cancelMonitor(ioid, sid uint32) {
|
||||||
|
var payload bytes.Buffer
|
||||||
|
binary.Write(&payload, binary.LittleEndian, sid)
|
||||||
|
binary.Write(&payload, binary.LittleEndian, ioid)
|
||||||
|
payload.WriteByte(SubCmdDEstroy)
|
||||||
|
_ = c.send(BuildMessage(CmdMonitor, flagApp, payload.Bytes()))
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- GET (one-shot) ---------------------------------------------------
|
||||||
|
|
||||||
|
// get issues a GET for pvName and calls cb when the response arrives.
|
||||||
|
func (c *conn) get(pvName string, cb func(v pvdata.StructValue, err error)) {
|
||||||
|
c.openChannel(pvName, func(sid uint32, err error) {
|
||||||
|
if err != nil {
|
||||||
|
cb(pvdata.StructValue{}, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ioid := c.nextIOID.Add(1)
|
||||||
|
|
||||||
|
// INIT phase: GET with subCmd=INIT only (spec §7.2 — INIT and GET are separate messages)
|
||||||
|
var payload bytes.Buffer
|
||||||
|
binary.Write(&payload, binary.LittleEndian, sid)
|
||||||
|
binary.Write(&payload, binary.LittleEndian, ioid)
|
||||||
|
payload.WriteByte(SubCmdInit) // INIT only — server returns FieldDesc
|
||||||
|
// pvRequest: empty struct = select all fields
|
||||||
|
payload.WriteByte(pvdata.TypeCodeStruct)
|
||||||
|
pvdata.WriteString(&payload, "")
|
||||||
|
pvdata.WriteSize(&payload, 0)
|
||||||
|
|
||||||
|
c.mu.Lock()
|
||||||
|
ms := &monitorSub{ioid: ioid, sid: sid, updates: make(chan MonitorEvent, 1)}
|
||||||
|
c.monitors[ioid] = ms
|
||||||
|
c.mu.Unlock()
|
||||||
|
|
||||||
|
if err := c.send(BuildMessage(CmdGet, flagApp, payload.Bytes())); err != nil {
|
||||||
|
cb(pvdata.StructValue{}, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// wait for INIT response, then issue GET
|
||||||
|
go func() {
|
||||||
|
evt := <-ms.updates
|
||||||
|
if evt.Err != nil {
|
||||||
|
cb(pvdata.StructValue{}, evt.Err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// INIT done — send GET sub-command
|
||||||
|
var p2 bytes.Buffer
|
||||||
|
binary.Write(&p2, binary.LittleEndian, sid)
|
||||||
|
binary.Write(&p2, binary.LittleEndian, ioid)
|
||||||
|
p2.WriteByte(SubCmdGet)
|
||||||
|
if err := c.send(BuildMessage(CmdGet, flagApp, p2.Bytes())); err != nil {
|
||||||
|
cb(pvdata.StructValue{}, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// wait for data response
|
||||||
|
evt = <-ms.updates
|
||||||
|
cb(evt.Value, evt.Err)
|
||||||
|
|
||||||
|
c.mu.Lock()
|
||||||
|
delete(c.monitors, ioid)
|
||||||
|
c.mu.Unlock()
|
||||||
|
}()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Read loop --------------------------------------------------------
|
||||||
|
|
||||||
|
func (c *conn) readLoop() {
|
||||||
|
defer close(c.done)
|
||||||
|
for {
|
||||||
|
hdr, err := ReadHeader(c.br)
|
||||||
|
if err != nil {
|
||||||
|
if err != io.EOF {
|
||||||
|
slog.Debug("pva readLoop", "err", err)
|
||||||
|
}
|
||||||
|
c.closeAllWithError(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
payload := make([]byte, hdr.Size)
|
||||||
|
if _, err := io.ReadFull(c.br, payload); err != nil {
|
||||||
|
c.closeAllWithError(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
r := bytes.NewReader(payload)
|
||||||
|
bo := hdr.byteOrder()
|
||||||
|
|
||||||
|
if hdr.isControl() {
|
||||||
|
c.handleControl(hdr, payload)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
switch hdr.Command {
|
||||||
|
case CmdCreateChannel:
|
||||||
|
c.handleCreateChannel(r, bo)
|
||||||
|
case CmdGet:
|
||||||
|
c.handleGet(r, bo)
|
||||||
|
case CmdMonitor:
|
||||||
|
c.handleMonitor(r, bo)
|
||||||
|
case CmdDestroyChannel:
|
||||||
|
// server killed the channel; clean up
|
||||||
|
case CmdMessage:
|
||||||
|
handleServerMessage(r, bo)
|
||||||
|
default:
|
||||||
|
// ignore unknown commands
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *conn) handleControl(hdr PVAHeader, payload []byte) {
|
||||||
|
switch hdr.Command {
|
||||||
|
case CtrlSetByteOrder:
|
||||||
|
// server's byte-order announcement — we always use LE so ignore
|
||||||
|
case CtrlEchoRequest:
|
||||||
|
// respond with ECHO_RESPONSE
|
||||||
|
_ = c.send(BuildMessage(CtrlEchoResponse, flagControl, payload))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleCreateChannel decodes a CREATE_CHANNEL response.
|
||||||
|
// Wire: count(2) × { cid(4) + status(1+…) + sid(4) }
|
||||||
|
func (c *conn) handleCreateChannel(r io.Reader, bo binary.ByteOrder) {
|
||||||
|
var count uint16
|
||||||
|
binary.Read(r, bo, &count)
|
||||||
|
for i := 0; i < int(count); i++ {
|
||||||
|
var cid uint32
|
||||||
|
binary.Read(r, bo, &cid)
|
||||||
|
st, err := ReadStatus(r, bo)
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var sid uint32
|
||||||
|
if st.OK() {
|
||||||
|
binary.Read(r, bo, &sid)
|
||||||
|
}
|
||||||
|
|
||||||
|
c.mu.Lock()
|
||||||
|
ch, ok := c.chans[cid]
|
||||||
|
if !ok {
|
||||||
|
c.mu.Unlock()
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
cbs := ch.onCreate
|
||||||
|
ch.onCreate = nil
|
||||||
|
if st.OK() {
|
||||||
|
ch.sid = sid
|
||||||
|
ch.state = chanCreated
|
||||||
|
} else {
|
||||||
|
ch.state = chanFailed
|
||||||
|
}
|
||||||
|
c.mu.Unlock()
|
||||||
|
|
||||||
|
var cbErr error
|
||||||
|
if !st.OK() {
|
||||||
|
cbErr = fmt.Errorf("pva: %s", st.Error())
|
||||||
|
}
|
||||||
|
for _, cb := range cbs {
|
||||||
|
cb(sid, cbErr)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleGet decodes a GET response (both INIT and data phases).
|
||||||
|
func (c *conn) handleGet(r io.Reader, bo binary.ByteOrder) {
|
||||||
|
var ioid uint32
|
||||||
|
binary.Read(r, bo, &ioid)
|
||||||
|
var subCmdBuf [1]byte
|
||||||
|
io.ReadFull(r, subCmdBuf[:])
|
||||||
|
subCmd := subCmdBuf[0]
|
||||||
|
|
||||||
|
st, err := ReadStatus(r, bo)
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.mu.Lock()
|
||||||
|
ms, ok := c.monitors[ioid]
|
||||||
|
c.mu.Unlock()
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if subCmd&SubCmdInit != 0 {
|
||||||
|
// INIT response — read FieldDesc
|
||||||
|
if st.OK() {
|
||||||
|
desc, err := pvdata.ReadFieldDesc(r)
|
||||||
|
if err == nil {
|
||||||
|
ms.desc = desc
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ms.updates <- MonitorEvent{Err: asError(st)}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// GET data response
|
||||||
|
if !st.OK() {
|
||||||
|
ms.updates <- MonitorEvent{Err: fmt.Errorf("pva GET: %s", st.Error())}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// read bitSet (changed mask) then value
|
||||||
|
changed, err := pvdata.ReadBitSet(r)
|
||||||
|
if err != nil {
|
||||||
|
ms.updates <- MonitorEvent{Err: err}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
v, err := pvdata.ReadValue(r, ms.desc)
|
||||||
|
if err != nil {
|
||||||
|
ms.updates <- MonitorEvent{Err: err}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
sv, _ := v.(pvdata.StructValue)
|
||||||
|
ms.updates <- MonitorEvent{Changed: changed, Value: sv}
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleMonitor decodes a MONITOR response.
|
||||||
|
// Sub-commands: INIT (0x08) → FieldDesc, pipeline data.
|
||||||
|
func (c *conn) handleMonitor(r io.Reader, bo binary.ByteOrder) {
|
||||||
|
var ioid uint32
|
||||||
|
binary.Read(r, bo, &ioid)
|
||||||
|
var subCmdBuf2 [1]byte
|
||||||
|
io.ReadFull(r, subCmdBuf2[:])
|
||||||
|
subCmd := subCmdBuf2[0]
|
||||||
|
|
||||||
|
c.mu.Lock()
|
||||||
|
ms, ok := c.monitors[ioid]
|
||||||
|
c.mu.Unlock()
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if subCmd&SubCmdInit != 0 {
|
||||||
|
st, err := ReadStatus(r, bo)
|
||||||
|
if err != nil || !st.OK() {
|
||||||
|
ms.updates <- MonitorEvent{Err: asError(st)}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
desc, err := pvdata.ReadFieldDesc(r)
|
||||||
|
if err != nil {
|
||||||
|
ms.updates <- MonitorEvent{Err: err}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ms.desc = desc
|
||||||
|
// Acknowledge pipeline
|
||||||
|
c.sendMonitorAck(ioid)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if subCmd&SubCmdDEstroy != 0 {
|
||||||
|
close(ms.updates)
|
||||||
|
c.mu.Lock()
|
||||||
|
delete(c.monitors, ioid)
|
||||||
|
c.mu.Unlock()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Data update: changed BitSet + overrun BitSet + value
|
||||||
|
changed, err := pvdata.ReadBitSet(r)
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
_, err = pvdata.ReadBitSet(r) // overrun — discard for now
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
v, err := pvdata.ReadValue(r, ms.desc)
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
sv, _ := v.(pvdata.StructValue)
|
||||||
|
select {
|
||||||
|
case ms.updates <- MonitorEvent{Changed: changed, Value: sv}:
|
||||||
|
default:
|
||||||
|
// slow consumer — drop (overrun)
|
||||||
|
}
|
||||||
|
// Acknowledge to allow more updates (pipeline)
|
||||||
|
c.sendMonitorAck(ioid)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *conn) sendMonitorAck(ioid uint32) {
|
||||||
|
// MONITOR pipeline ACK: sid(4) + ioid(4) + subCmd(0x80)
|
||||||
|
c.mu.Lock()
|
||||||
|
ms, ok := c.monitors[ioid]
|
||||||
|
sid := uint32(0)
|
||||||
|
if ok {
|
||||||
|
sid = ms.sid
|
||||||
|
}
|
||||||
|
c.mu.Unlock()
|
||||||
|
|
||||||
|
var p bytes.Buffer
|
||||||
|
binary.Write(&p, binary.LittleEndian, sid)
|
||||||
|
binary.Write(&p, binary.LittleEndian, ioid)
|
||||||
|
p.WriteByte(SubCmdPipeline)
|
||||||
|
_ = c.send(BuildMessage(CmdMonitor, flagApp, p.Bytes()))
|
||||||
|
}
|
||||||
|
|
||||||
|
func handleServerMessage(r io.Reader, bo binary.ByteOrder) {
|
||||||
|
// MESSAGE: type(1) + message(string) — log and ignore
|
||||||
|
var t [1]byte
|
||||||
|
r.Read(t[:])
|
||||||
|
msg, _ := readPVAString(r, bo)
|
||||||
|
slog.Debug("pva server message", "type", t[0], "msg", msg)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *conn) closeAllWithError(err error) {
|
||||||
|
c.mu.Lock()
|
||||||
|
defer c.mu.Unlock()
|
||||||
|
for _, ms := range c.monitors {
|
||||||
|
select {
|
||||||
|
case ms.updates <- MonitorEvent{Err: err}:
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *conn) close() {
|
||||||
|
c.nc.Close()
|
||||||
|
select {
|
||||||
|
case <-c.done:
|
||||||
|
case <-time.After(2 * time.Second):
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func asError(s Status) error {
|
||||||
|
if s.OK() {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return fmt.Errorf("%s", s.Error())
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
package pva
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// addrsFromEnv returns addresses from EPICS_PVA_ADDR_LIST, if set.
|
||||||
|
func addrsFromEnv() []string {
|
||||||
|
v := os.Getenv("EPICS_PVA_ADDR_LIST")
|
||||||
|
if v == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
var addrs []string
|
||||||
|
for _, a := range strings.Fields(v) {
|
||||||
|
if a != "" {
|
||||||
|
addrs = append(addrs, a)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return addrs
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
module github.com/uopi/gopva
|
||||||
|
|
||||||
|
go 1.22
|
||||||
@@ -0,0 +1,232 @@
|
|||||||
|
// Package pva implements an EPICS PV Access (PVA) client in pure Go.
|
||||||
|
//
|
||||||
|
// PVA is the modern EPICS network protocol introduced in EPICS 7, replacing
|
||||||
|
// Channel Access (CA) for structured/typed data. This package implements
|
||||||
|
// the client side of the PVA TCP protocol sufficient for GET and MONITOR
|
||||||
|
// operations on NTScalar and NTScalarArray normative types.
|
||||||
|
//
|
||||||
|
// Reference: EPICS PVAccess Protocol Specification r1.1
|
||||||
|
// https://epics-pvdata.sourceforge.net/pvAccess.html
|
||||||
|
package pva
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/binary"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ---- Message flags ----------------------------------------------------
|
||||||
|
|
||||||
|
const (
|
||||||
|
flagApp byte = 0x00 // application message (vs control)
|
||||||
|
flagControl byte = 0x01 // control message
|
||||||
|
flagSegFirst byte = 0x08 // first segment
|
||||||
|
flagSegLast byte = 0x10 // last segment
|
||||||
|
flagSegMid byte = 0x18 // middle segment
|
||||||
|
flagFromServer byte = 0x40 // direction: server→client
|
||||||
|
flagBigEndian byte = 0x80 // byte order: big-endian (we use little-endian)
|
||||||
|
)
|
||||||
|
|
||||||
|
// ---- Application message command codes --------------------------------
|
||||||
|
|
||||||
|
const (
|
||||||
|
CmdBeacon byte = 0x00
|
||||||
|
CmdConnectionValid byte = 0x01
|
||||||
|
CmdEcho byte = 0x02
|
||||||
|
CmdSearchRequest byte = 0x03
|
||||||
|
CmdSearchResponse byte = 0x04
|
||||||
|
CmdAuthNZ byte = 0x05 // authentication/authorisation
|
||||||
|
CmdAclChange byte = 0x06
|
||||||
|
CmdCreateChannel byte = 0x07
|
||||||
|
CmdDestroyChannel byte = 0x08
|
||||||
|
CmdConnectionReq byte = 0x09
|
||||||
|
CmdGet byte = 0x0A
|
||||||
|
CmdPut byte = 0x0B
|
||||||
|
CmdPutGet byte = 0x0C
|
||||||
|
CmdMonitor byte = 0x0D
|
||||||
|
CmdArray byte = 0x0E
|
||||||
|
CmdDestroyReq byte = 0x0F
|
||||||
|
CmdProcess byte = 0x10
|
||||||
|
CmdGetField byte = 0x11
|
||||||
|
CmdMessage byte = 0x12 // server status/warning
|
||||||
|
CmdMultipleData byte = 0x13
|
||||||
|
CmdRpcCall byte = 0x14
|
||||||
|
CmdCancelRequest byte = 0x15
|
||||||
|
CmdOriginTag byte = 0x16
|
||||||
|
)
|
||||||
|
|
||||||
|
// ---- Control message sub-commands -------------------------------------
|
||||||
|
|
||||||
|
const (
|
||||||
|
CtrlSetMarker byte = 0x00
|
||||||
|
CtrlAckMarker byte = 0x01
|
||||||
|
CtrlSetByteOrder byte = 0x02
|
||||||
|
CtrlEchoRequest byte = 0x03
|
||||||
|
CtrlEchoResponse byte = 0x04
|
||||||
|
)
|
||||||
|
|
||||||
|
// ---- Status codes -----------------------------------------------------
|
||||||
|
|
||||||
|
const (
|
||||||
|
StatusOK byte = 0xFF // special "OK" short encoding
|
||||||
|
StatusOKFull byte = 0x00 // full OK message (type=OK, msg="")
|
||||||
|
StatusWarn byte = 0x01
|
||||||
|
StatusError byte = 0x02
|
||||||
|
StatusFatal byte = 0x03
|
||||||
|
)
|
||||||
|
|
||||||
|
// ---- Request sub-command bits -----------------------------------------
|
||||||
|
|
||||||
|
const (
|
||||||
|
SubCmdInit byte = 0x08 // initialise request (send FieldDesc)
|
||||||
|
SubCmdGet byte = 0x40 // GET sub-command
|
||||||
|
SubCmdPipeline byte = 0x80 // pipeline (for monitor)
|
||||||
|
SubCmdDEstroy byte = 0x10 // destroy request
|
||||||
|
SubCmdProcess byte = 0x04
|
||||||
|
)
|
||||||
|
|
||||||
|
// ---- Header -----------------------------------------------------------
|
||||||
|
|
||||||
|
// PVAHeader is the 8-byte fixed header on every PVA message.
|
||||||
|
//
|
||||||
|
// byte 0 : magic 0xCA
|
||||||
|
// byte 1 : protocol version (0x01)
|
||||||
|
// byte 2 : flags (see flag* constants)
|
||||||
|
// byte 3 : command code
|
||||||
|
// bytes 4–7 : payload size (uint32, matches byte-order flag)
|
||||||
|
type PVAHeader struct {
|
||||||
|
Version byte
|
||||||
|
Flags byte
|
||||||
|
Command byte
|
||||||
|
Size uint32
|
||||||
|
}
|
||||||
|
|
||||||
|
const magic byte = 0xCA
|
||||||
|
const headerSize = 8
|
||||||
|
|
||||||
|
func (h PVAHeader) isLittleEndian() bool { return h.Flags&flagBigEndian == 0 }
|
||||||
|
func (h PVAHeader) isFromServer() bool { return h.Flags&flagFromServer != 0 }
|
||||||
|
func (h PVAHeader) isControl() bool { return h.Flags&flagControl != 0 }
|
||||||
|
|
||||||
|
// byteOrder returns the binary.ByteOrder matching the header flags.
|
||||||
|
func (h PVAHeader) byteOrder() binary.ByteOrder {
|
||||||
|
if h.isLittleEndian() {
|
||||||
|
return binary.LittleEndian
|
||||||
|
}
|
||||||
|
return binary.BigEndian
|
||||||
|
}
|
||||||
|
|
||||||
|
// ReadHeader reads an 8-byte PVA message header from r.
|
||||||
|
func ReadHeader(r io.Reader) (PVAHeader, error) {
|
||||||
|
var raw [headerSize]byte
|
||||||
|
if _, err := io.ReadFull(r, raw[:]); err != nil {
|
||||||
|
return PVAHeader{}, err
|
||||||
|
}
|
||||||
|
if raw[0] != magic {
|
||||||
|
return PVAHeader{}, fmt.Errorf("pva: bad magic 0x%02X (expected 0xCA)", raw[0])
|
||||||
|
}
|
||||||
|
h := PVAHeader{
|
||||||
|
Version: raw[1],
|
||||||
|
Flags: raw[2],
|
||||||
|
Command: raw[3],
|
||||||
|
}
|
||||||
|
bo := h.byteOrder()
|
||||||
|
h.Size = bo.Uint32(raw[4:8])
|
||||||
|
return h, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// WriteHeader encodes h and writes the 8-byte header to w.
|
||||||
|
// Always writes in little-endian (client sends LE after SetByteOrder).
|
||||||
|
func WriteHeader(w io.Writer, h PVAHeader) error {
|
||||||
|
raw := [headerSize]byte{
|
||||||
|
magic,
|
||||||
|
h.Version,
|
||||||
|
h.Flags,
|
||||||
|
h.Command,
|
||||||
|
}
|
||||||
|
binary.LittleEndian.PutUint32(raw[4:8], h.Size)
|
||||||
|
_, err := w.Write(raw[:])
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// BuildMessage assembles a complete PVA message (header + payload).
|
||||||
|
func BuildMessage(cmd byte, flags byte, payload []byte) []byte {
|
||||||
|
var buf bytes.Buffer
|
||||||
|
_ = WriteHeader(&buf, PVAHeader{
|
||||||
|
Version: 1,
|
||||||
|
Flags: flags & ^flagFromServer, // clear server bit — this is client
|
||||||
|
Command: cmd,
|
||||||
|
Size: uint32(len(payload)),
|
||||||
|
})
|
||||||
|
buf.Write(payload)
|
||||||
|
return buf.Bytes()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Status decoding ---------------------------------------------------
|
||||||
|
|
||||||
|
// Status is a decoded PVA status message embedded in many response types.
|
||||||
|
type Status struct {
|
||||||
|
Type byte
|
||||||
|
Message string
|
||||||
|
Stack string
|
||||||
|
}
|
||||||
|
|
||||||
|
// OK reports whether the status is success.
|
||||||
|
func (s Status) OK() bool { return s.Type == StatusOKFull || s.Type == StatusOK }
|
||||||
|
|
||||||
|
// Error implements the error interface so Status can be returned as error.
|
||||||
|
func (s Status) Error() string {
|
||||||
|
if s.OK() {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("pva status %d: %s", s.Type, s.Message)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ReadStatus reads a PVA status from r.
|
||||||
|
// The short form (0xFF = OK) is handled transparently.
|
||||||
|
func ReadStatus(r io.Reader, bo binary.ByteOrder) (Status, error) {
|
||||||
|
var b [1]byte
|
||||||
|
if _, err := io.ReadFull(r, b[:]); err != nil {
|
||||||
|
return Status{}, err
|
||||||
|
}
|
||||||
|
if b[0] == StatusOK {
|
||||||
|
return Status{Type: StatusOKFull}, nil
|
||||||
|
}
|
||||||
|
typ := b[0]
|
||||||
|
msg, err := readPVAString(r, bo)
|
||||||
|
if err != nil {
|
||||||
|
return Status{}, err
|
||||||
|
}
|
||||||
|
stack, err := readPVAString(r, bo)
|
||||||
|
if err != nil {
|
||||||
|
return Status{}, err
|
||||||
|
}
|
||||||
|
return Status{Type: typ, Message: msg, Stack: stack}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// readPVAString reads a PVA length-prefixed string.
|
||||||
|
// PVA uses compact size encoding (same as pvdata package).
|
||||||
|
func readPVAString(r io.Reader, _ binary.ByteOrder) (string, error) {
|
||||||
|
// delegate to pvdata compact-size reader
|
||||||
|
var b [1]byte
|
||||||
|
if _, err := io.ReadFull(r, b[:]); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
n := int(b[0])
|
||||||
|
if n == 0xFF {
|
||||||
|
var v int32
|
||||||
|
if err := binary.Read(r, binary.LittleEndian, &v); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
n = int(v)
|
||||||
|
}
|
||||||
|
if n <= 0 {
|
||||||
|
return "", nil
|
||||||
|
}
|
||||||
|
buf := make([]byte, n)
|
||||||
|
if _, err := io.ReadFull(r, buf); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return string(buf), nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
package pvdata
|
||||||
|
|
||||||
|
import "io"
|
||||||
|
|
||||||
|
// BitSet is a variable-length set of bit indices, encoded on the wire as:
|
||||||
|
// compact-size(n_bytes) + n_bytes of little-endian bits.
|
||||||
|
// Bit 0 of byte 0 is field index 0, bit 1 of byte 0 is field index 1, etc.
|
||||||
|
// Used in pvData Monitor responses for "changed" and "overrun" masks.
|
||||||
|
type BitSet struct {
|
||||||
|
bits []byte
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewBitSet returns an empty BitSet.
|
||||||
|
func NewBitSet() BitSet { return BitSet{} }
|
||||||
|
|
||||||
|
// Set sets bit i in the BitSet.
|
||||||
|
func (bs *BitSet) Set(i int) {
|
||||||
|
byteIdx := i / 8
|
||||||
|
for len(bs.bits) <= byteIdx {
|
||||||
|
bs.bits = append(bs.bits, 0)
|
||||||
|
}
|
||||||
|
bs.bits[byteIdx] |= 1 << (uint(i) % 8)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Has reports whether bit i is set.
|
||||||
|
func (bs *BitSet) Has(i int) bool {
|
||||||
|
byteIdx := i / 8
|
||||||
|
if byteIdx >= len(bs.bits) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return bs.bits[byteIdx]&(1<<(uint(i)%8)) != 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// ReadBitSet reads a BitSet from r.
|
||||||
|
func ReadBitSet(r io.Reader) (BitSet, error) {
|
||||||
|
n, err := ReadSize(r)
|
||||||
|
if err != nil {
|
||||||
|
return BitSet{}, err
|
||||||
|
}
|
||||||
|
if n == 0 {
|
||||||
|
return BitSet{}, nil
|
||||||
|
}
|
||||||
|
buf := make([]byte, n)
|
||||||
|
if _, err := io.ReadFull(r, buf); err != nil {
|
||||||
|
return BitSet{}, err
|
||||||
|
}
|
||||||
|
return BitSet{bits: buf}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// WriteBitSet writes bs to w.
|
||||||
|
func WriteBitSet(w io.Writer, bs BitSet) error {
|
||||||
|
if err := WriteSize(w, int64(len(bs.bits))); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if len(bs.bits) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
_, err := w.Write(bs.bits)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// FieldIndices returns all set bit indices in ascending order.
|
||||||
|
func (bs *BitSet) FieldIndices() []int {
|
||||||
|
var out []int
|
||||||
|
for bi, b := range bs.bits {
|
||||||
|
for bit := 0; bit < 8; bit++ {
|
||||||
|
if b&(1<<uint(bit)) != 0 {
|
||||||
|
out = append(out, bi*8+bit)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
@@ -0,0 +1,679 @@
|
|||||||
|
// Package pvdata implements the EPICS pvData serialisation layer.
|
||||||
|
//
|
||||||
|
// pvData defines a rich, self-describing type system used by PV Access (PVA).
|
||||||
|
// Every value that flows over a PVA connection is encoded according to a
|
||||||
|
// FieldDesc (field description / introspection interface), which is itself
|
||||||
|
// serialised on the wire before the payload.
|
||||||
|
//
|
||||||
|
// Reference: EPICS pvData Specification r1.1
|
||||||
|
// https://epics-pvdata.sourceforge.net/pvData.html
|
||||||
|
package pvdata
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/binary"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"math"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ---- Type codes -------------------------------------------------------
|
||||||
|
//
|
||||||
|
// pvData uses a one-byte "type code" to identify scalar and array element
|
||||||
|
// types. Values from the spec §3.1.
|
||||||
|
|
||||||
|
const (
|
||||||
|
TypeCodeBoolean byte = 0x00
|
||||||
|
TypeCodeByte byte = 0x20 // int8
|
||||||
|
TypeCodeShort byte = 0x21 // int16
|
||||||
|
TypeCodeInt byte = 0x22 // int32
|
||||||
|
TypeCodeLong byte = 0x23 // int64
|
||||||
|
TypeCodeUByte byte = 0x24 // uint8
|
||||||
|
TypeCodeUShort byte = 0x25 // uint16
|
||||||
|
TypeCodeUInt byte = 0x26 // uint32
|
||||||
|
TypeCodeULong byte = 0x27 // uint64
|
||||||
|
TypeCodeFloat byte = 0x42 // float32
|
||||||
|
TypeCodeDouble byte = 0x43 // float64
|
||||||
|
TypeCodeString byte = 0x60
|
||||||
|
TypeCodeStruct byte = 0x80
|
||||||
|
TypeCodeUnion byte = 0x81
|
||||||
|
TypeCodeBoolArr byte = 0x08
|
||||||
|
TypeCodeByteArr byte = 0x28
|
||||||
|
TypeCodeShortArr byte = 0x29
|
||||||
|
TypeCodeIntArr byte = 0x2A
|
||||||
|
TypeCodeLongArr byte = 0x2B
|
||||||
|
TypeCodeUByteArr byte = 0x2C
|
||||||
|
TypeCodeUShortArr byte = 0x2D
|
||||||
|
TypeCodeUIntArr byte = 0x2E
|
||||||
|
TypeCodeULongArr byte = 0x2F
|
||||||
|
TypeCodeFloatArr byte = 0x4A
|
||||||
|
TypeCodeDoubleArr byte = 0x4B
|
||||||
|
TypeCodeStringArr byte = 0x68
|
||||||
|
TypeCodeStructArr byte = 0x88
|
||||||
|
TypeCodeUnionArr byte = 0x89
|
||||||
|
TypeCodeVariant byte = 0x82 // any
|
||||||
|
TypeCodeNull byte = 0xFF
|
||||||
|
)
|
||||||
|
|
||||||
|
// ---- Compact size encoding --------------------------------------------
|
||||||
|
//
|
||||||
|
// pvData encodes array/string lengths as a "size" using 1, 4, or 8 bytes
|
||||||
|
// (spec §3.2).
|
||||||
|
|
||||||
|
// ReadSize reads a compact-format size from r.
|
||||||
|
// 0x00–0xFE → 1-byte value
|
||||||
|
// 0xFF → read 4-byte int32 (negative = 8-byte int64 follows)
|
||||||
|
func ReadSize(r io.Reader) (int64, error) {
|
||||||
|
var b [1]byte
|
||||||
|
if _, err := io.ReadFull(r, b[:]); err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
if b[0] != 0xFF {
|
||||||
|
return int64(b[0]), nil
|
||||||
|
}
|
||||||
|
var v int32
|
||||||
|
if err := binary.Read(r, binary.LittleEndian, &v); err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
if v >= 0 {
|
||||||
|
return int64(v), nil
|
||||||
|
}
|
||||||
|
// extended 8-byte size
|
||||||
|
var v8 int64
|
||||||
|
if err := binary.Read(r, binary.LittleEndian, &v8); err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
return v8, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// WriteSize writes n as a compact-format size to w.
|
||||||
|
func WriteSize(w io.Writer, n int64) error {
|
||||||
|
if n < 0xFF {
|
||||||
|
_, err := w.Write([]byte{byte(n)})
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if n <= math.MaxInt32 {
|
||||||
|
if _, err := w.Write([]byte{0xFF}); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return binary.Write(w, binary.LittleEndian, int32(n))
|
||||||
|
}
|
||||||
|
// rare: >2 GiB array
|
||||||
|
if _, err := w.Write([]byte{0xFF}); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := binary.Write(w, binary.LittleEndian, int32(-1)); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return binary.Write(w, binary.LittleEndian, n)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ReadString reads a pvData string (compact size + UTF-8 bytes).
|
||||||
|
func ReadString(r io.Reader) (string, error) {
|
||||||
|
n, err := ReadSize(r)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
if n == 0 {
|
||||||
|
return "", nil
|
||||||
|
}
|
||||||
|
buf := make([]byte, n)
|
||||||
|
if _, err := io.ReadFull(r, buf); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return string(buf), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// WriteString writes a pvData string to w.
|
||||||
|
func WriteString(w io.Writer, s string) error {
|
||||||
|
if err := WriteSize(w, int64(len(s))); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
_, err := io.WriteString(w, s)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Field description (introspection) --------------------------------
|
||||||
|
|
||||||
|
// Kind classifies a FieldDesc at a high level.
|
||||||
|
type Kind byte
|
||||||
|
|
||||||
|
const (
|
||||||
|
KindScalar Kind = iota // single scalar value
|
||||||
|
KindScalarArr // variable-length array of scalars
|
||||||
|
KindString // pvData string (treated specially)
|
||||||
|
KindStringArr
|
||||||
|
KindStruct // structure
|
||||||
|
KindStructArr
|
||||||
|
KindUnion // discriminated union
|
||||||
|
KindUnionArr
|
||||||
|
KindVariant // any (variant)
|
||||||
|
)
|
||||||
|
|
||||||
|
// FieldDesc describes the type of a single field in a pvData structure.
|
||||||
|
type FieldDesc struct {
|
||||||
|
Kind Kind
|
||||||
|
TypeCode byte // scalar/array type code; 0 for struct/union
|
||||||
|
TypeID string // optional structure/union type ID string
|
||||||
|
Fields []Field // child fields for struct/union
|
||||||
|
}
|
||||||
|
|
||||||
|
// Field is a named FieldDesc (one member of a struct or union).
|
||||||
|
type Field struct {
|
||||||
|
Name string
|
||||||
|
Desc FieldDesc
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- FieldDesc codec --------------------------------------------------
|
||||||
|
|
||||||
|
// ReadFieldDesc decodes a FieldDesc from r.
|
||||||
|
// This follows the pvData introspection encoding (spec §4).
|
||||||
|
func ReadFieldDesc(r io.Reader) (FieldDesc, error) {
|
||||||
|
var tc [1]byte
|
||||||
|
if _, err := io.ReadFull(r, tc[:]); err != nil {
|
||||||
|
return FieldDesc{}, err
|
||||||
|
}
|
||||||
|
return readFieldDescFromTypeCode(r, tc[0])
|
||||||
|
}
|
||||||
|
|
||||||
|
func readFieldDescFromTypeCode(r io.Reader, tc byte) (FieldDesc, error) {
|
||||||
|
switch {
|
||||||
|
case tc == TypeCodeNull:
|
||||||
|
return FieldDesc{TypeCode: TypeCodeNull}, nil
|
||||||
|
|
||||||
|
case tc == TypeCodeString:
|
||||||
|
return FieldDesc{Kind: KindString, TypeCode: tc}, nil
|
||||||
|
|
||||||
|
case tc == TypeCodeStringArr:
|
||||||
|
return FieldDesc{Kind: KindStringArr, TypeCode: tc}, nil
|
||||||
|
|
||||||
|
case tc == TypeCodeVariant:
|
||||||
|
return FieldDesc{Kind: KindVariant, TypeCode: tc}, nil
|
||||||
|
|
||||||
|
case tc == TypeCodeStruct || tc == TypeCodeUnion:
|
||||||
|
kind := KindStruct
|
||||||
|
if tc == TypeCodeUnion {
|
||||||
|
kind = KindUnion
|
||||||
|
}
|
||||||
|
typeID, err := ReadString(r)
|
||||||
|
if err != nil {
|
||||||
|
return FieldDesc{}, err
|
||||||
|
}
|
||||||
|
nfields, err := ReadSize(r)
|
||||||
|
if err != nil {
|
||||||
|
return FieldDesc{}, err
|
||||||
|
}
|
||||||
|
fields := make([]Field, nfields)
|
||||||
|
for i := range fields {
|
||||||
|
name, err := ReadString(r)
|
||||||
|
if err != nil {
|
||||||
|
return FieldDesc{}, err
|
||||||
|
}
|
||||||
|
desc, err := ReadFieldDesc(r)
|
||||||
|
if err != nil {
|
||||||
|
return FieldDesc{}, err
|
||||||
|
}
|
||||||
|
fields[i] = Field{Name: name, Desc: desc}
|
||||||
|
}
|
||||||
|
return FieldDesc{Kind: kind, TypeCode: tc, TypeID: typeID, Fields: fields}, nil
|
||||||
|
|
||||||
|
case tc == TypeCodeStructArr || tc == TypeCodeUnionArr:
|
||||||
|
kind := KindStructArr
|
||||||
|
if tc == TypeCodeUnionArr {
|
||||||
|
kind = KindUnionArr
|
||||||
|
}
|
||||||
|
// element descriptor follows
|
||||||
|
elem, err := ReadFieldDesc(r)
|
||||||
|
if err != nil {
|
||||||
|
return FieldDesc{}, err
|
||||||
|
}
|
||||||
|
return FieldDesc{Kind: kind, TypeCode: tc, TypeID: elem.TypeID, Fields: elem.Fields}, nil
|
||||||
|
|
||||||
|
// scalar array types: 0x28–0x2F, 0x4A–0x4B, 0x08, 0x68
|
||||||
|
case isScalarArrayCode(tc):
|
||||||
|
return FieldDesc{Kind: KindScalarArr, TypeCode: tc}, nil
|
||||||
|
|
||||||
|
// plain scalars and boolean
|
||||||
|
default:
|
||||||
|
return FieldDesc{Kind: KindScalar, TypeCode: tc}, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func isScalarArrayCode(tc byte) bool {
|
||||||
|
switch tc {
|
||||||
|
case TypeCodeBoolArr,
|
||||||
|
TypeCodeByteArr, TypeCodeShortArr, TypeCodeIntArr, TypeCodeLongArr,
|
||||||
|
TypeCodeUByteArr, TypeCodeUShortArr, TypeCodeUIntArr, TypeCodeULongArr,
|
||||||
|
TypeCodeFloatArr, TypeCodeDoubleArr,
|
||||||
|
TypeCodeStringArr:
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// WriteFieldDesc encodes fd into w.
|
||||||
|
func WriteFieldDesc(w io.Writer, fd FieldDesc) error {
|
||||||
|
if _, err := w.Write([]byte{fd.TypeCode}); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
switch fd.Kind {
|
||||||
|
case KindStruct, KindUnion:
|
||||||
|
if err := WriteString(w, fd.TypeID); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := WriteSize(w, int64(len(fd.Fields))); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
for _, f := range fd.Fields {
|
||||||
|
if err := WriteString(w, f.Name); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := WriteFieldDesc(w, f.Desc); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
case KindStructArr, KindUnionArr:
|
||||||
|
// write element descriptor
|
||||||
|
elemTC := TypeCodeStruct
|
||||||
|
if fd.Kind == KindUnionArr {
|
||||||
|
elemTC = TypeCodeUnion
|
||||||
|
}
|
||||||
|
if err := WriteFieldDesc(w, FieldDesc{
|
||||||
|
Kind: KindStruct, TypeCode: byte(elemTC),
|
||||||
|
TypeID: fd.TypeID, Fields: fd.Fields,
|
||||||
|
}); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Value codec -------------------------------------------------------
|
||||||
|
|
||||||
|
// Value is a decoded pvData value. We use interface{} for flexibility;
|
||||||
|
// callers type-assert to concrete types listed below.
|
||||||
|
//
|
||||||
|
// Scalar types:
|
||||||
|
// bool, int8, int16, int32, int64,
|
||||||
|
// uint8, uint16, uint32, uint64,
|
||||||
|
// float32, float64, string
|
||||||
|
//
|
||||||
|
// Array types: []T for each scalar T above; []string.
|
||||||
|
// Struct: StructValue
|
||||||
|
// Union: UnionValue
|
||||||
|
// Variant: VariantValue
|
||||||
|
// Null: nil
|
||||||
|
type Value = interface{}
|
||||||
|
|
||||||
|
// StructValue holds the fields of a decoded pvData structure.
|
||||||
|
type StructValue struct {
|
||||||
|
TypeID string
|
||||||
|
Fields []FieldValue
|
||||||
|
}
|
||||||
|
|
||||||
|
// FieldValue is a named value within a StructValue.
|
||||||
|
type FieldValue struct {
|
||||||
|
Name string
|
||||||
|
Value Value
|
||||||
|
}
|
||||||
|
|
||||||
|
// UnionValue holds a selected field from a discriminated union.
|
||||||
|
type UnionValue struct {
|
||||||
|
Selected int // index into union's Fields slice
|
||||||
|
Name string // field name
|
||||||
|
Value Value
|
||||||
|
}
|
||||||
|
|
||||||
|
// VariantValue wraps an any-typed value together with its runtime descriptor.
|
||||||
|
type VariantValue struct {
|
||||||
|
Desc FieldDesc
|
||||||
|
Value Value
|
||||||
|
}
|
||||||
|
|
||||||
|
// ReadValue decodes a value conforming to desc from r.
|
||||||
|
func ReadValue(r io.Reader, desc FieldDesc) (Value, error) {
|
||||||
|
switch desc.Kind {
|
||||||
|
case KindScalar:
|
||||||
|
return readScalar(r, desc.TypeCode)
|
||||||
|
case KindScalarArr:
|
||||||
|
return readScalarArray(r, desc.TypeCode)
|
||||||
|
case KindString:
|
||||||
|
return ReadString(r)
|
||||||
|
case KindStringArr:
|
||||||
|
n, err := ReadSize(r)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
ss := make([]string, n)
|
||||||
|
for i := range ss {
|
||||||
|
ss[i], err = ReadString(r)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ss, nil
|
||||||
|
case KindStruct:
|
||||||
|
return readStruct(r, desc)
|
||||||
|
case KindStructArr:
|
||||||
|
return readStructArr(r, desc)
|
||||||
|
case KindUnion:
|
||||||
|
return readUnion(r, desc)
|
||||||
|
case KindUnionArr:
|
||||||
|
return readUnionArr(r, desc)
|
||||||
|
case KindVariant:
|
||||||
|
return readVariant(r)
|
||||||
|
default:
|
||||||
|
return nil, fmt.Errorf("pvdata: unknown kind %d", desc.Kind)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func readScalar(r io.Reader, tc byte) (Value, error) {
|
||||||
|
switch tc {
|
||||||
|
case TypeCodeBoolean:
|
||||||
|
var b [1]byte
|
||||||
|
_, err := io.ReadFull(r, b[:])
|
||||||
|
return b[0] != 0, err
|
||||||
|
case TypeCodeByte:
|
||||||
|
var v int8
|
||||||
|
return v, binary.Read(r, binary.LittleEndian, &v)
|
||||||
|
case TypeCodeShort:
|
||||||
|
var v int16
|
||||||
|
return v, binary.Read(r, binary.LittleEndian, &v)
|
||||||
|
case TypeCodeInt:
|
||||||
|
var v int32
|
||||||
|
return v, binary.Read(r, binary.LittleEndian, &v)
|
||||||
|
case TypeCodeLong:
|
||||||
|
var v int64
|
||||||
|
return v, binary.Read(r, binary.LittleEndian, &v)
|
||||||
|
case TypeCodeUByte:
|
||||||
|
var v uint8
|
||||||
|
return v, binary.Read(r, binary.LittleEndian, &v)
|
||||||
|
case TypeCodeUShort:
|
||||||
|
var v uint16
|
||||||
|
return v, binary.Read(r, binary.LittleEndian, &v)
|
||||||
|
case TypeCodeUInt:
|
||||||
|
var v uint32
|
||||||
|
return v, binary.Read(r, binary.LittleEndian, &v)
|
||||||
|
case TypeCodeULong:
|
||||||
|
var v uint64
|
||||||
|
return v, binary.Read(r, binary.LittleEndian, &v)
|
||||||
|
case TypeCodeFloat:
|
||||||
|
var v uint32
|
||||||
|
if err := binary.Read(r, binary.LittleEndian, &v); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return math.Float32frombits(v), nil
|
||||||
|
case TypeCodeDouble:
|
||||||
|
var v uint64
|
||||||
|
if err := binary.Read(r, binary.LittleEndian, &v); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return math.Float64frombits(v), nil
|
||||||
|
default:
|
||||||
|
return nil, fmt.Errorf("pvdata: unknown scalar type code 0x%02X", tc)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func readScalarArray(r io.Reader, tc byte) (Value, error) {
|
||||||
|
n, err := ReadSize(r)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
count := int(n)
|
||||||
|
switch tc {
|
||||||
|
case TypeCodeBoolArr:
|
||||||
|
v := make([]bool, count)
|
||||||
|
for i := range v {
|
||||||
|
b := [1]byte{}
|
||||||
|
if _, err := io.ReadFull(r, b[:]); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
v[i] = b[0] != 0
|
||||||
|
}
|
||||||
|
return v, nil
|
||||||
|
case TypeCodeByteArr:
|
||||||
|
v := make([]int8, count)
|
||||||
|
return v, binary.Read(r, binary.LittleEndian, v)
|
||||||
|
case TypeCodeShortArr:
|
||||||
|
v := make([]int16, count)
|
||||||
|
return v, binary.Read(r, binary.LittleEndian, v)
|
||||||
|
case TypeCodeIntArr:
|
||||||
|
v := make([]int32, count)
|
||||||
|
return v, binary.Read(r, binary.LittleEndian, v)
|
||||||
|
case TypeCodeLongArr:
|
||||||
|
v := make([]int64, count)
|
||||||
|
return v, binary.Read(r, binary.LittleEndian, v)
|
||||||
|
case TypeCodeUByteArr:
|
||||||
|
v := make([]uint8, count)
|
||||||
|
return v, binary.Read(r, binary.LittleEndian, v)
|
||||||
|
case TypeCodeUShortArr:
|
||||||
|
v := make([]uint16, count)
|
||||||
|
return v, binary.Read(r, binary.LittleEndian, v)
|
||||||
|
case TypeCodeUIntArr:
|
||||||
|
v := make([]uint32, count)
|
||||||
|
return v, binary.Read(r, binary.LittleEndian, v)
|
||||||
|
case TypeCodeULongArr:
|
||||||
|
v := make([]uint64, count)
|
||||||
|
return v, binary.Read(r, binary.LittleEndian, v)
|
||||||
|
case TypeCodeFloatArr:
|
||||||
|
bits := make([]uint32, count)
|
||||||
|
if err := binary.Read(r, binary.LittleEndian, bits); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
v := make([]float32, count)
|
||||||
|
for i, b := range bits {
|
||||||
|
v[i] = math.Float32frombits(b)
|
||||||
|
}
|
||||||
|
return v, nil
|
||||||
|
case TypeCodeDoubleArr:
|
||||||
|
bits := make([]uint64, count)
|
||||||
|
if err := binary.Read(r, binary.LittleEndian, bits); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
v := make([]float64, count)
|
||||||
|
for i, b := range bits {
|
||||||
|
v[i] = math.Float64frombits(b)
|
||||||
|
}
|
||||||
|
return v, nil
|
||||||
|
default:
|
||||||
|
return nil, fmt.Errorf("pvdata: unknown array type code 0x%02X", tc)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func readStruct(r io.Reader, desc FieldDesc) (StructValue, error) {
|
||||||
|
sv := StructValue{TypeID: desc.TypeID, Fields: make([]FieldValue, len(desc.Fields))}
|
||||||
|
for i, f := range desc.Fields {
|
||||||
|
v, err := ReadValue(r, f.Desc)
|
||||||
|
if err != nil {
|
||||||
|
return StructValue{}, fmt.Errorf("field %q: %w", f.Name, err)
|
||||||
|
}
|
||||||
|
sv.Fields[i] = FieldValue{Name: f.Name, Value: v}
|
||||||
|
}
|
||||||
|
return sv, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func readStructArr(r io.Reader, desc FieldDesc) ([]StructValue, error) {
|
||||||
|
n, err := ReadSize(r)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
arr := make([]StructValue, n)
|
||||||
|
for i := range arr {
|
||||||
|
sv, err := readStruct(r, desc)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
arr[i] = sv
|
||||||
|
}
|
||||||
|
return arr, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func readUnion(r io.Reader, desc FieldDesc) (UnionValue, error) {
|
||||||
|
idx, err := ReadSize(r)
|
||||||
|
if err != nil {
|
||||||
|
return UnionValue{}, err
|
||||||
|
}
|
||||||
|
if idx < 0 || int(idx) >= len(desc.Fields) {
|
||||||
|
return UnionValue{Selected: int(idx)}, nil // null selector
|
||||||
|
}
|
||||||
|
f := desc.Fields[idx]
|
||||||
|
v, err := ReadValue(r, f.Desc)
|
||||||
|
if err != nil {
|
||||||
|
return UnionValue{}, err
|
||||||
|
}
|
||||||
|
return UnionValue{Selected: int(idx), Name: f.Name, Value: v}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func readUnionArr(r io.Reader, desc FieldDesc) ([]UnionValue, error) {
|
||||||
|
n, err := ReadSize(r)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
arr := make([]UnionValue, n)
|
||||||
|
for i := range arr {
|
||||||
|
uv, err := readUnion(r, desc)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
arr[i] = uv
|
||||||
|
}
|
||||||
|
return arr, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func readVariant(r io.Reader) (VariantValue, error) {
|
||||||
|
desc, err := ReadFieldDesc(r)
|
||||||
|
if err != nil {
|
||||||
|
return VariantValue{}, err
|
||||||
|
}
|
||||||
|
v, err := ReadValue(r, desc)
|
||||||
|
if err != nil {
|
||||||
|
return VariantValue{}, err
|
||||||
|
}
|
||||||
|
return VariantValue{Desc: desc, Value: v}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// WriteValue encodes v (which must match desc) into w.
|
||||||
|
func WriteValue(w io.Writer, desc FieldDesc, v Value) error {
|
||||||
|
switch desc.Kind {
|
||||||
|
case KindScalar:
|
||||||
|
return writeScalar(w, desc.TypeCode, v)
|
||||||
|
case KindScalarArr:
|
||||||
|
return writeScalarArray(w, desc.TypeCode, v)
|
||||||
|
case KindString:
|
||||||
|
return WriteString(w, v.(string))
|
||||||
|
case KindStringArr:
|
||||||
|
ss := v.([]string)
|
||||||
|
if err := WriteSize(w, int64(len(ss))); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
for _, s := range ss {
|
||||||
|
if err := WriteString(w, s); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
case KindStruct:
|
||||||
|
return writeStruct(w, desc, v.(StructValue))
|
||||||
|
case KindUnion:
|
||||||
|
return writeUnion(w, v.(UnionValue))
|
||||||
|
case KindVariant:
|
||||||
|
vv := v.(VariantValue)
|
||||||
|
if err := WriteFieldDesc(w, vv.Desc); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return WriteValue(w, vv.Desc, vv.Value)
|
||||||
|
default:
|
||||||
|
return fmt.Errorf("pvdata: WriteValue unsupported kind %d", desc.Kind)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeScalar(w io.Writer, tc byte, v Value) error {
|
||||||
|
switch tc {
|
||||||
|
case TypeCodeBoolean:
|
||||||
|
b := byte(0)
|
||||||
|
if v.(bool) {
|
||||||
|
b = 1
|
||||||
|
}
|
||||||
|
_, err := w.Write([]byte{b})
|
||||||
|
return err
|
||||||
|
case TypeCodeFloat:
|
||||||
|
return binary.Write(w, binary.LittleEndian, math.Float32bits(v.(float32)))
|
||||||
|
case TypeCodeDouble:
|
||||||
|
return binary.Write(w, binary.LittleEndian, math.Float64bits(v.(float64)))
|
||||||
|
default:
|
||||||
|
return binary.Write(w, binary.LittleEndian, v)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeScalarArray(w io.Writer, tc byte, v Value) error {
|
||||||
|
switch tc {
|
||||||
|
case TypeCodeDoubleArr:
|
||||||
|
arr := v.([]float64)
|
||||||
|
if err := WriteSize(w, int64(len(arr))); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
bits := make([]uint64, len(arr))
|
||||||
|
for i, f := range arr {
|
||||||
|
bits[i] = math.Float64bits(f)
|
||||||
|
}
|
||||||
|
return binary.Write(w, binary.LittleEndian, bits)
|
||||||
|
case TypeCodeFloatArr:
|
||||||
|
arr := v.([]float32)
|
||||||
|
if err := WriteSize(w, int64(len(arr))); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
bits := make([]uint32, len(arr))
|
||||||
|
for i, f := range arr {
|
||||||
|
bits[i] = math.Float32bits(f)
|
||||||
|
}
|
||||||
|
return binary.Write(w, binary.LittleEndian, bits)
|
||||||
|
default:
|
||||||
|
// for integer arrays, v is already []intN or []uintN
|
||||||
|
if err := WriteSize(w, int64(arrayLen(v))); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return binary.Write(w, binary.LittleEndian, v)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeStruct(w io.Writer, desc FieldDesc, sv StructValue) error {
|
||||||
|
for i, fv := range sv.Fields {
|
||||||
|
if err := WriteValue(w, desc.Fields[i].Desc, fv.Value); err != nil {
|
||||||
|
return fmt.Errorf("field %q: %w", fv.Name, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeUnion(w io.Writer, uv UnionValue) error {
|
||||||
|
if err := WriteSize(w, int64(uv.Selected)); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
// caller must supply desc to encode value; not needed if Selected is null
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// arrayLen returns reflect-free length for the supported array types.
|
||||||
|
func arrayLen(v Value) int {
|
||||||
|
switch x := v.(type) {
|
||||||
|
case []bool:
|
||||||
|
return len(x)
|
||||||
|
case []int8:
|
||||||
|
return len(x)
|
||||||
|
case []int16:
|
||||||
|
return len(x)
|
||||||
|
case []int32:
|
||||||
|
return len(x)
|
||||||
|
case []int64:
|
||||||
|
return len(x)
|
||||||
|
case []uint8:
|
||||||
|
return len(x)
|
||||||
|
case []uint16:
|
||||||
|
return len(x)
|
||||||
|
case []uint32:
|
||||||
|
return len(x)
|
||||||
|
case []uint64:
|
||||||
|
return len(x)
|
||||||
|
default:
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,220 @@
|
|||||||
|
package pvdata_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/uopi/gopva/pvdata"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ---- Compact size encoding -------------------------------------------
|
||||||
|
|
||||||
|
func TestCompactSize(t *testing.T) {
|
||||||
|
cases := []int64{0, 1, 10, 254, 255, 256, 1000, 65535, 70000, 1<<31 - 1}
|
||||||
|
for _, n := range cases {
|
||||||
|
var buf bytes.Buffer
|
||||||
|
if err := pvdata.WriteSize(&buf, n); err != nil {
|
||||||
|
t.Fatalf("WriteSize(%d): %v", n, err)
|
||||||
|
}
|
||||||
|
got, err := pvdata.ReadSize(&buf)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ReadSize for %d: %v", n, err)
|
||||||
|
}
|
||||||
|
if got != n {
|
||||||
|
t.Errorf("round-trip size %d → %d", n, got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- String encoding ------------------------------------------------
|
||||||
|
|
||||||
|
func TestString(t *testing.T) {
|
||||||
|
cases := []string{"", "hello", "UOPI:TICK", "a string with spaces and unicode: é"}
|
||||||
|
for _, s := range cases {
|
||||||
|
var buf bytes.Buffer
|
||||||
|
if err := pvdata.WriteString(&buf, s); err != nil {
|
||||||
|
t.Fatalf("WriteString(%q): %v", s, err)
|
||||||
|
}
|
||||||
|
got, err := pvdata.ReadString(&buf)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ReadString for %q: %v", s, err)
|
||||||
|
}
|
||||||
|
if got != s {
|
||||||
|
t.Errorf("round-trip string %q → %q", s, got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- FieldDesc (introspection) round-trip ---------------------------
|
||||||
|
|
||||||
|
func TestFieldDescScalar(t *testing.T) {
|
||||||
|
desc := pvdata.FieldDesc{Kind: pvdata.KindScalar, TypeCode: pvdata.TypeCodeDouble}
|
||||||
|
var buf bytes.Buffer
|
||||||
|
if err := pvdata.WriteFieldDesc(&buf, desc); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
got, err := pvdata.ReadFieldDesc(&buf)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if got.TypeCode != desc.TypeCode || got.Kind != desc.Kind {
|
||||||
|
t.Errorf("scalar round-trip: got %+v, want %+v", got, desc)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFieldDescStruct(t *testing.T) {
|
||||||
|
// Minimal NTScalar-like struct: value(double) + timeStamp(struct{…})
|
||||||
|
desc := pvdata.FieldDesc{
|
||||||
|
Kind: pvdata.KindStruct,
|
||||||
|
TypeCode: pvdata.TypeCodeStruct,
|
||||||
|
TypeID: "epics:nt/NTScalar:1.0",
|
||||||
|
Fields: []pvdata.Field{
|
||||||
|
{Name: "value", Desc: pvdata.FieldDesc{Kind: pvdata.KindScalar, TypeCode: pvdata.TypeCodeDouble}},
|
||||||
|
{Name: "alarm", Desc: pvdata.FieldDesc{
|
||||||
|
Kind: pvdata.KindStruct, TypeCode: pvdata.TypeCodeStruct, TypeID: "alarm_t",
|
||||||
|
Fields: []pvdata.Field{
|
||||||
|
{Name: "severity", Desc: pvdata.FieldDesc{Kind: pvdata.KindScalar, TypeCode: pvdata.TypeCodeInt}},
|
||||||
|
{Name: "status", Desc: pvdata.FieldDesc{Kind: pvdata.KindScalar, TypeCode: pvdata.TypeCodeInt}},
|
||||||
|
{Name: "message", Desc: pvdata.FieldDesc{Kind: pvdata.KindString, TypeCode: pvdata.TypeCodeString}},
|
||||||
|
},
|
||||||
|
}},
|
||||||
|
{Name: "timeStamp", Desc: pvdata.FieldDesc{
|
||||||
|
Kind: pvdata.KindStruct, TypeCode: pvdata.TypeCodeStruct, TypeID: "time_t",
|
||||||
|
Fields: []pvdata.Field{
|
||||||
|
{Name: "secondsPastEpoch", Desc: pvdata.FieldDesc{Kind: pvdata.KindScalar, TypeCode: pvdata.TypeCodeLong}},
|
||||||
|
{Name: "nanoseconds", Desc: pvdata.FieldDesc{Kind: pvdata.KindScalar, TypeCode: pvdata.TypeCodeInt}},
|
||||||
|
{Name: "userTag", Desc: pvdata.FieldDesc{Kind: pvdata.KindScalar, TypeCode: pvdata.TypeCodeInt}},
|
||||||
|
},
|
||||||
|
}},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
var buf bytes.Buffer
|
||||||
|
if err := pvdata.WriteFieldDesc(&buf, desc); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
got, err := pvdata.ReadFieldDesc(&buf)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if got.TypeID != desc.TypeID {
|
||||||
|
t.Errorf("typeID: got %q want %q", got.TypeID, desc.TypeID)
|
||||||
|
}
|
||||||
|
if len(got.Fields) != len(desc.Fields) {
|
||||||
|
t.Fatalf("field count: got %d want %d", len(got.Fields), len(desc.Fields))
|
||||||
|
}
|
||||||
|
for i, f := range desc.Fields {
|
||||||
|
if got.Fields[i].Name != f.Name {
|
||||||
|
t.Errorf("field[%d] name: got %q want %q", i, got.Fields[i].Name, f.Name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Value round-trip -----------------------------------------------
|
||||||
|
|
||||||
|
func TestValueDouble(t *testing.T) {
|
||||||
|
desc := pvdata.FieldDesc{Kind: pvdata.KindScalar, TypeCode: pvdata.TypeCodeDouble}
|
||||||
|
var v pvdata.Value = float64(3.14159)
|
||||||
|
var buf bytes.Buffer
|
||||||
|
if err := pvdata.WriteValue(&buf, desc, v); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
got, err := pvdata.ReadValue(&buf, desc)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if got.(float64) != v.(float64) {
|
||||||
|
t.Errorf("double round-trip: got %v want %v", got, v)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestValueIntArray(t *testing.T) {
|
||||||
|
desc := pvdata.FieldDesc{Kind: pvdata.KindScalarArr, TypeCode: pvdata.TypeCodeIntArr}
|
||||||
|
var v pvdata.Value = []int32{1, 2, 3, -7, 1<<30}
|
||||||
|
var buf bytes.Buffer
|
||||||
|
if err := pvdata.WriteValue(&buf, desc, v); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
got, err := pvdata.ReadValue(&buf, desc)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
arr := got.([]int32)
|
||||||
|
src := v.([]int32)
|
||||||
|
if len(arr) != len(src) {
|
||||||
|
t.Fatalf("int32 array length: got %d want %d", len(arr), len(src))
|
||||||
|
}
|
||||||
|
for i := range src {
|
||||||
|
if arr[i] != src[i] {
|
||||||
|
t.Errorf("int32[%d]: got %d want %d", i, arr[i], src[i])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestValueStruct(t *testing.T) {
|
||||||
|
desc := pvdata.FieldDesc{
|
||||||
|
Kind: pvdata.KindStruct,
|
||||||
|
TypeCode: pvdata.TypeCodeStruct,
|
||||||
|
TypeID: "test_t",
|
||||||
|
Fields: []pvdata.Field{
|
||||||
|
{Name: "x", Desc: pvdata.FieldDesc{Kind: pvdata.KindScalar, TypeCode: pvdata.TypeCodeDouble}},
|
||||||
|
{Name: "n", Desc: pvdata.FieldDesc{Kind: pvdata.KindScalar, TypeCode: pvdata.TypeCodeInt}},
|
||||||
|
{Name: "s", Desc: pvdata.FieldDesc{Kind: pvdata.KindString, TypeCode: pvdata.TypeCodeString}},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
sv := pvdata.StructValue{
|
||||||
|
TypeID: "test_t",
|
||||||
|
Fields: []pvdata.FieldValue{
|
||||||
|
{Name: "x", Value: float64(2.718)},
|
||||||
|
{Name: "n", Value: int32(42)},
|
||||||
|
{Name: "s", Value: "hello"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
var buf bytes.Buffer
|
||||||
|
if err := pvdata.WriteValue(&buf, desc, sv); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
got, err := pvdata.ReadValue(&buf, desc)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
gsv := got.(pvdata.StructValue)
|
||||||
|
if gsv.Fields[0].Value.(float64) != sv.Fields[0].Value.(float64) {
|
||||||
|
t.Errorf("x field mismatch")
|
||||||
|
}
|
||||||
|
if gsv.Fields[1].Value.(int32) != sv.Fields[1].Value.(int32) {
|
||||||
|
t.Errorf("n field mismatch")
|
||||||
|
}
|
||||||
|
if gsv.Fields[2].Value.(string) != sv.Fields[2].Value.(string) {
|
||||||
|
t.Errorf("s field mismatch")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- BitSet ---------------------------------------------------------
|
||||||
|
|
||||||
|
func TestBitSet(t *testing.T) {
|
||||||
|
var bs pvdata.BitSet
|
||||||
|
bs.Set(0)
|
||||||
|
bs.Set(3)
|
||||||
|
bs.Set(15)
|
||||||
|
bs.Set(16)
|
||||||
|
|
||||||
|
var buf bytes.Buffer
|
||||||
|
if err := pvdata.WriteBitSet(&buf, bs); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
got, err := pvdata.ReadBitSet(&buf)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
for _, idx := range []int{0, 3, 15, 16} {
|
||||||
|
if !got.Has(idx) {
|
||||||
|
t.Errorf("BitSet missing index %d after round-trip", idx)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, idx := range []int{1, 2, 4, 14, 17} {
|
||||||
|
if got.Has(idx) {
|
||||||
|
t.Errorf("BitSet has unexpected index %d", idx)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+66
-52
@@ -5,9 +5,11 @@
|
|||||||
# bash workspace/run.sh
|
# bash workspace/run.sh
|
||||||
#
|
#
|
||||||
# Requirements:
|
# Requirements:
|
||||||
# • EPICS Base installed (EPICS_BASE set, or auto-detected below)
|
# • EPICS Base installed (softIoc binary available in PATH or EPICS_BASE)
|
||||||
# • softIoc binary available
|
# • uopi built (script builds it if missing)
|
||||||
# • uopi built with EPICS support (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
|
set -euo pipefail
|
||||||
|
|
||||||
@@ -15,32 +17,24 @@ PROJECT_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
|||||||
WORKSPACE="$PROJECT_ROOT/workspace"
|
WORKSPACE="$PROJECT_ROOT/workspace"
|
||||||
BINARY="$PROJECT_ROOT/dist/uopi"
|
BINARY="$PROJECT_ROOT/dist/uopi"
|
||||||
|
|
||||||
# ── Locate EPICS Base ─────────────────────────────────────────────────────────
|
# ── Locate EPICS tools ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
if [ -z "${EPICS_BASE:-}" ]; then
|
# If EPICS_BASE is set, add its bin directory to PATH automatically.
|
||||||
for candidate in \
|
if [ -n "${EPICS_BASE:-}" ]; then
|
||||||
/usr/lib/epics \
|
if [ -z "${EPICS_ARCH:-}" ]; then
|
||||||
/usr/local/epics \
|
EPICS_ARCH="$(ls "$EPICS_BASE/bin" 2>/dev/null | grep -i linux | head -1 || true)"
|
||||||
/opt/epics/base \
|
EPICS_ARCH="${EPICS_ARCH:-linux-x86_64}"
|
||||||
/opt/epics; do
|
fi
|
||||||
if [ -f "$candidate/include/cadef.h" ]; then
|
export PATH="$EPICS_BASE/bin/$EPICS_ARCH:$PATH"
|
||||||
export EPICS_BASE="$candidate"
|
echo "EPICS_BASE=$EPICS_BASE EPICS_ARCH=$EPICS_ARCH"
|
||||||
break
|
|
||||||
fi
|
|
||||||
done
|
|
||||||
fi
|
fi
|
||||||
|
|
||||||
if [ -z "${EPICS_BASE:-}" ]; then
|
if ! command -v softIoc &>/dev/null; then
|
||||||
echo "ERROR: EPICS_BASE is not set and could not be auto-detected."
|
echo "ERROR: softIoc not found in PATH."
|
||||||
echo " export EPICS_BASE=/path/to/epics/base"
|
echo " Set EPICS_BASE=/path/to/epics/base, or add softIoc to your PATH."
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
echo "softIoc: $(command -v softIoc)"
|
||||||
# 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
|
|
||||||
|
|
||||||
# ── Environment ───────────────────────────────────────────────────────────────
|
# ── Environment ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -48,24 +42,17 @@ fi
|
|||||||
export EPICS_CA_ADDR_LIST="127.0.0.1"
|
export EPICS_CA_ADDR_LIST="127.0.0.1"
|
||||||
export EPICS_CA_AUTO_ADDR_LIST="NO"
|
export EPICS_CA_AUTO_ADDR_LIST="NO"
|
||||||
|
|
||||||
# Ensure the EPICS shared libraries are found at runtime.
|
# Enable CA-level debug logging in uopi (set to "" to disable).
|
||||||
export LD_LIBRARY_PATH="$EPICS_BASE/lib/$EPICS_ARCH${LD_LIBRARY_PATH:+:$LD_LIBRARY_PATH}"
|
export UOPI_CA_DEBUG="${UOPI_CA_DEBUG:-1}"
|
||||||
|
|
||||||
# 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
|
|
||||||
|
|
||||||
# ── Build uopi ────────────────────────────────────────────────────────────────
|
# ── Build uopi ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
echo "Building uopi with EPICS support..."
|
echo "Building uopi + catools..."
|
||||||
cd "$PROJECT_ROOT"
|
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 ""
|
echo ""
|
||||||
|
|
||||||
# ── Cleanup handler ───────────────────────────────────────────────────────────
|
# ── Cleanup handler ───────────────────────────────────────────────────────────
|
||||||
@@ -85,39 +72,66 @@ trap cleanup EXIT INT TERM
|
|||||||
|
|
||||||
pkill -x softIoc 2>/dev/null || true
|
pkill -x softIoc 2>/dev/null || true
|
||||||
# Give the OS a moment to release the CA port (5064/tcp+udp).
|
# Give the OS a moment to release the CA port (5064/tcp+udp).
|
||||||
sleep 0.3
|
sleep 0.5
|
||||||
|
|
||||||
# ── Start softIOC ─────────────────────────────────────────────────────────────
|
# ── Start softIOC ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
echo "Starting softIOC..."
|
echo "Starting softIOC..."
|
||||||
|
echo " DB: $WORKSPACE/softioc/uopi_test.db"
|
||||||
|
echo " Log: $WORKSPACE/softioc.log"
|
||||||
softIoc -S -d "$WORKSPACE/softioc/uopi_test.db" \
|
softIoc -S -d "$WORKSPACE/softioc/uopi_test.db" \
|
||||||
>"$WORKSPACE/softioc.log" 2>&1 &
|
>"$WORKSPACE/softioc.log" 2>&1 &
|
||||||
IOC_PID=$!
|
IOC_PID=$!
|
||||||
PIDS+=("$IOC_PID")
|
PIDS+=("$IOC_PID")
|
||||||
|
|
||||||
# Wait for the IOC to accept Channel Access connections (up to 10 s).
|
# Wait for the IOC to start listening on TCP 5064 (up to 10 s).
|
||||||
echo -n "Waiting for IOC to be ready"
|
# 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
|
for i in $(seq 1 20); do
|
||||||
if caget -w 1 UOPI:TICK >/dev/null 2>&1; then
|
if nc -z 127.0.0.1 5064 2>/dev/null; then
|
||||||
echo " OK"
|
echo " OK (TCP 5064 open)"
|
||||||
|
IOC_READY=1
|
||||||
break
|
break
|
||||||
fi
|
fi
|
||||||
echo -n "."
|
echo -n "."
|
||||||
sleep 0.5
|
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
|
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 ────────────────────────────────────────────────────────────────
|
# ── Start uopi ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
echo ""
|
||||||
echo "Starting uopi (http://localhost:8080)..."
|
echo "Starting uopi (http://localhost:8080)..."
|
||||||
echo " Open the browser, switch to Edit mode, load workspace/data/epics_test.xml"
|
echo " UOPI_CA_DEBUG=$UOPI_CA_DEBUG"
|
||||||
echo " or select it from the interface list in View mode."
|
echo " Config: $WORKSPACE/uopi.toml"
|
||||||
echo " Press Ctrl-C to stop."
|
echo " Log will appear below. Press Ctrl-C to stop."
|
||||||
echo ""
|
echo ""
|
||||||
|
|
||||||
cd "$PROJECT_ROOT"
|
cd "$PROJECT_ROOT"
|
||||||
|
|||||||
Reference in New Issue
Block a user