Initial go port of epics
This commit is contained in:
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user