Add control logic engine, panel logic dialogs, logic-edit restriction
Introduce server-side control-logic flow graphs (cron/alarm triggers, Lua blocks) with CRUD endpoints, panel-logic lifecycle triggers and user-interaction dialog nodes, and a synthetic node-graph editor. Add an optional logic-editor allowlist (server.logic_editors) gating who may add/edit panel logic and control logic, surfaced via /api/v1/me and enforced in the API; hide logic affordances in the UI accordingly. Update README, example config, and functional/technical specs to cover all current features (plot panels, panel/control logic, local variables, access control) and refresh the in-app manual and contextual help. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -56,19 +56,29 @@ func ParseLevel(s string) Level {
|
||||
// Policy holds the resolved global access configuration. It is immutable after
|
||||
// construction and safe for concurrent use.
|
||||
type Policy struct {
|
||||
defaultUser string
|
||||
blacklist map[string]Level // user → downgraded level
|
||||
userGroups map[string][]string // user → groups they belong to
|
||||
groupNames []string // all configured group names (sorted)
|
||||
defaultUser string
|
||||
blacklist map[string]Level // user → downgraded level
|
||||
userGroups map[string][]string // user → groups they belong to
|
||||
groupNames []string // all configured group names (sorted)
|
||||
logicEditors map[string]bool // users + group names allowed to edit logic
|
||||
}
|
||||
|
||||
// New builds a Policy. blacklist maps a username to a config level string;
|
||||
// groups maps a group name to its member usernames.
|
||||
func New(defaultUser string, blacklist map[string]string, groups map[string][]string) *Policy {
|
||||
// groups maps a group name to its member usernames. logicEditors optionally
|
||||
// restricts who may edit panel/control logic (usernames or group names); empty
|
||||
// means no restriction.
|
||||
func New(defaultUser string, blacklist map[string]string, groups map[string][]string, logicEditors []string) *Policy {
|
||||
p := &Policy{
|
||||
defaultUser: strings.TrimSpace(defaultUser),
|
||||
blacklist: make(map[string]Level),
|
||||
userGroups: make(map[string][]string),
|
||||
defaultUser: strings.TrimSpace(defaultUser),
|
||||
blacklist: make(map[string]Level),
|
||||
userGroups: make(map[string][]string),
|
||||
logicEditors: make(map[string]bool),
|
||||
}
|
||||
for _, e := range logicEditors {
|
||||
e = strings.TrimSpace(e)
|
||||
if e != "" {
|
||||
p.logicEditors[e] = true
|
||||
}
|
||||
}
|
||||
for user, lvl := range blacklist {
|
||||
u := strings.TrimSpace(user)
|
||||
@@ -126,6 +136,35 @@ func (p *Policy) Level(user string) Level {
|
||||
return LevelWrite
|
||||
}
|
||||
|
||||
// LogicRestricted reports whether a logic-editor allowlist is configured. When
|
||||
// false, any write-capable user may edit panel/control logic.
|
||||
func (p *Policy) LogicRestricted() bool {
|
||||
return len(p.logicEditors) > 0
|
||||
}
|
||||
|
||||
// CanEditLogic reports whether a user may add or edit panel logic and
|
||||
// server-side control logic. When no allowlist is configured everyone with
|
||||
// write access qualifies; otherwise the user (or one of their groups) must be
|
||||
// listed. Anonymous/trusted-LAN callers (user=="") are always permitted.
|
||||
func (p *Policy) CanEditLogic(user string) bool {
|
||||
user = strings.TrimSpace(user)
|
||||
if user == "" {
|
||||
return true
|
||||
}
|
||||
if !p.LogicRestricted() {
|
||||
return true
|
||||
}
|
||||
if p.logicEditors[user] {
|
||||
return true
|
||||
}
|
||||
for _, g := range p.userGroups[user] {
|
||||
if p.logicEditors[g] {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// GroupsOf returns a copy of the groups a user belongs to.
|
||||
func (p *Policy) GroupsOf(user string) []string {
|
||||
src := p.userGroups[strings.TrimSpace(user)]
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
package access
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestCanEditLogic(t *testing.T) {
|
||||
groups := map[string][]string{"ops": {"carol"}}
|
||||
|
||||
// No allowlist configured: everyone with write access may edit logic.
|
||||
open := New("", nil, groups, nil)
|
||||
for _, u := range []string{"", "alice", "carol"} {
|
||||
if !open.CanEditLogic(u) {
|
||||
t.Errorf("unrestricted: CanEditLogic(%q) = false, want true", u)
|
||||
}
|
||||
}
|
||||
if open.LogicRestricted() {
|
||||
t.Error("LogicRestricted() = true with no allowlist")
|
||||
}
|
||||
|
||||
// Allowlist by username and by group name.
|
||||
p := New("", nil, groups, []string{"alice", "ops"})
|
||||
if !p.LogicRestricted() {
|
||||
t.Error("LogicRestricted() = false with allowlist set")
|
||||
}
|
||||
cases := map[string]bool{
|
||||
"": true, // anonymous / trusted LAN
|
||||
"alice": true, // listed user
|
||||
"carol": true, // member of listed group "ops"
|
||||
"bob": false, // not listed
|
||||
}
|
||||
for u, want := range cases {
|
||||
if got := p.CanEditLogic(u); got != want {
|
||||
t.Errorf("CanEditLogic(%q) = %v, want %v", u, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
+187
-7
@@ -15,6 +15,7 @@ import (
|
||||
|
||||
"github.com/uopi/uopi/internal/access"
|
||||
"github.com/uopi/uopi/internal/broker"
|
||||
"github.com/uopi/uopi/internal/controllogic"
|
||||
"github.com/uopi/uopi/internal/datasource"
|
||||
"github.com/uopi/uopi/internal/datasource/synthetic"
|
||||
"github.com/uopi/uopi/internal/panelacl"
|
||||
@@ -28,19 +29,23 @@ type Handler struct {
|
||||
store *storage.Store
|
||||
policy *access.Policy
|
||||
acl *panelacl.Store
|
||||
ctrlLogic *controllogic.Store
|
||||
ctrlEngine *controllogic.Engine
|
||||
channelFinderURL string // empty if not configured
|
||||
archiverURL string // empty if not configured
|
||||
log *slog.Logger
|
||||
}
|
||||
|
||||
// New creates an API Handler. synth may be nil if the synthetic DS is disabled.
|
||||
func New(b *broker.Broker, synth *synthetic.Synthetic, store *storage.Store, policy *access.Policy, acl *panelacl.Store, channelFinderURL, archiverURL string, log *slog.Logger) *Handler {
|
||||
func New(b *broker.Broker, synth *synthetic.Synthetic, store *storage.Store, policy *access.Policy, acl *panelacl.Store, ctrlLogic *controllogic.Store, ctrlEngine *controllogic.Engine, channelFinderURL, archiverURL string, log *slog.Logger) *Handler {
|
||||
return &Handler{
|
||||
broker: b,
|
||||
synthetic: synth,
|
||||
store: store,
|
||||
policy: policy,
|
||||
acl: acl,
|
||||
ctrlLogic: ctrlLogic,
|
||||
ctrlEngine: ctrlEngine,
|
||||
channelFinderURL: channelFinderURL,
|
||||
archiverURL: archiverURL,
|
||||
log: log,
|
||||
@@ -87,6 +92,13 @@ func (h *Handler) Register(mux *http.ServeMux, prefix string) {
|
||||
mux.HandleFunc("GET "+prefix+"/synthetic/{name}", h.getSynthetic)
|
||||
mux.HandleFunc("PUT "+prefix+"/synthetic/{name}", h.updateSynthetic)
|
||||
mux.HandleFunc("DELETE "+prefix+"/synthetic/{name}", h.deleteSynthetic)
|
||||
// Server-side control logic CRUD (mutations are write-gated by the access
|
||||
// middleware; each mutation reloads the running engine).
|
||||
mux.HandleFunc("GET "+prefix+"/controllogic", h.listControlLogic)
|
||||
mux.HandleFunc("POST "+prefix+"/controllogic", h.createControlLogic)
|
||||
mux.HandleFunc("GET "+prefix+"/controllogic/{id}", h.getControlLogic)
|
||||
mux.HandleFunc("PUT "+prefix+"/controllogic/{id}", h.updateControlLogic)
|
||||
mux.HandleFunc("DELETE "+prefix+"/controllogic/{id}", h.deleteControlLogic)
|
||||
}
|
||||
|
||||
// ── /me ─────────────────────────────────────────────────────────────────────
|
||||
@@ -102,9 +114,10 @@ func (h *Handler) getMe(w http.ResponseWriter, r *http.Request) {
|
||||
groups = []string{}
|
||||
}
|
||||
jsonOK(w, map[string]any{
|
||||
"user": user,
|
||||
"level": h.policy.Level(user).String(),
|
||||
"groups": groups,
|
||||
"user": user,
|
||||
"level": h.policy.Level(user).String(),
|
||||
"groups": groups,
|
||||
"canEditLogic": h.policy.CanEditLogic(user),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -420,9 +433,10 @@ func (h *Handler) putGroups(w http.ResponseWriter, r *http.Request) {
|
||||
// render sharing affordances and filter the visible set.
|
||||
type interfaceListItem struct {
|
||||
storage.InterfaceMeta
|
||||
Owner string `json:"owner,omitempty"`
|
||||
Folder string `json:"folder,omitempty"`
|
||||
Perm string `json:"perm"`
|
||||
Owner string `json:"owner,omitempty"`
|
||||
Folder string `json:"folder,omitempty"`
|
||||
Order float64 `json:"order,omitempty"`
|
||||
Perm string `json:"perm"`
|
||||
}
|
||||
|
||||
func (h *Handler) listInterfaces(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -442,6 +456,7 @@ func (h *Handler) listInterfaces(w http.ResponseWriter, r *http.Request) {
|
||||
if acl := h.acl.GetPanel(m.ID); acl != nil {
|
||||
item.Owner = acl.Owner
|
||||
item.Folder = acl.Folder
|
||||
item.Order = acl.Order
|
||||
}
|
||||
out = append(out, item)
|
||||
}
|
||||
@@ -455,6 +470,12 @@ func (h *Handler) createInterface(w http.ResponseWriter, r *http.Request) {
|
||||
jsonError(w, http.StatusBadRequest, "read body: "+err.Error())
|
||||
return
|
||||
}
|
||||
// Editing panel logic may be restricted to an allowlist. A brand-new panel
|
||||
// carrying a non-empty <logic> block requires that permission.
|
||||
if !h.policy.CanEditLogic(caller(r)) && extractLogicBlock(body) != "" {
|
||||
jsonError(w, http.StatusForbidden, "you are not permitted to edit panel logic")
|
||||
return
|
||||
}
|
||||
id, err := h.store.Create(body, r.URL.Query().Get("tag"))
|
||||
if err != nil {
|
||||
h.log.Error("create interface", "err", err)
|
||||
@@ -474,6 +495,44 @@ func (h *Handler) createInterface(w http.ResponseWriter, r *http.Request) {
|
||||
_ = json.NewEncoder(w).Encode(map[string]string{"id": id})
|
||||
}
|
||||
|
||||
// reorderInterfaces places a set of panels into a folder (or the root when folder
|
||||
// is empty) in the given order, stamping each id with order=index. The caller must
|
||||
// have write access to the destination folder (if any) and to each panel.
|
||||
func (h *Handler) reorderInterfaces(w http.ResponseWriter, r *http.Request) {
|
||||
var req struct {
|
||||
Folder string `json:"folder"`
|
||||
IDs []string `json:"ids"`
|
||||
}
|
||||
if err := json.NewDecoder(io.LimitReader(r.Body, 1<<20)).Decode(&req); err != nil {
|
||||
jsonError(w, http.StatusBadRequest, "decode body: "+err.Error())
|
||||
return
|
||||
}
|
||||
if req.Folder != "" {
|
||||
if _, err := h.acl.GetFolder(req.Folder); err != nil {
|
||||
jsonError(w, http.StatusNotFound, "folder not found: "+req.Folder)
|
||||
return
|
||||
}
|
||||
if h.folderPerm(r, req.Folder) < panelacl.PermWrite {
|
||||
jsonError(w, http.StatusForbidden, "you do not have write access to this folder")
|
||||
return
|
||||
}
|
||||
}
|
||||
for _, id := range req.IDs {
|
||||
if h.panelPerm(r, id) < panelacl.PermWrite {
|
||||
jsonError(w, http.StatusForbidden, "you do not have write access to panel: "+id)
|
||||
return
|
||||
}
|
||||
}
|
||||
for i, id := range req.IDs {
|
||||
if err := h.acl.PlacePanel(id, req.Folder, float64(i)); err != nil {
|
||||
h.log.Error("reorder interface", "id", id, "err", err)
|
||||
jsonError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
func (h *Handler) getInterface(w http.ResponseWriter, r *http.Request) {
|
||||
id := r.PathValue("id")
|
||||
if h.panelPerm(r, id) < panelacl.PermRead {
|
||||
@@ -504,6 +563,14 @@ func (h *Handler) updateInterface(w http.ResponseWriter, r *http.Request) {
|
||||
jsonError(w, http.StatusBadRequest, "read body: "+err.Error())
|
||||
return
|
||||
}
|
||||
// Editing panel logic may be restricted to an allowlist. Permit unrestricted
|
||||
// edits to the rest of the panel as long as the <logic> block is unchanged.
|
||||
if !h.policy.CanEditLogic(caller(r)) {
|
||||
if cur, err := h.store.Get(id); err != nil || extractLogicBlock(body) != extractLogicBlock(cur) {
|
||||
jsonError(w, http.StatusForbidden, "you are not permitted to edit panel logic")
|
||||
return
|
||||
}
|
||||
}
|
||||
if err := h.store.Update(id, body, r.URL.Query().Get("tag")); err != nil {
|
||||
if errors.Is(err, storage.ErrNotFound) {
|
||||
jsonError(w, http.StatusNotFound, "interface not found: "+id)
|
||||
@@ -1009,8 +1076,121 @@ func (h *Handler) deleteSynthetic(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
// ── Control logic ──────────────────────────────────────────────────────────────
|
||||
|
||||
func (h *Handler) listControlLogic(w http.ResponseWriter, _ *http.Request) {
|
||||
if h.ctrlLogic == nil {
|
||||
jsonOK(w, []any{})
|
||||
return
|
||||
}
|
||||
graphs := h.ctrlLogic.List()
|
||||
if graphs == nil {
|
||||
graphs = []controllogic.Graph{}
|
||||
}
|
||||
jsonOK(w, graphs)
|
||||
}
|
||||
|
||||
func (h *Handler) getControlLogic(w http.ResponseWriter, r *http.Request) {
|
||||
if h.ctrlLogic == nil {
|
||||
jsonError(w, http.StatusServiceUnavailable, "control logic not enabled")
|
||||
return
|
||||
}
|
||||
g, err := h.ctrlLogic.Get(r.PathValue("id"))
|
||||
if err != nil {
|
||||
jsonError(w, http.StatusNotFound, "control logic graph not found: "+r.PathValue("id"))
|
||||
return
|
||||
}
|
||||
jsonOK(w, g)
|
||||
}
|
||||
|
||||
func (h *Handler) createControlLogic(w http.ResponseWriter, r *http.Request) {
|
||||
if h.ctrlLogic == nil {
|
||||
jsonError(w, http.StatusServiceUnavailable, "control logic not enabled")
|
||||
return
|
||||
}
|
||||
if !h.policy.CanEditLogic(caller(r)) {
|
||||
jsonError(w, http.StatusForbidden, "you are not permitted to edit logic")
|
||||
return
|
||||
}
|
||||
var g controllogic.Graph
|
||||
if err := json.NewDecoder(r.Body).Decode(&g); err != nil {
|
||||
jsonError(w, http.StatusBadRequest, "invalid JSON: "+err.Error())
|
||||
return
|
||||
}
|
||||
g.ID = genID("cl")
|
||||
if err := h.ctrlLogic.Save(g); err != nil {
|
||||
jsonError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
h.ctrlEngine.Reload()
|
||||
w.WriteHeader(http.StatusCreated)
|
||||
jsonOK(w, g)
|
||||
}
|
||||
|
||||
func (h *Handler) updateControlLogic(w http.ResponseWriter, r *http.Request) {
|
||||
if h.ctrlLogic == nil {
|
||||
jsonError(w, http.StatusServiceUnavailable, "control logic not enabled")
|
||||
return
|
||||
}
|
||||
if !h.policy.CanEditLogic(caller(r)) {
|
||||
jsonError(w, http.StatusForbidden, "you are not permitted to edit logic")
|
||||
return
|
||||
}
|
||||
id := r.PathValue("id")
|
||||
if _, err := h.ctrlLogic.Get(id); err != nil {
|
||||
jsonError(w, http.StatusNotFound, "control logic graph not found: "+id)
|
||||
return
|
||||
}
|
||||
var g controllogic.Graph
|
||||
if err := json.NewDecoder(r.Body).Decode(&g); err != nil {
|
||||
jsonError(w, http.StatusBadRequest, "invalid JSON: "+err.Error())
|
||||
return
|
||||
}
|
||||
g.ID = id
|
||||
if err := h.ctrlLogic.Save(g); err != nil {
|
||||
jsonError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
h.ctrlEngine.Reload()
|
||||
jsonOK(w, g)
|
||||
}
|
||||
|
||||
func (h *Handler) deleteControlLogic(w http.ResponseWriter, r *http.Request) {
|
||||
if h.ctrlLogic == nil {
|
||||
jsonError(w, http.StatusServiceUnavailable, "control logic not enabled")
|
||||
return
|
||||
}
|
||||
if !h.policy.CanEditLogic(caller(r)) {
|
||||
jsonError(w, http.StatusForbidden, "you are not permitted to edit logic")
|
||||
return
|
||||
}
|
||||
if err := h.ctrlLogic.Delete(r.PathValue("id")); err != nil {
|
||||
jsonError(w, http.StatusNotFound, "control logic graph not found: "+r.PathValue("id"))
|
||||
return
|
||||
}
|
||||
h.ctrlEngine.Reload()
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
// ── Helpers ───────────────────────────────────────────────────────────────────
|
||||
|
||||
// extractLogicBlock returns the verbatim <logic>…</logic> section of an
|
||||
// interface XML document, or "" when absent. The frontend serializes the block
|
||||
// only when it holds nodes/wires and uses a stable format, so comparing the
|
||||
// extracted substrings reliably detects whether the panel logic changed.
|
||||
func extractLogicBlock(xml []byte) string {
|
||||
s := string(xml)
|
||||
start := strings.Index(s, "<logic>")
|
||||
if start < 0 {
|
||||
return ""
|
||||
}
|
||||
end := strings.Index(s[start:], "</logic>")
|
||||
if end < 0 {
|
||||
return ""
|
||||
}
|
||||
return s[start : start+end+len("</logic>")]
|
||||
}
|
||||
|
||||
func metaToSignalInfo(m datasource.Metadata) signalInfo {
|
||||
return signalInfo{
|
||||
Name: m.Name,
|
||||
|
||||
@@ -15,6 +15,7 @@ import (
|
||||
"github.com/uopi/uopi/internal/access"
|
||||
"github.com/uopi/uopi/internal/api"
|
||||
"github.com/uopi/uopi/internal/broker"
|
||||
"github.com/uopi/uopi/internal/controllogic"
|
||||
"github.com/uopi/uopi/internal/datasource/stub"
|
||||
"github.com/uopi/uopi/internal/panelacl"
|
||||
"github.com/uopi/uopi/internal/storage"
|
||||
@@ -45,8 +46,14 @@ func setup(t *testing.T) (*httptest.Server, func()) {
|
||||
t.Fatal("panelacl.New:", err)
|
||||
}
|
||||
|
||||
clStore, err := controllogic.NewStore(dir)
|
||||
if err != nil {
|
||||
t.Fatal("controllogic.NewStore:", err)
|
||||
}
|
||||
clEngine := controllogic.NewEngine(ctx, brk, clStore, log)
|
||||
|
||||
mux := http.NewServeMux()
|
||||
api.New(brk, nil, store, access.New("", nil, nil), acl, "", "", log).Register(mux, "/api/v1")
|
||||
api.New(brk, nil, store, access.New("", nil, nil, nil), acl, clStore, clEngine, "", "", log).Register(mux, "/api/v1")
|
||||
|
||||
srv := httptest.NewServer(mux)
|
||||
return srv, func() {
|
||||
|
||||
@@ -50,6 +50,13 @@ type ServerConfig struct {
|
||||
// Blacklist downgrades specific users' global access level. Everyone not
|
||||
// listed is trusted with full write access.
|
||||
Blacklist []BlacklistEntry `toml:"blacklist"`
|
||||
|
||||
// LogicEditors optionally restricts who may add or edit panel logic (the
|
||||
// <logic> block of interfaces) and server-side control logic. Entries are
|
||||
// usernames or group names. When empty, no restriction applies (any user
|
||||
// with write access may edit logic). Anonymous/trusted-LAN callers are
|
||||
// always permitted.
|
||||
LogicEditors []string `toml:"logic_editors"`
|
||||
}
|
||||
|
||||
type DatasourceConfig struct {
|
||||
@@ -131,6 +138,9 @@ func applyEnv(cfg *Config) {
|
||||
if v := env("UOPI_SERVER_DEFAULT_USER"); v != "" {
|
||||
cfg.Server.DefaultUser = v
|
||||
}
|
||||
if v := env("UOPI_SERVER_LOGIC_EDITORS"); v != "" {
|
||||
cfg.Server.LogicEditors = strings.Fields(v)
|
||||
}
|
||||
if v := env("UOPI_EPICS_CA_ADDR_LIST"); v != "" {
|
||||
cfg.Datasource.EPICS.CAAddrList = v
|
||||
}
|
||||
|
||||
@@ -0,0 +1,161 @@
|
||||
// Minimal 5-field cron parser for control-logic cron triggers.
|
||||
//
|
||||
// Fields, in order: minute hour day-of-month month day-of-week.
|
||||
// Each field supports:
|
||||
//
|
||||
// * any value
|
||||
// */n every n (step over the whole range)
|
||||
// a-b inclusive range
|
||||
// a-b/n range with step
|
||||
// a,b,c comma-separated list of the above
|
||||
// N a single value
|
||||
//
|
||||
// Day-of-week is 0-6 with 0 = Sunday (7 is also accepted as Sunday). When both
|
||||
// day-of-month and day-of-week are restricted (neither is "*"), the schedule
|
||||
// matches when EITHER matches, following Vixie cron semantics.
|
||||
package controllogic
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Schedule is a parsed 5-field cron specification.
|
||||
type Schedule struct {
|
||||
minute uint64 // bitmask 0..59
|
||||
hour uint64 // bitmask 0..23
|
||||
dom uint64 // bitmask 1..31
|
||||
month uint64 // bitmask 1..12
|
||||
dow uint64 // bitmask 0..6
|
||||
domStar bool // day-of-month field was "*"
|
||||
dowStar bool // day-of-week field was "*"
|
||||
}
|
||||
|
||||
// ParseSchedule parses a 5-field cron spec. Extra whitespace is tolerated.
|
||||
func ParseSchedule(spec string) (*Schedule, error) {
|
||||
fields := strings.Fields(spec)
|
||||
if len(fields) != 5 {
|
||||
return nil, fmt.Errorf("cron spec must have 5 fields, got %d", len(fields))
|
||||
}
|
||||
s := &Schedule{}
|
||||
var err error
|
||||
if s.minute, err = parseField(fields[0], 0, 59); err != nil {
|
||||
return nil, fmt.Errorf("minute: %w", err)
|
||||
}
|
||||
if s.hour, err = parseField(fields[1], 0, 23); err != nil {
|
||||
return nil, fmt.Errorf("hour: %w", err)
|
||||
}
|
||||
if s.dom, err = parseField(fields[2], 1, 31); err != nil {
|
||||
return nil, fmt.Errorf("day-of-month: %w", err)
|
||||
}
|
||||
if s.month, err = parseField(fields[3], 1, 12); err != nil {
|
||||
return nil, fmt.Errorf("month: %w", err)
|
||||
}
|
||||
if s.dow, err = parseDOW(fields[4]); err != nil {
|
||||
return nil, fmt.Errorf("day-of-week: %w", err)
|
||||
}
|
||||
s.domStar = strings.TrimSpace(fields[2]) == "*"
|
||||
s.dowStar = strings.TrimSpace(fields[4]) == "*"
|
||||
return s, nil
|
||||
}
|
||||
|
||||
// Match reports whether t (truncated to the minute) satisfies the schedule.
|
||||
func (s *Schedule) Match(t time.Time) bool {
|
||||
if s.minute&bit(t.Minute()) == 0 {
|
||||
return false
|
||||
}
|
||||
if s.hour&bit(t.Hour()) == 0 {
|
||||
return false
|
||||
}
|
||||
if s.month&bit(int(t.Month())) == 0 {
|
||||
return false
|
||||
}
|
||||
domMatch := s.dom&bit(t.Day()) != 0
|
||||
dowMatch := s.dow&bit(int(t.Weekday())) != 0
|
||||
// Vixie semantics: when both day fields are restricted, match either.
|
||||
switch {
|
||||
case s.domStar && s.dowStar:
|
||||
return true
|
||||
case s.domStar:
|
||||
return dowMatch
|
||||
case s.dowStar:
|
||||
return domMatch
|
||||
default:
|
||||
return domMatch || dowMatch
|
||||
}
|
||||
}
|
||||
|
||||
func bit(n int) uint64 { return uint64(1) << uint(n) }
|
||||
|
||||
func parseDOW(field string) (uint64, error) {
|
||||
// Normalise 7 → 0 (both mean Sunday) by parsing then folding.
|
||||
mask, err := parseField(field, 0, 7)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if mask&bit(7) != 0 {
|
||||
mask = (mask &^ bit(7)) | bit(0)
|
||||
}
|
||||
return mask, nil
|
||||
}
|
||||
|
||||
func parseField(field string, lo, hi int) (uint64, error) {
|
||||
var mask uint64
|
||||
for _, part := range strings.Split(field, ",") {
|
||||
m, err := parsePart(strings.TrimSpace(part), lo, hi)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
mask |= m
|
||||
}
|
||||
if mask == 0 {
|
||||
return 0, fmt.Errorf("empty field %q", field)
|
||||
}
|
||||
return mask, nil
|
||||
}
|
||||
|
||||
func parsePart(part string, lo, hi int) (uint64, error) {
|
||||
if part == "" {
|
||||
return 0, fmt.Errorf("empty term")
|
||||
}
|
||||
step := 1
|
||||
rangePart := part
|
||||
if idx := strings.IndexByte(part, '/'); idx >= 0 {
|
||||
rangePart = part[:idx]
|
||||
st, err := strconv.Atoi(part[idx+1:])
|
||||
if err != nil || st < 1 {
|
||||
return 0, fmt.Errorf("bad step %q", part[idx+1:])
|
||||
}
|
||||
step = st
|
||||
}
|
||||
|
||||
start, end := lo, hi
|
||||
if rangePart != "*" {
|
||||
if idx := strings.IndexByte(rangePart, '-'); idx >= 0 {
|
||||
a, err1 := strconv.Atoi(rangePart[:idx])
|
||||
b, err2 := strconv.Atoi(rangePart[idx+1:])
|
||||
if err1 != nil || err2 != nil {
|
||||
return 0, fmt.Errorf("bad range %q", rangePart)
|
||||
}
|
||||
start, end = a, b
|
||||
} else {
|
||||
v, err := strconv.Atoi(rangePart)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("bad value %q", rangePart)
|
||||
}
|
||||
start, end = v, v
|
||||
}
|
||||
}
|
||||
|
||||
if start < lo || end > hi || start > end {
|
||||
return 0, fmt.Errorf("value out of range [%d,%d] in %q", lo, hi, part)
|
||||
}
|
||||
|
||||
var mask uint64
|
||||
for v := start; v <= end; v += step {
|
||||
mask |= bit(v)
|
||||
}
|
||||
return mask, nil
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
package controllogic
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func mustSched(t *testing.T, spec string) *Schedule {
|
||||
t.Helper()
|
||||
s, err := ParseSchedule(spec)
|
||||
if err != nil {
|
||||
t.Fatalf("ParseSchedule(%q): %v", spec, err)
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
func TestCronEveryMinute(t *testing.T) {
|
||||
s := mustSched(t, "* * * * *")
|
||||
if !s.Match(time.Date(2026, 6, 18, 12, 34, 0, 0, time.UTC)) {
|
||||
t.Error("* * * * * should match any time")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCronSpecificTime(t *testing.T) {
|
||||
s := mustSched(t, "30 9 * * *")
|
||||
if !s.Match(time.Date(2026, 6, 18, 9, 30, 0, 0, time.UTC)) {
|
||||
t.Error("should match 09:30")
|
||||
}
|
||||
if s.Match(time.Date(2026, 6, 18, 9, 31, 0, 0, time.UTC)) {
|
||||
t.Error("should not match 09:31")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCronStep(t *testing.T) {
|
||||
s := mustSched(t, "*/15 * * * *")
|
||||
for _, m := range []int{0, 15, 30, 45} {
|
||||
if !s.Match(time.Date(2026, 6, 18, 1, m, 0, 0, time.UTC)) {
|
||||
t.Errorf("*/15 should match minute %d", m)
|
||||
}
|
||||
}
|
||||
if s.Match(time.Date(2026, 6, 18, 1, 7, 0, 0, time.UTC)) {
|
||||
t.Error("*/15 should not match minute 7")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCronRangeAndList(t *testing.T) {
|
||||
s := mustSched(t, "0 9-17 * * 1,2,3,4,5")
|
||||
// 2026-06-18 is a Thursday (weekday 4).
|
||||
if !s.Match(time.Date(2026, 6, 18, 10, 0, 0, 0, time.UTC)) {
|
||||
t.Error("should match Thursday 10:00")
|
||||
}
|
||||
if s.Match(time.Date(2026, 6, 18, 18, 0, 0, 0, time.UTC)) {
|
||||
t.Error("should not match 18:00 (out of 9-17)")
|
||||
}
|
||||
// 2026-06-20 is a Saturday (weekday 6).
|
||||
if s.Match(time.Date(2026, 6, 20, 10, 0, 0, 0, time.UTC)) {
|
||||
t.Error("should not match Saturday")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCronDOWSunday7(t *testing.T) {
|
||||
s := mustSched(t, "0 0 * * 7")
|
||||
// 2026-06-21 is a Sunday.
|
||||
if !s.Match(time.Date(2026, 6, 21, 0, 0, 0, 0, time.UTC)) {
|
||||
t.Error("7 should mean Sunday")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCronDomOrDow(t *testing.T) {
|
||||
// When both day fields restricted, match either (Vixie semantics).
|
||||
s := mustSched(t, "0 0 1 * 5")
|
||||
// 2026-06-01 is a Monday — matches via day-of-month=1.
|
||||
if !s.Match(time.Date(2026, 6, 1, 0, 0, 0, 0, time.UTC)) {
|
||||
t.Error("should match day-of-month 1")
|
||||
}
|
||||
// 2026-06-19 is a Friday (weekday 5) — matches via day-of-week.
|
||||
if !s.Match(time.Date(2026, 6, 19, 0, 0, 0, 0, time.UTC)) {
|
||||
t.Error("should match Friday")
|
||||
}
|
||||
// 2026-06-18 Thursday, not the 1st — no match.
|
||||
if s.Match(time.Date(2026, 6, 18, 0, 0, 0, 0, time.UTC)) {
|
||||
t.Error("should not match Thursday the 18th")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCronInvalid(t *testing.T) {
|
||||
for _, bad := range []string{"* * * *", "60 * * * *", "* 24 * * *", "* * 0 * *", "a * * * *", "*/0 * * * *"} {
|
||||
if _, err := ParseSchedule(bad); err == nil {
|
||||
t.Errorf("ParseSchedule(%q) should error", bad)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,688 @@
|
||||
package controllogic
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log/slog"
|
||||
"math"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/uopi/uopi/internal/broker"
|
||||
)
|
||||
|
||||
// Guards against runaway flows (cycles / pathological loops).
|
||||
const (
|
||||
maxSteps = 100000
|
||||
maxLoop = 100000
|
||||
)
|
||||
|
||||
// Engine runs all enabled control-logic graphs continuously under a root
|
||||
// context. Reload tears down the current generation (subscriptions, timers,
|
||||
// in-flight flows) and rebuilds from the store's enabled graphs.
|
||||
type Engine struct {
|
||||
broker *broker.Broker
|
||||
store *Store
|
||||
log *slog.Logger
|
||||
root context.Context
|
||||
|
||||
mu sync.Mutex
|
||||
cancel context.CancelFunc // cancels the current generation
|
||||
wg *sync.WaitGroup // tracks the current generation's goroutines
|
||||
|
||||
// Shared live signal cache for the current generation (key "ds\0name").
|
||||
liveMu sync.RWMutex
|
||||
live map[string]float64
|
||||
}
|
||||
|
||||
// NewEngine creates an engine bound to root. Call Reload to start it.
|
||||
func NewEngine(root context.Context, brk *broker.Broker, store *Store, log *slog.Logger) *Engine {
|
||||
return &Engine{
|
||||
broker: brk,
|
||||
store: store,
|
||||
log: log,
|
||||
root: root,
|
||||
live: map[string]float64{},
|
||||
}
|
||||
}
|
||||
|
||||
// ── reference / value helpers ──────────────────────────────────────────────────
|
||||
|
||||
func refKey(ds, name string) string { return ds + "\x00" + name }
|
||||
|
||||
// parseRef splits a "ds:name" target on the FIRST ':' (EPICS PV names contain
|
||||
// ':'). A bare name (no ':') is a graph-local variable in data source "local".
|
||||
func parseRef(target string) (ds, name string, ok bool) {
|
||||
t := strings.TrimSpace(target)
|
||||
if t == "" {
|
||||
return "", "", false
|
||||
}
|
||||
i := strings.IndexByte(t, ':')
|
||||
if i < 0 {
|
||||
return "local", t, true
|
||||
}
|
||||
return t[:i], t[i+1:], true
|
||||
}
|
||||
|
||||
func toNum(v any) float64 {
|
||||
switch x := v.(type) {
|
||||
case float64:
|
||||
return x
|
||||
case float32:
|
||||
return float64(x)
|
||||
case int64:
|
||||
return float64(x)
|
||||
case int:
|
||||
return float64(x)
|
||||
case bool:
|
||||
if x {
|
||||
return 1
|
||||
}
|
||||
return 0
|
||||
case string:
|
||||
f, err := strconv.ParseFloat(strings.TrimSpace(x), 64)
|
||||
if err != nil {
|
||||
return math.NaN()
|
||||
}
|
||||
return f
|
||||
default:
|
||||
return math.NaN()
|
||||
}
|
||||
}
|
||||
|
||||
// ── lifecycle ───────────────────────────────────────────────────────────────
|
||||
|
||||
// Reload rebuilds the engine from the store. Safe to call repeatedly (after any
|
||||
// graph mutation). It is a no-op-safe full restart of the running generation.
|
||||
func (e *Engine) Reload() {
|
||||
e.mu.Lock()
|
||||
defer e.mu.Unlock()
|
||||
|
||||
// Tear down the previous generation and wait for its goroutines to exit.
|
||||
if e.cancel != nil {
|
||||
e.cancel()
|
||||
e.cancel = nil
|
||||
}
|
||||
if e.wg != nil {
|
||||
e.wg.Wait()
|
||||
e.wg = nil
|
||||
}
|
||||
|
||||
e.liveMu.Lock()
|
||||
e.live = map[string]float64{}
|
||||
e.liveMu.Unlock()
|
||||
|
||||
graphs := e.store.List()
|
||||
var compiled []*compiledGraph
|
||||
refs := map[string]RefLite{}
|
||||
for i := range graphs {
|
||||
g := graphs[i]
|
||||
if !g.Enabled {
|
||||
continue
|
||||
}
|
||||
cg := compile(g)
|
||||
compiled = append(compiled, cg)
|
||||
for k, r := range cg.refs {
|
||||
refs[k] = r
|
||||
}
|
||||
}
|
||||
if len(compiled) == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
genCtx, cancel := context.WithCancel(e.root)
|
||||
wg := &sync.WaitGroup{}
|
||||
e.cancel = cancel
|
||||
e.wg = wg
|
||||
|
||||
for _, cg := range compiled {
|
||||
cg.engine = e
|
||||
cg.genCtx = genCtx
|
||||
cg.wg = wg
|
||||
}
|
||||
|
||||
// One shared updates channel feeds a single dispatch goroutine; every
|
||||
// subscription delivers into it. Subscriptions are released on teardown.
|
||||
updates := make(chan broker.Update, 128)
|
||||
var unsubs []func()
|
||||
for _, r := range refs {
|
||||
unsub, err := e.broker.Subscribe(broker.SignalRef{DS: r.DS, Name: r.Name}, updates)
|
||||
if err != nil {
|
||||
e.log.Warn("control logic: subscribe failed", "ds", r.DS, "signal", r.Name, "err", err)
|
||||
continue
|
||||
}
|
||||
unsubs = append(unsubs, unsub)
|
||||
}
|
||||
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
<-genCtx.Done()
|
||||
for _, u := range unsubs {
|
||||
u()
|
||||
}
|
||||
}()
|
||||
|
||||
// Dispatch goroutine: keep the live cache fresh and drive level/edge triggers.
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
for {
|
||||
select {
|
||||
case u := <-updates:
|
||||
val := toNum(u.Value.Data)
|
||||
key := refKey(u.Ref.DS, u.Ref.Name)
|
||||
e.liveMu.Lock()
|
||||
e.live[key] = val
|
||||
e.liveMu.Unlock()
|
||||
for _, cg := range compiled {
|
||||
cg.onSignal(key, val)
|
||||
}
|
||||
case <-genCtx.Done():
|
||||
return
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
// Start timer and cron triggers.
|
||||
for _, cg := range compiled {
|
||||
cg.startTriggers()
|
||||
}
|
||||
|
||||
e.log.Info("control logic engine reloaded", "graphs", len(compiled), "signals", len(refs))
|
||||
}
|
||||
|
||||
// liveGet reads the current value of a signal from the shared cache.
|
||||
func (e *Engine) liveGet(ds, name string) float64 {
|
||||
if ds == "sys" {
|
||||
if name == "time" {
|
||||
return float64(time.Now().UnixNano()) / 1e9
|
||||
}
|
||||
return math.NaN() // sys:dt handled per-activation
|
||||
}
|
||||
e.liveMu.RLock()
|
||||
defer e.liveMu.RUnlock()
|
||||
v, ok := e.live[refKey(ds, name)]
|
||||
if !ok {
|
||||
return math.NaN()
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
// write applies an action.write/lua-set to a target: a bare name updates a
|
||||
// graph-local var; a ds:name target writes to the data source.
|
||||
func (e *Engine) write(cg *compiledGraph, target string, val float64) {
|
||||
ds, name, ok := parseRef(target)
|
||||
if !ok || math.IsNaN(val) {
|
||||
return
|
||||
}
|
||||
if ds == "local" {
|
||||
cg.setLocal(name, val)
|
||||
return
|
||||
}
|
||||
src, ok := e.broker.Source(ds)
|
||||
if !ok {
|
||||
e.log.Warn("control logic: write to unknown data source", "ds", ds, "signal", name)
|
||||
return
|
||||
}
|
||||
if err := src.Write(e.root, name, val); err != nil {
|
||||
e.log.Warn("control logic: write failed", "ds", ds, "signal", name, "err", err)
|
||||
}
|
||||
}
|
||||
|
||||
// ── compiled graph ─────────────────────────────────────────────────────────────
|
||||
|
||||
type wireOut struct {
|
||||
to string
|
||||
port string
|
||||
}
|
||||
|
||||
// compiledGraph holds the runtime state for one enabled graph.
|
||||
type compiledGraph struct {
|
||||
engine *Engine
|
||||
genCtx context.Context
|
||||
wg *sync.WaitGroup
|
||||
|
||||
name string
|
||||
byId map[string]Node
|
||||
out map[string][]wireOut
|
||||
inc map[string][]string // incoming source ids per node (for gates)
|
||||
refs map[string]RefLite // unique signals to subscribe (excl. sys/local)
|
||||
|
||||
watchers map[string][]string // signal key → trigger node ids
|
||||
luaNodes map[string]*luaRuntime
|
||||
|
||||
stateMu sync.Mutex
|
||||
levelState map[string]bool // current truth of level triggers (threshold/alarm)
|
||||
prevBool map[string]bool // edge detection for threshold/alarm
|
||||
prevVal map[string]float64 // last value for change triggers
|
||||
hasVal map[string]bool
|
||||
lastFire map[string]int64 // ns wall clock each trigger last fired
|
||||
locals map[string]float64
|
||||
}
|
||||
|
||||
func compile(g Graph) *compiledGraph {
|
||||
cg := &compiledGraph{
|
||||
name: g.Name,
|
||||
byId: map[string]Node{},
|
||||
out: map[string][]wireOut{},
|
||||
inc: map[string][]string{},
|
||||
refs: map[string]RefLite{},
|
||||
watchers: map[string][]string{},
|
||||
luaNodes: map[string]*luaRuntime{},
|
||||
levelState: map[string]bool{},
|
||||
prevBool: map[string]bool{},
|
||||
prevVal: map[string]float64{},
|
||||
hasVal: map[string]bool{},
|
||||
lastFire: map[string]int64{},
|
||||
locals: map[string]float64{},
|
||||
}
|
||||
for _, n := range g.Nodes {
|
||||
cg.byId[n.ID] = n
|
||||
}
|
||||
for _, w := range g.Wires {
|
||||
port := w.FromPort
|
||||
if port == "" {
|
||||
port = "out"
|
||||
}
|
||||
cg.out[w.From] = append(cg.out[w.From], wireOut{to: w.To, port: port})
|
||||
cg.inc[w.To] = append(cg.inc[w.To], w.From)
|
||||
}
|
||||
|
||||
want := func(ds, name string) {
|
||||
if ds == "sys" || ds == "local" {
|
||||
return
|
||||
}
|
||||
if name == "" {
|
||||
return
|
||||
}
|
||||
cg.refs[refKey(ds, name)] = RefLite{DS: ds, Name: name}
|
||||
}
|
||||
wantExpr := func(expr string) {
|
||||
for _, r := range CollectRefs(expr) {
|
||||
want(r.DS, r.Name)
|
||||
}
|
||||
}
|
||||
|
||||
for _, n := range g.Nodes {
|
||||
switch n.Kind {
|
||||
case "trigger.threshold", "trigger.change", "trigger.alarm":
|
||||
if ds, name, ok := parseRef(n.param("signal")); ok {
|
||||
want(ds, name)
|
||||
key := refKey(ds, name)
|
||||
cg.watchers[key] = append(cg.watchers[key], n.ID)
|
||||
}
|
||||
case "flow.if":
|
||||
wantExpr(n.param("cond"))
|
||||
case "flow.loop":
|
||||
if n.param("mode") == "while" {
|
||||
wantExpr(n.param("cond"))
|
||||
}
|
||||
case "action.write", "action.log":
|
||||
wantExpr(n.param("expr"))
|
||||
case "action.lua":
|
||||
cg.luaNodes[n.ID] = newLuaRuntime(n.param("script"))
|
||||
for _, r := range luaGetRefs(n.param("script")) {
|
||||
want(r.DS, r.Name)
|
||||
}
|
||||
}
|
||||
}
|
||||
return cg
|
||||
}
|
||||
|
||||
func (cg *compiledGraph) setLocal(name string, v float64) {
|
||||
cg.stateMu.Lock()
|
||||
cg.locals[name] = v
|
||||
cg.stateMu.Unlock()
|
||||
}
|
||||
|
||||
func (cg *compiledGraph) getLocal(name string) float64 {
|
||||
cg.stateMu.Lock()
|
||||
defer cg.stateMu.Unlock()
|
||||
v, ok := cg.locals[name]
|
||||
if !ok {
|
||||
return 0
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
// startTriggers launches timer and cron trigger goroutines for the generation.
|
||||
func (cg *compiledGraph) startTriggers() {
|
||||
hasCron := false
|
||||
for _, n := range cg.byId {
|
||||
switch n.Kind {
|
||||
case "trigger.timer":
|
||||
node := n
|
||||
cg.wg.Add(1)
|
||||
go func() {
|
||||
defer cg.wg.Done()
|
||||
d := intervalOf(node)
|
||||
t := time.NewTicker(d)
|
||||
defer t.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-t.C:
|
||||
cg.activate(node.ID)
|
||||
case <-cg.genCtx.Done():
|
||||
return
|
||||
}
|
||||
}
|
||||
}()
|
||||
case "trigger.cron":
|
||||
hasCron = true
|
||||
}
|
||||
}
|
||||
if hasCron {
|
||||
cg.startCron()
|
||||
}
|
||||
}
|
||||
|
||||
func (cg *compiledGraph) startCron() {
|
||||
type cronNode struct {
|
||||
id string
|
||||
sched *Schedule
|
||||
}
|
||||
var crons []cronNode
|
||||
for _, n := range cg.byId {
|
||||
if n.Kind != "trigger.cron" {
|
||||
continue
|
||||
}
|
||||
sched, err := ParseSchedule(n.param("spec"))
|
||||
if err != nil {
|
||||
cg.engine.log.Warn("control logic: bad cron spec", "graph", cg.name, "spec", n.param("spec"), "err", err)
|
||||
continue
|
||||
}
|
||||
crons = append(crons, cronNode{id: n.ID, sched: sched})
|
||||
}
|
||||
if len(crons) == 0 {
|
||||
return
|
||||
}
|
||||
cg.wg.Add(1)
|
||||
go func() {
|
||||
defer cg.wg.Done()
|
||||
t := time.NewTicker(time.Second)
|
||||
defer t.Stop()
|
||||
lastMinute := -1
|
||||
for {
|
||||
select {
|
||||
case now := <-t.C:
|
||||
minute := now.Hour()*60 + now.Minute()
|
||||
if minute == lastMinute {
|
||||
continue // fire at most once per minute
|
||||
}
|
||||
lastMinute = minute
|
||||
for _, c := range crons {
|
||||
if c.sched.Match(now) {
|
||||
cg.activate(c.id)
|
||||
}
|
||||
}
|
||||
case <-cg.genCtx.Done():
|
||||
return
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
func intervalOf(n Node) time.Duration {
|
||||
ms, err := strconv.Atoi(strings.TrimSpace(n.param("interval")))
|
||||
if err != nil || ms < 50 {
|
||||
ms = 1000
|
||||
}
|
||||
return time.Duration(ms) * time.Millisecond
|
||||
}
|
||||
|
||||
// ── trigger evaluation ─────────────────────────────────────────────────────────
|
||||
|
||||
// onSignal drives threshold/alarm (rising edge) and change triggers when a
|
||||
// watched signal updates.
|
||||
func (cg *compiledGraph) onSignal(key string, value float64) {
|
||||
for _, id := range cg.watchers[key] {
|
||||
node, ok := cg.byId[id]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
switch node.Kind {
|
||||
case "trigger.threshold":
|
||||
cur := testThreshold(value, node.param("op"), parseFloat(node.param("value")))
|
||||
cg.stateMu.Lock()
|
||||
cg.levelState[id] = cur
|
||||
prev := cg.prevBool[id]
|
||||
cg.prevBool[id] = cur
|
||||
cg.stateMu.Unlock()
|
||||
if cur && !prev {
|
||||
cg.activate(id)
|
||||
}
|
||||
case "trigger.alarm":
|
||||
lo := parseFloat(node.param("min"))
|
||||
hi := parseFloat(node.param("max"))
|
||||
cur := !math.IsNaN(value) && (value < lo || value > hi)
|
||||
cg.stateMu.Lock()
|
||||
cg.levelState[id] = cur
|
||||
prev := cg.prevBool[id]
|
||||
cg.prevBool[id] = cur
|
||||
cg.stateMu.Unlock()
|
||||
if cur && !prev {
|
||||
cg.activate(id)
|
||||
}
|
||||
case "trigger.change":
|
||||
cg.stateMu.Lock()
|
||||
had := cg.hasVal[id]
|
||||
prev := cg.prevVal[id]
|
||||
cg.prevVal[id] = value
|
||||
cg.hasVal[id] = true
|
||||
cg.stateMu.Unlock()
|
||||
if had && value != prev {
|
||||
cg.activate(id)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func testThreshold(val float64, op string, cmp float64) bool {
|
||||
if math.IsNaN(val) {
|
||||
return false
|
||||
}
|
||||
switch op {
|
||||
case "<":
|
||||
return val < cmp
|
||||
case ">=":
|
||||
return val >= cmp
|
||||
case "<=":
|
||||
return val <= cmp
|
||||
case "==":
|
||||
return val == cmp
|
||||
case "!=":
|
||||
return val != cmp
|
||||
default: // ">"
|
||||
return val > cmp
|
||||
}
|
||||
}
|
||||
|
||||
func parseFloat(s string) float64 {
|
||||
f, err := strconv.ParseFloat(strings.TrimSpace(s), 64)
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
return f
|
||||
}
|
||||
|
||||
// ── execution ──────────────────────────────────────────────────────────────────
|
||||
|
||||
type runCtx struct {
|
||||
fired string
|
||||
steps int
|
||||
resolve Resolver
|
||||
}
|
||||
|
||||
// activate spawns a flow run for a trigger on its own goroutine so that
|
||||
// action.delay does not block signal dispatch or other flows.
|
||||
func (cg *compiledGraph) activate(triggerID string) {
|
||||
now := time.Now().UnixNano()
|
||||
cg.stateMu.Lock()
|
||||
last, had := cg.lastFire[triggerID]
|
||||
cg.lastFire[triggerID] = now
|
||||
cg.stateMu.Unlock()
|
||||
dt := 0.0
|
||||
if had {
|
||||
dt = float64(now-last) / 1e9
|
||||
}
|
||||
|
||||
resolve := func(ds, name string) float64 {
|
||||
switch ds {
|
||||
case "sys":
|
||||
if name == "dt" {
|
||||
return dt
|
||||
}
|
||||
return cg.engine.liveGet("sys", name)
|
||||
case "local":
|
||||
return cg.getLocal(name)
|
||||
default:
|
||||
return cg.engine.liveGet(ds, name)
|
||||
}
|
||||
}
|
||||
|
||||
cg.wg.Add(1)
|
||||
go func() {
|
||||
defer cg.wg.Done()
|
||||
ctx := &runCtx{fired: triggerID, resolve: resolve}
|
||||
cg.follow(triggerID, "out", ctx)
|
||||
}()
|
||||
}
|
||||
|
||||
func (cg *compiledGraph) follow(fromID, port string, ctx *runCtx) {
|
||||
for _, w := range cg.out[fromID] {
|
||||
if w.port == port {
|
||||
cg.run(w.to, ctx)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (cg *compiledGraph) run(nodeID string, ctx *runCtx) {
|
||||
if ctx.steps > maxSteps {
|
||||
return
|
||||
}
|
||||
ctx.steps++
|
||||
node, ok := cg.byId[nodeID]
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
select {
|
||||
case <-cg.genCtx.Done():
|
||||
return
|
||||
default:
|
||||
}
|
||||
|
||||
switch node.Kind {
|
||||
case "gate.and":
|
||||
if cg.gateSatisfied(node.ID, ctx.fired) {
|
||||
cg.follow(node.ID, "out", ctx)
|
||||
}
|
||||
|
||||
case "flow.if":
|
||||
branch := "else"
|
||||
if EvalBool(node.param("cond"), ctx.resolve) {
|
||||
branch = "then"
|
||||
}
|
||||
cg.follow(node.ID, branch, ctx)
|
||||
|
||||
case "flow.loop":
|
||||
if node.param("mode") == "while" {
|
||||
for i := 0; i < maxLoop && ctx.steps <= maxSteps && EvalBool(node.param("cond"), ctx.resolve); i++ {
|
||||
cg.follow(node.ID, "body", ctx)
|
||||
}
|
||||
} else {
|
||||
n := int(EvalExpr(node.param("count"), ctx.resolve))
|
||||
if n < 0 {
|
||||
n = 0
|
||||
}
|
||||
if n > maxLoop {
|
||||
n = maxLoop
|
||||
}
|
||||
for i := 0; i < n && ctx.steps <= maxSteps; i++ {
|
||||
cg.follow(node.ID, "body", ctx)
|
||||
}
|
||||
}
|
||||
cg.follow(node.ID, "done", ctx)
|
||||
|
||||
case "action.write":
|
||||
val := EvalExpr(node.param("expr"), ctx.resolve)
|
||||
cg.engine.write(cg, node.param("target"), val)
|
||||
cg.follow(node.ID, "out", ctx)
|
||||
|
||||
case "action.delay":
|
||||
ms := 0
|
||||
if v, err := strconv.Atoi(strings.TrimSpace(node.param("ms"))); err == nil && v > 0 {
|
||||
ms = v
|
||||
}
|
||||
if ms > 0 {
|
||||
t := time.NewTimer(time.Duration(ms) * time.Millisecond)
|
||||
select {
|
||||
case <-t.C:
|
||||
case <-cg.genCtx.Done():
|
||||
t.Stop()
|
||||
return
|
||||
}
|
||||
}
|
||||
cg.follow(node.ID, "out", ctx)
|
||||
|
||||
case "action.log":
|
||||
val := EvalExpr(node.param("expr"), ctx.resolve)
|
||||
label := strings.TrimSpace(node.param("label"))
|
||||
cg.engine.log.Info("control logic log", "graph", cg.name, "label", label, "value", val)
|
||||
cg.follow(node.ID, "out", ctx)
|
||||
|
||||
case "action.lua":
|
||||
cg.runLua(node.ID, ctx)
|
||||
cg.follow(node.ID, "out", ctx)
|
||||
|
||||
default:
|
||||
cg.follow(node.ID, "out", ctx)
|
||||
}
|
||||
}
|
||||
|
||||
// gateSatisfied: every incoming trigger must currently be satisfied. The firing
|
||||
// trigger counts as satisfied; level triggers (threshold/alarm) use their truth.
|
||||
func (cg *compiledGraph) gateSatisfied(gateID, fired string) bool {
|
||||
inputs := cg.inc[gateID]
|
||||
if len(inputs) == 0 {
|
||||
return false
|
||||
}
|
||||
cg.stateMu.Lock()
|
||||
defer cg.stateMu.Unlock()
|
||||
for _, src := range inputs {
|
||||
if src == fired || cg.levelState[src] {
|
||||
continue
|
||||
}
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// luaGetRefs scans a Lua script for get("ds:name") / get('ds:name') literals so
|
||||
// the engine can subscribe to the signals the script reads.
|
||||
var luaGetRe = regexp.MustCompile(`get\s*\(\s*["']([^"']+)["']`)
|
||||
|
||||
func luaGetRefs(script string) []RefLite {
|
||||
var out []RefLite
|
||||
for _, m := range luaGetRe.FindAllStringSubmatch(script, -1) {
|
||||
if ds, name, ok := parseRef(m[1]); ok {
|
||||
out = append(out, RefLite{DS: ds, Name: name})
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func (cg *compiledGraph) runLua(nodeID string, ctx *runCtx) {
|
||||
lr := cg.luaNodes[nodeID]
|
||||
if lr == nil {
|
||||
return
|
||||
}
|
||||
lr.run(ctx.resolve, func(target string, val float64) {
|
||||
cg.engine.write(cg, target, val)
|
||||
}, func(msg string) {
|
||||
cg.engine.log.Info("control logic lua", "graph", cg.name, "msg", msg)
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,527 @@
|
||||
// Small, safe expression evaluator — a Go port of web/src/lib/expr.ts.
|
||||
//
|
||||
// Supports numbers, booleans (true/false → 1/0), arithmetic (+ - * / %),
|
||||
// comparison (< <= > >= == !=), boolean (&& || !), ternary (a ? b : c),
|
||||
// parentheses, and a handful of math functions. Two kinds of variable
|
||||
// reference are resolved live at evaluation time:
|
||||
//
|
||||
// {ds:name} a data-source signal value (the brace content is split on the
|
||||
// FIRST ':' so EPICS PV names like "MY:PV:NAME" work).
|
||||
// bareIdent a graph-local state variable (data source "local").
|
||||
//
|
||||
// Booleans are represented as numbers: comparisons / logical ops yield 1 or 0,
|
||||
// and any nonzero value is truthy. The evaluator never uses reflection or eval;
|
||||
// it walks a parsed AST against a caller-supplied Resolver.
|
||||
package controllogic
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"math"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
)
|
||||
|
||||
// Resolver returns the current numeric value of a signal/local reference.
|
||||
type Resolver func(ds, name string) float64
|
||||
|
||||
// RefLite identifies one signal/local reference read by an expression.
|
||||
type RefLite struct {
|
||||
DS string
|
||||
Name string
|
||||
}
|
||||
|
||||
// ── AST ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
type exprNode interface{ eval(R Resolver) float64 }
|
||||
|
||||
type numNode struct{ v float64 }
|
||||
type sigNode struct{ ds, name string }
|
||||
type varNode struct{ name string }
|
||||
type unNode struct {
|
||||
op string
|
||||
a exprNode
|
||||
}
|
||||
type binNode struct {
|
||||
op string
|
||||
a, b exprNode
|
||||
}
|
||||
type ternNode struct{ c, a, b exprNode }
|
||||
type callNode struct {
|
||||
fn string
|
||||
args []exprNode
|
||||
}
|
||||
|
||||
func (n numNode) eval(R Resolver) float64 { return n.v }
|
||||
func (n sigNode) eval(R Resolver) float64 { return R(n.ds, n.name) }
|
||||
func (n varNode) eval(R Resolver) float64 { return R("local", n.name) }
|
||||
func (n unNode) eval(R Resolver) float64 {
|
||||
if n.op == "-" {
|
||||
return -n.a.eval(R)
|
||||
}
|
||||
if n.a.eval(R) == 0 {
|
||||
return 1
|
||||
}
|
||||
return 0
|
||||
}
|
||||
func (n ternNode) eval(R Resolver) float64 {
|
||||
if n.c.eval(R) != 0 {
|
||||
return n.a.eval(R)
|
||||
}
|
||||
return n.b.eval(R)
|
||||
}
|
||||
func (n callNode) eval(R Resolver) float64 {
|
||||
args := make([]float64, len(n.args))
|
||||
for i, a := range n.args {
|
||||
args[i] = a.eval(R)
|
||||
}
|
||||
return funcs[n.fn](args)
|
||||
}
|
||||
func (n binNode) eval(R Resolver) float64 {
|
||||
a, b := n.a.eval(R), n.b.eval(R)
|
||||
switch n.op {
|
||||
case "+":
|
||||
return a + b
|
||||
case "-":
|
||||
return a - b
|
||||
case "*":
|
||||
return a * b
|
||||
case "/":
|
||||
return a / b
|
||||
case "%":
|
||||
return math.Mod(a, b)
|
||||
case "<":
|
||||
return boolf(a < b)
|
||||
case "<=":
|
||||
return boolf(a <= b)
|
||||
case ">":
|
||||
return boolf(a > b)
|
||||
case ">=":
|
||||
return boolf(a >= b)
|
||||
case "==":
|
||||
return boolf(a == b)
|
||||
case "!=":
|
||||
return boolf(a != b)
|
||||
case "&&":
|
||||
return boolf(a != 0 && b != 0)
|
||||
case "||":
|
||||
return boolf(a != 0 || b != 0)
|
||||
}
|
||||
return math.NaN()
|
||||
}
|
||||
|
||||
func boolf(b bool) float64 {
|
||||
if b {
|
||||
return 1
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
var funcs = map[string]func([]float64) float64{
|
||||
"abs": func(a []float64) float64 { return math.Abs(a[0]) },
|
||||
"min": func(a []float64) float64 { return minSlice(a) },
|
||||
"max": func(a []float64) float64 { return maxSlice(a) },
|
||||
"sqrt": func(a []float64) float64 { return math.Sqrt(a[0]) },
|
||||
"floor": func(a []float64) float64 { return math.Floor(a[0]) },
|
||||
"ceil": func(a []float64) float64 { return math.Ceil(a[0]) },
|
||||
"round": func(a []float64) float64 { return math.Round(a[0]) },
|
||||
"sign": func(a []float64) float64 { return float64(sign(a[0])) },
|
||||
"pow": func(a []float64) float64 { return math.Pow(a[0], a[1]) },
|
||||
"log": func(a []float64) float64 { return math.Log(a[0]) },
|
||||
"exp": func(a []float64) float64 { return math.Exp(a[0]) },
|
||||
"sin": func(a []float64) float64 { return math.Sin(a[0]) },
|
||||
"cos": func(a []float64) float64 { return math.Cos(a[0]) },
|
||||
}
|
||||
|
||||
func minSlice(a []float64) float64 {
|
||||
if len(a) == 0 {
|
||||
return math.Inf(1)
|
||||
}
|
||||
m := a[0]
|
||||
for _, x := range a[1:] {
|
||||
m = math.Min(m, x)
|
||||
}
|
||||
return m
|
||||
}
|
||||
func maxSlice(a []float64) float64 {
|
||||
if len(a) == 0 {
|
||||
return math.Inf(-1)
|
||||
}
|
||||
m := a[0]
|
||||
for _, x := range a[1:] {
|
||||
m = math.Max(m, x)
|
||||
}
|
||||
return m
|
||||
}
|
||||
func sign(x float64) int {
|
||||
switch {
|
||||
case x > 0:
|
||||
return 1
|
||||
case x < 0:
|
||||
return -1
|
||||
default:
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
// ── Tokenizer ────────────────────────────────────────────────────────────────
|
||||
|
||||
type tok struct {
|
||||
k string
|
||||
v string
|
||||
}
|
||||
|
||||
func tokenize(src string) ([]tok, error) {
|
||||
var toks []tok
|
||||
two := map[string]bool{"<=": true, ">=": true, "==": true, "!=": true, "&&": true, "||": true}
|
||||
r := []rune(src)
|
||||
i := 0
|
||||
for i < len(r) {
|
||||
c := r[i]
|
||||
switch {
|
||||
case c == ' ' || c == '\t' || c == '\n' || c == '\r':
|
||||
i++
|
||||
continue
|
||||
case c == '{':
|
||||
end := -1
|
||||
for j := i + 1; j < len(r); j++ {
|
||||
if r[j] == '}' {
|
||||
end = j
|
||||
break
|
||||
}
|
||||
}
|
||||
if end < 0 {
|
||||
return nil, fmt.Errorf("unterminated { in expression")
|
||||
}
|
||||
toks = append(toks, tok{k: "sig", v: string(r[i+1 : end])})
|
||||
i = end + 1
|
||||
continue
|
||||
}
|
||||
if isDigit(c) || (c == '.' && i+1 < len(r) && isDigit(r[i+1])) {
|
||||
j := i + 1
|
||||
for j < len(r) && (isDigit(r[j]) || r[j] == '.') {
|
||||
j++
|
||||
}
|
||||
toks = append(toks, tok{k: "num", v: string(r[i:j])})
|
||||
i = j
|
||||
continue
|
||||
}
|
||||
if isIdentStart(c) {
|
||||
j := i + 1
|
||||
for j < len(r) && isIdentPart(r[j]) {
|
||||
j++
|
||||
}
|
||||
toks = append(toks, tok{k: "ident", v: string(r[i:j])})
|
||||
i = j
|
||||
continue
|
||||
}
|
||||
if i+1 < len(r) {
|
||||
pair := string(r[i : i+2])
|
||||
if two[pair] {
|
||||
toks = append(toks, tok{k: pair})
|
||||
i += 2
|
||||
continue
|
||||
}
|
||||
}
|
||||
if strings.ContainsRune("+-*/%<>!()?:,", c) {
|
||||
toks = append(toks, tok{k: string(c)})
|
||||
i++
|
||||
continue
|
||||
}
|
||||
return nil, fmt.Errorf("unexpected character %q in expression", string(c))
|
||||
}
|
||||
return toks, nil
|
||||
}
|
||||
|
||||
func isDigit(c rune) bool { return c >= '0' && c <= '9' }
|
||||
func isIdentStart(c rune) bool { return c == '_' || (c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z') }
|
||||
func isIdentPart(c rune) bool { return isIdentStart(c) || isDigit(c) }
|
||||
|
||||
// ── Parser (recursive descent) ────────────────────────────────────────────────
|
||||
|
||||
type parser struct {
|
||||
toks []tok
|
||||
p int
|
||||
}
|
||||
|
||||
func (ps *parser) peek() (tok, bool) {
|
||||
if ps.p < len(ps.toks) {
|
||||
return ps.toks[ps.p], true
|
||||
}
|
||||
return tok{}, false
|
||||
}
|
||||
|
||||
func (ps *parser) eat(k string) (tok, error) {
|
||||
if ps.p >= len(ps.toks) {
|
||||
return tok{}, fmt.Errorf("unexpected end of expression")
|
||||
}
|
||||
t := ps.toks[ps.p]
|
||||
if k != "" && t.k != k {
|
||||
return tok{}, fmt.Errorf("expected %q in expression", k)
|
||||
}
|
||||
ps.p++
|
||||
return t, nil
|
||||
}
|
||||
|
||||
func parse(src string) (exprNode, error) {
|
||||
toks, err := tokenize(src)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
ps := &parser{toks: toks}
|
||||
root, err := ps.ternary()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if ps.p < len(ps.toks) {
|
||||
return nil, fmt.Errorf("trailing tokens in expression")
|
||||
}
|
||||
return root, nil
|
||||
}
|
||||
|
||||
func (ps *parser) primary() (exprNode, error) {
|
||||
t, ok := ps.peek()
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("unexpected end of expression")
|
||||
}
|
||||
switch t.k {
|
||||
case "num":
|
||||
ps.eat("")
|
||||
v, err := strconv.ParseFloat(t.v, 64)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("bad number %q", t.v)
|
||||
}
|
||||
return numNode{v: v}, nil
|
||||
case "sig":
|
||||
ps.eat("")
|
||||
idx := strings.IndexByte(t.v, ':')
|
||||
if idx < 0 {
|
||||
return sigNode{ds: t.v, name: ""}, nil
|
||||
}
|
||||
return sigNode{ds: t.v[:idx], name: t.v[idx+1:]}, nil
|
||||
case "ident":
|
||||
ps.eat("")
|
||||
id := t.v
|
||||
if id == "true" {
|
||||
return numNode{v: 1}, nil
|
||||
}
|
||||
if id == "false" {
|
||||
return numNode{v: 0}, nil
|
||||
}
|
||||
if nx, ok := ps.peek(); ok && nx.k == "(" {
|
||||
ps.eat("(")
|
||||
var args []exprNode
|
||||
if nx2, ok := ps.peek(); ok && nx2.k != ")" {
|
||||
a, err := ps.ternary()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
args = append(args, a)
|
||||
for {
|
||||
nx3, ok := ps.peek()
|
||||
if !ok || nx3.k != "," {
|
||||
break
|
||||
}
|
||||
ps.eat(",")
|
||||
a, err := ps.ternary()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
args = append(args, a)
|
||||
}
|
||||
}
|
||||
if _, err := ps.eat(")"); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if _, ok := funcs[id]; !ok {
|
||||
return nil, fmt.Errorf("unknown function %q", id)
|
||||
}
|
||||
return callNode{fn: id, args: args}, nil
|
||||
}
|
||||
return varNode{name: id}, nil
|
||||
case "(":
|
||||
ps.eat("(")
|
||||
e, err := ps.ternary()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if _, err := ps.eat(")"); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return e, nil
|
||||
}
|
||||
return nil, fmt.Errorf("unexpected token %q in expression", t.k)
|
||||
}
|
||||
|
||||
func (ps *parser) unary() (exprNode, error) {
|
||||
if t, ok := ps.peek(); ok && (t.k == "-" || t.k == "!") {
|
||||
ps.eat("")
|
||||
a, err := ps.unary()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return unNode{op: t.k, a: a}, nil
|
||||
}
|
||||
return ps.primary()
|
||||
}
|
||||
|
||||
func (ps *parser) binLevel(next func() (exprNode, error), ops ...string) (exprNode, error) {
|
||||
a, err := next()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for {
|
||||
t, ok := ps.peek()
|
||||
if !ok || !contains(ops, t.k) {
|
||||
return a, nil
|
||||
}
|
||||
op, _ := ps.eat("")
|
||||
b, err := next()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
a = binNode{op: op.k, a: a, b: b}
|
||||
}
|
||||
}
|
||||
|
||||
func (ps *parser) mul() (exprNode, error) { return ps.binLevel(ps.unary, "*", "/", "%") }
|
||||
func (ps *parser) add() (exprNode, error) { return ps.binLevel(ps.mul, "+", "-") }
|
||||
func (ps *parser) cmp() (exprNode, error) { return ps.binLevel(ps.add, "<", "<=", ">", ">=") }
|
||||
func (ps *parser) eq() (exprNode, error) { return ps.binLevel(ps.cmp, "==", "!=") }
|
||||
func (ps *parser) and() (exprNode, error) { return ps.binLevel(ps.eq, "&&") }
|
||||
func (ps *parser) or() (exprNode, error) { return ps.binLevel(ps.and, "||") }
|
||||
|
||||
func (ps *parser) ternary() (exprNode, error) {
|
||||
c, err := ps.or()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if t, ok := ps.peek(); ok && t.k == "?" {
|
||||
ps.eat("?")
|
||||
a, err := ps.ternary()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if _, err := ps.eat(":"); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
b, err := ps.ternary()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return ternNode{c: c, a: a, b: b}, nil
|
||||
}
|
||||
return c, nil
|
||||
}
|
||||
|
||||
func contains(s []string, v string) bool {
|
||||
for _, x := range s {
|
||||
if x == v {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// ── Cache + public API ─────────────────────────────────────────────────────────
|
||||
|
||||
type cacheEntry struct {
|
||||
node exprNode
|
||||
err error
|
||||
}
|
||||
|
||||
var (
|
||||
cacheMu sync.Mutex
|
||||
cache = map[string]cacheEntry{}
|
||||
)
|
||||
|
||||
func parseCached(src string) (exprNode, error) {
|
||||
cacheMu.Lock()
|
||||
e, ok := cache[src]
|
||||
cacheMu.Unlock()
|
||||
if ok {
|
||||
return e.node, e.err
|
||||
}
|
||||
n, err := parse(src)
|
||||
cacheMu.Lock()
|
||||
cache[src] = cacheEntry{node: n, err: err}
|
||||
cacheMu.Unlock()
|
||||
return n, err
|
||||
}
|
||||
|
||||
// EvalExpr evaluates an expression string, returning NaN on parse/eval failure.
|
||||
func EvalExpr(src string, resolve Resolver) float64 {
|
||||
n, err := parseCached(src)
|
||||
if err != nil {
|
||||
return math.NaN()
|
||||
}
|
||||
return safeEval(n, resolve)
|
||||
}
|
||||
|
||||
func safeEval(n exprNode, resolve Resolver) (out float64) {
|
||||
defer func() {
|
||||
if recover() != nil {
|
||||
out = math.NaN()
|
||||
}
|
||||
}()
|
||||
return n.eval(resolve)
|
||||
}
|
||||
|
||||
// EvalBool reports whether the expression evaluates to a nonzero, non-NaN value.
|
||||
func EvalBool(src string, resolve Resolver) bool {
|
||||
v := EvalExpr(src, resolve)
|
||||
return !math.IsNaN(v) && v != 0
|
||||
}
|
||||
|
||||
// CollectRefs returns every signal/local reference an expression reads, for
|
||||
// subscription. Returns nil for an unparseable expression.
|
||||
func CollectRefs(src string) []RefLite {
|
||||
root, err := parseCached(src)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
var out []RefLite
|
||||
seen := map[string]bool{}
|
||||
add := func(ds, name string) {
|
||||
k := ds + "\x00" + name
|
||||
if !seen[k] {
|
||||
seen[k] = true
|
||||
out = append(out, RefLite{DS: ds, Name: name})
|
||||
}
|
||||
}
|
||||
var walk func(n exprNode)
|
||||
walk = func(n exprNode) {
|
||||
switch t := n.(type) {
|
||||
case sigNode:
|
||||
add(t.ds, t.name)
|
||||
case varNode:
|
||||
add("local", t.name)
|
||||
case unNode:
|
||||
walk(t.a)
|
||||
case binNode:
|
||||
walk(t.a)
|
||||
walk(t.b)
|
||||
case ternNode:
|
||||
walk(t.c)
|
||||
walk(t.a)
|
||||
walk(t.b)
|
||||
case callNode:
|
||||
for _, a := range t.args {
|
||||
walk(a)
|
||||
}
|
||||
}
|
||||
}
|
||||
walk(root)
|
||||
return out
|
||||
}
|
||||
|
||||
// CheckExpr validates an expression; returns an error message or "" if it parses.
|
||||
func CheckExpr(src string) string {
|
||||
if strings.TrimSpace(src) == "" {
|
||||
return ""
|
||||
}
|
||||
if _, err := parse(src); err != nil {
|
||||
return err.Error()
|
||||
}
|
||||
return ""
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
package controllogic
|
||||
|
||||
import (
|
||||
"math"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestEvalExpr(t *testing.T) {
|
||||
resolve := func(ds, name string) float64 {
|
||||
switch {
|
||||
case ds == "stub" && name == "x":
|
||||
return 10
|
||||
case ds == "local" && name == "y":
|
||||
return 3
|
||||
case ds == "sys" && name == "dt":
|
||||
return 0.5
|
||||
}
|
||||
return math.NaN()
|
||||
}
|
||||
cases := []struct {
|
||||
expr string
|
||||
want float64
|
||||
}{
|
||||
{"1 + 2 * 3", 7},
|
||||
{"(1 + 2) * 3", 9},
|
||||
{"10 % 3", 1},
|
||||
{"{stub:x} + y", 13},
|
||||
{"{stub:x} > 5 ? 1 : 0", 1},
|
||||
{"min(4, 2, 8)", 2},
|
||||
{"max(4, 2, 8)", 8},
|
||||
{"abs(-5)", 5},
|
||||
{"sqrt(16)", 4},
|
||||
{"2 < 3 && 3 < 4", 1},
|
||||
{"!0", 1},
|
||||
{"-{stub:x}", -10},
|
||||
{"{sys:dt} * 2", 1},
|
||||
{"true + false", 1},
|
||||
}
|
||||
for _, c := range cases {
|
||||
got := EvalExpr(c.expr, resolve)
|
||||
if math.Abs(got-c.want) > 1e-9 {
|
||||
t.Errorf("EvalExpr(%q) = %v, want %v", c.expr, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestEvalExprErrors(t *testing.T) {
|
||||
r := func(ds, name string) float64 { return 0 }
|
||||
for _, bad := range []string{"1 +", "(1", "1 2", "{unterminated"} {
|
||||
if v := EvalExpr(bad, r); !math.IsNaN(v) {
|
||||
t.Errorf("EvalExpr(%q) = %v, want NaN", bad, v)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCollectRefs(t *testing.T) {
|
||||
refs := CollectRefs("{stub:x} + y + {epics:MY:PV} * z")
|
||||
got := map[string]bool{}
|
||||
for _, r := range refs {
|
||||
got[r.DS+":"+r.Name] = true
|
||||
}
|
||||
for _, want := range []string{"stub:x", "local:y", "epics:MY:PV", "local:z"} {
|
||||
if !got[want] {
|
||||
t.Errorf("CollectRefs missing %q (got %v)", want, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckExpr(t *testing.T) {
|
||||
if msg := CheckExpr("1 + 2"); msg != "" {
|
||||
t.Errorf("CheckExpr valid returned %q", msg)
|
||||
}
|
||||
if msg := CheckExpr("1 +"); msg == "" {
|
||||
t.Errorf("CheckExpr invalid returned empty")
|
||||
}
|
||||
if msg := CheckExpr(""); msg != "" {
|
||||
t.Errorf("CheckExpr empty returned %q", msg)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEpicsRefSplitFirstColon(t *testing.T) {
|
||||
refs := CollectRefs("{epics:SR:BPM:01:X}")
|
||||
if len(refs) != 1 || refs[0].DS != "epics" || refs[0].Name != "SR:BPM:01:X" {
|
||||
t.Errorf("got %+v, want epics / SR:BPM:01:X", refs)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
package controllogic
|
||||
|
||||
import (
|
||||
"sync"
|
||||
|
||||
lua "github.com/yuin/gopher-lua"
|
||||
)
|
||||
|
||||
// luaRuntime holds a sandboxed Lua VM for one action.lua node. The VM is created
|
||||
// lazily and reused; access is serialised so concurrent flow activations of the
|
||||
// same node don't share VM state unsafely. Host functions exposed to scripts:
|
||||
//
|
||||
// get("ds:name") → number read a signal / sys / local value (NaN if absent)
|
||||
// set("ds:name", v) write a value (bare name → graph-local var)
|
||||
// log(msg) append to the server log
|
||||
//
|
||||
// The os, io, package, debug and code-loading globals are removed, mirroring the
|
||||
// dsp LuaNode sandbox.
|
||||
type luaRuntime struct {
|
||||
script string
|
||||
|
||||
mu sync.Mutex
|
||||
L *lua.LState
|
||||
|
||||
// Bound to the current activation while run() holds mu.
|
||||
curResolve Resolver
|
||||
curSet func(target string, val float64)
|
||||
curLog func(msg string)
|
||||
}
|
||||
|
||||
func newLuaRuntime(script string) *luaRuntime {
|
||||
return &luaRuntime{script: script}
|
||||
}
|
||||
|
||||
func (lr *luaRuntime) ensure() error {
|
||||
if lr.L != nil {
|
||||
return nil
|
||||
}
|
||||
L := lua.NewState(lua.Options{SkipOpenLibs: true})
|
||||
for _, pair := range []struct {
|
||||
name string
|
||||
fn lua.LGFunction
|
||||
}{
|
||||
{lua.LoadLibName, lua.OpenPackage},
|
||||
{lua.BaseLibName, lua.OpenBase},
|
||||
{lua.MathLibName, lua.OpenMath},
|
||||
{lua.StringLibName, lua.OpenString},
|
||||
{lua.TabLibName, lua.OpenTable},
|
||||
} {
|
||||
if err := L.CallByParam(lua.P{Fn: L.NewFunction(pair.fn), NRet: 0, Protect: true}, lua.LString(pair.name)); err != nil {
|
||||
L.Close()
|
||||
return err
|
||||
}
|
||||
}
|
||||
L.SetGlobal("load", lua.LNil)
|
||||
L.SetGlobal("loadfile", lua.LNil)
|
||||
L.SetGlobal("dofile", lua.LNil)
|
||||
L.SetGlobal("require", lua.LNil)
|
||||
|
||||
L.SetGlobal("get", L.NewFunction(func(s *lua.LState) int {
|
||||
target := s.CheckString(1)
|
||||
ds, name, ok := parseRef(target)
|
||||
var v float64
|
||||
if ok && lr.curResolve != nil {
|
||||
v = lr.curResolve(ds, name)
|
||||
}
|
||||
s.Push(lua.LNumber(v))
|
||||
return 1
|
||||
}))
|
||||
L.SetGlobal("set", L.NewFunction(func(s *lua.LState) int {
|
||||
target := s.CheckString(1)
|
||||
val := float64(s.CheckNumber(2))
|
||||
if lr.curSet != nil {
|
||||
lr.curSet(target, val)
|
||||
}
|
||||
return 0
|
||||
}))
|
||||
L.SetGlobal("log", L.NewFunction(func(s *lua.LState) int {
|
||||
if lr.curLog != nil {
|
||||
lr.curLog(s.CheckString(1))
|
||||
}
|
||||
return 0
|
||||
}))
|
||||
|
||||
lr.L = L
|
||||
return nil
|
||||
}
|
||||
|
||||
// run executes the script once with the given host hooks bound.
|
||||
func (lr *luaRuntime) run(resolve Resolver, set func(string, float64), logf func(string)) {
|
||||
lr.mu.Lock()
|
||||
defer lr.mu.Unlock()
|
||||
|
||||
if err := lr.ensure(); err != nil {
|
||||
logf("lua init error: " + err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
lr.curResolve = resolve
|
||||
lr.curSet = set
|
||||
lr.curLog = logf
|
||||
defer func() {
|
||||
lr.curResolve = nil
|
||||
lr.curSet = nil
|
||||
lr.curLog = nil
|
||||
if r := recover(); r != nil {
|
||||
logf("lua panic")
|
||||
}
|
||||
}()
|
||||
|
||||
lr.L.SetTop(0)
|
||||
if err := lr.L.DoString(lr.script); err != nil {
|
||||
logf("lua error: " + err.Error())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
// Package controllogic implements a server-side flow-graph engine. It mirrors
|
||||
// the client-side panel logic engine (web/src/lib/logic.ts) but runs
|
||||
// continuously on the server under the root context, independent of any panel.
|
||||
//
|
||||
// A control-logic graph is a Node-RED-style flow of trigger, gate, control-flow
|
||||
// and action nodes connected by wires. Triggers are flow entry points; when one
|
||||
// activates the engine follows the outgoing wires executing downstream nodes.
|
||||
//
|
||||
// Compared with the panel engine, control logic has no button trigger (buttons
|
||||
// are panel-UI driven) and gains two headless triggers — cron (5-field schedule)
|
||||
// and alarm (signal out of an allowed range) — plus a Lua script action node
|
||||
// with get/set/log host functions.
|
||||
//
|
||||
// Graphs are persisted as JSON in {storageDir}/controllogic.json and CRUD'd via
|
||||
// the REST API; the engine rebuilds itself (Reload) whenever they change.
|
||||
package controllogic
|
||||
|
||||
// Node kinds. Triggers begin flows; the rest execute downstream.
|
||||
//
|
||||
// trigger.threshold — fires on the rising edge of cmp(signal, value).
|
||||
// trigger.change — fires whenever a watched signal's value changes.
|
||||
// trigger.timer — fires every `interval` ms.
|
||||
// trigger.cron — fires when the wall clock matches a 5-field cron `spec`.
|
||||
// trigger.alarm — fires on the rising edge of signal leaving [min,max].
|
||||
// gate.and — passes only when all incoming triggers are satisfied.
|
||||
// flow.if — evaluates `cond`, continues on the 'then'/'else' port.
|
||||
// flow.loop — repeats the 'body' port (count / while, capped) then 'done'.
|
||||
// action.write — evaluates `expr`, writes the result to `target`.
|
||||
// action.delay — waits `ms` before continuing.
|
||||
// action.log — logs an expression value to the server log.
|
||||
// action.lua — runs a sandboxed Lua script with get/set/log host funcs.
|
||||
|
||||
// Node is a single node in a control-logic graph. Params are stored as strings
|
||||
// (matching the panel logic model) and parsed per-kind by the engine.
|
||||
type Node struct {
|
||||
ID string `json:"id"`
|
||||
Kind string `json:"kind"`
|
||||
X float64 `json:"x"`
|
||||
Y float64 `json:"y"`
|
||||
Params map[string]string `json:"params,omitempty"`
|
||||
}
|
||||
|
||||
// Wire connects an output port of one node to the input of another.
|
||||
// FromPort defaults to "out" when empty.
|
||||
type Wire struct {
|
||||
From string `json:"from"`
|
||||
FromPort string `json:"fromPort,omitempty"`
|
||||
To string `json:"to"`
|
||||
}
|
||||
|
||||
// Graph is a named, independently-enableable control-logic flow.
|
||||
type Graph struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Enabled bool `json:"enabled"`
|
||||
Nodes []Node `json:"nodes"`
|
||||
Wires []Wire `json:"wires"`
|
||||
}
|
||||
|
||||
func (n Node) param(key string) string {
|
||||
if n.Params == nil {
|
||||
return ""
|
||||
}
|
||||
return n.Params[key]
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
package controllogic
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
)
|
||||
|
||||
const definitionsFile = "controllogic.json"
|
||||
|
||||
// ErrNotFound is returned when a graph id does not exist.
|
||||
var ErrNotFound = errors.New("control logic graph not found")
|
||||
|
||||
// Store persists control-logic graphs as a single JSON file in the storage dir.
|
||||
// Writes are atomic (tmp file + rename); all access is mutex-guarded.
|
||||
type Store struct {
|
||||
mu sync.RWMutex
|
||||
path string
|
||||
items map[string]Graph
|
||||
}
|
||||
|
||||
// NewStore opens (or initialises) the control-logic store under storageDir.
|
||||
func NewStore(storageDir string) (*Store, error) {
|
||||
s := &Store{
|
||||
path: filepath.Join(storageDir, definitionsFile),
|
||||
items: map[string]Graph{},
|
||||
}
|
||||
if err := s.load(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return s, nil
|
||||
}
|
||||
|
||||
func (s *Store) load() error {
|
||||
data, err := os.ReadFile(s.path)
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
return nil
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var graphs []Graph
|
||||
if err := json.Unmarshal(data, &graphs); err != nil {
|
||||
return fmt.Errorf("parse %s: %w", s.path, err)
|
||||
}
|
||||
for _, g := range graphs {
|
||||
s.items[g.ID] = g
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// saveLocked writes the current set atomically. Caller must hold s.mu.
|
||||
func (s *Store) saveLocked() error {
|
||||
graphs := make([]Graph, 0, len(s.items))
|
||||
for _, g := range s.items {
|
||||
graphs = append(graphs, g)
|
||||
}
|
||||
data, err := json.MarshalIndent(graphs, "", " ")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
tmp := s.path + ".tmp"
|
||||
if err := os.WriteFile(tmp, data, 0o644); err != nil {
|
||||
return err
|
||||
}
|
||||
return os.Rename(tmp, s.path)
|
||||
}
|
||||
|
||||
// List returns all graphs.
|
||||
func (s *Store) List() []Graph {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
out := make([]Graph, 0, len(s.items))
|
||||
for _, g := range s.items {
|
||||
out = append(out, g)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// Get returns a single graph by id.
|
||||
func (s *Store) Get(id string) (Graph, error) {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
g, ok := s.items[id]
|
||||
if !ok {
|
||||
return Graph{}, ErrNotFound
|
||||
}
|
||||
return g, nil
|
||||
}
|
||||
|
||||
// Save inserts or replaces a graph and persists the store.
|
||||
func (s *Store) Save(g Graph) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.items[g.ID] = g
|
||||
return s.saveLocked()
|
||||
}
|
||||
|
||||
// Delete removes a graph by id.
|
||||
func (s *Store) Delete(id string) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if _, ok := s.items[id]; !ok {
|
||||
return ErrNotFound
|
||||
}
|
||||
delete(s.items, id)
|
||||
return s.saveLocked()
|
||||
}
|
||||
@@ -11,6 +11,7 @@ import (
|
||||
"github.com/uopi/uopi/internal/access"
|
||||
"github.com/uopi/uopi/internal/api"
|
||||
"github.com/uopi/uopi/internal/broker"
|
||||
"github.com/uopi/uopi/internal/controllogic"
|
||||
"github.com/uopi/uopi/internal/datasource/synthetic"
|
||||
"github.com/uopi/uopi/internal/metrics"
|
||||
"github.com/uopi/uopi/internal/panelacl"
|
||||
@@ -26,7 +27,7 @@ type Server struct {
|
||||
|
||||
// New creates the HTTP server, registers all routes, and returns a ready-to-start Server.
|
||||
// synth may be nil if the synthetic data source is not enabled.
|
||||
func New(addr string, webFS fs.FS, brk *broker.Broker, synth *synthetic.Synthetic, store *storage.Store, policy *access.Policy, acl *panelacl.Store, channelFinderURL, archiverURL, trustedUserHeader string, log *slog.Logger) *Server {
|
||||
func New(addr string, webFS fs.FS, brk *broker.Broker, synth *synthetic.Synthetic, store *storage.Store, policy *access.Policy, acl *panelacl.Store, ctrlLogic *controllogic.Store, ctrlEngine *controllogic.Engine, channelFinderURL, archiverURL, trustedUserHeader string, log *slog.Logger) *Server {
|
||||
mux := http.NewServeMux()
|
||||
|
||||
// Health check
|
||||
@@ -44,7 +45,7 @@ func New(addr string, webFS fs.FS, brk *broker.Broker, synth *synthetic.Syntheti
|
||||
// REST API — registered on a dedicated mux so it can be wrapped with the
|
||||
// access-control middleware (identity resolution + global level enforcement).
|
||||
apiMux := http.NewServeMux()
|
||||
api.New(brk, synth, store, policy, acl, channelFinderURL, archiverURL, log).Register(apiMux, apiPrefix)
|
||||
api.New(brk, synth, store, policy, acl, ctrlLogic, ctrlEngine, channelFinderURL, archiverURL, log).Register(apiMux, apiPrefix)
|
||||
mux.Handle(apiPrefix+"/", accessMiddleware(policy, trustedUserHeader, apiMux))
|
||||
|
||||
// Embedded frontend — must be last (catch-all)
|
||||
|
||||
Reference in New Issue
Block a user