Initial go port of epics

This commit is contained in:
Martino Ferrari
2026-04-28 00:09:22 +02:00
parent 47e481a461
commit 6e51ffc5e1
28 changed files with 5634 additions and 178 deletions
+421
View File
@@ -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
}
}
+397
View File
@@ -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")
}
}
+743
View File
@@ -0,0 +1,743 @@
package ca
import (
"context"
"fmt"
"io"
"net"
"sync"
"sync/atomic"
"time"
"github.com/uopi/goca/proto"
)
const (
writeQueueDepth = 256
echoInterval = 15 * time.Second
connectTimeout = 5 * time.Second
maxReconnDelay = 60 * time.Second
)
// -------------------------------------------------------------------------- //
// reply — async GET callback payload //
// -------------------------------------------------------------------------- //
// reply is delivered to a pending GET waiter via a buffered channel.
// If ok is false the circuit died before the reply arrived.
type reply struct {
hdr proto.Header
payload []byte
ok bool
}
// -------------------------------------------------------------------------- //
// monState — one active EVENT_ADD subscription //
// -------------------------------------------------------------------------- //
// monState survives reconnections; the circuit re-sends EVENT_ADD with the
// new SID each time a CREATE_CHAN reply is received.
type monState struct {
subID uint32
dbrType uint16
count uint32
ch chan<- proto.TimeValue // caller-owned, never closed here
}
// -------------------------------------------------------------------------- //
// chanState — one CA channel (persists across reconnections) //
// -------------------------------------------------------------------------- //
// chanState field invariants:
// - cid, pvName: immutable after construction.
// - sid, dbfType, count, access: valid only while readyC is closed.
// - readyC: closed when CREATE_CHAN reply received; replaced on reconnect.
// - monitors: append-only (except removeMonitor); never cleared on reconnect.
// - pending: ioid → GET reply channel; drained (channels closed) on reconnect.
type chanState struct {
cid uint32
pvName string
mu sync.RWMutex
sid uint32 // server-assigned channel ID (0 until CREATE_CHAN reply)
dbfType int // native DBF field type
count uint32 // element count
access uint32 // AccessRead | AccessWrite bitmask
readyC chan struct{} // closed once CREATE_CHAN reply is received
monitors []*monState // active subscriptions
pending map[uint32]chan reply // ioid → one-shot GET callback
}
func newChanState(cid uint32, pvName string) *chanState {
return &chanState{
cid: cid,
pvName: pvName,
readyC: make(chan struct{}),
pending: make(map[uint32]chan reply),
}
}
// resetForReconnect prepares cs for a new TCP connection.
// It closes the current readyC (waking any waitReady callers so they can
// re-wait on the fresh channel) and then replaces it.
// Must be called before the circuit sends CREATE_CHAN on the new conn.
func (cs *chanState) resetForReconnect() {
cs.mu.Lock()
cs.sid = 0
old := cs.readyC
cs.readyC = make(chan struct{})
// Drain pending GETs — they will receive zero reply (ok=false).
for id, ch := range cs.pending {
close(ch)
delete(cs.pending, id)
}
cs.mu.Unlock()
// Close the old readyC outside the lock to avoid deadlock with waitReady.
select {
case <-old:
// Already closed (CmdCreateFail path or double-reset guard).
default:
close(old)
}
}
// waitReady blocks until sid != 0 (CREATE_CHAN reply received for the current
// connection) or ctx expires. It loops through reconnections automatically:
// when resetForReconnect closes the current readyC the goroutine wakes, sees
// sid == 0, and waits on the freshly created channel.
func (cs *chanState) waitReady(ctx context.Context) error {
for {
cs.mu.RLock()
sid := cs.sid
ready := cs.readyC
cs.mu.RUnlock()
if sid != 0 {
return nil
}
select {
case <-ready:
// State changed — either CREATE_CHAN reply (sid set) or reconnect
// started (sid cleared, new readyC installed). Loop to check.
case <-ctx.Done():
return ctx.Err()
}
}
}
// -------------------------------------------------------------------------- //
// circuit — persistent TCP connection to one CA server //
// -------------------------------------------------------------------------- //
// circuit manages a persistent, auto-reconnecting TCP connection to a single
// CA server. All channels and monitors created on a circuit survive
// reconnections; the circuit re-creates them transparently.
//
// Goroutine model:
// - run() goroutine: reconnect loop; drives the write loop for each conn.
// - readLoop() goroutine: one per active conn; dispatches inbound messages.
//
// Lock ordering: circuit.mu → chanState.mu (never the reverse).
type circuit struct {
addr string
clientName string
hostName string
ctx context.Context
cancel context.CancelFunc
mu sync.RWMutex
channels []*chanState // append-only; survive reconnect
byCID map[uint32]*chanState // cid → chanState (fast lookup)
bySubID map[uint32]*monState // subID → monState (fast event dispatch)
writeQ chan []byte // serialised outbound message queue
seq atomic.Uint32 // shared ID sequence (cid, ioid, subID)
}
func newCircuit(ctx context.Context, addr, clientName, hostName string) *circuit {
cctx, cancel := context.WithCancel(ctx)
c := &circuit{
addr: addr,
clientName: clientName,
hostName: hostName,
ctx: cctx,
cancel: cancel,
byCID: make(map[uint32]*chanState),
bySubID: make(map[uint32]*monState),
writeQ: make(chan []byte, writeQueueDepth),
}
go c.run()
return c
}
func (c *circuit) close() { c.cancel() }
func (c *circuit) nextID() uint32 { return c.seq.Add(1) }
// -------------------------------------------------------------------------- //
// Reconnect loop //
// -------------------------------------------------------------------------- //
func (c *circuit) run() {
delay := time.Second
for {
if c.ctx.Err() != nil {
return
}
dbg("CA circuit connecting", "addr", c.addr)
conn, err := c.dial()
if err != nil {
dbg("CA circuit dial failed", "addr", c.addr, "err", err)
select {
case <-time.After(delay):
case <-c.ctx.Done():
return
}
delay = min(delay*2, maxReconnDelay)
continue
}
dbg("CA circuit connected", "addr", c.addr)
delay = time.Second // reset back-off on successful connect
// Snapshot channels and reset their per-connection state.
c.mu.Lock()
for _, cs := range c.channels {
cs.resetForReconnect()
}
chs := make([]*chanState, len(c.channels))
copy(chs, c.channels)
c.mu.Unlock()
// Drain stale messages from the write queue (they carry old SIDs).
for len(c.writeQ) > 0 {
<-c.writeQ
}
// Send VERSION + HOST_NAME + CLIENT_NAME.
if err := c.sendHandshake(conn); err != nil {
conn.Close()
continue
}
// Re-create all known channels on the new connection.
setupOK := true
for _, cs := range chs {
if _, err := conn.Write(buildCreateChanMsg(cs.cid, cs.pvName)); err != nil {
setupOK = false
break
}
}
if !setupOK {
conn.Close()
continue
}
dbg("CA handshake sent", "addr", c.addr, "channels", len(chs))
// Start read loop.
readDone := make(chan struct{})
go func() {
c.readLoop(conn)
dbg("CA read loop exited", "addr", c.addr)
close(readDone)
}()
// Write loop (run goroutine acts as the write pump).
c.writeLoop(conn, readDone)
conn.Close()
<-readDone // drain read goroutine before reconnecting
if c.ctx.Err() != nil {
return
}
select {
case <-time.After(delay):
case <-c.ctx.Done():
return
}
delay = min(delay*2, maxReconnDelay)
}
}
func (c *circuit) writeLoop(conn net.Conn, readDone <-chan struct{}) {
ticker := time.NewTicker(echoInterval)
defer ticker.Stop()
for {
select {
case msg := <-c.writeQ:
if _, err := conn.Write(msg); err != nil {
return
}
case <-ticker.C:
// Periodic heartbeat; server echoes it back.
hb := proto.BuildMessage(proto.Header{Command: proto.CmdEcho}, nil)
if _, err := conn.Write(hb); err != nil {
return
}
case <-readDone:
return
case <-c.ctx.Done():
return
}
}
}
// -------------------------------------------------------------------------- //
// Dial + handshake //
// -------------------------------------------------------------------------- //
func (c *circuit) dial() (net.Conn, error) {
d := net.Dialer{Timeout: connectTimeout}
conn, err := d.DialContext(c.ctx, "tcp4", c.addr)
if err != nil {
return nil, fmt.Errorf("ca: dial %s: %w", c.addr, err)
}
return conn, nil
}
func (c *circuit) sendHandshake(conn net.Conn) error {
ver := proto.BuildMessage(proto.Header{
Command: proto.CmdVersion,
DataCount: proto.MinorVersion,
}, nil)
host := proto.BuildMessage(
proto.Header{Command: proto.CmdHostName},
proto.BuildStringPayload(c.hostName),
)
client := proto.BuildMessage(
proto.Header{Command: proto.CmdClientName},
proto.BuildStringPayload(c.clientName),
)
out := make([]byte, 0, len(ver)+len(host)+len(client))
out = append(out, ver...)
out = append(out, host...)
out = append(out, client...)
_, err := conn.Write(out)
return err
}
// -------------------------------------------------------------------------- //
// Read loop + message dispatch //
// -------------------------------------------------------------------------- //
func (c *circuit) readLoop(conn net.Conn) {
for {
hdr, _, err := proto.DecodeHeader(conn)
if err != nil {
if c.ctx.Err() == nil {
dbg("CA read loop error (header)", "addr", c.addr, "err", err)
}
return
}
dbg("CA recv", "addr", c.addr, "cmd", hdr.Command, "dataType", hdr.DataType,
"dataCount", hdr.DataCount, "payloadSize", hdr.PayloadSize,
"p1", hdr.Parameter1, "p2", hdr.Parameter2)
var payload []byte
if hdr.PayloadSize > 0 {
payload = make([]byte, hdr.PayloadSize)
if _, err = io.ReadFull(conn, payload); err != nil {
if c.ctx.Err() == nil {
dbg("CA read loop error (payload)", "addr", c.addr,
"cmd", hdr.Command, "wantBytes", hdr.PayloadSize, "err", err)
}
return
}
}
c.dispatch(conn, hdr, payload)
}
}
// dispatch handles one inbound CA message.
func (c *circuit) dispatch(conn net.Conn, hdr proto.Header, payload []byte) {
switch hdr.Command {
case proto.CmdVersion:
// Server version negotiation reply — nothing to do.
case proto.CmdCreateChan:
// Parameter1 = cid (echoed), Parameter2 = SID assigned by server.
c.mu.RLock()
cs, ok := c.byCID[hdr.Parameter1]
c.mu.RUnlock()
if !ok {
return
}
cs.mu.Lock()
cs.sid = hdr.Parameter2
cs.dbfType = int(hdr.DataType)
cs.count = hdr.DataCount
ready := cs.readyC
// Snapshot monitors for EVENT_ADD re-subscription.
mons := make([]*monState, len(cs.monitors))
copy(mons, cs.monitors)
cs.mu.Unlock()
dbg("CA CREATE_CHAN reply", "pv", cs.pvName, "sid", hdr.Parameter2,
"dbfType", hdr.DataType, "count", hdr.DataCount, "monitors", len(mons))
// Re-subscribe all monitors with the new SID.
for _, ms := range mons {
msg := buildEventAddMsg(ms.subID, hdr.Parameter2, ms.dbrType, ms.count)
dbg("CA EVENT_ADD sent", "pv", cs.pvName, "subID", ms.subID,
"sid", hdr.Parameter2, "dbrType", ms.dbrType, "count", ms.count)
select {
case c.writeQ <- msg:
default:
}
}
close(ready) // unblock waitReady callers
case proto.CmdCreateFail:
// Server does not host this PV (or quota exceeded).
c.mu.RLock()
cs, ok := c.byCID[hdr.Parameter1]
c.mu.RUnlock()
if !ok {
return
}
dbg("CA CREATE_FAIL", "pv", cs.pvName, "cid", hdr.Parameter1)
cs.mu.RLock()
ready := cs.readyC
cs.mu.RUnlock()
// readyC is only closed once per connection cycle by this goroutine,
// so the select here is safe (no concurrent close).
select {
case <-ready:
// Already closed (shouldn't happen, but guard against it).
default:
close(ready)
}
case proto.CmdAccessRights:
// Parameter1 = cid, Parameter2 = access bitmask.
c.mu.RLock()
cs, ok := c.byCID[hdr.Parameter1]
c.mu.RUnlock()
if ok {
cs.mu.Lock()
cs.access = hdr.Parameter2
cs.mu.Unlock()
}
case proto.CmdEventAdd:
// Monitor update: Parameter1 = subscriptionID.
c.mu.RLock()
ms, ok := c.bySubID[hdr.Parameter1]
c.mu.RUnlock()
if !ok {
dbg("CA EVENT_ADD for unknown subID", "subID", hdr.Parameter1)
return
}
tv, ok := proto.DecodeTimeValue(hdr.DataType, hdr.DataCount, payload)
if !ok {
dbg("CA EVENT_ADD decode failed", "subID", hdr.Parameter1,
"dbrType", hdr.DataType, "count", hdr.DataCount, "payloadLen", len(payload))
return
}
dbg("CA EVENT_ADD value", "subID", hdr.Parameter1, "dbrType", hdr.DataType,
"double", tv.Double, "severity", tv.Severity)
select {
case ms.ch <- tv:
default: // drop if consumer is slow
}
case proto.CmdReadNotify:
// GET reply: Parameter1 = ioid.
ioid := hdr.Parameter1
c.mu.RLock()
var replyCh chan reply
var found bool
for _, cs := range c.channels {
cs.mu.Lock()
if ch, exists := cs.pending[ioid]; exists {
delete(cs.pending, ioid)
replyCh = ch
found = true
}
cs.mu.Unlock()
if found {
break
}
}
c.mu.RUnlock()
if found {
select {
case replyCh <- reply{hdr: hdr, payload: payload, ok: true}:
default:
}
}
case proto.CmdEcho:
// Server heartbeat — no response needed (client sends its own echoes).
case proto.CmdServerDisc:
// Server is shutting down — close conn to trigger reconnect.
conn.Close()
}
}
// -------------------------------------------------------------------------- //
// Message builders //
// -------------------------------------------------------------------------- //
func buildCreateChanMsg(cid uint32, pvName string) []byte {
return proto.BuildMessage(proto.Header{
Command: proto.CmdCreateChan,
Parameter1: cid,
Parameter2: proto.MinorVersion,
}, proto.BuildStringPayload(pvName))
}
func buildEventAddMsg(subID, sid uint32, dbrType uint16, count uint32) []byte {
return proto.BuildMessage(proto.Header{
Command: proto.CmdEventAdd,
DataType: dbrType,
DataCount: count,
Parameter1: sid, // m_cid = channel SID (per CA spec)
Parameter2: subID, // m_available = client subscription ID
}, proto.EncodeEventMask(proto.DBEDefault))
}
func buildEventCancelMsg(subID, sid uint32, dbrType uint16) []byte {
return proto.BuildMessage(proto.Header{
Command: proto.CmdEventCancel,
DataType: dbrType,
Parameter1: sid, // m_cid = channel SID
Parameter2: subID, // m_available = client subscription ID
}, nil)
}
// -------------------------------------------------------------------------- //
// Channel and monitor management //
// -------------------------------------------------------------------------- //
// getOrCreateChannel returns the chanState for pvName, creating it if needed.
// If the circuit is currently connected, CREATE_CHAN is queued immediately;
// otherwise the reconnect loop will send it when it next connects.
func (c *circuit) getOrCreateChannel(pvName string) *chanState {
c.mu.Lock()
defer c.mu.Unlock()
for _, cs := range c.channels {
if cs.pvName == pvName {
return cs
}
}
cid := c.nextID()
cs := newChanState(cid, pvName)
c.channels = append(c.channels, cs)
c.byCID[cid] = cs
// Best-effort: deliver CREATE_CHAN if circuit is up.
msg := buildCreateChanMsg(cid, pvName)
select {
case c.writeQ <- msg:
default:
}
return cs
}
// addMonitor registers ms on cs and queues EVENT_ADD if the channel is ready.
// If the channel is not yet ready the CREATE_CHAN reply handler will send
// EVENT_ADD when the connection is established.
func (c *circuit) addMonitor(cs *chanState, ms *monState) {
c.mu.Lock()
c.bySubID[ms.subID] = ms
c.mu.Unlock()
cs.mu.Lock()
cs.monitors = append(cs.monitors, ms)
sid := cs.sid
cs.mu.Unlock()
if sid != 0 {
dbg("CA EVENT_ADD sent (addMonitor)", "pv", cs.pvName, "subID", ms.subID,
"sid", sid, "dbrType", ms.dbrType, "count", ms.count)
msg := buildEventAddMsg(ms.subID, sid, ms.dbrType, ms.count)
select {
case c.writeQ <- msg:
default:
}
}
}
// removeMonitor unregisters a monitor and queues EVENT_CANCEL if connected.
func (c *circuit) removeMonitor(cs *chanState, subID uint32) {
c.mu.Lock()
delete(c.bySubID, subID)
c.mu.Unlock()
cs.mu.Lock()
var dbrType uint16
remaining := cs.monitors[:0:0] // fresh backing array
for _, ms := range cs.monitors {
if ms.subID == subID {
dbrType = ms.dbrType
} else {
remaining = append(remaining, ms)
}
}
cs.monitors = remaining
sid := cs.sid
cs.mu.Unlock()
if sid != 0 {
msg := buildEventCancelMsg(subID, sid, dbrType)
select {
case c.writeQ <- msg:
default:
}
}
}
// -------------------------------------------------------------------------- //
// Channel operations: get and put //
// -------------------------------------------------------------------------- //
// getRaw performs a READ_NOTIFY and returns the undecoded header + payload.
// Use this when you need to decode a type that get() does not handle
// (e.g. DBR_CTRL_* for metadata retrieval).
func (c *circuit) getRaw(ctx context.Context, cs *chanState, dbrType uint16, count uint32) (proto.Header, []byte, error) {
if err := cs.waitReady(ctx); err != nil {
return proto.Header{}, nil, fmt.Errorf("ca: %q: %w", cs.pvName, err)
}
cs.mu.RLock()
sid := cs.sid
cs.mu.RUnlock()
if sid == 0 {
return proto.Header{}, nil, fmt.Errorf("ca: %q: channel not connected", cs.pvName)
}
ioid := c.nextID()
replyCh := make(chan reply, 1)
cs.mu.Lock()
cs.pending[ioid] = replyCh
cs.mu.Unlock()
msg := proto.BuildMessage(proto.Header{
Command: proto.CmdReadNotify,
DataType: dbrType,
DataCount: count,
Parameter1: sid, // m_cid = channel SID (per CA spec)
Parameter2: ioid, // m_available = request IOId
}, nil)
select {
case c.writeQ <- msg:
case <-ctx.Done():
cs.mu.Lock()
delete(cs.pending, ioid)
cs.mu.Unlock()
return proto.Header{}, nil, ctx.Err()
}
select {
case r := <-replyCh:
if !r.ok {
return proto.Header{}, nil, fmt.Errorf("ca: %q: circuit disconnected", cs.pvName)
}
return r.hdr, r.payload, nil
case <-ctx.Done():
cs.mu.Lock()
delete(cs.pending, ioid)
cs.mu.Unlock()
return proto.Header{}, nil, ctx.Err()
}
}
// get performs a READ_NOTIFY (async GET) and returns the decoded value.
// It blocks until the reply arrives, the channel is not yet ready, or ctx expires.
func (c *circuit) get(ctx context.Context, cs *chanState, dbrType uint16, count uint32) (proto.TimeValue, error) {
if err := cs.waitReady(ctx); err != nil {
return proto.TimeValue{}, fmt.Errorf("ca: %q: %w", cs.pvName, err)
}
cs.mu.RLock()
sid := cs.sid
cs.mu.RUnlock()
if sid == 0 {
return proto.TimeValue{}, fmt.Errorf("ca: %q: channel not connected", cs.pvName)
}
ioid := c.nextID()
replyCh := make(chan reply, 1)
cs.mu.Lock()
cs.pending[ioid] = replyCh
cs.mu.Unlock()
msg := proto.BuildMessage(proto.Header{
Command: proto.CmdReadNotify,
DataType: dbrType,
DataCount: count,
Parameter1: sid, // m_cid = channel SID (per CA spec)
Parameter2: ioid, // m_available = request IOId
}, nil)
select {
case c.writeQ <- msg:
case <-ctx.Done():
cs.mu.Lock()
delete(cs.pending, ioid)
cs.mu.Unlock()
return proto.TimeValue{}, ctx.Err()
}
select {
case r := <-replyCh:
if !r.ok {
return proto.TimeValue{}, fmt.Errorf("ca: %q: circuit disconnected during GET", cs.pvName)
}
tv, ok := proto.DecodeTimeValue(r.hdr.DataType, r.hdr.DataCount, r.payload)
if !ok {
return proto.TimeValue{}, fmt.Errorf("ca: %q: failed to decode GET reply", cs.pvName)
}
return tv, nil
case <-ctx.Done():
cs.mu.Lock()
delete(cs.pending, ioid)
cs.mu.Unlock()
return proto.TimeValue{}, ctx.Err()
}
}
// put queues a CA_PROTO_WRITE (fire-and-forget put).
// It waits for the channel to be ready and access rights to be confirmed.
func (c *circuit) put(ctx context.Context, cs *chanState, dbrType uint16, payload []byte) error {
if err := cs.waitReady(ctx); err != nil {
return fmt.Errorf("ca: %q: %w", cs.pvName, err)
}
cs.mu.RLock()
sid := cs.sid
access := cs.access
cs.mu.RUnlock()
if sid == 0 {
return fmt.Errorf("ca: %q: channel not connected", cs.pvName)
}
if access&proto.AccessWrite == 0 {
return fmt.Errorf("ca: %q: read-only", cs.pvName)
}
// CA_PROTO_WRITE: DataType=dbrType, DataCount=1, Parameter1=SID, Parameter2=0 (no ioid).
msg := proto.BuildMessage(proto.Header{
Command: proto.CmdWrite,
DataType: dbrType,
DataCount: 1,
Parameter1: sid,
Parameter2: 0,
}, payload)
select {
case c.writeQ <- msg:
return nil
case <-ctx.Done():
return ctx.Err()
}
}
+26
View File
@@ -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...)
}
}
+65
View File
@@ -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
+3
View File
@@ -0,0 +1,3 @@
module github.com/uopi/goca
go 1.26
+441
View File
@@ -0,0 +1,441 @@
package proto
import (
"encoding/binary"
"math"
"strings"
"time"
)
// epicsEpochOffset is the number of Unix seconds between the Unix epoch
// (1970-01-01 00:00:00 UTC) and the EPICS epoch (1990-01-01 00:00:00 UTC).
const epicsEpochOffset = 631152000
// maxStringSize is the fixed size of a CA string value (MAX_STRING_SIZE).
const maxStringSize = 40
// maxEnumStates is the maximum number of enum strings (MAX_ENUM_STATES).
const maxEnumStates = 16
// maxEnumStringSize is the fixed size of each enum string (MAX_ENUM_STRING_SIZE).
const maxEnumStringSize = 26
// -------------------------------------------------------------------------- //
// Alarm types //
// -------------------------------------------------------------------------- //
// AlarmSeverity mirrors epicsAlarmSeverity.
type AlarmSeverity int
const (
SeverityNone AlarmSeverity = 0
SeverityMinor AlarmSeverity = 1
SeverityMajor AlarmSeverity = 2
SeverityInvalid AlarmSeverity = 3
)
func (s AlarmSeverity) String() string {
switch s {
case SeverityNone:
return "NO_ALARM"
case SeverityMinor:
return "MINOR"
case SeverityMajor:
return "MAJOR"
case SeverityInvalid:
return "INVALID"
default:
return "UNKNOWN"
}
}
// -------------------------------------------------------------------------- //
// TimeValue — decoded DBR_TIME_* payload //
// -------------------------------------------------------------------------- //
// TimeValue is the decoded form of any DBR_TIME_* payload.
// Exactly one of the value fields is populated based on the DBR type.
type TimeValue struct {
Timestamp time.Time
Status int16
Severity AlarmSeverity
// Value fields — only one is set.
Double float64
Float float32
Long int32
Short int16
Enum uint16
Char uint8
Str string
// Waveform values (when element count > 1).
Doubles []float64
}
// decodeTimestamp converts EPICS secPastEpoch+nsec to a Go time.Time.
func decodeTimestamp(secPastEpoch, nsec uint32) time.Time {
return time.Unix(int64(secPastEpoch)+epicsEpochOffset, int64(nsec)).UTC()
}
// caString extracts a null-terminated string from a fixed-size byte slice.
func caString(b []byte) string {
idx := strings.IndexByte(string(b), 0)
if idx < 0 {
return string(b)
}
return string(b[:idx])
}
// DBR_TIME_* wire layouts (all big-endian):
//
// Common header (12 bytes):
// [0:2] int16 status
// [2:4] int16 severity
// [4:8] uint32 secPastEpoch
// [8:12] uint32 nsec
//
// DBR_TIME_DOUBLE (type 25): 4-byte RISC pad at [12:16], float64 at [16:24]. Total=24.
// DBR_TIME_FLOAT (type 20): float32 at [12:16]. Total=16.
// DBR_TIME_LONG (type 22): int32 at [12:16]. Total=16.
// DBR_TIME_SHORT (type 19): int16 at [12:14], 2-byte pad [14:16]. Total=16.
// DBR_TIME_ENUM (type 23): uint16 at [12:14], 2-byte pad [14:16]. Total=16.
// DBR_TIME_CHAR (type 24): uint8 at [12:13], 3-byte pad [13:16]. Total=16.
// DBR_TIME_STRING (type 21): [40]byte at [12:52]. Total=52.
// DecodeTimeValue decodes a DBR_TIME_* payload.
// dbrType is one of the DBRTime* constants; payload is the full message payload
// (may include multiple elements for waveforms; count is the element count).
func DecodeTimeValue(dbrType uint16, count uint32, payload []byte) (TimeValue, bool) {
if len(payload) < 12 {
return TimeValue{}, false
}
status := int16(binary.BigEndian.Uint16(payload[0:]))
severity := AlarmSeverity(binary.BigEndian.Uint16(payload[2:]))
sec := binary.BigEndian.Uint32(payload[4:])
nsec := binary.BigEndian.Uint32(payload[8:])
tv := TimeValue{
Timestamp: decodeTimestamp(sec, nsec),
Status: status,
Severity: severity,
}
switch dbrType {
case DBRTimeDouble:
// 4-byte RISC pad at [12:16], value at [16:24]
if count > 1 {
if len(payload) < 16+int(count)*8 {
return TimeValue{}, false
}
vals := make([]float64, count)
for i := range vals {
bits := binary.BigEndian.Uint64(payload[16+i*8:])
vals[i] = math.Float64frombits(bits)
}
tv.Doubles = vals
tv.Double = vals[0]
} else {
if len(payload) < 24 {
return TimeValue{}, false
}
bits := binary.BigEndian.Uint64(payload[16:])
tv.Double = math.Float64frombits(bits)
}
case DBRTimeFloat:
if len(payload) < 16 {
return TimeValue{}, false
}
bits := binary.BigEndian.Uint32(payload[12:])
tv.Float = math.Float32frombits(bits)
tv.Double = float64(tv.Float)
case DBRTimeLong:
if len(payload) < 16 {
return TimeValue{}, false
}
tv.Long = int32(binary.BigEndian.Uint32(payload[12:]))
tv.Double = float64(tv.Long)
case DBRTimeShort:
if len(payload) < 16 {
return TimeValue{}, false
}
tv.Short = int16(binary.BigEndian.Uint16(payload[12:]))
tv.Double = float64(tv.Short)
case DBRTimeEnum:
if len(payload) < 16 {
return TimeValue{}, false
}
tv.Enum = binary.BigEndian.Uint16(payload[12:])
tv.Double = float64(tv.Enum)
case DBRTimeChar:
if len(payload) < 16 {
return TimeValue{}, false
}
tv.Char = payload[12]
tv.Double = float64(tv.Char)
case DBRTimeString:
if len(payload) < 12+maxStringSize {
return TimeValue{}, false
}
tv.Str = caString(payload[12 : 12+maxStringSize])
tv.Double = 0
default:
return TimeValue{}, false
}
return tv, true
}
// -------------------------------------------------------------------------- //
// CtrlDouble — decoded DBR_CTRL_DOUBLE payload (type 34) //
// -------------------------------------------------------------------------- //
//
// Wire layout (88 bytes total, all big-endian):
//
// [0:2] int16 status
// [2:4] int16 severity
// [4:6] int16 precision
// [6:8] uint16 RISC_pad0
// [8:16] [8]byte units
// [16:24] float64 upper_disp_limit
// [24:32] float64 lower_disp_limit
// [32:40] float64 upper_alarm_limit
// [40:48] float64 upper_warning_limit
// [48:56] float64 lower_warning_limit
// [56:64] float64 lower_alarm_limit
// [64:72] float64 upper_ctrl_limit
// [72:80] float64 lower_ctrl_limit
// [80:88] float64 value
// CtrlDouble is the decoded form of a DBR_CTRL_DOUBLE payload.
type CtrlDouble struct {
Status int16
Severity AlarmSeverity
Precision int16
Units string
UpperDispLimit float64
LowerDispLimit float64
UpperAlarmLimit float64
UpperWarnLimit float64
LowerWarnLimit float64
LowerAlarmLimit float64
UpperCtrlLimit float64
LowerCtrlLimit float64
Value float64
}
// DecodeCtrlDouble decodes a DBR_CTRL_DOUBLE payload (minimum 88 bytes).
func DecodeCtrlDouble(p []byte) (CtrlDouble, bool) {
if len(p) < 88 {
return CtrlDouble{}, false
}
f64 := func(off int) float64 {
return math.Float64frombits(binary.BigEndian.Uint64(p[off:]))
}
return CtrlDouble{
Status: int16(binary.BigEndian.Uint16(p[0:])),
Severity: AlarmSeverity(binary.BigEndian.Uint16(p[2:])),
Precision: int16(binary.BigEndian.Uint16(p[4:])),
Units: caString(p[8:16]),
UpperDispLimit: f64(16),
LowerDispLimit: f64(24),
UpperAlarmLimit: f64(32),
UpperWarnLimit: f64(40),
LowerWarnLimit: f64(48),
LowerAlarmLimit: f64(56),
UpperCtrlLimit: f64(64),
LowerCtrlLimit: f64(72),
Value: f64(80),
}, true
}
// -------------------------------------------------------------------------- //
// CtrlLong — decoded DBR_CTRL_LONG payload (type 33) //
// -------------------------------------------------------------------------- //
//
// Wire layout (48 bytes total):
//
// [0:2] int16 status
// [2:4] int16 severity
// [4:12] [8]byte units
// [12:16] int32 upper_disp_limit
// [16:20] int32 lower_disp_limit
// [20:24] int32 upper_alarm_limit
// [24:28] int32 upper_warning_limit
// [28:32] int32 lower_warning_limit
// [32:36] int32 lower_alarm_limit
// [36:40] int32 upper_ctrl_limit
// [40:44] int32 lower_ctrl_limit
// [44:48] int32 value
// CtrlLong is the decoded form of a DBR_CTRL_LONG payload.
type CtrlLong struct {
Status int16
Severity AlarmSeverity
Units string
UpperDispLimit int32
LowerDispLimit int32
UpperAlarmLimit int32
UpperWarnLimit int32
LowerWarnLimit int32
LowerAlarmLimit int32
UpperCtrlLimit int32
LowerCtrlLimit int32
Value int32
}
// DecodeCtrlLong decodes a DBR_CTRL_LONG payload (minimum 48 bytes).
func DecodeCtrlLong(p []byte) (CtrlLong, bool) {
if len(p) < 48 {
return CtrlLong{}, false
}
i32 := func(off int) int32 {
return int32(binary.BigEndian.Uint32(p[off:]))
}
return CtrlLong{
Status: int16(binary.BigEndian.Uint16(p[0:])),
Severity: AlarmSeverity(binary.BigEndian.Uint16(p[2:])),
Units: caString(p[4:12]),
UpperDispLimit: i32(12),
LowerDispLimit: i32(16),
UpperAlarmLimit: i32(20),
UpperWarnLimit: i32(24),
LowerWarnLimit: i32(28),
LowerAlarmLimit: i32(32),
UpperCtrlLimit: i32(36),
LowerCtrlLimit: i32(40),
Value: i32(44),
}, true
}
// -------------------------------------------------------------------------- //
// CtrlEnum — decoded DBR_CTRL_ENUM payload (type 31) //
// -------------------------------------------------------------------------- //
//
// Wire layout (424 bytes total):
//
// [0:2] int16 status
// [2:4] int16 severity
// [4:6] int16 no_str (number of valid enum strings, max 16)
// [6:422] [16][26]byte strs (enum string table)
// [422:424] uint16 value
// CtrlEnum is the decoded form of a DBR_CTRL_ENUM payload.
type CtrlEnum struct {
Status int16
Severity AlarmSeverity
Strings []string // len == no_str
Value uint16
}
const ctrlEnumSize = 2 + 2 + 2 + maxEnumStates*maxEnumStringSize + 2 // 424
// DecodeCtrlEnum decodes a DBR_CTRL_ENUM payload (minimum 424 bytes).
func DecodeCtrlEnum(p []byte) (CtrlEnum, bool) {
if len(p) < ctrlEnumSize {
return CtrlEnum{}, false
}
noStr := int(binary.BigEndian.Uint16(p[4:]))
noStr = min(noStr, maxEnumStates)
strs := make([]string, noStr)
for i := range strs {
off := 6 + i*maxEnumStringSize
strs[i] = caString(p[off : off+maxEnumStringSize])
}
return CtrlEnum{
Status: int16(binary.BigEndian.Uint16(p[0:])),
Severity: AlarmSeverity(binary.BigEndian.Uint16(p[2:])),
Strings: strs,
Value: binary.BigEndian.Uint16(p[422:]),
}, true
}
// -------------------------------------------------------------------------- //
// CtrlString — decoded DBR_CTRL_STRING payload (type 28) //
// -------------------------------------------------------------------------- //
//
// Wire layout (44 bytes):
//
// [0:2] int16 status
// [2:4] int16 severity
// [4:44] [40]byte value
// CtrlString is the decoded form of a DBR_CTRL_STRING payload.
type CtrlString struct {
Status int16
Severity AlarmSeverity
Value string
}
// DecodeCtrlString decodes a DBR_CTRL_STRING payload (minimum 44 bytes).
func DecodeCtrlString(p []byte) (CtrlString, bool) {
if len(p) < 4+maxStringSize {
return CtrlString{}, false
}
return CtrlString{
Status: int16(binary.BigEndian.Uint16(p[0:])),
Severity: AlarmSeverity(binary.BigEndian.Uint16(p[2:])),
Value: caString(p[4 : 4+maxStringSize]),
}, true
}
// -------------------------------------------------------------------------- //
// Put payload encoders //
// -------------------------------------------------------------------------- //
// EncodeDouble encodes a float64 value as a big-endian DBR_DOUBLE payload
// padded to 8 bytes.
func EncodeDouble(v float64) []byte {
b := make([]byte, 8)
binary.BigEndian.PutUint64(b, math.Float64bits(v))
return b
}
// EncodeLong encodes an int32 value as a big-endian DBR_LONG payload
// padded to 8 bytes.
func EncodeLong(v int32) []byte {
b := make([]byte, 8) // 4 bytes value + 4 bytes pad
binary.BigEndian.PutUint32(b, uint32(v))
return b
}
// EncodeShort encodes an int16 value as a big-endian DBR_SHORT payload
// padded to 8 bytes.
func EncodeShort(v int16) []byte {
b := make([]byte, 8)
binary.BigEndian.PutUint16(b, uint16(v))
return b
}
// EncodeString encodes a string as a null-terminated, 8-byte padded
// DBR_STRING payload (capped at maxStringSize characters).
func EncodeString(v string) []byte {
if len(v) >= maxStringSize {
v = v[:maxStringSize-1]
}
b := make([]byte, maxStringSize)
copy(b, v)
return PadBytes(b)
}
// EncodeEventMask builds the 16-byte payload for a CA_PROTO_EVENT_ADD request.
// mask is typically DBEDefault (DBEValue | DBEAlarm).
//
// Wire layout (16 bytes, all big-endian):
//
// [0:4] float32 m_lval (not used, zero)
// [4:8] float32 p_delta (delta trigger, zero = disabled)
// [8:12] float32 p_final (final trigger, zero = disabled)
// [12:14] int16 p_count (element count, zero = use PV's count)
// [14:16] int16 m_mask (DBE_VALUE | DBE_ALARM | ...)
func EncodeEventMask(mask uint16) []byte {
b := make([]byte, 16)
binary.BigEndian.PutUint16(b[14:], mask) // m_mask is at offset 14, not 12
return b
}
+108
View File
@@ -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)
}
+139
View File
@@ -0,0 +1,139 @@
// Package proto contains the low-level Channel Access wire-protocol constants,
// header codec, and DBR type decode functions. All types are big-endian as
// required by the CA specification.
package proto
// CA command opcodes (uint16, first field of every message header).
const (
CmdVersion = 0 // version negotiation (first message on every TCP connection)
CmdEventAdd = 1 // subscribe to value updates / server event push
CmdEventCancel = 2 // cancel subscription
CmdWrite = 4 // put value (fire-and-forget, no server reply)
CmdSearch = 6 // UDP: locate IOC hosting a PV
CmdNotFound = 14 // UDP: IOC does not host the requested PV
CmdReadNotify = 15 // get value with callback (async GET)
CmdCreateChan = 18 // create channel on TCP circuit
CmdWriteNotify = 19 // put value with acknowledgement
CmdClientName = 20 // announce client username (sent after VERSION)
CmdHostName = 21 // announce client hostname (sent after VERSION)
CmdAccessRights = 22 // server → client: access rights bitmask
CmdEcho = 23 // heartbeat ping/pong
CmdCreateFail = 26 // server → client: CREATE_CHAN failed
CmdServerDisc = 27 // server → client: server shutting down
)
// CA minor protocol revision announced during the VERSION handshake.
// Corresponds to CA 4.13, supported by EPICS 3.14+ and all EPICS 7 servers.
const MinorVersion = 13
// Default CA port (TCP and UDP).
const DefaultPort = 5064
// Search reply data_type values.
const (
SearchReply = 10 // DO_REPLY: ask server to respond
SearchNoReply = 5 // DONT_REPLY: suppress response (used by repeater)
)
// AccessRights bitmask values (parameter2 of CmdAccessRights message).
const (
AccessRead = 1 << 0
AccessWrite = 1 << 1
)
// DBE event mask bits used in the 16-byte EVENT_ADD payload.
const (
DBEValue = 0x01 // trigger on any value change
DBEAlarm = 0x04 // trigger on alarm state change
DBEDefault = DBEValue | DBEAlarm
)
// ---- DBF field types (native IOC field type, reported in CREATE_CHAN reply) ----
const (
DBFString = 0
DBFShort = 1 // also DBFInt
DBFFloat = 2
DBFEnum = 3
DBFChar = 4
DBFLong = 5
DBFDouble = 6
)
// ---- DBR request types ----
// Plain value types (used in WRITE).
const (
DBRString = 0
DBRShort = 1
DBRFloat = 2
DBREnum = 3
DBRChar = 4
DBRLong = 5
DBRDouble = 6
)
// DBR_TIME_* types (value + timestamp + alarm status; used in EVENT_ADD).
const (
DBRTimeString = 21
DBRTimeShort = 19
DBRTimeFloat = 20
DBRTimeEnum = 23
DBRTimeChar = 24
DBRTimeLong = 22
DBRTimeDouble = 25
)
// DBR_CTRL_* types (full control info: units, limits, enum strings; used in READ_NOTIFY).
const (
DBRCtrlString = 28
DBRCtrlShort = 29
DBRCtrlFloat = 30
DBRCtrlEnum = 31
DBRCtrlChar = 32
DBRCtrlLong = 33
DBRCtrlDouble = 34
)
// NativeTimeType maps a DBF field type to the corresponding DBR_TIME_* type
// that should be used for monitor subscriptions.
func NativeTimeType(dbfType int, elementCount int) uint16 {
switch dbfType {
case DBFDouble:
return DBRTimeDouble
case DBFFloat:
return DBRTimeFloat
case DBFLong:
return DBRTimeLong
case DBFShort:
return DBRTimeShort
case DBFEnum:
return DBRTimeEnum
case DBFString:
return DBRTimeString
case DBFChar:
if elementCount > 1 {
return DBRTimeString // waveform of chars treated as string
}
return DBRTimeChar
default:
return DBRTimeDouble
}
}
// NativeCtrlType maps a DBF field type to the corresponding DBR_CTRL_* type
// that should be used for metadata gets.
func NativeCtrlType(dbfType int) uint16 {
switch dbfType {
case DBFDouble, DBFFloat:
return DBRCtrlDouble
case DBFLong, DBFShort, DBFChar:
return DBRCtrlLong
case DBFEnum:
return DBRCtrlEnum
case DBFString:
return DBRCtrlString
default:
return DBRCtrlDouble
}
}
+387
View File
@@ -0,0 +1,387 @@
package proto_test
import (
"bytes"
"encoding/binary"
"math"
"testing"
"time"
"github.com/uopi/goca/proto"
)
// -------------------------------------------------------------------------- //
// Header round-trip //
// -------------------------------------------------------------------------- //
func TestHeaderRoundTrip(t *testing.T) {
h := proto.Header{
Command: proto.CmdCreateChan,
PayloadSize: 16,
DataType: proto.DBFDouble,
DataCount: 1,
Parameter1: 0xDEAD,
Parameter2: 0xBEEF,
}
wire := h.Bytes()
if len(wire) != proto.HeaderSize {
t.Fatalf("Bytes() len = %d, want %d", len(wire), proto.HeaderSize)
}
got, n, err := proto.DecodeHeader(bytes.NewReader(wire))
if err != nil {
t.Fatalf("DecodeHeader: %v", err)
}
if n != proto.HeaderSize {
t.Errorf("bytes consumed = %d, want %d", n, proto.HeaderSize)
}
if got.Command != h.Command {
t.Errorf("Command = %d, want %d", got.Command, h.Command)
}
if got.DataType != h.DataType {
t.Errorf("DataType = %d, want %d", got.DataType, h.DataType)
}
if got.Parameter1 != h.Parameter1 {
t.Errorf("Parameter1 = %d, want %d", got.Parameter1, h.Parameter1)
}
if got.Parameter2 != h.Parameter2 {
t.Errorf("Parameter2 = %d, want %d", got.Parameter2, h.Parameter2)
}
}
func TestBuildMessage(t *testing.T) {
payload := []byte("hello\x00\x00\x00") // 8 bytes (padded)
h := proto.Header{Command: proto.CmdHostName, PayloadSize: 0}
msg := proto.BuildMessage(h, payload)
if len(msg) != proto.HeaderSize+len(payload) {
t.Fatalf("len(msg) = %d, want %d", len(msg), proto.HeaderSize+len(payload))
}
// PayloadSize in wire should equal len(payload).
wireSize := binary.BigEndian.Uint16(msg[2:4])
if int(wireSize) != len(payload) {
t.Errorf("wire PayloadSize = %d, want %d", wireSize, len(payload))
}
}
func TestPadTo8(t *testing.T) {
cases := [][2]int{{0, 0}, {1, 8}, {7, 8}, {8, 8}, {9, 16}, {16, 16}, {17, 24}}
for _, c := range cases {
if got := proto.PadTo8(c[0]); got != c[1] {
t.Errorf("PadTo8(%d) = %d, want %d", c[0], got, c[1])
}
}
}
func TestBuildStringPayload(t *testing.T) {
p := proto.BuildStringPayload("TEST")
if len(p)%8 != 0 {
t.Errorf("payload length %d not padded to 8", len(p))
}
if p[4] != 0 {
t.Errorf("expected null terminator at index 4, got %d", p[4])
}
}
// -------------------------------------------------------------------------- //
// DBR_TIME_* decoding //
// -------------------------------------------------------------------------- //
// buildTimeHeader builds the 12-byte common DBR_TIME header.
func buildTimeHeader(status, severity int16, sec, nsec uint32) []byte {
b := make([]byte, 12)
binary.BigEndian.PutUint16(b[0:], uint16(status))
binary.BigEndian.PutUint16(b[2:], uint16(severity))
binary.BigEndian.PutUint32(b[4:], sec)
binary.BigEndian.PutUint32(b[8:], nsec)
return b
}
func TestDecodeTimeDouble(t *testing.T) {
const sec = 1_000_000
const nsec = 500_000_000
const want = 3.14159
hdr := buildTimeHeader(0, 0, sec, nsec)
pad := make([]byte, 4) // RISC pad
val := make([]byte, 8)
binary.BigEndian.PutUint64(val, math.Float64bits(want))
payload := append(append(hdr, pad...), val...)
tv, ok := proto.DecodeTimeValue(proto.DBRTimeDouble, 1, payload)
if !ok {
t.Fatal("DecodeTimeValue returned false")
}
if math.Abs(tv.Double-want) > 1e-10 {
t.Errorf("Double = %g, want %g", tv.Double, want)
}
// EPICS epoch offset: 1990-01-01 00:00:00 UTC = Unix 631152000.
wantUnix := int64(sec) + 631152000
if tv.Timestamp.Unix() != wantUnix {
t.Errorf("Timestamp.Unix() = %d, want %d", tv.Timestamp.Unix(), wantUnix)
}
if tv.Timestamp.Nanosecond() != int(nsec) {
t.Errorf("Timestamp.Nanosecond() = %d, want %d", tv.Timestamp.Nanosecond(), nsec)
}
}
func TestDecodeTimeLong(t *testing.T) {
hdr := buildTimeHeader(0, 1, 0, 0)
val := make([]byte, 4)
binary.BigEndian.PutUint32(val, 0xFFFFFFD6) // -42 as two's complement
payload := append(hdr, val...)
tv, ok := proto.DecodeTimeValue(proto.DBRTimeLong, 1, payload)
if !ok {
t.Fatal("DecodeTimeValue returned false")
}
if tv.Long != -42 {
t.Errorf("Long = %d, want -42", tv.Long)
}
if tv.Severity != proto.SeverityMinor {
t.Errorf("Severity = %v, want Minor", tv.Severity)
}
}
func TestDecodeTimeString(t *testing.T) {
hdr := buildTimeHeader(0, 0, 0, 0)
str := make([]byte, 40)
copy(str, "hello")
payload := append(hdr, str...)
tv, ok := proto.DecodeTimeValue(proto.DBRTimeString, 1, payload)
if !ok {
t.Fatal("DecodeTimeValue returned false")
}
if tv.Str != "hello" {
t.Errorf("Str = %q, want %q", tv.Str, "hello")
}
}
func TestDecodeTimeEnum(t *testing.T) {
hdr := buildTimeHeader(0, 0, 0, 0)
val := []byte{0x00, 0x03, 0x00, 0x00} // enum=3 + 2-byte pad
payload := append(hdr, val...)
tv, ok := proto.DecodeTimeValue(proto.DBRTimeEnum, 1, payload)
if !ok {
t.Fatal("DecodeTimeValue returned false")
}
if tv.Enum != 3 {
t.Errorf("Enum = %d, want 3", tv.Enum)
}
}
func TestDecodeTimeWaveform(t *testing.T) {
hdr := buildTimeHeader(0, 0, 0, 0)
pad := make([]byte, 4)
vals := []float64{1.0, 2.5, -0.5}
valbytes := make([]byte, len(vals)*8)
for i, v := range vals {
binary.BigEndian.PutUint64(valbytes[i*8:], math.Float64bits(v))
}
payload := append(append(hdr, pad...), valbytes...)
tv, ok := proto.DecodeTimeValue(proto.DBRTimeDouble, 3, payload)
if !ok {
t.Fatal("DecodeTimeValue returned false")
}
if len(tv.Doubles) != 3 {
t.Fatalf("len(Doubles) = %d, want 3", len(tv.Doubles))
}
for i, want := range vals {
if math.Abs(tv.Doubles[i]-want) > 1e-12 {
t.Errorf("Doubles[%d] = %g, want %g", i, tv.Doubles[i], want)
}
}
}
func TestDecodeTimeTooShort(t *testing.T) {
_, ok := proto.DecodeTimeValue(proto.DBRTimeDouble, 1, []byte{0, 1, 2})
if ok {
t.Error("expected false for short payload")
}
}
// -------------------------------------------------------------------------- //
// DBR_CTRL_DOUBLE decoding //
// -------------------------------------------------------------------------- //
func TestDecodeCtrlDouble(t *testing.T) {
p := make([]byte, 88)
// status=0, severity=0, precision=3, pad=0, units="mA\0..."
binary.BigEndian.PutUint16(p[4:], 3) // precision
copy(p[8:], "mA")
f64 := func(off int, v float64) {
binary.BigEndian.PutUint64(p[off:], math.Float64bits(v))
}
f64(16, 100.0) // upper_disp
f64(24, 0.0) // lower_disp
f64(32, 90.0) // upper_alarm
f64(40, 80.0) // upper_warn
f64(48, 20.0) // lower_warn
f64(56, 10.0) // lower_alarm
f64(64, 95.0) // upper_ctrl
f64(72, 5.0) // lower_ctrl
f64(80, 55.5) // value
cd, ok := proto.DecodeCtrlDouble(p)
if !ok {
t.Fatal("DecodeCtrlDouble returned false")
}
if cd.Units != "mA" {
t.Errorf("Units = %q, want %q", cd.Units, "mA")
}
if cd.Precision != 3 {
t.Errorf("Precision = %d, want 3", cd.Precision)
}
if math.Abs(cd.Value-55.5) > 1e-10 {
t.Errorf("Value = %g, want 55.5", cd.Value)
}
if math.Abs(cd.UpperDispLimit-100.0) > 1e-10 {
t.Errorf("UpperDispLimit = %g, want 100.0", cd.UpperDispLimit)
}
}
func TestDecodeCtrlEnum(t *testing.T) {
p := make([]byte, 424)
binary.BigEndian.PutUint16(p[4:], 3) // no_str = 3
strs := []string{"OFF", "ON", "FAULT"}
for i, s := range strs {
copy(p[6+i*26:], s)
}
binary.BigEndian.PutUint16(p[422:], 1) // value = 1
ce, ok := proto.DecodeCtrlEnum(p)
if !ok {
t.Fatal("DecodeCtrlEnum returned false")
}
if len(ce.Strings) != 3 {
t.Fatalf("len(Strings) = %d, want 3", len(ce.Strings))
}
if ce.Strings[2] != "FAULT" {
t.Errorf("Strings[2] = %q, want FAULT", ce.Strings[2])
}
if ce.Value != 1 {
t.Errorf("Value = %d, want 1", ce.Value)
}
}
// -------------------------------------------------------------------------- //
// Put payload encoders //
// -------------------------------------------------------------------------- //
func TestEncodeDouble(t *testing.T) {
b := proto.EncodeDouble(3.14)
if len(b) != 8 {
t.Fatalf("len = %d, want 8", len(b))
}
got := math.Float64frombits(binary.BigEndian.Uint64(b))
if math.Abs(got-3.14) > 1e-15 {
t.Errorf("decoded = %g, want 3.14", got)
}
}
func TestEncodeLong(t *testing.T) {
b := proto.EncodeLong(-1)
if len(b) != 8 {
t.Fatalf("len = %d, want 8", len(b))
}
got := int32(binary.BigEndian.Uint32(b))
if got != -1 {
t.Errorf("decoded = %d, want -1", got)
}
}
func TestEncodeString(t *testing.T) {
b := proto.EncodeString("hello")
if len(b)%8 != 0 {
t.Errorf("len %d not padded to 8", len(b))
}
if string(b[:5]) != "hello" {
t.Errorf("content = %q, want %q", b[:5], "hello")
}
}
func TestEncodeEventMask(t *testing.T) {
b := proto.EncodeEventMask(proto.DBEDefault)
if len(b) != 16 {
t.Fatalf("len = %d, want 16", len(b))
}
// m_mask is at offset 14 (after m_lval[0:4], p_delta[4:8], p_final[8:12], p_count[12:14]).
mask := binary.BigEndian.Uint16(b[14:])
if mask != proto.DBEDefault {
t.Errorf("mask = 0x%02X, want 0x%02X", mask, proto.DBEDefault)
}
// p_count at [12:14] must be zero.
if pc := binary.BigEndian.Uint16(b[12:]); pc != 0 {
t.Errorf("p_count = %d, want 0", pc)
}
}
// -------------------------------------------------------------------------- //
// NativeTimeType / NativeCtrlType //
// -------------------------------------------------------------------------- //
func TestNativeTimeType(t *testing.T) {
cases := []struct {
dbf int
n int
want uint16
}{
{proto.DBFDouble, 1, proto.DBRTimeDouble},
{proto.DBFFloat, 1, proto.DBRTimeFloat},
{proto.DBFLong, 1, proto.DBRTimeLong},
{proto.DBFShort, 1, proto.DBRTimeShort},
{proto.DBFEnum, 1, proto.DBRTimeEnum},
{proto.DBFString, 1, proto.DBRTimeString},
{proto.DBFChar, 1, proto.DBRTimeChar},
{proto.DBFChar, 10, proto.DBRTimeString}, // char waveform → string
}
for _, c := range cases {
got := proto.NativeTimeType(c.dbf, c.n)
if got != c.want {
t.Errorf("NativeTimeType(%d,%d) = %d, want %d", c.dbf, c.n, got, c.want)
}
}
}
// -------------------------------------------------------------------------- //
// AlarmSeverity.String //
// -------------------------------------------------------------------------- //
func TestAlarmSeverityString(t *testing.T) {
cases := []struct {
s proto.AlarmSeverity
want string
}{
{proto.SeverityNone, "NO_ALARM"},
{proto.SeverityMinor, "MINOR"},
{proto.SeverityMajor, "MAJOR"},
{proto.SeverityInvalid, "INVALID"},
}
for _, c := range cases {
if got := c.s.String(); got != c.want {
t.Errorf("Severity(%d).String() = %q, want %q", c.s, got, c.want)
}
}
}
// -------------------------------------------------------------------------- //
// Timestamp sanity check //
// -------------------------------------------------------------------------- //
func TestEPICSEpoch(t *testing.T) {
// secPastEpoch=0 should decode to 1990-01-01 00:00:00 UTC.
hdr := buildTimeHeader(0, 0, 0, 0)
val := make([]byte, 8)
payload := append(append(hdr, make([]byte, 4)...), val...)
tv, ok := proto.DecodeTimeValue(proto.DBRTimeDouble, 1, payload)
if !ok {
t.Fatal("DecodeTimeValue returned false")
}
want := time.Date(1990, 1, 1, 0, 0, 0, 0, time.UTC)
if !tv.Timestamp.Equal(want) {
t.Errorf("timestamp = %v, want %v", tv.Timestamp, want)
}
}
+395
View File
@@ -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)
}
+137
View File
@@ -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")
}
}
+601
View File
@@ -0,0 +1,601 @@
// Package testca provides an in-process fake CA server for use in tests.
// It supports a small, fixed set of PVs and is compatible with the goca Client.
package testca
import (
"encoding/binary"
"fmt"
"io"
"math"
"net"
"sync"
"time"
"github.com/uopi/goca/proto"
)
// PVSpec describes one test PV hosted by the fake server.
type PVSpec struct {
Name string
DBFType int // proto.DBF* constant
Count uint32 // element count (1 for scalars)
Value any // initial value
Access uint32 // proto.AccessRead | proto.AccessWrite
}
// Server is an in-process fake CA server. It listens on a random TCP port and
// an ephemeral UDP port. Use Addr() to get the addresses for client config.
type Server struct {
tcpLn net.Listener
udpCn *net.UDPConn
mu sync.RWMutex
pvs map[string]*serverPV
subs map[uint32]*serverSub // subID → sub
done chan struct{}
}
type serverPV struct {
spec PVSpec
mu sync.RWMutex
val any
}
type serverSub struct {
pvName string
subID uint32
cid uint32
sid uint32
conn net.Conn
dbrType uint16
count uint32
}
// New creates and starts a fake CA server hosting the given PVs.
func New(pvs []PVSpec) (*Server, error) {
tcpLn, err := net.Listen("tcp4", "127.0.0.1:0")
if err != nil {
return nil, fmt.Errorf("testca: tcp listen: %w", err)
}
udpCn, err := net.ListenUDP("udp4", &net.UDPAddr{IP: net.ParseIP("127.0.0.1")})
if err != nil {
tcpLn.Close()
return nil, fmt.Errorf("testca: udp listen: %w", err)
}
s := &Server{
tcpLn: tcpLn,
udpCn: udpCn,
pvs: make(map[string]*serverPV, len(pvs)),
subs: make(map[uint32]*serverSub),
done: make(chan struct{}),
}
for _, spec := range pvs {
s.pvs[spec.Name] = &serverPV{spec: spec, val: spec.Value}
}
go s.acceptLoop()
go s.udpLoop()
return s, nil
}
// TCPAddr returns the TCP "host:port" address clients should connect to.
func (s *Server) TCPAddr() string { return s.tcpLn.Addr().String() }
// UDPAddr returns the UDP "host:port" address clients should search against.
func (s *Server) UDPAddr() string { return s.udpCn.LocalAddr().String() }
// Close shuts down the server.
func (s *Server) Close() {
close(s.done)
s.tcpLn.Close()
s.udpCn.Close()
}
// SetValue updates a PV's value and pushes a monitor event to all subscribers.
func (s *Server) SetValue(pvName string, val any) error {
s.mu.RLock()
pv, ok := s.pvs[pvName]
s.mu.RUnlock()
if !ok {
return fmt.Errorf("testca: unknown PV %q", pvName)
}
pv.mu.Lock()
pv.val = val
pv.mu.Unlock()
// Push to all subscribers of this PV.
s.mu.RLock()
for _, sub := range s.subs {
if sub.pvName == pvName {
if msg := s.buildEventMsg(sub, pv); msg != nil {
_, _ = sub.conn.Write(msg)
}
}
}
s.mu.RUnlock()
return nil
}
// -------------------------------------------------------------------------- //
// UDP search loop //
// -------------------------------------------------------------------------- //
func (s *Server) udpLoop() {
buf := make([]byte, 65536)
for {
_ = s.udpCn.SetReadDeadline(time.Now().Add(200 * time.Millisecond))
n, src, err := s.udpCn.ReadFromUDP(buf)
if err != nil {
select {
case <-s.done:
return
default:
continue
}
}
s.handleUDP(buf[:n], src)
}
}
func (s *Server) handleUDP(data []byte, src *net.UDPAddr) {
for len(data) >= proto.HeaderSize {
hdr, n, err := proto.DecodeHeader(newBytesReader(data))
if err != nil {
return
}
payEnd := n + int(hdr.PayloadSize)
if payEnd > len(data) {
return
}
payload := data[n:payEnd]
data = data[payEnd:]
if hdr.Command != proto.CmdSearch {
continue
}
// Extract PV name from payload (null-terminated).
pvName := nullStr(payload)
s.mu.RLock()
_, ok := s.pvs[pvName]
s.mu.RUnlock()
if !ok {
continue
}
// Reply: data_type = TCP port, parameter2 = searchID.
tcpPort := s.tcpLn.Addr().(*net.TCPAddr).Port
// Use 0xFFFFFFFF IP to tell client to use sender's IP.
reply := buildSearchReply(hdr.Parameter2, tcpPort)
_, _ = s.udpCn.WriteToUDP(reply, src)
}
}
func buildSearchReply(searchID uint32, port int) []byte {
payload := []byte{0xFF, 0xFF, 0xFF, 0xFF, 0x00, 0x00, 0x00, 0x00}
h := proto.Header{
Command: proto.CmdSearch,
DataType: uint16(port),
Parameter1: proto.MinorVersion,
Parameter2: searchID,
}
return proto.BuildMessage(h, payload)
}
// -------------------------------------------------------------------------- //
// TCP accept + per-connection handler //
// -------------------------------------------------------------------------- //
func (s *Server) acceptLoop() {
for {
conn, err := s.tcpLn.Accept()
if err != nil {
select {
case <-s.done:
return
default:
continue
}
}
go s.handleConn(conn)
}
}
func (s *Server) handleConn(conn net.Conn) {
defer conn.Close()
// Track CID → (pvName, SID).
type chanInfo struct {
pvName string
sid uint32
}
sidSeq := uint32(1000)
channels := make(map[uint32]*chanInfo) // cid → info
for {
hdr, _, err := proto.DecodeHeader(conn)
if err != nil {
return
}
var payload []byte
if hdr.PayloadSize > 0 {
payload = make([]byte, hdr.PayloadSize)
if _, err = io.ReadFull(conn, payload); err != nil {
return
}
}
switch hdr.Command {
case proto.CmdVersion:
// Respond with VERSION.
reply := proto.BuildMessage(proto.Header{
Command: proto.CmdVersion,
DataCount: proto.MinorVersion,
}, nil)
conn.Write(reply)
case proto.CmdHostName, proto.CmdClientName:
// Ignore client identity.
case proto.CmdCreateChan:
cid := hdr.Parameter1
pvName := nullStr(payload)
s.mu.RLock()
pv, ok := s.pvs[pvName]
s.mu.RUnlock()
if !ok {
// CREATE_FAIL
fail := proto.BuildMessage(proto.Header{
Command: proto.CmdCreateFail,
Parameter1: cid,
}, nil)
conn.Write(fail)
continue
}
sid := sidSeq
sidSeq++
channels[cid] = &chanInfo{pvName: pvName, sid: sid}
// CREATE_CHAN reply: DataType=dbfType, DataCount=count, p1=cid, p2=sid.
pv.mu.RLock()
dbfType := pv.spec.DBFType
count := pv.spec.Count
pv.mu.RUnlock()
reply := proto.BuildMessage(proto.Header{
Command: proto.CmdCreateChan,
DataType: uint16(dbfType),
DataCount: count,
Parameter1: cid,
Parameter2: sid,
}, nil)
conn.Write(reply)
// ACCESS_RIGHTS: p1=cid, p2=access.
access := pv.spec.Access
if access == 0 {
access = proto.AccessRead | proto.AccessWrite
}
ar := proto.BuildMessage(proto.Header{
Command: proto.CmdAccessRights,
Parameter1: cid,
Parameter2: access,
}, nil)
conn.Write(ar)
case proto.CmdEventAdd:
// p1=SID (channel), p2=subscriptionID (client-assigned).
sid := hdr.Parameter1
subID := hdr.Parameter2
// Find channel by SID.
var pvName string
for _, info := range channels {
if info.sid == sid {
pvName = info.pvName
break
}
}
if pvName == "" {
continue
}
s.mu.Lock()
sub := &serverSub{
pvName: pvName,
subID: subID,
sid: sid,
conn: conn,
dbrType: hdr.DataType,
count: hdr.DataCount,
}
s.subs[subID] = sub
s.mu.Unlock()
// Send initial value.
s.mu.RLock()
pv := s.pvs[pvName]
s.mu.RUnlock()
if msg := s.buildEventMsg(sub, pv); msg != nil {
conn.Write(msg)
}
case proto.CmdEventCancel:
// p1=SID, p2=subscriptionID.
subID := hdr.Parameter2
s.mu.Lock()
delete(s.subs, subID)
s.mu.Unlock()
case proto.CmdReadNotify:
// p1=SID, p2=ioid (per CA spec).
sid := hdr.Parameter1
ioid := hdr.Parameter2
var pvName string
for _, info := range channels {
if info.sid == sid {
pvName = info.pvName
break
}
}
if pvName == "" {
continue
}
s.mu.RLock()
pv := s.pvs[pvName]
s.mu.RUnlock()
pv.mu.RLock()
val := pv.val
pv.mu.RUnlock()
var replyPayload []byte
switch hdr.DataType {
case proto.DBRCtrlDouble, proto.DBRCtrlLong, proto.DBRCtrlEnum, proto.DBRCtrlString,
proto.DBRCtrlFloat, proto.DBRCtrlShort, proto.DBRCtrlChar:
replyPayload = s.encodeCtrlValue(hdr.DataType, pv)
default:
replyPayload = s.encodeTimeValue(hdr.DataType, hdr.DataCount, val)
}
reply := proto.BuildMessage(proto.Header{
Command: proto.CmdReadNotify,
DataType: hdr.DataType,
DataCount: hdr.DataCount,
Parameter1: ioid,
}, replyPayload)
conn.Write(reply)
case proto.CmdWrite:
// p1=SID, p2=0.
sid := hdr.Parameter1
var pvName string
for _, info := range channels {
if info.sid == sid {
pvName = info.pvName
break
}
}
if pvName == "" {
continue
}
s.mu.RLock()
pv := s.pvs[pvName]
s.mu.RUnlock()
val := decodeWritePayload(hdr.DataType, payload)
if val == nil {
continue
}
pv.mu.Lock()
pv.val = val
pv.mu.Unlock()
// Push update to all subscribers.
s.mu.RLock()
for _, sub := range s.subs {
if sub.pvName == pvName {
if msg := s.buildEventMsg(sub, pv); msg != nil {
sub.conn.Write(msg)
}
}
}
s.mu.RUnlock()
case proto.CmdEcho:
// No response needed (server echoes are optional).
}
}
}
// -------------------------------------------------------------------------- //
// Value encoding helpers //
// -------------------------------------------------------------------------- //
func (s *Server) buildEventMsg(sub *serverSub, pv *serverPV) []byte {
pv.mu.RLock()
val := pv.val
pv.mu.RUnlock()
payload := s.encodeTimeValue(sub.dbrType, sub.count, val)
if payload == nil {
return nil
}
return proto.BuildMessage(proto.Header{
Command: proto.CmdEventAdd,
DataType: sub.dbrType,
DataCount: sub.count,
Parameter1: sub.subID,
}, payload)
}
// encodeTimeValue builds a DBR_TIME_* payload for val.
func (s *Server) encodeTimeValue(dbrType uint16, _ uint32, val any) []byte {
now := time.Now().UTC()
sec := uint32(now.Unix() - 631152000) // EPICS epoch offset
nsec := uint32(now.Nanosecond())
hdr := make([]byte, 12)
// status=0, severity=0
binary.BigEndian.PutUint32(hdr[4:], sec)
binary.BigEndian.PutUint32(hdr[8:], nsec)
var body []byte
switch dbrType {
case proto.DBRTimeDouble:
body = make([]byte, 12) // 4 pad + 8 value
f := toF64(val)
binary.BigEndian.PutUint64(body[4:], math.Float64bits(f))
case proto.DBRTimeFloat:
body = make([]byte, 4)
binary.BigEndian.PutUint32(body, math.Float32bits(float32(toF64(val))))
case proto.DBRTimeLong:
body = make([]byte, 4)
binary.BigEndian.PutUint32(body, uint32(int32(toF64(val))))
case proto.DBRTimeShort, proto.DBRTimeEnum:
body = make([]byte, 4) // 2 value + 2 pad
binary.BigEndian.PutUint16(body, uint16(int16(toF64(val))))
case proto.DBRTimeChar:
body = make([]byte, 4) // 1 value + 3 pad
body[0] = byte(uint8(toF64(val)))
case proto.DBRTimeString:
body = make([]byte, 40)
if str, ok := val.(string); ok {
copy(body, str)
}
default:
return nil
}
return append(hdr, body...)
}
// encodeCtrlValue builds a DBR_CTRL_* payload for a GET reply.
// The fake server returns minimal but structurally correct payloads so that
// the client-side decode functions succeed.
func (s *Server) encodeCtrlValue(dbrType uint16, pv *serverPV) []byte {
pv.mu.RLock()
val := pv.val
access := pv.spec.Access
pv.mu.RUnlock()
switch dbrType {
case proto.DBRCtrlDouble: // 88 bytes
p := make([]byte, 88)
if access == proto.AccessRead {
// status=0, severity=0 (read-only signalled via ACCESS_RIGHTS, not here)
}
// units at [8:16] (empty string = "")
// value at [80:88]
binary.BigEndian.PutUint64(p[80:], math.Float64bits(toF64(val)))
return p
case proto.DBRCtrlFloat, proto.DBRCtrlShort, proto.DBRCtrlChar: // map to Long-style
// Treat as CtrlLong (48 bytes); caller asked for these types but our
// NativeCtrlType maps them to Double/Long anyway. Return a valid stub.
p := make([]byte, 48)
binary.BigEndian.PutUint32(p[44:], uint32(int32(toF64(val))))
return p
case proto.DBRCtrlLong: // 48 bytes
p := make([]byte, 48)
binary.BigEndian.PutUint32(p[44:], uint32(int32(toF64(val))))
return p
case proto.DBRCtrlEnum: // 424 bytes
p := make([]byte, 424)
// no_str at [4:6] = 0 (no enum strings in fake server)
binary.BigEndian.PutUint16(p[422:], uint16(int16(toF64(val))))
return p
case proto.DBRCtrlString: // 44 bytes: status(2)+severity(2)+value[40]
p := make([]byte, 44)
if str, ok := val.(string); ok {
copy(p[4:], str)
}
return p
default:
return nil
}
}
func decodeWritePayload(dbrType uint16, payload []byte) any {
switch dbrType {
case proto.DBRDouble:
if len(payload) < 8 {
return nil
}
return math.Float64frombits(binary.BigEndian.Uint64(payload))
case proto.DBRLong:
if len(payload) < 4 {
return nil
}
return int32(binary.BigEndian.Uint32(payload))
case proto.DBRShort, proto.DBREnum:
if len(payload) < 2 {
return nil
}
return int16(binary.BigEndian.Uint16(payload))
case proto.DBRString:
return nullStr(payload)
default:
return nil
}
}
func toF64(v any) float64 {
switch x := v.(type) {
case float64:
return x
case float32:
return float64(x)
case int:
return float64(x)
case int32:
return float64(x)
case int16:
return float64(x)
case int64:
return float64(x)
default:
return 0
}
}
func nullStr(b []byte) string {
for i, c := range b {
if c == 0 {
return string(b[:i])
}
}
return string(b)
}
// -------------------------------------------------------------------------- //
// Minimal io.Reader for proto.DecodeHeader //
// -------------------------------------------------------------------------- //
type bytesReader struct{ b []byte; i int }
func newBytesReader(b []byte) *bytesReader { return &bytesReader{b: b} }
func (r *bytesReader) Read(p []byte) (int, error) {
if r.i >= len(r.b) {
return 0, io.EOF
}
n := copy(p, r.b[r.i:])
r.i += n
return n, nil
}