working epics ioc and tested

This commit is contained in:
Martino Ferrari
2026-05-04 21:13:36 +02:00
parent 90669c5fd6
commit 0a5a85e4c4
25 changed files with 1550 additions and 136 deletions
+44
View File
@@ -45,6 +45,8 @@ func (h *Handler) Register(mux *http.ServeMux, prefix string) {
// Synthetic signal CRUD
mux.HandleFunc("GET "+prefix+"/synthetic", h.listSynthetic)
mux.HandleFunc("POST "+prefix+"/synthetic", h.createSynthetic)
mux.HandleFunc("GET "+prefix+"/synthetic/{name}", h.getSynthetic)
mux.HandleFunc("PUT "+prefix+"/synthetic/{name}", h.updateSynthetic)
mux.HandleFunc("DELETE "+prefix+"/synthetic/{name}", h.deleteSynthetic)
}
@@ -307,6 +309,48 @@ func (h *Handler) createSynthetic(w http.ResponseWriter, r *http.Request) {
jsonOK(w, def)
}
func (h *Handler) getSynthetic(w http.ResponseWriter, r *http.Request) {
if h.synthetic == nil {
jsonError(w, http.StatusServiceUnavailable, "synthetic data source not enabled")
return
}
name := r.PathValue("name")
def, err := h.synthetic.GetSignal(name)
if err != nil {
if errors.Is(err, datasource.ErrNotFound) {
jsonError(w, http.StatusNotFound, "synthetic signal not found: "+name)
} else {
jsonError(w, http.StatusInternalServerError, err.Error())
}
return
}
jsonOK(w, def)
}
func (h *Handler) updateSynthetic(w http.ResponseWriter, r *http.Request) {
if h.synthetic == nil {
jsonError(w, http.StatusServiceUnavailable, "synthetic data source not enabled")
return
}
name := r.PathValue("name")
var def synthetic.SignalDef
if err := json.NewDecoder(r.Body).Decode(&def); err != nil {
jsonError(w, http.StatusBadRequest, "invalid JSON: "+err.Error())
return
}
// Ensure the name in the URL matches the body (or fill it in).
def.Name = name
if err := h.synthetic.UpdateSignal(def); err != nil {
if errors.Is(err, datasource.ErrNotFound) {
jsonError(w, http.StatusNotFound, "synthetic signal not found: "+name)
} else {
jsonError(w, http.StatusBadRequest, err.Error())
}
return
}
jsonOK(w, def)
}
func (h *Handler) deleteSynthetic(w http.ResponseWriter, r *http.Request) {
if h.synthetic == nil {
jsonError(w, http.StatusServiceUnavailable, "synthetic data source not enabled")
+4 -4
View File
@@ -342,10 +342,10 @@ func (e *EPICS) GetMetadata(ctx context.Context, signal string) (datasource.Meta
// fetchMetadata retrieves metadata from a connected chid using ca_get.
func (e *EPICS) fetchMetadata(chid C.chid, name string) datasource.Metadata {
// In EPICS, write access is governed by CA security (host/user rules), not
// by the field type. Default to writable=true; the IOC will reject puts
// that violate its security policy.
meta := datasource.Metadata{Name: name, Writable: true}
// ca_write_access returns 1 if CA security permits puts from this client.
// This reflects the IOC's actual security policy (host/user ACLs), so we
// use it directly rather than defaulting to true and relying on put failures.
meta := datasource.Metadata{Name: name, Writable: C.ca_write_access(chid) != 0}
fieldType := C.ca_field_type(chid)
count := C.ca_element_count(chid)
@@ -286,6 +286,52 @@ func (s *Synthetic) RemoveSignal(name string) error {
return nil
}
// GetSignal returns the definition of a single named signal.
func (s *Synthetic) GetSignal(name string) (SignalDef, error) {
s.mu.RLock()
defer s.mu.RUnlock()
st, ok := s.signals[name]
if !ok {
return SignalDef{}, datasource.ErrNotFound
}
return st.def, nil
}
// UpdateSignal replaces the pipeline of an existing synthetic signal at runtime
// and persists the change. The signal must already exist.
func (s *Synthetic) UpdateSignal(def SignalDef) error {
if def.Name == "" {
return errors.New("signal name must not be empty")
}
nodes, err := BuildPipeline(def.Pipeline)
if err != nil {
return fmt.Errorf("build pipeline: %w", err)
}
states := make([]map[string]any, len(nodes))
for i := range states {
states[i] = make(map[string]any)
}
s.mu.Lock()
old, exists := s.signals[def.Name]
if !exists {
s.mu.Unlock()
return datasource.ErrNotFound
}
if old.cancel != nil {
old.cancel()
}
s.signals[def.Name] = &signalState{def: def, nodes: nodes, states: states}
s.mu.Unlock()
if err := s.saveDefs(); err != nil {
return fmt.Errorf("persist definitions: %w", err)
}
return nil
}
// GetDefs returns a copy of all current signal definitions (for the REST API).
func (s *Synthetic) GetDefs() []SignalDef {
s.mu.RLock()
+150 -33
View File
@@ -277,12 +277,21 @@ func (n *ExprNode) Process(inputs []float64, _ map[string]any) (float64, error)
return result, nil
}
// exprParser is a recursive-descent parser for simple arithmetic expressions.
// exprParser is a recursive-descent parser for arithmetic expressions.
// Grammar:
//
// expr = term (('+' | '-') term)*
// term = factor (('*' | '/') factor)*
// expr = term (('+' | '-') term)*
// term = power (('*' | '/') power)*
// power = unary ('^' power)* — right-associative exponentiation
// unary = '-' unary | call
// call = ident '(' expr ')' | factor
// factor = '(' expr ')' | number | variable
//
// Supported functions: exp, log, log2, log10, sqrt, abs, sin, cos, tan,
//
// asin, acos, atan, floor, ceil, round, pow
//
// Variables: a, b, c, d (bound to inputs[0..3]).
type exprParser struct {
src string
pos int
@@ -328,7 +337,7 @@ func (p *exprParser) parseExpr() (float64, error) {
}
func (p *exprParser) parseTerm() (float64, error) {
left, err := p.parseFactor()
left, err := p.parsePower()
if err != nil {
return 0, err
}
@@ -338,7 +347,7 @@ func (p *exprParser) parseTerm() (float64, error) {
break
}
p.pos++
right, err := p.parseFactor()
right, err := p.parsePower()
if err != nil {
return 0, err
}
@@ -354,6 +363,136 @@ func (p *exprParser) parseTerm() (float64, error) {
return left, nil
}
// parsePower handles right-associative exponentiation: a^b^c = a^(b^c).
// Unary minus is consumed here so that -a^2 = -(a^2), not (-a)^2 — matching
// standard mathematical and Python/Julia precedence rules.
func (p *exprParser) parsePower() (float64, error) {
// Collect leading unary minuses; an even count cancels out.
sign := 1.0
p.skipSpaces()
for p.pos < len(p.src) && p.src[p.pos] == '-' {
p.pos++
sign = -sign
p.skipSpaces()
}
base, err := p.parseCall()
if err != nil {
return 0, err
}
p.skipSpaces()
if p.pos < len(p.src) && p.src[p.pos] == '^' {
p.pos++
exp, err := p.parsePower() // right-associative recursion
if err != nil {
return 0, err
}
return sign * math.Pow(base, exp), nil
}
return sign * base, nil
}
// parseCall handles function calls like exp(a) or pow(a, 2).
func (p *exprParser) parseCall() (float64, error) {
p.skipSpaces()
if p.pos >= len(p.src) {
return 0, errors.New("unexpected end of expression")
}
// Try to read an identifier (function name or variable).
if p.src[p.pos] >= 'a' && p.src[p.pos] <= 'z' || p.src[p.pos] >= 'A' && p.src[p.pos] <= 'Z' {
start := p.pos
for p.pos < len(p.src) && (p.src[p.pos] >= 'a' && p.src[p.pos] <= 'z' ||
p.src[p.pos] >= 'A' && p.src[p.pos] <= 'Z' ||
p.src[p.pos] >= '0' && p.src[p.pos] <= '9' ||
p.src[p.pos] == '_') {
p.pos++
}
name := p.src[start:p.pos]
// Check for function call syntax: name(...)
p.skipSpaces()
if p.pos < len(p.src) && p.src[p.pos] == '(' {
p.pos++ // consume '('
arg1, err := p.parseExpr()
if err != nil {
return 0, err
}
// Optional second argument for two-argument functions.
var arg2 float64
p.skipSpaces()
if p.pos < len(p.src) && p.src[p.pos] == ',' {
p.pos++
arg2, err = p.parseExpr()
if err != nil {
return 0, err
}
p.skipSpaces()
}
if p.pos >= len(p.src) || p.src[p.pos] != ')' {
return 0, fmt.Errorf("missing ')' after %s(...)", name)
}
p.pos++ // consume ')'
switch name {
case "exp":
return math.Exp(arg1), nil
case "log", "ln":
return math.Log(arg1), nil
case "log2":
return math.Log2(arg1), nil
case "log10":
return math.Log10(arg1), nil
case "sqrt":
return math.Sqrt(arg1), nil
case "abs":
return math.Abs(arg1), nil
case "sin":
return math.Sin(arg1), nil
case "cos":
return math.Cos(arg1), nil
case "tan":
return math.Tan(arg1), nil
case "asin":
return math.Asin(arg1), nil
case "acos":
return math.Acos(arg1), nil
case "atan":
return math.Atan(arg1), nil
case "atan2":
return math.Atan2(arg1, arg2), nil
case "pow":
return math.Pow(arg1, arg2), nil
case "floor":
return math.Floor(arg1), nil
case "ceil":
return math.Ceil(arg1), nil
case "round":
return math.Round(arg1), nil
case "min":
return math.Min(arg1, arg2), nil
case "max":
return math.Max(arg1, arg2), nil
default:
return 0, fmt.Errorf("unknown function %q", name)
}
}
// Not a function call — must be a single-letter variable.
if len(name) != 1 {
return 0, fmt.Errorf("unknown identifier %q (use ad for variables, or a known function name)", name)
}
val, ok := p.vars[name]
if !ok {
return 0, fmt.Errorf("unknown variable %q (allowed: a, b, c, d)", name)
}
return val, nil
}
return p.parseFactor()
}
func (p *exprParser) parseFactor() (float64, error) {
p.skipSpaces()
if p.pos >= len(p.src) {
@@ -362,7 +501,7 @@ func (p *exprParser) parseFactor() (float64, error) {
ch := p.src[p.pos]
// Parenthesised subexpression
// Parenthesised subexpression.
if ch == '(' {
p.pos++
val, err := p.parseExpr()
@@ -377,35 +516,13 @@ func (p *exprParser) parseFactor() (float64, error) {
return val, nil
}
// Unary minus
if ch == '-' {
p.pos++
val, err := p.parseFactor()
if err != nil {
return 0, err
}
return -val, nil
}
// Variable (a, b, c, d only)
if ch >= 'a' && ch <= 'z' {
name := string(ch)
p.pos++
// Make sure we only accept single-letter variables
if p.pos < len(p.src) && (p.src[p.pos] >= 'a' && p.src[p.pos] <= 'z' || p.src[p.pos] >= 'A' && p.src[p.pos] <= 'Z' || p.src[p.pos] == '_') {
return 0, fmt.Errorf("unknown identifier starting with %q", name)
}
val, ok := p.vars[name]
if !ok {
return 0, fmt.Errorf("unknown variable %q (allowed: a, b, c, d)", name)
}
return val, nil
}
// Number (including optional decimal point and exponent)
// Number (including optional decimal point and exponent notation).
if ch >= '0' && ch <= '9' || ch == '.' {
start := p.pos
for p.pos < len(p.src) && (p.src[p.pos] >= '0' && p.src[p.pos] <= '9' || p.src[p.pos] == '.' || p.src[p.pos] == 'e' || p.src[p.pos] == 'E' || ((p.src[p.pos] == '+' || p.src[p.pos] == '-') && p.pos > start && (p.src[p.pos-1] == 'e' || p.src[p.pos-1] == 'E'))) {
for p.pos < len(p.src) && (p.src[p.pos] >= '0' && p.src[p.pos] <= '9' ||
p.src[p.pos] == '.' || p.src[p.pos] == 'e' || p.src[p.pos] == 'E' ||
((p.src[p.pos] == '+' || p.src[p.pos] == '-') && p.pos > start &&
(p.src[p.pos-1] == 'e' || p.src[p.pos-1] == 'E'))) {
p.pos++
}
f, err := strconv.ParseFloat(p.src[start:p.pos], 64)
+11 -5
View File
@@ -115,7 +115,7 @@ func (h *wsHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
wg.Add(3)
go func() { defer wg.Done(); c.readLoop(ctx, cancel) }()
go func() { defer wg.Done(); c.dispatchLoop(ctx) }()
go func() { defer wg.Done(); c.writeLoop(ctx) }()
go func() { defer wg.Done(); c.writeLoop(ctx, cancel) }()
wg.Wait()
// Cancel all subscriptions on disconnect.
@@ -193,7 +193,10 @@ func (c *wsClient) dispatchLoop(ctx context.Context) {
}
// writeLoop sends queued messages over the WebSocket connection.
func (c *wsClient) writeLoop(ctx context.Context) {
// It cancels ctx on any write error so that readLoop and dispatchLoop exit
// promptly rather than blocking forever waiting for outCh to drain.
func (c *wsClient) writeLoop(ctx context.Context, cancel context.CancelFunc) {
defer cancel()
for {
select {
case msg := <-c.outCh:
@@ -251,8 +254,11 @@ func (c *wsClient) handleSubscribe(ctx context.Context, refs []sigRef) {
c.subs[ref] = cancelSub
c.subsMu.Unlock()
// Send metadata once on subscribe.
c.sendMeta(ctx, ref)
// Send metadata in a goroutine: for EPICS signals GetMetadata may block
// for several seconds waiting for the CA channel to connect and for
// ca_pend_io to complete. Running it inline would stall readLoop and
// prevent any further messages from this client from being processed.
go c.sendMeta(ctx, ref)
}
}
@@ -341,7 +347,7 @@ func (c *wsClient) handleHistory(ctx context.Context, msg inMsg) {
b, _ := json.Marshal(resp)
select {
case c.outCh <- b:
default:
case <-ctx.Done():
}
}